|
| 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