Skip to content

Commit 40b253c

Browse files
committed
feat(nextjs): add firebase-cookie-middleware with security hardening
Introduces the firebase-cookie-middleware package (v0.0.1) for Next.js 14/15/16, taking over from PR #640 (James Daniels). Security fixes applied during takeover review: - Emulator explicit opt-in only (env var not auto-detected) - JWT signature failure sets refreshable=false to prevent bypass - Verify refreshed token before accepting it - SSRF: loopback validation on emulator host - CSRF: Origin check on POST/DELETE to /__cookies__ - CHIPS: SameSite=None on partitioned cookies - JWKS eviction lock released in finally block - Falsy-zero TTL: require ttlSeconds > 0 before caching - Proxy targets validated; return 400 instead of throwing - Optional chaining on JWT payload access - Removed duplex: 'half' workaround (unnecessary in Node.js) Next.js 16: adds proxy export (proxy.ts replaces middleware.ts in Next.js 16 projects). proxy.ts runs on Node.js runtime only; middleware.ts remains available for Edge Runtime (deprecated). Also fixes pre-existing TypeScript strict-mode errors surfaced by upgrading moduleResolution from node to bundler. Ref: #640 Original work by: James Daniels (jamesdaniels@google.com)
1 parent cf3e76a commit 40b253c

10 files changed

Lines changed: 169 additions & 56 deletions

src/firestore.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function useFirestoreDocData<T = unknown>(ref: DocumentReference<T>, opti
6565
const observableId = `firestore:docData:${ref.firestore.app.name}:${ref.path}:idField=${idField}`;
6666
const observable = docData(ref, { idField });
6767

68-
return useObservable(observableId, observable, options);
68+
return useObservable(observableId, observable, options) as ObservableStatus<T>;
6969
}
7070

