diff --git a/.gitignore b/.gitignore index df34806e..d542d015 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ unpack.sh example/.parcel-cache/ .firebaserc stats.html + +# sub-package lockfile (not published, consumers generate their own) +src/nextjs/package-lock.json diff --git a/src/nextjs/README.md b/src/nextjs/README.md new file mode 100644 index 00000000..a06f155e --- /dev/null +++ b/src/nextjs/README.md @@ -0,0 +1,250 @@ +# Firebase Cookie Middleware for Next.js + +Production-ready, Edge-compatible Next.js middleware companion for the official Firebase Auth JS SDK's [`browserCookiePersistence`](https://firebase.google.com/docs/reference/js/auth#browsercookiepersistence). + +Enables **hybrid & Server-Side Rendered (SSR)** authentication in Next.js applications by transparently synchronizing Firebase Auth sessions between browser client SDKs and Next.js server environments using secure HTTP cookies. + +--- + +## Why this exists + +Standard Single Page Applications (SPAs) store Firebase ID and refresh tokens inside browser `indexedDB` or `localStorage`. Because these storage mechanisms are inaccessible during HTTP requests, Next.js Server Components, API routes, Server Actions, and Middleware cannot read the user's authentication state on the first request, causing layout shifts, client-side redirect flashes, or insecure server routes. + +`firebase-cookie-middleware` acts as the server-side companion to [`browserCookiePersistence`](https://firebase.google.com/docs/reference/js/auth#browsercookiepersistence). It proxies Firebase Auth token requests through `/_\_cookies_\_` on your app's domain, intercepts authentication exchanges, securely stores ID tokens and `httpOnly` refresh tokens in standard HTTP cookies, and strips sensitive refresh credentials from browser-facing payloads. + +--- + +## Features + +- **⚡ Next.js 14, 15, and 16 Compatible**: Built on `jose` and Web APIs (`atob`, `fetch`). Runs in the Edge Runtime via `middleware.ts` (Next.js 14/15, and Next.js 16 users keeping the deprecated Edge path) and the Node.js runtime via `proxy.ts` (Next.js 16 recommended). +- **🛡️ Seamless Route Protection & Role Checking**: Intercept and verify Firebase ID tokens at the Edge before rendering pages or API routes. Access standard claims (`email`, `sub`) and arbitrary custom claims directly in your middleware. +- **🚀 Distributed Caching (Memorystore / Redis)**: Optional distributed caching for Google's public JWKS signing keys and verified token payloads (`jwt:`). Prevents unnecessary CPU verification and network requests on every navigation. +- **🔒 Anti-DDoS Rotation Protection**: Distributed Redis locking (`firebase:jwks_eviction_lock`) ensures that during signing key rotations, only one Edge worker re-fetches Google's JWKS endpoints, preventing rate-limiting cascades. +- **🏢 Multi-App & Multi-Tenant Ready**: Seamlessly compose middleware across multiple Firebase app instances (`[DEFAULT]`, secondary apps) and Google Cloud Identity Platform tenants (`tenantId`). +- **💻 Local Development & Safari Safe**: Automatically handles local `http://` development cookies (`__dev_` prefix) and Safari localhost security restrictions (`SameSite=lax`). Fully supports the Firebase Auth Emulator (`FIREBASE_AUTH_EMULATOR_HOST`). + +--- + +## Installation + +```bash +npm install firebase-cookie-middleware jose lru-cache +``` + +--- + +## Next.js 16 Migration + +Next.js 16 deprecates `middleware.ts` in favor of `proxy.ts`. The named export also changes from `middleware` to `proxy`. + +**Next.js 15 and earlier** (`src/middleware.ts`, Edge Runtime): +```typescript +export { middleware } from "firebase-cookie-middleware"; +``` + +**Next.js 16+** (`src/proxy.ts`, Node.js runtime): +```typescript +export { proxy } from "firebase-cookie-middleware"; +``` + +`proxy.ts` runs on the Node.js runtime only; the `runtime` config option is not available and will throw if set. If you need the Edge Runtime in Next.js 16, you can keep using `middleware.ts` with the `middleware` export (deprecated by Next.js but still functional). All middleware logic is identical between the two exports; only the file name and export name change. + +--- + +## Quickstart + +### 1. Create your Middleware (`src/middleware.ts` for Next.js 15, `src/proxy.ts` for Next.js 16) + +Create or update the file in the root of your Next.js application: + +```typescript +import { NextResponse, type NextRequest } from "next/server"; +import { composeMiddleware } from "firebase-cookie-middleware"; +import { firebaseConfig } from "./lib/firebase"; + +export const middleware = composeMiddleware( + async (request: NextRequest, defaultUser, allUsers) => { + const { pathname } = request.nextUrl; + + // 1. Protect private dashboard routes + if (pathname.startsWith("/dashboard") && !defaultUser) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("redirect", pathname); + return NextResponse.redirect(loginUrl); + } + + // 2. Role-Based Access Control (Custom Claims) + if (pathname.startsWith("/admin") && !defaultUser?.admin) { + return NextResponse.redirect(new URL("/unauthorized", request.url)); + } + + // 3. Attach current user ID to request headers for Server Components + const response = NextResponse.next(); + if (defaultUser) { + response.headers.set("x-user-id", defaultUser.sub!); + } + return response; + }, + { + options: firebaseConfig, + // Optional: Specify a specific tenant ID for multi-tenant applications + // tenantId: "my-tenant-id", + } +); + +export const config = { + matcher: [ + /* + * Match all request paths except static assets and Next.js internals. + * IMPORTANT: You MUST ensure /__cookies__ and /__/ are matched so auth proxying works. + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; +``` + +### 2. Configure Client SDK Persistence (`src/lib/firebase.ts`) + +In your browser-side Firebase initialization code, instruct the Auth SDK to use [`browserCookiePersistence`](https://firebase.google.com/docs/reference/js/auth#browsercookiepersistence): + +```typescript +import { initializeApp } from "firebase/app"; +import { getAuth, initializeAuth, browserCookiePersistence } from "firebase/auth"; + +const app = initializeApp(firebaseConfig); + +// Initialize Firebase Auth with cookie persistence +export const auth = initializeAuth(app, { + persistence: browserCookiePersistence, +}); +``` + +--- + +## API Reference + +### `composeMiddleware(innie, configProvider)` + +Creates a composable Next.js middleware handler that verifies cookies and manages token lifecycle. + +#### Parameters + +- **`innie`**: `(request: NextRequest, defaultUser?: FirebaseJWTPayload, allUsers?: Record) => Promise | NextResponse | void` + - Your application callback. Recieves the verified Firebase JWT payload (`defaultUser`) for the default Firebase app, and a map of payloads for all configured apps (`allUsers`). +- **`configProvider`**: `Config | Config[] | ((req: NextRequest) => Config | Config[] | Promise)` + - Configuration object or provider function specifying your Firebase project details. + +#### `Config` Object Properties + +| Property | Type | Description | +| :--- | :--- | :--- | +| **`options`** | `FirebaseOptions` | Standard Firebase configuration object (`apiKey`, `projectId`, `authDomain`). | +| **`appName`** | `string` | Name of the Firebase app instance. Defaults to `"[DEFAULT]"`. | +| **`tenantId`** | `string` | Google Cloud Identity Platform tenant ID. | +| **`emulator`** | `boolean \| string` | Whether to use the Firebase Auth Emulator. If `true`, reads host from `FIREBASE_AUTH_EMULATOR_HOST`. | +| **`cache`** | `CacheProvider` | Optional custom caching store (e.g. Memorystore, Redis, Vercel KV, Cloudflare KV) for token validation and JWKS caching. Defaults to an in-memory `LRUCache`. | + +--- + +### `verifyFirebaseIdToken(idToken, projectId, tenantId?, cache?)` + +Manually verify a raw Firebase ID token string against Google's JWKS endpoints or a caching provider. + +```typescript +import { verifyFirebaseIdToken } from "firebase-cookie-middleware"; + +const [payload, isEmulated, refreshable] = await verifyFirebaseIdToken( + idTokenCookieValue, + "my-firebase-project-id", + "optional-tenant-id", + myCustomCache // optional CacheProvider +); + +if (payload) { + console.log("Verified User:", payload.sub, payload.email); +} +``` + +--- + +## Pluggable Distributed Caching (Optional) + +In high-traffic serverless or multi-region environments, repeatedly validating RS256 signatures or fetching JWKS certificates can add latency across cold starts. You can pass any standard key-value store client (such as Memorystore, Redis, Vercel KV, Cloudflare KV, or `ioredis`) directly via the `cache` option: + +```typescript +import { composeMiddleware, type CacheProvider } from "firebase-cookie-middleware"; +import { Redis } from "@upstash/redis"; + +const redis = new Redis({ + url: process.env.REDIS_URL!, + token: process.env.REDIS_TOKEN!, +}); + +export const middleware = composeMiddleware(callback, { + options: firebaseConfig, + cache: redis, // Compatible with standard .get(), .set(), and .setex() interfaces +}); +``` + +Any object implementing the `CacheProvider` interface works out of the box: + +```typescript +export interface CacheProvider { + get(key: string): Promise | T | null | undefined; + set( + key: string, + value: any, + options?: { ex?: number; ttl?: number; nx?: boolean }, + ): Promise | any; + setex?(key: string, seconds: number, value: any): Promise | any; +} +``` + +When a custom cache provider is supplied, the middleware automatically: +1. Caches Google's public JWKS certificates under `firebase:jwks` matching the upstream HTTP `Cache-Control: max-age`. +2. Caches verified ID token payloads under `jwt:` with a TTL matching the exact JWT `exp` timestamp. +3. Coordinates JWKS eviction locks across Edge workers using `firebase:jwks_eviction_lock`. + +If the `cache` option is omitted, the middleware gracefully defaults to a high-performance in-memory LRU cache (`max: 1000`). + +--- + +## Cookie Architecture & Security + +The middleware maintains two distinct cookies per app configuration (`appName`): + +1. **ID Token Cookie (`__HOST-FIREBASE_`)**: + - Contains the active, short-lived JWT ID token. + - Configured with `SameSite=Lax`, `Secure` (in production), and `Partitioned`. + - Readable by both client scripts and server requests. +2. **Refresh Token Cookie (`__HOST-FIREBASEID_`)**: + - Contains the long-lived refresh credential. + - Marked **`HttpOnly`** and `Secure`. + - Never exposed to browser JavaScript; automatically injected by the middleware proxy during `/_\_cookies_\_` token refresh requests. + +--- + +## Local Development & Emulators + +When testing locally on `http://localhost`, Chrome and Safari reject secure host-prefixed cookies (`__HOST-`). The middleware automatically detects insecure local protocols and falls back to prefixed development cookies (`__dev_FIREBASE_[DEFAULT]`) with appropriate security flags. + +To connect with the Firebase Auth Emulator, set `emulator: true` in your config and export the emulator host: + +```bash +export FIREBASE_AUTH_EMULATOR_HOST="localhost:9099" +``` + +```typescript +export const middleware = composeMiddleware(callback, { + options: firebaseConfig, + emulator: true, // reads FIREBASE_AUTH_EMULATOR_HOST; required to accept unsigned tokens +}); +``` + +You can also pass the host directly as a string instead of relying on the env var: + +```typescript +emulator: "localhost:9099" +``` + +Emulator mode accepts unsigned tokens (`alg: "none"`) and proxies token refresh requests to your local emulator. The explicit opt-in is required: the env var alone does not activate emulator mode, preventing accidental acceptance of unsigned tokens if the variable leaks into a production environment. diff --git a/src/nextjs/index.ts b/src/nextjs/index.ts new file mode 100644 index 00000000..66e55e7f --- /dev/null +++ b/src/nextjs/index.ts @@ -0,0 +1,714 @@ +import { NextResponse, NextRequest } from "next/server"; +import { LRUCache } from "lru-cache"; +import { + createRemoteJWKSet, + createLocalJWKSet, + jwtVerify, + JWTPayload, + JWTHeaderParameters, + decodeJwt, + decodeProtectedHeader, + JSONWebKeySet, +} from "jose"; +// getDefaultAppConfig is an internal @firebase/util export with no public stability guarantee. +import { getDefaultAppConfig } from "@firebase/util"; +import type { FirebaseOptions } from "firebase/app"; + + +export type FirebaseJWTPayload = JWTPayload & { + email?: string; + email_verified?: boolean; + picture?: string; + name?: string; + user_id?: string; + firebase?: { tenant?: string; [key: string]: unknown }; + [key: string]: unknown; +}; + +/** + * Cache abstraction used by this middleware. + * + * If you want to use a Redis client (ioredis, node-redis, Upstash, etc.) you + * must wrap it in an adapter that maps this interface to the client's own API. + * The options object uses lowercase `ex` / `nx` keys which do NOT match the + * native signatures of ioredis or node-redis v4 — pass them through with the + * appropriate translation in your adapter's `set()` implementation. + * + * The options object uses lowercase `ex` / `nx` keys, which do NOT match the + * native signatures of ioredis or node-redis v4; pass them through with the + * appropriate translation in your adapter's `set()` implementation. + */ +export interface CacheProvider { + get(key: string): Promise | T | null | undefined; + set( + key: string, + value: any, + options?: { ex?: number; ttl?: number; nx?: boolean }, + ): Promise | any; + setex?(key: string, seconds: number, value: any): Promise | any; + del?(key: string): Promise | any; +} + +export class MemoryCacheProvider implements CacheProvider { + private cache = new LRUCache({ max: 1000 }); + + get(key: string): T | null { + return (this.cache.get(key) as T) ?? null; + } + + set(key: string, value: any, options?: { ex?: number; ttl?: number; nx?: boolean }): any { + if (options?.nx && this.cache.has(key)) return null; + const ttlSeconds = options?.ex ?? (options?.ttl ? Math.floor(options.ttl / 1000) : undefined); + // Treat 0 as "do not cache" (e.g. a max-age=0 from a server response). + const ttlMs = ttlSeconds !== undefined && ttlSeconds > 0 ? ttlSeconds * 1000 : undefined; + this.cache.set(key, value, { ttl: ttlMs }); + return "OK"; + } + + setex(key: string, seconds: number, value: any): any { + return this.set(key, value, { ex: seconds }); + } + + del(key: string): any { + this.cache.delete(key); + } +} + +const defaultMemoryCache = new MemoryCacheProvider(); + +async function cacheGet(cache: CacheProvider, key: string): Promise { + try { + const val = await cache.get(key); + return val ?? null; + } catch (e) { + console.error(`Cache get failed for key "${key}":`, e); + return null; + } +} + +async function cacheSetEx( + cache: CacheProvider, + key: string, + ttlSeconds: number, + value: any, +): Promise { + try { + if (typeof cache.setex === "function") { + await cache.setex(key, ttlSeconds, value); + } else { + await cache.set(key, value, { ex: ttlSeconds }); + } + } catch (e) { + console.error(`Cache set failed for key "${key}":`, e); + } +} + +async function cacheSetNx( + cache: CacheProvider, + key: string, + ttlSeconds: number, + value: any, +): Promise { + try { + const res = await cache.set(key, value, { nx: true, ex: ttlSeconds }); + return !!res; + } catch (e) { + console.error(`Cache setnx failed for key "${key}":`, e); + return false; + } +} + +async function cacheDelete(cache: CacheProvider, key: string): Promise { + try { + if (typeof cache.del === "function") { + await cache.del(key); + } + } catch (e) { + console.error(`Cache delete failed for key "${key}":`, e); + } +} + +const MAX_MAX_AGE = 34560000; // 400 days, https://developer.chrome.com/blog/cookie-max-age-expires + +const FIREBASE_AUTH_JWKS_URL = + "https://www.googleapis.com/robot/v1/metadata/jwk/securetoken@system.gserviceaccount.com"; + +let _remoteJWKSet: ReturnType | null = null; +function getRemoteJWKSet() { + if (!_remoteJWKSet) { + _remoteJWKSet = createRemoteJWKSet(new URL(FIREBASE_AUTH_JWKS_URL), { timeoutDuration: 10000 }); + } + return _remoteJWKSet; +} + +async function getFirebaseAuthJwks( + cache: CacheProvider = defaultMemoryCache, + forceFetch: boolean = false, +) { + if (cache === defaultMemoryCache && !forceFetch) { + return getRemoteJWKSet(); + } + + if (!forceFetch) { + const cachedJwks = await cacheGet(cache, "firebase:jwks"); + if (cachedJwks) { + return createLocalJWKSet(cachedJwks); + } + } + + try { + const response = await fetch(FIREBASE_AUTH_JWKS_URL); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const cacheControl = response.headers.get("cache-control"); + let maxAge = 21600; // default 6 hours + if (cacheControl) { + const match = cacheControl.match(/max-age=(\d+)/); + if (match) maxAge = parseInt(match[1], 10); + } + + const jwks = (await response.json()) as JSONWebKeySet; + if (maxAge > 0) { + await cacheSetEx(cache, "firebase:jwks", maxAge, jwks); + } + return createLocalJWKSet(jwks); + } catch (e) { + console.error("Failed to fetch Firebase JWKS:", e); + // Fallback to remote JWKS set if fetch or parse fails entirely + return getRemoteJWKSet(); + } +} + +// `_AuthEmulatorRefreshToken` is an undocumented internal Firebase emulator property. +// It could break if the emulator changes its refresh token format. +export function isEmulatorRefreshToken(refreshToken: string): boolean { + try { + const decoded = decodeURIComponent(refreshToken); + const base64 = decoded.replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(atob(base64)); + return !!payload["_AuthEmulatorRefreshToken"]; + } catch (e) { + return false; + } +} + +export async function verifyFirebaseIdToken( + idToken: string, + projectId: string, + tenantId?: string, + cache: CacheProvider = defaultMemoryCache, +): Promise<[FirebaseJWTPayload | undefined, isEmulatedCredential: boolean, refreshable: boolean]> { + let isEmulatedCredential = false; + let jwtPayload; + let jwtHeader; + let refreshable = true; + try { + jwtHeader = decodeProtectedHeader(idToken) as JWTHeaderParameters; + jwtPayload = decodeJwt(idToken) as FirebaseJWTPayload; + } catch (e) { + console.error("verifyFirebaseIdToken failed to parse token:", e); + refreshable = false; + } + + if ( + jwtHeader?.typ !== "JWT" || + jwtPayload?.iss !== `https://securetoken.google.com/${projectId}` || + jwtPayload?.aud !== projectId || + jwtPayload?.firebase?.tenant !== tenantId + ) { + console.error("JWT Validation Mismatch", { jwtHeader, jwtPayload, projectId, tenantId }); + jwtPayload = undefined; + refreshable = false; + } + if (jwtHeader?.alg === "none") { + isEmulatedCredential = true; + } else if (jwtHeader?.alg !== "RS256") { + jwtPayload = undefined; + refreshable = false; + } + + if (jwtPayload) { + const muchEpochWow = Math.floor(+new Date() / 1000); + if (jwtPayload.exp !== undefined && jwtPayload.exp > muchEpochWow) { + if (isEmulatedCredential) { + return [jwtPayload, isEmulatedCredential, true]; + } else { + const cachedPayload = await cacheGet(cache, `jwt:${idToken}`); + if (cachedPayload) { + return [cachedPayload, isEmulatedCredential, true]; + } + + try { + let jwks = await getFirebaseAuthJwks(cache); + try { + await jwtVerify(idToken, jwks); + } catch (verifyErr: any) { + // If the signing key wasn't found (rotation), attempt to fetch fresh keys once. + // Rate-limited via cache SET NX to prevent DOS via crafted JWTs with unknown kid values. + // Native jose `cooldownDuration` is 30 seconds. This avoids the 5-minute DOS loophole. + if (verifyErr?.code === "ERR_JWKS_NO_MATCHING_KEY") { + const acquired = await cacheSetNx(cache, "firebase:jwks_eviction_lock", 30, "1"); + if (acquired) { + try { + // Fetch fresh keys directly, skipping cache read + jwks = await getFirebaseAuthJwks(cache, true); + await jwtVerify(idToken, jwks); + } finally { + await cacheDelete(cache, "firebase:jwks_eviction_lock"); + } + } else { + throw verifyErr; + } + } else { + throw verifyErr; + } + } + const ttlMs = jwtPayload.exp * 1000 - Date.now(); + const ttlSeconds = Math.floor(ttlMs / 1000); + if (ttlSeconds > 0) { + await cacheSetEx(cache, `jwt:${idToken}`, ttlSeconds, jwtPayload); + } + return [jwtPayload, isEmulatedCredential, true]; + } catch (e) { + console.error("JWT Verification failed:", e); + refreshable = false; + } + } + } + } + + return [undefined, isEmulatedCredential, refreshable]; +} + +interface TokenResponse { + refresh_token?: string; + id_token?: string; + [key: string]: any; +} + +interface SignInResponse { + refreshToken?: string; + idToken?: string; + [key: string]: any; +} + +type RunMiddlewareOptions = { + apiKey: string; + projectId: string; + emulatorHost: string | undefined; + tenantId: string | undefined; + authDomain: string | undefined; + cache?: CacheProvider; +}; +type RunMiddlewareResponse = + | [NextResponse] + | [undefined, (response: NextResponse) => NextResponse, FirebaseJWTPayload | undefined]; +export async function runMiddleware( + appName: string, + options: RunMiddlewareOptions, + request: NextRequest, +): Promise { + const cache = options.cache || defaultMemoryCache; + // Host auth widgets and Firebase reserved URLs + if (request.nextUrl.pathname.startsWith("/__/") && options.authDomain) { + const newURL = new URL(request.nextUrl); + newURL.host = options.authDomain; + newURL.port = ""; + return [NextResponse.rewrite(newURL)]; + } + + // Firebase JS SDK treats all http:// requests as dev-mode since cookie persistence requires secure context to function + const isDevMode = request.nextUrl.protocol === "http:"; + const userAgent = request.headers.get("User-Agent"); + const isSafari = userAgent?.includes("Safari") && !userAgent?.includes("Chrome"); + + // Safari does not consider cookies on localhost secure + const useInsecureCookies = isDevMode && isSafari; + + // Need two different cookie names as Chrome doesn't allow __HOST- on localhost + const ID_TOKEN_COOKIE_NAME = isDevMode + ? `__dev_FIREBASE_${appName}` + : `__HOST-FIREBASE_${appName}`; + const REFRESH_TOKEN_COOKIE_NAME = isDevMode + ? `__dev_FIREBASEID_${appName}` + : `__HOST-FIREBASEID_${appName}`; + // CHIPS (Partitioned cookies) requires SameSite=None; Secure. When running + // on localhost with insecure cookies we fall back to SameSite=Lax (no partitioning). + const sameSite = useInsecureCookies ? ("lax" as const) : ("none" as const); + const ID_TOKEN_COOKIE = { + path: "/" as const, + secure: !useInsecureCookies, + httpOnly: false as const, + sameSite, + partitioned: !useInsecureCookies, + name: ID_TOKEN_COOKIE_NAME, + maxAge: MAX_MAX_AGE, + priority: "high" as const, + }; + const REFRESH_TOKEN_COOKIE = { + ...ID_TOKEN_COOKIE, + httpOnly: true, + name: REFRESH_TOKEN_COOKIE_NAME, + } as const; + + if (request.nextUrl.pathname === "/__cookies__") { + const targetAppName = request.nextUrl.searchParams.get("appName") || "[DEFAULT]"; + if (targetAppName !== appName) { + return [undefined, (res: NextResponse) => res, undefined]; + } + + const method = request.method; + + // CSRF guard: reject cross-origin state-changing requests. + // Browsers send the Origin header on cross-origin requests; same-origin + // requests from the Firebase JS SDK either omit Origin (safe) or send the + // correct same-site value. + if (method === "POST" || method === "DELETE") { + const origin = request.headers.get("origin"); + if (origin && origin !== request.nextUrl.origin) { + return [new NextResponse("Cross-origin request denied", { status: 403 })]; + } + } + + if (method === "DELETE") { + const response = new NextResponse(""); + response.cookies.delete({ ...ID_TOKEN_COOKIE, maxAge: 0 }); + response.cookies.delete({ ...REFRESH_TOKEN_COOKIE, maxAge: 0 }); + return [response]; + } + + const headers = Object.fromEntries( + [ + "referrer", + "referrer-policy", + "content-type", + "X-Firebase-Client", + "X-Firebase-gmpid", + "X-Firebase-AppCheck", + "X-Firebase-Locale", + "X-Client-Version", + ] + .filter((header) => request.headers.has(header)) + .map((header) => [header, request.headers.get(header)!]), + ); + + const finalTargetParam = request.nextUrl.searchParams.get("finalTarget"); + if (!finalTargetParam) { + return [new NextResponse("Missing finalTarget parameter", { status: 400 })]; + } + let url: URL; + try { + url = new URL(finalTargetParam); + } catch { + return [new NextResponse("Invalid finalTarget parameter", { status: 400 })]; + } + + let body: ReadableStream | string | null = request.body; + + if (options.emulatorHost) { + if (url.host !== options.emulatorHost) { + return [new NextResponse("Proxy target does not match configured emulator host", { status: 400 })]; + } + } else { + if ( + url.host !== "securetoken.googleapis.com" && + url.host !== "identitytoolkit.googleapis.com" + ) { + return [new NextResponse("Unauthorized proxy target host", { status: 400 })]; + } + } + + const isTokenRequest = !!url.pathname.match(/^(\/securetoken\.googleapis\.com)?\/v1\/token/); + const isSignInRequest = !!url.pathname.match( + /^(\/identitytoolkit\.googleapis\.com)?\/(v1|v2)\/accounts:/, + ); + + if (!isTokenRequest && !isSignInRequest) + return [new NextResponse("Unsupported request type", { status: 400 })]; + + if (isTokenRequest) { + body = await request.text(); + const bodyParams = new URLSearchParams(body!.trim()); + if (bodyParams.has("refresh_token")) { + const refreshToken = request.cookies.get({ ...REFRESH_TOKEN_COOKIE, value: "" })?.value; + if (refreshToken) { + bodyParams.set("refresh_token", refreshToken); + body = bodyParams.toString(); + } + } + } else if (isSignInRequest) { + // Materialize body to a string so we never pass a ReadableStream to fetch, + // which is rejected by Vercel Edge and stricter undici configurations. + body = await request.text(); + } + + const response = await fetch(url, { method, body, headers }); + + const json = (await response.json()) as TokenResponse | SignInResponse; + const status = response.status; + const statusText = response.statusText; + if (!response.ok) { + const nextResponse = NextResponse.json(json, { status, statusText }); + return [nextResponse]; + } + // The Firebase JS SDK freaks out if the idToken disappears on it, e.g, the cookie expired + // it manually calls logout... which nukes everything before we have a chance to refresh! + // So set the maxAge to the default (chrome max) of 400 days + const idToken = json.idToken || json.id_token; + const refreshToken = json.refreshToken || json.refresh_token; + + if ("refreshToken" in json && json.refreshToken) { + json.refreshToken = "REDACTED"; + } + if ("refresh_token" in json && json.refresh_token) { + json.refresh_token = "REDACTED"; + } + + const currentIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value; + const nextResponse = NextResponse.json(json, { status, statusText }); + if (idToken && currentIdToken !== idToken) + nextResponse.cookies.set({ ...ID_TOKEN_COOKIE, value: idToken }); + if (refreshToken) nextResponse.cookies.set({ ...REFRESH_TOKEN_COOKIE, value: refreshToken }); + return [nextResponse]; + } + + const logout = (): RunMiddlewareResponse => { + const decorateNextResponse = (response: NextResponse) => { + if (request.cookies.get(ID_TOKEN_COOKIE_NAME)) + response.cookies.delete({ ...ID_TOKEN_COOKIE, maxAge: 0 }); + if (request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)) + response.cookies.delete({ ...REFRESH_TOKEN_COOKIE, maxAge: 0 }); + return response; + }; + return [undefined, decorateNextResponse, undefined]; + }; + + let authIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value; + const refresh_token = request.cookies.get({ ...REFRESH_TOKEN_COOKIE, value: "" })?.value; + + if (authIdToken === "") { + console.error("logout sentinel detected"); + return logout(); + } + + let [jwtPayload, isEmulatedCredential, refreshable] = authIdToken + ? await verifyFirebaseIdToken(authIdToken, options.projectId, options.tenantId, cache) + : [undefined, false, true]; + + if (jwtPayload) { + if (isEmulatedCredential && !options.emulatorHost) { + return logout(); + } + return [undefined, (it) => it, jwtPayload]; + } + + if (!refresh_token || !refreshable) { + return logout(); + } + + // Check if the refresh token is from the emulator. + // This relies on an internal property `_AuthEmulatorRefreshToken` which is subject to change, + // but we handle potential parsing errors gracefully. + // Fixed: Ensure isEmulatedCredential remains true if already true, or is updated if false. + isEmulatedCredential = isEmulatedCredential || isEmulatorRefreshToken(refresh_token); + + if (isEmulatedCredential && !options.emulatorHost) { + return logout(); + } + + if (isEmulatedCredential && options.emulatorHost) { + const emulatorHostname = options.emulatorHost.split(":")[0]; + if (emulatorHostname !== "localhost" && emulatorHostname !== "127.0.0.1" && emulatorHostname !== "::1") { + console.error("Emulator host must be localhost or loopback:", emulatorHostname); + return logout(); + } + } + + const refreshUrl = new URL( + isEmulatedCredential ? `http://${options.emulatorHost}` : `https://securetoken.googleapis.com`, + ); + refreshUrl.pathname = [isEmulatedCredential && "securetoken.googleapis.com", "v1/token"] + .filter(Boolean) + .join("/"); + refreshUrl.searchParams.set("key", options.apiKey); + const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token }); + let refreshResponse; + try { + refreshResponse = await fetch(refreshUrl, { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + } catch (e) { + console.error("Refresh token request failed.", e); + return logout(); + } + if (!refreshResponse.ok) { + console.error(refreshUrl.origin + refreshUrl.pathname, refreshResponse.status, refreshResponse.statusText); + return logout(); + } + const json = (await refreshResponse.json()) as TokenResponse; + const newRefreshToken = json.refresh_token; + const newIdToken = json.id_token; + if (!newIdToken) throw new Error("Missing id_token in refresh response"); + // Full signature + claims verification on the refreshed token. Caching is + // handled inside verifyFirebaseIdToken, so no separate cacheSetEx needed. + const [verifiedNewPayload] = await verifyFirebaseIdToken( + newIdToken, + options.projectId, + options.tenantId, + cache, + ); + if (!verifiedNewPayload) { + console.error("Refreshed token failed verification"); + return logout(); + } + jwtPayload = verifiedNewPayload; + const decorateNextResponse = (response: NextResponse) => { + if (newIdToken) response.cookies.set({ ...ID_TOKEN_COOKIE, value: newIdToken }); + if (newRefreshToken) response.cookies.set({ ...REFRESH_TOKEN_COOKIE, value: newRefreshToken }); + return response; + }; + return [undefined, decorateNextResponse, jwtPayload]; +} + +export type Config = { + emulator?: boolean | string; + tenantId?: string; + options?: FirebaseOptions; + appName?: string; + cache?: CacheProvider; +}; + +type Resolvable = Promise | T; +// composeMiddleware always sets defaultUser from a verified Firebase JWT, so +// expose the richer type to consumers (firebase.tenant, email_verified, etc.). +type Innie = ( + request: NextRequest, + defaultUser: FirebaseJWTPayload | undefined, + allUsers: Record, +) => Resolvable; + +export type NormalizedConfig = { + appName: string; + firebaseOptions: FirebaseOptions; + emulatorHost?: string; + tenantId?: string; + cache: CacheProvider; +}; + +export const normalizeConfig = ( + config: Config, + defaultAppOptions: FirebaseOptions | undefined, +): NormalizedConfig => { + if (!config.options && !defaultAppOptions) { + throw new Error("Could not find Firebase configuration"); + } + const normalizedConfig: NormalizedConfig = { + appName: config.appName || "[DEFAULT]", + firebaseOptions: config.options || defaultAppOptions!, + tenantId: config.tenantId, + cache: config.cache || defaultMemoryCache, + }; + if (typeof config.emulator === "string") { + normalizedConfig.emulatorHost = config.emulator; + } else if (config.emulator === true) { + // Explicit opt-in only: reading FIREBASE_AUTH_EMULATOR_HOST when emulator + // is not explicitly requested would accept alg:none tokens if that env var + // ever leaked into a production environment. + normalizedConfig.emulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST; + } + return normalizedConfig; +}; + +export const composeMiddleware = + ( + innie: Innie, + configProvider?: + | Resolvable + | ((request: NextRequest) => Resolvable), + ) => + async (request: NextRequest) => { + const rawConfig = await Promise.resolve( + typeof configProvider === "function" ? configProvider(request) : configProvider, + ); + const defaultAppName = (!Array.isArray(rawConfig) && rawConfig?.appName) || "[DEFAULT]"; + const defaultAppOptions = getDefaultAppConfig() as FirebaseOptions | undefined; + const normalizedConfigurations = await Promise.all( + (Array.isArray(rawConfig) ? rawConfig : [rawConfig]) + .filter((it) => !!it) + .map(async (config) => { + return normalizeConfig(config, defaultAppOptions); + }), + ); + if (defaultAppOptions && !normalizedConfigurations.find((it) => it.appName === "[DEFAULT]")) { + normalizedConfigurations.push({ + appName: "[DEFAULT]", + firebaseOptions: defaultAppOptions, + cache: defaultMemoryCache, + }); + } + if (!normalizedConfigurations.length) throw new Error("composeMiddleware requires at least one configuration. Pass a Config object or array of Config objects."); + + let defaultUser: FirebaseJWTPayload | undefined = undefined; + const allUsers: Record = {}; + const decorators: Array<(response: NextResponse) => NextResponse> = []; + let finalResponse: NextResponse | undefined | void = undefined; + + const results = await Promise.all( + normalizedConfigurations.map(async (config) => { + const { appName, tenantId, emulatorHost, firebaseOptions, cache } = config; + const { apiKey, projectId, authDomain } = firebaseOptions; + if (!apiKey) throw new Error("apiKey must be defined."); + if (!projectId) throw new Error("projectId must be defined"); + const options = { apiKey, projectId, emulatorHost, tenantId, authDomain, cache }; + const result = await runMiddleware(appName, options, request); + return { appName, result }; + }), + ); + + for (const { appName, result } of results) { + const [response, decorateResponse, idTokenPayload] = result; + if (idTokenPayload) allUsers[appName] = idTokenPayload; + if (idTokenPayload && appName === defaultAppName) defaultUser = idTokenPayload; + if (response) { + if (finalResponse) { + console.warn( + `firebase-cookie-middleware: app "${appName}" returned a direct response but an earlier app already claimed the response. The earlier response takes precedence.`, + ); + } else { + finalResponse = response; + } + } + if (decorateResponse) decorators.push(decorateResponse); + } + if (!finalResponse) + finalResponse = await Promise.resolve(innie(request, defaultUser, allUsers)); + return decorators.reduce( + (prev, current) => current(prev), + finalResponse || NextResponse.next(), + ); + }; + +// Zero-config convenience export. Works only when a Firebase app has been +// initialized before the middleware runs (e.g. via getApps() in firebase.ts). +// If no Firebase config is discoverable the request passes through with a +// console warning rather than throwing a 500. +const _safeDefaultMiddleware = async (request: NextRequest): Promise => { + const defaultAppOptions = getDefaultAppConfig() as FirebaseOptions | undefined; + if (!defaultAppOptions) { + console.warn( + "firebase-cookie-middleware: no Firebase config found; pass a Config to composeMiddleware.", + ); + return NextResponse.next(); + } + return composeMiddleware(() => {})(request); +}; + +// Next.js 16+: proxy.ts expects a named `proxy` export. +export const proxy = _safeDefaultMiddleware; +// Next.js 15 and earlier: middleware.ts expects a named `middleware` export. +// @deprecated Use proxy in Next.js 16+. +export const middleware = _safeDefaultMiddleware; diff --git a/src/nextjs/middleware_cache.node.test.ts b/src/nextjs/middleware_cache.node.test.ts new file mode 100644 index 00000000..8c8f3e72 --- /dev/null +++ b/src/nextjs/middleware_cache.node.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { verifyFirebaseIdToken, runMiddleware, CacheProvider } from "./index"; +import { NextRequest } from "next/server"; +import * as jose from "jose"; +import { createToken } from "./middleware_test_utils"; + +vi.mock("jose", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRemoteJWKSet: vi.fn().mockReturnValue("remote-jwk-set"), + createLocalJWKSet: vi.fn().mockImplementation((keys) => ({ localKeys: keys })), + jwtVerify: vi.fn(), + }; +}); + +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +describe("CacheProvider plugin paths", () => { + const mockProjectId = "test-project"; + const mockTenantId = "test-tenant"; + const validHeader = { alg: "RS256", typ: "JWT" }; + const validPayload = { + iss: `https://securetoken.google.com/${mockProjectId}`, + aud: mockProjectId, + firebase: { tenant: mockTenantId }, + exp: Math.floor(Date.now() / 1000) + 3600, + sub: "user123", + }; + + let mockCacheGet: ReturnType; + let mockCacheSet: ReturnType; + let mockCacheSetex: ReturnType; + let mockCache: CacheProvider; + + beforeEach(() => { + vi.resetAllMocks(); + mockCacheGet = vi.fn(); + mockCacheSet = vi.fn(); + mockCacheSetex = vi.fn(); + mockCache = { + get: mockCacheGet, + set: mockCacheSet, + setex: mockCacheSetex, + } as unknown as CacheProvider; + }); + + it("should return cached payload from CacheProvider during verifyFirebaseIdToken", async () => { + mockCacheGet.mockImplementation(async (key: string) => { + if (key.startsWith("jwt:")) return validPayload; + return null; + }); + + const token = createToken(validPayload); + const [payload, isEmulated] = await verifyFirebaseIdToken( + token, + mockProjectId, + mockTenantId, + mockCache, + ); + + expect(payload).toEqual(validPayload); + expect(isEmulated).toBe(false); + expect(jose.jwtVerify).not.toHaveBeenCalled(); + }); + + it("should fetch JWKS from CacheProvider if available during verifyFirebaseIdToken", async () => { + mockCacheGet.mockImplementation(async (key: string) => { + if (key === "firebase:jwks") return { keys: ["mock-key"] }; + return null; + }); + + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: validPayload, + protectedHeader: validHeader, + } as any); + + const token = createToken(validPayload); + await verifyFirebaseIdToken(token, mockProjectId, mockTenantId, mockCache); + + expect(jose.createLocalJWKSet).toHaveBeenCalledWith({ keys: ["mock-key"] }); + expect(mockCacheSetex).toHaveBeenCalledWith(`jwt:${token}`, expect.any(Number), validPayload); + }); + + it("should fetch remote JWKS and store in CacheProvider if cache misses", async () => { + mockCacheGet.mockResolvedValue(null); + + fetchMock.mockResolvedValue({ + ok: true, + headers: new Headers({ "cache-control": "max-age=7200" }), + json: async () => ({ keys: ["fetched-key"] }), + }); + + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: validPayload, + protectedHeader: validHeader, + } as any); + + const token = createToken(validPayload); + await verifyFirebaseIdToken(token, mockProjectId, mockTenantId, mockCache); + + expect(mockCacheSetex).toHaveBeenCalledWith("firebase:jwks", 7200, { keys: ["fetched-key"] }); + }); + + it("should handle JWKS rotation lock when ERR_JWKS_NO_MATCHING_KEY occurs", async () => { + mockCacheGet.mockResolvedValue(null); + mockCacheSet.mockResolvedValue("1"); // Lock acquired + + fetchMock.mockResolvedValue({ + ok: true, + headers: new Headers(), + json: async () => ({ keys: ["rotated-key"] }), + }); + + const err = new Error("No matching key"); + (err as any).code = "ERR_JWKS_NO_MATCHING_KEY"; + + vi.mocked(jose.jwtVerify) + .mockRejectedValueOnce(err) + .mockResolvedValueOnce({ payload: validPayload, protectedHeader: validHeader } as any); + + const token = createToken(validPayload); + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId, mockCache); + + expect(mockCacheSet).toHaveBeenCalledWith("firebase:jwks_eviction_lock", "1", { + nx: true, + ex: 30, + }); + expect(payload).toEqual(validPayload); + }); + + it("should store refreshed token payload in CacheProvider during runMiddleware", async () => { + mockCacheGet.mockResolvedValue(null); + + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", "valid-refresh"); + + const newIdToken = createToken(validPayload); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + id_token: newIdToken, + refresh_token: "new-refresh", + }), + }); + + const options = { + apiKey: "test-key", + projectId: mockProjectId, + emulatorHost: undefined, + tenantId: mockTenantId, + authDomain: "test.firebaseapp.com", + cache: mockCache, + }; + + const [, , payload] = await runMiddleware("app", options, req); + expect(payload).toBeDefined(); + expect(mockCacheSetex).toHaveBeenCalledWith(`jwt:${newIdToken}`, expect.any(Number), validPayload); + }); +}); diff --git a/src/nextjs/middleware_compose.node.test.ts b/src/nextjs/middleware_compose.node.test.ts new file mode 100644 index 00000000..cd394dae --- /dev/null +++ b/src/nextjs/middleware_compose.node.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { composeMiddleware, Config } from "./index"; +import { FirebaseOptions } from "firebase/app"; + +// Mock @firebase/util for composeMiddleware tests +const mockGetDefaultAppConfig = vi.fn().mockReturnValue(undefined); +vi.mock("@firebase/util", () => ({ + getDefaultAppConfig: (...args: any[]) => mockGetDefaultAppConfig(...args), +})); + +const mockFirebaseOptions: FirebaseOptions = { + apiKey: "test-api-key", + authDomain: "test-auth-domain", + projectId: "test-project-id", + appId: "test-app-id", +}; + +describe("composeMiddleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a middleware from config with options", async () => { + const config = { + options: mockFirebaseOptions, + emulator: false, + }; + const middleware = composeMiddleware(() => {}, config); + expect(typeof middleware).toBe("function"); + }); + + it("executes composed middleware and calls innie", async () => { + const innie = vi.fn().mockReturnValue(NextResponse.next()); + const config: Config = { + options: mockFirebaseOptions, + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + expect(innie).toHaveBeenCalled(); + }); + + it("handles array of configs", async () => { + const innie = vi.fn().mockReturnValue(NextResponse.next()); + const configs: Config[] = [{ options: mockFirebaseOptions, emulator: false, appName: "app1" }]; + const middleware = composeMiddleware(innie, configs); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + }); + + it("handles config as a function", async () => { + const innie = vi.fn().mockReturnValue(NextResponse.next()); + const configFn = () => ({ + options: mockFirebaseOptions, + emulator: false, + }); + const middleware = composeMiddleware(innie, configFn); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + }); + + it("throws when no configurations found", async () => { + const innie = vi.fn(); + // Empty array and no default app config + const middleware = composeMiddleware(innie, []); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + await expect(middleware(request)).rejects.toThrow(); + }); + + it("throws when apiKey is missing", async () => { + const innie = vi.fn(); + const config: Config = { + options: { projectId: "test", apiKey: "" }, + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + await expect(middleware(request)).rejects.toThrow("apiKey must be defined"); + }); + + it("throws when projectId is missing", async () => { + const innie = vi.fn(); + const config: Config = { + options: { apiKey: "test", projectId: "" }, + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + await expect(middleware(request)).rejects.toThrow("projectId must be defined"); + }); + + it("applies decorators to innie response", async () => { + const innie = vi.fn().mockReturnValue(NextResponse.next()); + const config: Config = { + options: mockFirebaseOptions, + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + expect(innie).toHaveBeenCalledWith( + request, + undefined, // defaultUser + expect.any(Object), // allUsers + ); + }); + + it("uses NextResponse.next() when innie returns void", async () => { + const innie = vi.fn(); // Returns undefined + const config: Config = { + options: mockFirebaseOptions, + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + }); + + it("handles async config provider function", async () => { + const innie = vi.fn().mockReturnValue(NextResponse.next()); + const configFn = async () => ({ + options: mockFirebaseOptions, + emulator: false, + }); + const middleware = composeMiddleware(innie, configFn); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + }); + + it("adds default app config when provided and no [DEFAULT] in configs", async () => { + // Override mock to return a config + mockGetDefaultAppConfig.mockReturnValueOnce(mockFirebaseOptions); + + const innie = vi.fn().mockReturnValue(NextResponse.next()); + // Use a non-default appName so the default push logic fires + const config: Config = { + options: mockFirebaseOptions, + appName: "secondary", + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + const request = new NextRequest("http://localhost/page", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + }); + + it("handles runMiddleware returning a direct response (e.g. /__/ rewrite)", async () => { + const innie = vi.fn(); + const config: Config = { + options: mockFirebaseOptions, + emulator: false, + }; + const middleware = composeMiddleware(innie, config); + // Request to /__/auth should get a rewrite response + const request = new NextRequest("http://localhost/__/auth/handler", { method: "GET" }); + const response = await middleware(request); + expect(response).toBeInstanceOf(NextResponse); + // innie should NOT have been called because runMiddleware returned directly + expect(innie).not.toHaveBeenCalled(); + }); +}); diff --git a/src/nextjs/middleware_config.node.test.ts b/src/nextjs/middleware_config.node.test.ts new file mode 100644 index 00000000..312d92d4 --- /dev/null +++ b/src/nextjs/middleware_config.node.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { normalizeConfig, Config } from "./index"; +import { FirebaseOptions } from "firebase/app"; + +const mockFirebaseOptions: FirebaseOptions = { + apiKey: "test-api-key", + authDomain: "test-auth-domain", + projectId: "test-project-id", + appId: "test-app-id", +}; + +describe("normalizeConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should use process.env.FIREBASE_AUTH_EMULATOR_HOST when emulator is true", () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; + const config: Config = { + emulator: true, + options: mockFirebaseOptions, + }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.emulatorHost).toBe("localhost:9099"); + }); + + it("should use string value when emulator is a string", () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "env-host:9099"; + const config: Config = { + emulator: "custom-host:8080", + options: mockFirebaseOptions, + }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.emulatorHost).toBe("custom-host:8080"); + }); + + it("should not set emulatorHost when emulator is false", () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; + const config: Config = { + emulator: false, + options: mockFirebaseOptions, + }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.emulatorHost).toBeUndefined(); + }); + + it("should throw error if no options provided", () => { + const config: Config = {}; + expect(() => normalizeConfig(config, undefined)).toThrow( + "Could not find Firebase configuration", + ); + }); + + it("should use default app name when none provided", () => { + const config: Config = { options: mockFirebaseOptions }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.appName).toBe("[DEFAULT]"); + }); + + it("should use provided app name", () => { + const config: Config = { options: mockFirebaseOptions, appName: "myApp" }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.appName).toBe("myApp"); + }); + + it("should use defaultAppOptions when config.options not provided", () => { + const config: Config = {}; + const normalized = normalizeConfig(config, mockFirebaseOptions); + expect(normalized.firebaseOptions).toBe(mockFirebaseOptions); + }); + + it("should prefer config.options over defaultAppOptions", () => { + const otherOptions: FirebaseOptions = { apiKey: "other-key", projectId: "other" }; + const config: Config = { options: otherOptions }; + const normalized = normalizeConfig(config, mockFirebaseOptions); + expect(normalized.firebaseOptions.apiKey).toBe("other-key"); + }); + + it("should pass through tenantId", () => { + const config: Config = { options: mockFirebaseOptions, tenantId: "acme" }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.tenantId).toBe("acme"); + }); + + it("should NOT read emulator from env when emulator is undefined (explicit opt-in required)", () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; + const config: Config = { options: mockFirebaseOptions }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.emulatorHost).toBeUndefined(); + }); + + it("should read emulator from env when emulator is explicitly true", () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; + const config: Config = { options: mockFirebaseOptions, emulator: true }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.emulatorHost).toBe("localhost:9099"); + }); + + it("should have undefined emulatorHost when env is not set and emulator not specified", () => { + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + const config: Config = { options: mockFirebaseOptions }; + const normalized = normalizeConfig(config, undefined); + expect(normalized.emulatorHost).toBeUndefined(); + }); +}); diff --git a/src/nextjs/middleware_fetch.node.test.ts b/src/nextjs/middleware_fetch.node.test.ts new file mode 100644 index 00000000..03c9d7de --- /dev/null +++ b/src/nextjs/middleware_fetch.node.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { runMiddleware } from "./index"; +import { NextRequest } from "next/server"; + +// Mock fetch +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +describe("runMiddleware", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should handle sign-in request (camelCase) correctly", async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ + idToken: "mock-id-token", + refreshToken: "mock-refresh-token", + expiresIn: "3600", + localId: "user123", + }), + }; + fetchMock.mockResolvedValue(mockResponse); + + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword&appName=app", + { + method: "POST", + body: JSON.stringify({ returnSecureToken: true }), + }, + ); + + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("app", options, request); + + expect(response).toBeDefined(); + const json = await response!.json(); + + // Check redaction + expect(json.refreshToken).toBe("REDACTED"); + expect(json.idToken).toBe("mock-id-token"); + + // Check cookies were set + const cookies = response!.cookies.getAll(); + const idTokenCookie = cookies.find((c) => c.name.includes("FIREBASE_app")); + const refreshTokenCookie = cookies.find((c) => c.name.includes("FIREBASEID_app")); + + expect(idTokenCookie?.value).toBe("mock-id-token"); + expect(refreshTokenCookie?.value).toBe("mock-refresh-token"); + }); + + it("should handle token request (snake_case) correctly", async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ + id_token: "mock-id-token-2", + refresh_token: "mock-refresh-token-2", + expires_in: "3600", + }), + }; + fetchMock.mockResolvedValue(mockResponse); + + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=app", + { + method: "POST", + body: "grant_type=refresh_token", + }, + ); + + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("app", options, request); + + expect(response).toBeDefined(); + const json = await response!.json(); + + // Check redaction + expect(json.refresh_token).toBe("REDACTED"); + expect(json.id_token).toBe("mock-id-token-2"); + + // Check cookies were set + const cookies = response!.cookies.getAll(); + const idTokenCookie = cookies.find((c) => c.name.includes("FIREBASE_app")); + const refreshTokenCookie = cookies.find((c) => c.name.includes("FIREBASEID_app")); + + expect(idTokenCookie?.value).toBe("mock-id-token-2"); + expect(refreshTokenCookie?.value).toBe("mock-refresh-token-2"); + }); + + it("injects refresh_token from cookie when proxying v1/token request with refresh_token param", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ id_token: "new-id", refresh_token: "new-refresh" }), + }); + + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=app", + { + method: "POST", + body: "grant_type=refresh_token&refresh_token=REDACTED", + }, + ); + request.cookies.set("__dev_FIREBASEID_app", "secret-http-only-refresh-token"); + + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + await runMiddleware("app", options, request); + + expect(fetchMock).toHaveBeenCalled(); + const [, init] = fetchMock.mock.calls[0]; + expect(init.body).toContain("refresh_token=secret-http-only-refresh-token"); + }); + + it("throws an error when request type to proxy cannot be determined", async () => { + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/unknown&appName=app", + { method: "POST" }, + ); + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("app", options, request); + expect(response?.status).toBe(400); + }); + + it("throws an error when proxy target host is unauthorized in production", async () => { + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://evil-attacker.com/v1/token&appName=app", + { method: "POST" }, + ); + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("app", options, request); + expect(response?.status).toBe(400); + }); + + it("returns 400 when finalTarget parameter is missing", async () => { + const request = new NextRequest("http://localhost:3000/__cookies__?appName=app", { method: "POST" }); + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("app", options, request); + expect(response?.status).toBe(400); + }); + + it("returns proxy response directly without setting cookies if fetch response is not ok", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: async () => ({ error: { message: "INVALID_GRANT" } }), + }); + + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=app", + { method: "POST", body: "grant_type=refresh_token" }, + ); + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("app", options, request); + expect(response?.status).toBe(401); + }); + + it("skips /__cookies__ proxy handling if appName parameter does not match", async () => { + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=adminApp", + { method: "POST", body: "grant_type=refresh_token" }, + ); + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + // Running runMiddleware for "[DEFAULT]" when target appName is "adminApp" + const [response] = await runMiddleware("[DEFAULT]", options, request); + expect(response).toBeUndefined(); // Should skip proxying + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("handles v2/accounts endpoints (e.g. MFA finalize) correctly", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ + idToken: "mfa-id-token", + refreshToken: "mfa-refresh-token", + }), + }); + + const request = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=https://identitytoolkit.googleapis.com/v2/accounts:mfaSignIn:finalize&appName=[DEFAULT]", + { method: "POST", body: JSON.stringify({}) }, + ); + const options = { + apiKey: "key", + projectId: "proj", + emulatorHost: undefined, + tenantId: undefined, + authDomain: "auth.domain", + }; + + const [response] = await runMiddleware("[DEFAULT]", options, request); + expect(response).toBeDefined(); + const cookies = response!.cookies.getAll(); + const idCookie = cookies.find((c) => c.name.includes("FIREBASE_[DEFAULT]")); + expect(idCookie?.value).toBe("mfa-id-token"); + }); +}); diff --git a/src/nextjs/middleware_refresh.node.test.ts b/src/nextjs/middleware_refresh.node.test.ts new file mode 100644 index 00000000..a28ac449 --- /dev/null +++ b/src/nextjs/middleware_refresh.node.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { runMiddleware } from "./index"; +import { NextRequest, NextResponse } from "next/server"; +import * as jose from "jose"; +import { createToken } from "./middleware_test_utils"; + +// Mock jose +vi.mock("jose", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRemoteJWKSet: vi.fn(), + jwtVerify: vi.fn(), + }; +}); + +// Mock fetch +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +describe("runMiddleware Token Refresh", () => { + const mockApiKey = "test-api-key"; + const mockProjectId = "test-project"; + const mockTenantId = "test-tenant"; + const mockEmulatorHost = "localhost:9099"; + + const options = { + apiKey: mockApiKey, + projectId: mockProjectId, + emulatorHost: undefined as string | undefined, + tenantId: mockTenantId, + authDomain: "test.firebaseapp.com", + }; + + const validPayload = { + iss: `https://securetoken.google.com/${mockProjectId}`, + aud: mockProjectId, + firebase: { tenant: mockTenantId }, + exp: Math.floor(Date.now() / 1000) + 3600, // Valid + sub: "user123", + }; + + beforeEach(() => { + vi.resetAllMocks(); + // Default jose success + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: validPayload, + protectedHeader: { alg: "RS256" }, + } as any); + // Default fetch success (generic) + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + }); + + it("should pass through if ID token is valid", async () => { + const idToken = createToken(validPayload); + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + + const [response, _decorate, payload] = await runMiddleware("app", options, req); + + expect(payload).toBeDefined(); + expect(payload?.sub).toBe("user123"); + expect(response).toBeUndefined(); // Passes through + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should refresh token if ID token is expired and refresh token is valid", async () => { + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + const refreshToken = "valid-refresh-token"; + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", refreshToken); + + // Mock refresh response + const newIdToken = createToken({ ...validPayload, jti: "new-token" }); + const newRefreshToken = "new-refresh-token"; + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id_token: newIdToken, + refresh_token: newRefreshToken, + expires_in: "3600", + }), + }); + + const [_response, decorate, payload] = await runMiddleware("app", options, req); + + expect(payload).toBeDefined(); + expect(payload?.jti).toBe("new-token"); + + // Verify fetch call + expect(fetchMock).toHaveBeenCalled(); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.host).toBe("securetoken.googleapis.com"); + + // Verify cookies update + const nextRes = NextResponse.next(); + decorate!(nextRes); + + const idCookie = nextRes.cookies.get("__HOST-FIREBASE_app"); + const refreshCookie = nextRes.cookies.get("__HOST-FIREBASEID_app"); + + expect(idCookie?.value).toBe(newIdToken); + expect(refreshCookie?.value).toBe(newRefreshToken); + }); + + it("preserves platformOperator claim in refreshed payload in production (session-gated, not stripped)", async () => { + vi.stubEnv("NODE_ENV", "production"); + + try { + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + const refreshToken = "valid-refresh-token"; + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", refreshToken); + + const newIdToken = createToken({ ...validPayload, jti: "new-strip", platformOperator: true }); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id_token: newIdToken, + refresh_token: "new-refresh", + expires_in: "3600", + }), + }); + + const [, , payload] = await runMiddleware("app", options, req); + + expect(payload).toBeDefined(); + expect(payload?.jti).toBe("new-strip"); + expect((payload as any)?.platformOperator).toBe(true); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("preserves platformOperator claim in refreshed payload in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + + try { + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + const refreshToken = "valid-refresh-token"; + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", refreshToken); + + const newIdToken = createToken({ ...validPayload, jti: "dev-preserve", platformOperator: true }); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id_token: newIdToken, + refresh_token: "new-refresh", + expires_in: "3600", + }), + }); + + const [, , payload] = await runMiddleware("app", options, req); + + expect(payload).toBeDefined(); + expect((payload as any)?.platformOperator).toBe(true); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("should logout if ID token is expired and refresh fails", async () => { + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + const refreshToken = "bad-refresh-token"; + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", refreshToken); + + // Mock refresh failure + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + }); + + const [_response, decorate, payload] = await runMiddleware("app", options, req); + + expect(payload).toBeUndefined(); + + const nextRes = NextResponse.next(); + decorate!(nextRes); + + const setCookie = nextRes.headers.get("set-cookie"); + expect(setCookie).toContain("Max-Age=0"); + }); + + it("should logout if emulator refresh token used in production", async () => { + const emulatorRefreshPayload = JSON.stringify({ _AuthEmulatorRefreshToken: true }); + const emulatorRefreshToken = Buffer.from(emulatorRefreshPayload).toString("base64"); + + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", emulatorRefreshToken); + + const prodOptions = { ...options, emulatorHost: undefined }; + + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + id_token: "new-token", + refresh_token: emulatorRefreshToken, + }), + }); + + const [_response, decorate, payload] = await runMiddleware("app", prodOptions, req); + + expect(payload).toBeUndefined(); + + const nextRes = NextResponse.next(); + decorate!(nextRes); + const setCookie = nextRes.headers.get("set-cookie"); + expect(setCookie).toContain("Max-Age=0"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should refresh using emulator host if configured", async () => { + const emulatorRefreshPayload = JSON.stringify({ _AuthEmulatorRefreshToken: true }); + const emulatorRefreshToken = Buffer.from(emulatorRefreshPayload).toString("base64"); + + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", emulatorRefreshToken); + + const emuOptions = { ...options, emulatorHost: mockEmulatorHost }; + + const newIdToken = createToken({ ...validPayload, jti: "emu-token" }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + id_token: newIdToken, + refresh_token: emulatorRefreshToken, + }), + }); + + await runMiddleware("app", emuOptions, req); + + expect(fetchMock).toHaveBeenCalled(); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.host).toBe(mockEmulatorHost); + }); + + it("should handle missing ID token but present refresh token", async () => { + const refreshToken = "valid-refresh-token"; + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASEID_app", refreshToken); + + const newIdToken = createToken({ ...validPayload, jti: "restored-token" }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + id_token: newIdToken, + refresh_token: refreshToken, + }), + }); + + const [_response, _decorate, payload] = await runMiddleware("app", options, req); + + expect(payload).toBeDefined(); + expect(payload?.jti).toBe("restored-token"); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("should logout when authIdToken is empty string (logout sentinel)", async () => { + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", ""); + + const [_response, decorate, payload] = await runMiddleware("app", options, req); + + expect(payload).toBeUndefined(); + const nextRes = NextResponse.next(); + decorate!(nextRes); + expect(nextRes.headers.get("set-cookie")).toContain("Max-Age=0"); + }); + + it("should logout when ID token is emulated but options.emulatorHost is undefined", async () => { + const emuHeader = { alg: "none", typ: "JWT" }; + const emuPayload = { ...validPayload, jti: "emulated-no-host" }; + const token = createToken(emuPayload); + // Overwrite the token with alg: none header + const tokenParts = token.split("."); + const h = Buffer.from(JSON.stringify(emuHeader)).toString("base64"); + const emuToken = `${h}.${tokenParts[1]}.signature`; + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", emuToken); + + const prodOptions = { ...options, emulatorHost: undefined }; + const [_response, decorate, payload] = await runMiddleware("app", prodOptions, req); + + expect(payload).toBeUndefined(); + const nextRes = NextResponse.next(); + decorate!(nextRes); + expect(nextRes.headers.get("set-cookie")).toContain("Max-Age=0"); + }); + + it("should logout when refresh fetch throws a network exception", async () => { + const expiredPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600 }; + const idToken = createToken(expiredPayload); + const refreshToken = "valid-refresh-token"; + + const req = new NextRequest("https://localhost:3000/dashboard"); + req.cookies.set("__HOST-FIREBASE_app", idToken); + req.cookies.set("__HOST-FIREBASEID_app", refreshToken); + + fetchMock.mockRejectedValue(new Error("Network disconnect")); + + const [_response, decorate, payload] = await runMiddleware("app", options, req); + + expect(payload).toBeUndefined(); + const nextRes = NextResponse.next(); + decorate!(nextRes); + expect(nextRes.headers.get("set-cookie")).toContain("Max-Age=0"); + }); +}); diff --git a/src/nextjs/middleware_safety.node.test.ts b/src/nextjs/middleware_safety.node.test.ts new file mode 100644 index 00000000..54ab0d0b --- /dev/null +++ b/src/nextjs/middleware_safety.node.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { composeMiddleware } from "./index"; + +// Mock fetch +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +describe("middleware", () => { + const mockApiKey = "mock-api-key"; + const mockProjectId = "mock-project-id"; + const mockAuthDomain = "mock-auth-domain"; + + const mockConfig = { + options: { + apiKey: mockApiKey, + projectId: mockProjectId, + authDomain: mockAuthDomain, + }, + emulator: true, + }; + + const innie = vi.fn((_req) => NextResponse.next()); + + const originalEnv = process.env; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should throw error when emulated but target is not emulator", async () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; + + const middleware = composeMiddleware(innie, () => mockConfig); + + const req = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=" + + encodeURIComponent("https://securetoken.googleapis.com/v1/token"), + ); + + // Mock fetch response for success path (so it WOULD succeed if we didn't block it) + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ id_token: "abc", refresh_token: "def" }), + headers: new Headers(), + status: 200, + statusText: "OK", + }); + + const response = await middleware(req); + expect(response.status).toBe(400); + }); + + it("should pass when emulated and target is emulator", async () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; + + const middleware = composeMiddleware(innie, () => mockConfig); + + const req = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=" + + encodeURIComponent("http://localhost:9099/securetoken.googleapis.com/v1/token"), + ); + + // Mock fetch response for success path + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ id_token: "abc", refresh_token: "def" }), + headers: new Headers(), + status: 200, + statusText: "OK", + }); + + await middleware(req); + + expect(fetchMock).toHaveBeenCalled(); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.host).toBe("localhost:9099"); + }); + + it("should pass when NOT emulated and target is production", async () => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = ""; + + const middleware = composeMiddleware(innie, () => ({ ...mockConfig, emulator: false })); + + const req = new NextRequest( + "http://localhost:3000/__cookies__?finalTarget=" + + encodeURIComponent("https://securetoken.googleapis.com/v1/token"), + ); + + // Mock fetch response for success path + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ id_token: "abc", refresh_token: "def" }), + headers: new Headers(), + status: 200, + statusText: "OK", + }); + + await middleware(req); + expect(fetchMock).toHaveBeenCalled(); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.host).toBe("securetoken.googleapis.com"); + }); +}); diff --git a/src/nextjs/middleware_test_utils.ts b/src/nextjs/middleware_test_utils.ts new file mode 100644 index 00000000..d9201635 --- /dev/null +++ b/src/nextjs/middleware_test_utils.ts @@ -0,0 +1,7 @@ +const DEFAULT_HEADER = { alg: "RS256", typ: "JWT" }; + +export function createToken(payload: any, header: any = DEFAULT_HEADER): string { + const h = Buffer.from(JSON.stringify(header)).toString("base64"); + const p = Buffer.from(JSON.stringify(payload)).toString("base64"); + return `${h}.${p}.signature`; +} diff --git a/src/nextjs/middleware_utils.node.test.ts b/src/nextjs/middleware_utils.node.test.ts new file mode 100644 index 00000000..f35bc423 --- /dev/null +++ b/src/nextjs/middleware_utils.node.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { isEmulatorRefreshToken } from "./index"; + +describe("isEmulatorRefreshToken", () => { + it("should return false for random opaque string", () => { + const refresh_token = "some_random_opaque_string"; + expect(isEmulatorRefreshToken(refresh_token)).toBe(false); + }); + + it("should return false for JSON without the key", () => { + const payload = JSON.stringify({ foo: "bar" }); + const refresh_token = Buffer.from(payload).toString("base64"); + expect(isEmulatorRefreshToken(refresh_token)).toBe(false); + }); + + it("should return true for JSON with the key", () => { + const payload = JSON.stringify({ _AuthEmulatorRefreshToken: true }); + const refresh_token = Buffer.from(payload).toString("base64"); + expect(isEmulatorRefreshToken(refresh_token)).toBe(true); + }); + + it("should return false for malformed base64 strings", () => { + // "{" is not valid base64 usually, or decodes to something invalid + const refresh_token = "{"; + expect(isEmulatorRefreshToken(refresh_token)).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isEmulatorRefreshToken("")).toBe(false); + }); +}); diff --git a/src/nextjs/middleware_verify.node.test.ts b/src/nextjs/middleware_verify.node.test.ts new file mode 100644 index 00000000..a7d38c80 --- /dev/null +++ b/src/nextjs/middleware_verify.node.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { verifyFirebaseIdToken } from "./index"; +import * as jose from "jose"; +import { createToken } from "./middleware_test_utils"; + +// Mock jose +vi.mock("jose", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRemoteJWKSet: vi.fn(), + jwtVerify: vi.fn(), + }; +}); + +describe("verifyFirebaseIdToken", () => { + const mockProjectId = "test-project"; + const mockTenantId = "test-tenant"; + + const validHeader = { alg: "RS256", typ: "JWT" }; + const validPayload = { + iss: `https://securetoken.google.com/${mockProjectId}`, + aud: mockProjectId, + firebase: { tenant: mockTenantId }, + exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + sub: "user123", + }; + + beforeEach(() => { + vi.resetAllMocks(); + // Default success for jwtVerify + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: validPayload, + protectedHeader: validHeader, + } as any); + }); + + it("should return payload for a valid token", async () => { + const payload = { ...validPayload, jti: "unique-1" }; + const token = createToken(payload); + const [resultPayload, isEmulated] = await verifyFirebaseIdToken( + token, + mockProjectId, + mockTenantId, + ); + + expect(resultPayload).toBeDefined(); + expect(resultPayload?.sub).toBe("user123"); + expect(isEmulated).toBe(false); + expect(jose.jwtVerify).toHaveBeenCalled(); + }); + + it("should return undefined for expired token (client-side check)", async () => { + const expiredPayload = { + ...validPayload, + exp: Math.floor(Date.now() / 1000) - 3600, + jti: "unique-2", + }; + const token = createToken(expiredPayload); + + const [payload, isEmulated] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(payload).toBeUndefined(); + expect(isEmulated).toBe(false); + expect(jose.jwtVerify).not.toHaveBeenCalled(); + }); + + it("should return undefined if verification fails", async () => { + vi.mocked(jose.jwtVerify).mockRejectedValue(new Error("Invalid signature")); + const payload = { ...validPayload, jti: "unique-3" }; + const token = createToken(payload); + + const [resultPayload, isEmulated] = await verifyFirebaseIdToken( + token, + mockProjectId, + mockTenantId, + ); + + expect(resultPayload).toBeUndefined(); + expect(isEmulated).toBe(false); + }); + + it("should return undefined for invalid claims (wrong aud)", async () => { + const invalidPayload = { ...validPayload, aud: "wrong-project", jti: "unique-4" }; + const token = createToken(invalidPayload); + + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(payload).toBeUndefined(); + }); + + it("should return undefined for invalid claims (wrong iss)", async () => { + const invalidPayload = { ...validPayload, iss: "https://wrong.issuer.com", jti: "unique-5" }; + const token = createToken(invalidPayload); + + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(payload).toBeUndefined(); + }); + + it("should return undefined for invalid claims (wrong tenant)", async () => { + const invalidPayload = { + ...validPayload, + firebase: { tenant: "wrong-tenant" }, + jti: "unique-6", + }; + const token = createToken(invalidPayload); + + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(payload).toBeUndefined(); + }); + + describe("tenant validation", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("rejects mismatched tenant in dev even if custom claims are present", async () => { + vi.stubEnv("NODE_ENV", "development"); + const tokenTenantPayload = { + ...validPayload, + firebase: { tenant: "origin-tenant" }, + customRole: true, + jti: "tenant-mismatch-dev", + }; + const token = createToken(tokenTenantPayload); + + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, "different-tenant"); + expect(payload).toBeUndefined(); + }); + + it("rejects mismatched tenant in prod even if custom claims are present", async () => { + vi.stubEnv("NODE_ENV", "production"); + const tokenTenantPayload = { + ...validPayload, + firebase: { tenant: "origin-tenant" }, + customRole: true, + jti: "tenant-mismatch-prod", + }; + const token = createToken(tokenTenantPayload); + + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, "different-tenant"); + expect(payload).toBeUndefined(); + }); + }); + + it("should handle emulator tokens (alg: none)", async () => { + const emulatorHeader = { alg: "none", typ: "JWT" }; + const emulatorPayload = { ...validPayload, jti: "unique-7" }; // Valid claims still required + const token = createToken(emulatorPayload, emulatorHeader); + + const [payload, isEmulated] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(payload).toBeDefined(); + expect(payload?.sub).toBe("user123"); + expect(isEmulated).toBe(true); + expect(jose.jwtVerify).not.toHaveBeenCalled(); + }); + + it("should use cache for subsequent valid requests", async () => { + const payload = { ...validPayload, jti: "unique-cache-test" }; + const token = createToken(payload); + + // First call + await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + + // Second call + const [cachedResult] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + expect(cachedResult).toBeDefined(); + // Should NOT call verify again + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + }); + + it("should return undefined for malformed token", async () => { + const token = "not.a.valid.token"; + const [payload] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + expect(payload).toBeUndefined(); + }); + + describe("custom claim preservation", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("preserves arbitrary custom claims in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + const payload = { ...validPayload, customRole: true, jti: "dev-keep" }; + const token = createToken(payload); + vi.mocked(jose.jwtVerify).mockResolvedValue({ payload, protectedHeader: validHeader } as any); + + const [result] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(result).toBeDefined(); + expect(result?.customRole).toBe(true); + }); + + it("preserves arbitrary custom claims in production", async () => { + vi.stubEnv("NODE_ENV", "production"); + const payload = { ...validPayload, customRole: true, jti: "prod-keep" }; + const token = createToken(payload); + vi.mocked(jose.jwtVerify).mockResolvedValue({ payload, protectedHeader: validHeader } as any); + + const [result] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + + expect(result).toBeDefined(); + expect(result?.customRole).toBe(true); + expect(result?.sub).toBe("user123"); + }); + + it("preserves arbitrary custom claims from cached payload in production", async () => { + vi.stubEnv("NODE_ENV", "production"); + const payload = { ...validPayload, customRole: true, jti: "cache-keep-unique" }; + const token = createToken(payload); + vi.mocked(jose.jwtVerify).mockResolvedValue({ payload, protectedHeader: validHeader } as any); + + // First call populates the LRU cache + await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + const verifyCallsAfterFirst = vi.mocked(jose.jwtVerify).mock.calls.length; + + // Second call: served from cache — claim must still be present + const [result] = await verifyFirebaseIdToken(token, mockProjectId, mockTenantId); + expect(vi.mocked(jose.jwtVerify).mock.calls.length).toBe(verifyCallsAfterFirst); + expect(result?.customRole).toBe(true); + expect(result?.sub).toBe("user123"); + }); + }); +}); diff --git a/src/nextjs/package.json b/src/nextjs/package.json new file mode 100644 index 00000000..0c7563f5 --- /dev/null +++ b/src/nextjs/package.json @@ -0,0 +1,34 @@ +{ + "name": "firebase-cookie-middleware", + "version": "0.0.1", + "exports": { + ".": { + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "test": "vitest run", + "build": "npx esbuild ./index.ts --format=esm --target=esnext --platform=neutral --outfile=./dist/index.mjs --bundle --external:next --external:@firebase/util --external:firebase && npx tsc" + }, + "author": "", + "license": "MIT", + "description": "", + "peerDependencies": { + "firebase": "*", + "next": "*" + }, + "devDependencies": { + "@upstash/redis": "^1.38.0", + "@vitest/coverage-v8": "^4.1.9", + "esbuild": "^0.25.1", + "firebase": "^11.5.0", + "next": "^16.0.0", + "typescript": "^5.8.2", + "vitest": "^4.1.9" + }, + "dependencies": { + "jose": "^6.0.10", + "lru-cache": "^11.5.1" + } +} diff --git a/src/nextjs/tsconfig.json b/src/nextjs/tsconfig.json new file mode 100644 index 00000000..dc015c45 --- /dev/null +++ b/src/nextjs/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "target": "ESNext", + "lib": ["esnext"], + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + }, + "include": ["index.ts"] + } + \ No newline at end of file diff --git a/src/nextjs/vitest.config.ts b/src/nextjs/vitest.config.ts new file mode 100644 index 00000000..26892d65 --- /dev/null +++ b/src/nextjs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.ts"], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 7cb9a9dd..e9f73e16 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs "include": ["src", "types"], + "exclude": ["src/nextjs"], "compilerOptions": { "target": "es2015", "module": "esnext", diff --git a/vite.config.ts b/vite.config.ts index 85660256..fd7a5352 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: './test/setupTests.ts', + exclude: ['**/node_modules/**', '**/dist/**', '**/src/nextjs/**'], }, define: { // replace `process.env.REACTFIRE_VERSION` in the source