Skip to content

Commit 9ce46b1

Browse files
committed
First.
1 parent 9429194 commit 9ce46b1

File tree

4 files changed

+2777
-0
lines changed

4 files changed

+2777
-0
lines changed

src/nextjs/index.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { NextResponse, NextRequest } from 'next/server';
2+
import { createRemoteJWKSet, jwtVerify, JWTPayload, JWTHeaderParameters } from 'jose';
3+
import { getDefaultAppConfig } from "@firebase/util";
4+
import { FirebaseOptions } from 'firebase/app';
5+
6+
type FirebaseJWTPayload = JWTPayload & { firebase?: { tenant?: string }};
7+
8+
type RunMiddlewareOptions = { apiKey: string, projectId: string, emulatorHost: string|undefined, tenantId: string|undefined, authDomain: string|undefined };
9+
type RunMiddlewareResponse = [NextResponse]|[undefined, ((response: NextResponse) => NextResponse), FirebaseJWTPayload|undefined];
10+
async function runMiddleware(options: RunMiddlewareOptions, request: NextRequest): Promise<RunMiddlewareResponse> {
11+
if (request.nextUrl.pathname.startsWith("/__/") && options.authDomain) {
12+
const newURL = new URL(request.nextUrl);
13+
newURL.host = options.authDomain;
14+
newURL.port = '';
15+
return [NextResponse.rewrite(newURL)];
16+
}
17+
18+
// TODO safari doesn't like "secure: true" if insecure, even when localhost, need to check emulators
19+
// replace tenant id, we should probably use different cookies for emulated and not
20+
// need to test in firefox
21+
const ID_TOKEN_COOKIE_NAME = `firebase:authUser:${options.apiKey}:[DEFAULT]`;
22+
const REFRESH_TOKEN_COOKIE_NAME = `${ID_TOKEN_COOKIE_NAME}:refreshToken`;
23+
const ID_TOKEN_COOKIE = { path: "/", secure: true, sameSite: "strict", partitioned: true, name: ID_TOKEN_COOKIE_NAME, maxAge: 34560000 } as const;
24+
const REFRESH_TOKEN_COOKIE = { ...ID_TOKEN_COOKIE, httpOnly: true, name: REFRESH_TOKEN_COOKIE_NAME } as const;
25+
26+
if (request.nextUrl.pathname === '/__cookies__') {
27+
const method = request.method;
28+
if (method === 'DELETE') {
29+
const response = new NextResponse("");
30+
response.cookies.delete({...ID_TOKEN_COOKIE, maxAge: 0});
31+
response.cookies.delete({...REFRESH_TOKEN_COOKIE, maxAge: 0});
32+
return [response];
33+
}
34+
35+
const headers = Object.fromEntries([
36+
"referrer",
37+
"referrer-policy",
38+
"content-type",
39+
"X-Firebase-Client",
40+
"X-Firebase-gmpid",
41+
"X-Firebase-AppCheck",
42+
"X-Firebase-Locale",
43+
"X-Client-Version",
44+
].
45+
filter((header) => request.headers.has(header)).
46+
map((header) => [header, request.headers.get(header)!])
47+
);
48+
49+
const url = new URL(request.nextUrl.searchParams.get('finalTarget')!);
50+
51+
let body: ReadableStream<any>|string|null = request.body;
52+
53+
const isTokenRequest = !!url.pathname.match(/^(\/securetoken\.googleapis\.com)?\/v1\/token/);
54+
const isSignInRequest = !!url.pathname.match(/^(\/identitytoolkit\.googleapis\.com)?\/v1\/accounts:signInWith/);
55+
56+
if (!isTokenRequest && !isSignInRequest) throw new Error("Could not determine the request type to proxy");
57+
58+
if (isTokenRequest) {
59+
body = await request.text();
60+
const bodyParams = new URLSearchParams(body!.trim());
61+
if (bodyParams.has("refresh_token")) {
62+
const refreshToken = request.cookies.get({ ...REFRESH_TOKEN_COOKIE, value: "" })?.value;
63+
if (refreshToken) {
64+
bodyParams.set("refresh_token", refreshToken);
65+
body = bodyParams.toString();
66+
}
67+
}
68+
}
69+
70+
const response = await fetch(url, { method, body, headers });
71+
72+
const json = await response.json() as any; // TODO better types here
73+
const status = response.status;
74+
const statusText = response.statusText;
75+
if (!response.ok) {
76+
const nextResponse = NextResponse.json(json, { status, statusText });
77+
return [nextResponse];
78+
}
79+
let refreshToken;
80+
let idToken;
81+
if (isSignInRequest) {
82+
refreshToken = json.refreshToken;
83+
idToken = json.idToken;
84+
json.refreshToken = "REDACTED";
85+
} else {
86+
refreshToken = json.refresh_token;
87+
idToken = json.id_token;
88+
json.refresh_token = "REDACTED";
89+
}
90+
91+
const currentIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value;
92+
const nextResponse = NextResponse.json(json, { status, statusText });
93+
if (idToken && currentIdToken !== idToken) nextResponse.cookies.set({...ID_TOKEN_COOKIE, value: idToken });
94+
if (refreshToken) nextResponse.cookies.set({...REFRESH_TOKEN_COOKIE, value: refreshToken });
95+
return [nextResponse];
96+
}
97+
98+
const logout = (): RunMiddlewareResponse => {
99+
const decorateNextResponse = (response: NextResponse) => {
100+
if (request.cookies.get({...ID_TOKEN_COOKIE, value: "" })) response.cookies.delete({...ID_TOKEN_COOKIE, maxAge: 0});
101+
if (request.cookies.get({...REFRESH_TOKEN_COOKIE, value: "" })) response.cookies.delete({...REFRESH_TOKEN_COOKIE, maxAge: 0});
102+
return response;
103+
}
104+
return [undefined, decorateNextResponse, undefined];
105+
}
106+
107+
let authIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value;
108+
if (!authIdToken) return logout();
109+
110+
let isEmulatedCredential = false;
111+
let [jwtHeader, jwtPayload] = authIdToken.split(".").slice(0,2).map(it => JSON.parse(atob(it))) as [JWTHeaderParameters, FirebaseJWTPayload];
112+
// TODO check the tenantId
113+
if (jwtHeader?.typ !== 'JWT' || jwtPayload?.iss !== `https://securetoken.google.com/${options.projectId}` || jwtPayload.aud !== options.projectId || jwtPayload.firebase?.tenant !== options.tenantId) {
114+
console.error("I hates the claims.", jwtHeader, jwtPayload);
115+
return logout();
116+
}
117+
if (jwtHeader?.alg === 'none') {
118+
isEmulatedCredential = true;
119+
} else if (jwtHeader?.alg !== 'RS256') {
120+
console.error("I hates the alg.", jwtHeader?.alg);
121+
return logout();
122+
}
123+
124+
if (isEmulatedCredential && !options.emulatorHost) throw new Error("could not detirmine emulator hostname.");
125+
126+
const muchEpochWow = Math.floor(+new Date() / 1000);
127+
if (jwtPayload.exp && jwtPayload.exp > muchEpochWow) {
128+
if (!isEmulatedCredential) {
129+
try {
130+
const jwks = createRemoteJWKSet(new URL('https://www.googleapis.com/robot/v1/metadata/jwk/[email protected]'));
131+
await jwtVerify(authIdToken, jwks);
132+
} catch(e) {
133+
console.error("Jose hates the JWT", e);
134+
return logout();
135+
}
136+
}
137+
const decorateNextResponse = (response: NextResponse) => response;
138+
return [undefined, decorateNextResponse, jwtPayload];
139+
}
140+
141+
const refresh_token = request.cookies.get({...REFRESH_TOKEN_COOKIE, value: "" })?.value;
142+
if (!refresh_token) {
143+
console.error("Where's the refresh token bro?");
144+
return logout();
145+
}
146+
147+
const refreshUrl = new URL(isEmulatedCredential ? `http://${options.emulatorHost}` : `https://securetoken.googleapis.com`);
148+
refreshUrl.pathname = [isEmulatedCredential && 'securetoken.googleapis.com', 'v1/token'].filter(Boolean).join('/');
149+
refreshUrl.searchParams.set("key", options.apiKey);
150+
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token });
151+
const refreshResponse = await fetch(refreshUrl, {
152+
method: 'POST',
153+
body,
154+
headers: {
155+
'Content-Type': 'application/x-www-form-urlencoded',
156+
},
157+
});
158+
if (!refreshResponse.ok) {
159+
console.error(refreshUrl.toString(), refreshResponse.status, refreshResponse.statusText);
160+
return logout();
161+
}
162+
const json = await refreshResponse.json() as any;
163+
const newRefreshToken = json.refresh_token;
164+
const newIdToken = json.id_token;
165+
const decorateNextResponse = (response: NextResponse) => {
166+
if (newIdToken) response.cookies.set({ ...ID_TOKEN_COOKIE, value: newIdToken });
167+
if (newRefreshToken) response.cookies.set({ ...REFRESH_TOKEN_COOKIE, value: newRefreshToken });
168+
return response;
169+
}
170+
return [undefined, decorateNextResponse, jwtPayload];
171+
}
172+
173+
interface Config {
174+
options?: FirebaseOptions,
175+
emulator?: boolean | string,
176+
tenantId?: string | ((request: NextRequest) => Resolvable<string>),
177+
}
178+
179+
type Resolvable<T> = Promise<T> | T;
180+
type Innie = (request: NextRequest, idTokenPayload: JWTPayload|undefined) => Resolvable<NextResponse|void>;
181+
182+
export const composeMiddleware = (configOrInnie?: Config|Innie, optionalInnie?: Innie) => async (request: NextRequest) => {
183+
const config = typeof configOrInnie === "object" ? configOrInnie : {};
184+
const tenantId = typeof config.tenantId === "function" ? await Promise.resolve(config.tenantId(request)) : config.tenantId;
185+
const firebaseOptions = config.options || getDefaultAppConfig() as FirebaseOptions|undefined;
186+
if (!firebaseOptions) throw new Error("Could not find Firebase configuration");
187+
const { apiKey, projectId, authDomain } = firebaseOptions;
188+
if (!apiKey) throw new Error("apiKey must be defined.");
189+
if (!projectId) throw new Error("projectId must be defined");
190+
const emulatorHost = typeof config.emulator === 'string' ? config.emulator : process.env.FIREBASE_AUTH_EMULATOR_HOST;
191+
const useEmulator = !!(config.emulator ?? emulatorHost);
192+
if (useEmulator && !emulatorHost) throw new Error("Could not determine emulator host");
193+
const innie = typeof configOrInnie === "function" ? configOrInnie : optionalInnie || ((_: NextRequest) => {});
194+
const options = { apiKey, projectId, emulatorHost, tenantId, authDomain };
195+
const [response, decorateResponse, idTokenPayload] = await runMiddleware(options, request);
196+
if (response) return response;
197+
const userResponse = await Promise.resolve(innie(request, idTokenPayload)) || NextResponse.next();
198+
return decorateResponse(userResponse);
199+
};

0 commit comments

Comments
 (0)