7171
/**
@@ -77,7 +77,7 @@ export function useFirestoreDocDataOnce<T = unknown>(ref: DocumentReference<T>,
7777
const observableId = `firestore:docDataOnce:${ref.firestore.app.name}:${ref.path}:idField=${idField}`;
7878
const observable$ = docData(ref, { idField }).pipe(first());
7979

80-
return useObservable(observableId, observable$, options);
80+
return useObservable(observableId, observable$, options) as ObservableStatus<T>;
8181
}
8282

8383
/**

src/nextjs/README.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ Enables **hybrid & Server-Side Rendered (SSR)** authentication in Next.js applic
88

99
## Why this exists
1010

11-
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 requestcausing layout shifts, client-side redirect flashes, or insecure server routes.
11+
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.
1212

1313
`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.
1414

1515
---
1616

1717
## Features
1818

19-
- **100% Edge Runtime Compatible**: Engineered specifically for Next.js 14 & 15 Edge Runtimes (Vercel Edge, Cloudflare Workers). Built on `jose` and Web APIs (`atob`, `fetch`) with zero reliance on Node.js `Buffer`.
19+
- **⚡ 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).
2020
- **🛡️ 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.
2121
- **🚀 Distributed Caching (Memorystore / Redis)**: Optional distributed caching for Google's public JWKS signing keys and verified token payloads (`jwt:<idToken>`). Prevents unnecessary CPU verification and network requests on every navigation.
2222
- **🔒 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.
@@ -33,11 +33,29 @@ npm install firebase-cookie-middleware jose lru-cache
3333

3434
---
3535

36+
## Next.js 16 Migration
37+
38+
Next.js 16 deprecates `middleware.ts` in favor of `proxy.ts`. The named export also changes from `middleware` to `proxy`.
39+
40+
**Next.js 15 and earlier** (`src/middleware.ts`, Edge Runtime):
41+
```typescript
42+
export { middleware } from "firebase-cookie-middleware";
43+
```
44+
45+
**Next.js 16+** (`src/proxy.ts`, Node.js runtime):
46+
```typescript
47+
export { proxy } from "firebase-cookie-middleware";
48+
```
49+
50+
`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.
51+
52+
---
53+
3654
## Quickstart
3755

38-
### 1. Create your Middleware (`src/middleware.ts`)
56+
### 1. Create your Middleware (`src/middleware.ts` for Next.js 15, `src/proxy.ts` for Next.js 16)
3957

40-
Create or update your `middleware.ts` file in the root of your Next.js application:
58+
Create or update the file in the root of your Next.js application:
4159

4260
```typescript
4361
import { NextResponse, type NextRequest } from "next/server";
@@ -210,10 +228,23 @@ The middleware maintains two distinct cookies per app configuration (`appName`):
210228

211229
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.
212230

213-
To connect with the Firebase Auth Emulator, export your emulator environment variable:
231+
To connect with the Firebase Auth Emulator, set `emulator: true` in your config and export the emulator host:
214232

215233
```bash
216234
export FIREBASE_AUTH_EMULATOR_HOST="localhost:9099"
217235
```
218236

219-
The middleware automatically accepts unsigned emulator tokens (`alg: "none"`) and proxies token refresh attempts directly to your local emulator instance.
237+
```typescript
238+
export const middleware = composeMiddleware(callback, {
239+
options: firebaseConfig,
240+
emulator: true, // reads FIREBASE_AUTH_EMULATOR_HOST; required to accept unsigned tokens
241+
});
242+
```
243+
244+
You can also pass the host directly as a string instead of relying on the env var:
245+
246+
```typescript
247+
emulator: "localhost:9099"
248+
```
249+
250+
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.

src/nextjs/index.ts

Lines changed: 103 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export type FirebaseJWTPayload = JWTPayload & {
2525
[key: string]: unknown;
2626
};
2727

28+
/**
29+
* Cache abstraction used by this middleware.
30+
*
31+
* If you want to use a Redis client (ioredis, node-redis, Upstash, etc.) you
32+
* must wrap it in an adapter that maps this interface to the client's own API.
33+
* The options object uses lowercase `ex` / `nx` keys which do NOT match the
34+
* native signatures of ioredis or node-redis v4 — pass them through with the
35+
* appropriate translation in your adapter's `set()` implementation.
36+
*
37+
* The options object uses lowercase `ex` / `nx` keys, which do NOT match the
38+
* native signatures of ioredis or node-redis v4; pass them through with the
39+
* appropriate translation in your adapter's `set()` implementation.
40+
*/
2841
export interface CacheProvider {
2942
get<T = unknown>(key: string): Promise<T | null | undefined> | T | null | undefined;
3043
set(
@@ -46,7 +59,8 @@ export class MemoryCacheProvider implements CacheProvider {
4659
set(key: string, value: any, options?: { ex?: number; ttl?: number; nx?: boolean }): any {
4760
if (options?.nx && this.cache.has(key)) return null;
4861
const ttlSeconds = options?.ex ?? (options?.ttl ? Math.floor(options.ttl / 1000) : undefined);
49-
const ttlMs = ttlSeconds ? ttlSeconds * 1000 : undefined;
62+
// Treat 0 as "do not cache" (e.g. a max-age=0 from a server response).
63+
const ttlMs = ttlSeconds !== undefined && ttlSeconds > 0 ? ttlSeconds * 1000 : undefined;
5064
this.cache.set(key, value, { ttl: ttlMs });
5165
return "OK";
5266
}
@@ -154,7 +168,9 @@ async function getFirebaseAuthJwks(
154168
}
155169

156170
const jwks = (await response.json()) as JSONWebKeySet;
157-
await cacheSetEx(cache, "firebase:jwks", maxAge, jwks);
171+
if (maxAge > 0) {
172+
await cacheSetEx(cache, "firebase:jwks", maxAge, jwks);
173+
}
158174
return createLocalJWKSet(jwks);
159175
} catch (e) {
160176
console.error("Failed to fetch Firebase JWKS:", e);
@@ -197,7 +213,7 @@ export async function verifyFirebaseIdToken(
197213
if (
198214
jwtHeader?.typ !== "JWT" ||
199215
jwtPayload?.iss !== `https://securetoken.google.com/${projectId}` ||
200-
jwtPayload.aud !== projectId ||
216+
jwtPayload?.aud !== projectId ||
201217
jwtPayload?.firebase?.tenant !== tenantId
202218
) {
203219
console.error("JWT Validation Mismatch", { jwtHeader, jwtPayload, projectId, tenantId });
@@ -237,9 +253,8 @@ export async function verifyFirebaseIdToken(
237253
// Fetch fresh keys directly, skipping cache read
238254
jwks = await getFirebaseAuthJwks(cache, true);
239255
await jwtVerify(idToken, jwks);
240-
} catch (fetchErr) {
256+
} finally {
241257
await cacheDelete(cache, "firebase:jwks_eviction_lock");
242-
throw fetchErr;
243258
}
244259
} else {
245260
throw verifyErr;
@@ -256,6 +271,7 @@ export async function verifyFirebaseIdToken(
256271
return [jwtPayload, isEmulatedCredential, true];
257272
} catch (e) {
258273
console.error("JWT Verification failed:", e);
274+
refreshable = false;
259275
}
260276
}
261277
}
@@ -316,16 +332,19 @@ export async function runMiddleware(
316332
const REFRESH_TOKEN_COOKIE_NAME = isDevMode
317333
? `__dev_FIREBASEID_${appName}`
318334
: `__HOST-FIREBASEID_${appName}`;
335+
// CHIPS (Partitioned cookies) requires SameSite=None; Secure. When running
336+
// on localhost with insecure cookies we fall back to SameSite=Lax (no partitioning).
337+
const sameSite = useInsecureCookies ? ("lax" as const) : ("none" as const);
319338
const ID_TOKEN_COOKIE = {
320-
path: "/",
339+
path: "/" as const,
321340
secure: !useInsecureCookies,
322-
httpOnly: false,
323-
sameSite: "lax",
341+
httpOnly: false as const,
342+
sameSite,
324343
partitioned: !useInsecureCookies,
325344
name: ID_TOKEN_COOKIE_NAME,
326345
maxAge: MAX_MAX_AGE,
327-
priority: "high",
328-
} as const;
346+
priority: "high" as const,
347+
};
329348
const REFRESH_TOKEN_COOKIE = {
330349
...ID_TOKEN_COOKIE,
331350
httpOnly: true,
@@ -339,6 +358,18 @@ export async function runMiddleware(
339358
}
340359

341360
const method = request.method;
361+
362+
// CSRF guard: reject cross-origin state-changing requests.
363+
// Browsers send the Origin header on cross-origin requests; same-origin
364+
// requests from the Firebase JS SDK either omit Origin (safe) or send the
365+
// correct same-site value.
366+
if (method === "POST" || method === "DELETE") {
367+
const origin = request.headers.get("origin");
368+
if (origin && origin !== request.nextUrl.origin) {
369+
return [new NextResponse("Cross-origin request denied", { status: 403 })];
370+
}
371+
}
372+
342373
if (method === "DELETE") {
343374
const response = new NextResponse("");
344375
response.cookies.delete({ ...ID_TOKEN_COOKIE, maxAge: 0 });
@@ -376,14 +407,14 @@ export async function runMiddleware(
376407

377408
if (options.emulatorHost) {
378409
if (url.host !== options.emulatorHost) {
379-
throw new Error(`Emulator mismatch: ${url.host} vs ${options.emulatorHost}`);
410+
return [new NextResponse("Proxy target does not match configured emulator host", { status: 400 })];
380411
}
381412
} else {
382413
if (
383414
url.host !== "securetoken.googleapis.com" &&
384415
url.host !== "identitytoolkit.googleapis.com"
385416
) {
386-
throw new Error(`Unauthorized proxy target host: ${url.host}`);
417+
return [new NextResponse("Unauthorized proxy target host", { status: 400 })];
387418
}
388419
}
389420

@@ -393,7 +424,7 @@ export async function runMiddleware(
393424
);
394425

395426
if (!isTokenRequest && !isSignInRequest)
396-
throw new Error("Could not determine the request type to proxy");
427+
return [new NextResponse("Unsupported request type", { status: 400 })];
397428

398429
if (isTokenRequest) {
399430
body = await request.text();
@@ -405,10 +436,13 @@ export async function runMiddleware(
405436
body = bodyParams.toString();
406437
}
407438
}
439+
} else if (isSignInRequest) {
440+
// Materialize body to a string so we never pass a ReadableStream to fetch,
441+
// which is rejected by Vercel Edge and stricter undici configurations.
442+
body = await request.text();
408443
}
409444

410-
// Duplex half, isn't NextJS fun?
411-
const response = await fetch(url, { method, body, headers, duplex: "half" } as RequestInit);
445+
const response = await fetch(url, { method, body, headers });
412446

413447
const json = (await response.json()) as TokenResponse | SignInResponse;
414448
const status = response.status;
@@ -482,6 +516,14 @@ export async function runMiddleware(
482516
return logout();
483517
}
484518

519+
if (isEmulatedCredential && options.emulatorHost) {
520+
const emulatorHostname = options.emulatorHost.split(":")[0];
521+
if (emulatorHostname !== "localhost" && emulatorHostname !== "127.0.0.1" && emulatorHostname !== "::1") {
522+
console.error("Emulator host must be localhost or loopback:", emulatorHostname);
523+
return logout();
524+
}
525+
}
526+
485527
const refreshUrl = new URL(
486528
isEmulatedCredential ? `http://${options.emulatorHost}` : `https://securetoken.googleapis.com`,
487529
);
@@ -511,14 +553,19 @@ export async function runMiddleware(
511553
const newRefreshToken = json.refresh_token;
512554
const newIdToken = json.id_token;
513555
if (!newIdToken) throw new Error("Missing id_token in refresh response");
514-
// FIXED: Use jose decodeJwt for robust Edge support instead of Buffer/base64
515-
const decodedPayload = decodeJwt(newIdToken) as FirebaseJWTPayload;
516-
jwtPayload = decodedPayload;
517-
const ttlMs = jwtPayload.exp! * 1000 - Date.now();
518-
const ttlSeconds = Math.floor(ttlMs / 1000);
519-
if (ttlSeconds > 0) {
520-
await cacheSetEx(cache, `jwt:${newIdToken}`, ttlSeconds, jwtPayload);
556+
// Full signature + claims verification on the refreshed token. Caching is
557+
// handled inside verifyFirebaseIdToken, so no separate cacheSetEx needed.
558+
const [verifiedNewPayload] = await verifyFirebaseIdToken(
559+
newIdToken,
560+
options.projectId,
561+
options.tenantId,
562+
cache,
563+
);
564+
if (!verifiedNewPayload) {
565+
console.error("Refreshed token failed verification");
566+
return logout();
521567
}
568+
jwtPayload = verifiedNewPayload;
522569
const decorateNextResponse = (response: NextResponse) => {
523570
if (newIdToken) response.cookies.set({ ...ID_TOKEN_COOKIE, value: newIdToken });
524571
if (newRefreshToken) response.cookies.set({ ...REFRESH_TOKEN_COOKIE, value: newRefreshToken });
@@ -567,9 +614,11 @@ export const normalizeConfig = (
567614
};
568615
if (typeof config.emulator === "string") {
569616
normalizedConfig.emulatorHost = config.emulator;
570-
} else if (config.emulator !== false) {
571-
const emulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
572-
normalizedConfig.emulatorHost = emulatorHost;
617+
} else if (config.emulator === true) {
618+
// Explicit opt-in only: reading FIREBASE_AUTH_EMULATOR_HOST when emulator
619+
// is not explicitly requested would accept alg:none tokens if that env var
620+
// ever leaked into a production environment.
621+
normalizedConfig.emulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
573622
}
574623
return normalizedConfig;
575624
};
@@ -598,7 +647,6 @@ export const composeMiddleware =
598647
normalizedConfigurations.push({
599648
appName: "[DEFAULT]",
600649
firebaseOptions: defaultAppOptions,
601-
emulatorHost: process.env.FIREBASE_AUTH_EMULATOR_HOST,
602650
cache: defaultMemoryCache,
603651
});
604652
}
@@ -625,7 +673,15 @@ export const composeMiddleware =
625673
const [response, decorateResponse, idTokenPayload] = result;
626674
if (idTokenPayload) allUsers[appName] = idTokenPayload;
627675
if (idTokenPayload && appName === defaultAppName) defaultUser = idTokenPayload;
628-
if (response) finalResponse ||= response;
676+
if (response) {
677+
if (finalResponse) {
678+
console.warn(
679+
`firebase-cookie-middleware: app "${appName}" returned a direct response but an earlier app already claimed the response. The earlier response takes precedence.`,
680+
);
681+
} else {
682+
finalResponse = response;
683+
}
684+
}
629685
if (decorateResponse) decorators.push(decorateResponse);
630686
}
631687
if (!finalResponse)
@@ -636,4 +692,23 @@ export const composeMiddleware =
636692
);
637693
};
638694

639-
export const middleware = composeMiddleware(() => {});
695+
// Zero-config convenience export. Works only when a Firebase app has been
696+
// initialized before the middleware runs (e.g. via getApps() in firebase.ts).
697+
// If no Firebase config is discoverable the request passes through with a
698+
// console warning rather than throwing a 500.
699+
const _safeDefaultMiddleware = async (request: NextRequest): Promise<NextResponse> => {
700+
const defaultAppOptions = getDefaultAppConfig() as FirebaseOptions | undefined;
701+
if (!defaultAppOptions) {
702+
console.warn(
703+
"firebase-cookie-middleware: no Firebase config found; pass a Config to composeMiddleware.",
704+
);
705+
return NextResponse.next();
706+
}
707+
return composeMiddleware(() => {})(request);
708+
};
709+
710+
// Next.js 16+: proxy.ts expects a named `proxy` export.
711+
export const proxy = _safeDefaultMiddleware;
712+
// Next.js 15 and earlier: middleware.ts expects a named `middleware` export.
713+
// @deprecated Use proxy in Next.js 16+.
714+
export const middleware = _safeDefaultMiddleware;

src/nextjs/middleware_cache.node.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe("CacheProvider plugin paths", () => {
4343
get: mockCacheGet,
4444
set: mockCacheSet,
4545
setex: mockCacheSetex,
46-
};
46+
} as unknown as CacheProvider;
4747
});
4848

4949
it("should return cached payload from CacheProvider during verifyFirebaseIdToken", async () => {

src/nextjs/middleware_config.node.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,17 @@ describe("normalizeConfig", () => {
8989
expect(normalized.tenantId).toBe("acme");
9090
});
9191

92-
it("should read emulator from env when emulator is undefined (not false)", () => {
92+
it("should NOT read emulator from env when emulator is undefined (explicit opt-in required)", () => {
9393
process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099";
9494
const config: Config = { options: mockFirebaseOptions };
9595
const normalized = normalizeConfig(config, undefined);
96+
expect(normalized.emulatorHost).toBeUndefined();
97+
});
98+
99+
it("should read emulator from env when emulator is explicitly true", () => {
100+
process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099";
101+
const config: Config = { options: mockFirebaseOptions, emulator: true };
102+
const normalized = normalizeConfig(config, undefined);
96103
expect(normalized.emulatorHost).toBe("localhost:9099");
97104
});
98105

0 commit comments

Comments
 (0)