Skip to content

feat(nextjs): add firebase-cookie-middleware with security hardening#739

Open
tyler-reitz wants to merge 10 commits into
FirebaseExtended:mainfrom
tyler-reitz:feat/nextjs-middleware
Open

feat(nextjs): add firebase-cookie-middleware with security hardening#739
tyler-reitz wants to merge 10 commits into
FirebaseExtended:mainfrom
tyler-reitz:feat/nextjs-middleware

Conversation

@tyler-reitz

Copy link
Copy Markdown
Contributor

Summary

This PR introduces firebase-cookie-middleware (v0.0.1), a production-ready Next.js middleware package for Firebase Auth cookie persistence. It takes over from #640 (original work by James Daniels, @jamesdaniels), which is being closed in favor of this PR to establish clear ownership.

All logic from #640 has been carried forward. The following security issues were identified and addressed during the takeover review:

  • Emulator explicit opt-in: FIREBASE_AUTH_EMULATOR_HOST is no longer auto-detected; requires emulator: true in config to prevent unsigned token acceptance if the env var leaks into production
  • Verify-after-refresh: refreshed ID tokens are verified before being accepted, preventing a bypass via a compromised token endpoint
  • refreshable=false on signature failure: prevents refresh attempts when the original token has an invalid signature (not just expiry)
  • SSRF: emulator host validated to be loopback-only (localhost, 127.0.0.1, ::1)
  • CSRF: Origin header checked on POST/DELETE to /__cookies__; cross-origin requests rejected with 403
  • CHIPS: SameSite=None applied correctly on partitioned cookies (was incorrectly SameSite=Lax in production)
  • JWKS eviction lock: lock released in a finally block to prevent deadlock on verification error
  • Falsy-zero TTL: ttlSeconds > 0 required before caching; 0 no longer treated as "cache forever"
  • 400 not throw: invalid proxy target hosts return 400 instead of an uncaught throw
  • Optional chaining on JWT payload: guards against undefined access on malformed tokens
  • Removed duplex: 'half': workaround was unnecessary and caused Node.js type errors

Next.js 16 compatibility: adds a proxy named export for proxy.ts (Node.js runtime). The middleware export remains for middleware.ts users (Edge Runtime, v14/15 and v16 deprecated path). Logic is identical between the two exports.

Also fixes pre-existing TypeScript strict-mode errors surfaced by upgrading moduleResolution from node to bundler.

Test plan

  • 71 unit tests pass (npm test in src/nextjs/)
  • tsc --noEmit clean at repo root and in src/nextjs/
  • Manual smoke test: emulator flow with emulator: true + FIREBASE_AUTH_EMULATOR_HOST set
  • Manual smoke test: production token verify and refresh cycle in a Next.js 15 app
  • Manual smoke test: proxy.ts export in a Next.js 16 app

Attribution

Original implementation by James Daniels ([email protected]), PR #640. Takeover and security hardening by Tyler Dixon.

Closes #640

@tyler-reitz tyler-reitz mentioned this pull request Jul 16, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@tyler-reitz

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

jamesdaniels and others added 6 commits July 16, 2026 15:51
- Move jose from devDependencies to dependencies (runtime dep)
- Strip API key from refresh error log (log origin+pathname only)
- Fix exp=0 falsy guard (use !== undefined)
- Release JWKS eviction lock on fetch error (add cacheDelete + try/finally)
- Return 400 for malformed finalTarget URL instead of throwing TypeError
- Fill in empty error message in composeMiddleware config guard
- Align tenantId docs to string-only (function form deferred)
- Add del() to CacheProvider interface and MemoryCacheProvider
- Annotate @firebase/util and _AuthEmulatorRefreshToken as internal/fragile
- Remove self-referential PR comment and resolved TODO
- Centralize createToken test helper in middleware_test_utils.ts
Introduces the firebase-cookie-middleware package (v0.0.1) for
Next.js 14/15/16, taking over from PR FirebaseExtended#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: FirebaseExtended#640
Original work by: James Daniels ([email protected])
@tyler-reitz
tyler-reitz force-pushed the feat/nextjs-middleware branch from 40b253c to 37050fa Compare July 16, 2026 22:51
@tyler-reitz

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

The nextjs package is a self-contained sub-package with its own
tsconfig and test runner. Including it in the root tsc/vitest runs
causes failures because next is not installed as a root dependency.
Use **/ prefix so the patterns match at any directory depth,
covering both src/nextjs/ and package/src/nextjs/ (the CI test
job unpacks the npm tarball alongside the checkout, creating both
paths). Also broaden node_modules exclusion to catch nested
directories like functions/node_modules/.
@tyler-reitz

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@tyler-reitz

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

…e throw with logout

Three uncaught exceptions that crash middleware with 500:
- proxy path: response.json() fired before response.ok check, returns 502 on non-JSON
- refresh path: refreshResponse.json() now catches non-JSON bodies and calls logout()
- refresh path: missing id_token now calls logout() instead of throwing

Also remove duplicate JSDoc paragraph and fix two em-dashes in comments.
@tyler-reitz

Copy link
Copy Markdown
Contributor Author

Local code review (in lieu of Gemini bot)

The Gemini review bot has failed on every attempt for this PR (5+ runs). The diff is 2,500+ lines of all-new code, which appears to exceed the bot's input limit. I ran a local multi-angle review as a substitute. Three findings were fixed in the latest commit (bb006e9); the rest are documented below for your consideration.


Fixed in bb006e9

1. Proxy path: response.json() before response.ok check (line 447)
response.json() was called unconditionally before response.ok was tested. Any non-JSON response from Firebase (CDN error page, 429 with text body) would throw an uncaught exception, crashing the middleware with a 500 for all users. Now wrapped in try/catch; returns 502 on non-JSON.

2. Refresh path: refreshResponse.json() uncaught (line 552)
Same pattern. If Google's token endpoint returns non-JSON (e.g., a CDN error page with status 200), the middleware crashed. Now calls logout() on parse failure.

3. Refresh path: throw instead of return logout() on missing id_token (line 555)
throw new Error("Missing id_token in refresh response") was uncaught and crashed the middleware. Replaced with console.error + return logout().


Remaining findings (for your review)

Security:

  • Line 244: jwtVerify has no issuer/audience options. Jose does not cryptographically enforce iss or aud; the manual pre-checks on the decodeJwt output cover these, but passing { issuer, audience } to jwtVerify would add a second verification layer. Low severity since the manual checks exist, but worth the defense-in-depth.

  • Line 362: CSRF guard only covers POST and DELETE. GET requests to /__cookies__ bypass the origin check and reach finalTarget parsing. Google's endpoints do not process GET token requests, so exploitability is low, but the guard intent is clearly "state-changing requests" and GET slips through.

Operational correctness:

  • Line 164: MAX_MAX_AGE is defined but not applied to JWKS caching. parseInt(match[1], 10) on the Cache-Control: max-age= value is used directly as the cache TTL. A server returning max-age=999999999 would cache JWKS for ~31 years in a custom CacheProvider, preventing key rotation. The constant MAX_MAX_AGE = 34560000 (400 days) is used for cookies but not here. Fix: Math.min(maxAge, MAX_MAX_AGE).

  • Line 688: If innie throws, all cookie decorators are discarded. The decorators.reduce at line 689 runs after innie. If the user's callback throws, accumulated logout and token-refresh decorators are silently lost, leaving stale cookies in the browser. Consider wrapping innie in try/catch and still running decorators on any fallback response.

  • Line 326: useInsecureCookies only activates for localhost/127.0.0.1. Developers on non-loopback http:// addresses (Docker at http://192.168.1.x:3000, remote dev VMs) get Secure; SameSite=None cookies that Chrome silently rejects on non-secure non-loopback origins. Auth silently fails with no error. Consider documenting this constraint prominently or expanding the loopback check.

  • Line 685: Multi-app: decorators from other apps applied to direct responses. When app1 returns a direct proxy response for /__cookies__?appName=app1 and app2 produces a logout decorator, the app2 decorator is applied to app1's JSON proxy response. The Firebase SDK reading app1's response may see unexpected Set-Cookie deletion headers. Consider skipping decorators for apps other than the one that produced the direct response.

  • Line 637: Array config always sets defaultAppName to '[DEFAULT]'. When composeMiddleware receives an array (as opposed to a single object), defaultAppName is hardcoded to '[DEFAULT]'. If no app in the array has appName: '[DEFAULT]', defaultUser in the innie callback is always undefined even after successful sign-in. The user must look up allUsers[appName] manually with no warning. Worth documenting or auto-detecting the first app as default.

Minor correctness:

  • Line 355: Empty appName= silently routes to [DEFAULT]. searchParams.get('appName') || '[DEFAULT]' treats an empty string the same as absent, misrouting in multi-app setups.

  • Line 313: /__/ rewrite does not guard against authDomain matching the current host. A misconfigured authDomain equal to the deployed domain creates an infinite rewrite loop on platforms that re-invoke middleware on rewrites (not standard Vercel/Next.js, but worth a guard).

Forward compatibility:

  • Line 433: request.cookies.get() called with a full cookie-descriptor object (spread of REFRESH_TOKEN_COOKIE including secure, httpOnly, sameSite, partitioned) rather than the documented string | { name } shape. Works today because Next.js ignores the extra fields, but could break silently on a future Next.js release that validates the argument.

Overall the package is solid. The three crash-on-error cases are fixed; the remaining items are defense-in-depth or edge-case correctness issues appropriate for follow-up issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants