Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions src/nextjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# 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

- **⚡ 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`.
- **🛡️ 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:<idToken>`). 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
```

---

## Quickstart

### 1. Create your Middleware (`src/middleware.ts`)

Create or update your `middleware.ts` 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<string, FirebaseJWTPayload>) => Promise<NextResponse | void> | 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<Config | Config[]>)`
- 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 \| ((req: NextRequest) => string \| Promise<string>)` | Google Cloud Identity Platform tenant ID, or a function resolving the tenant dynamically per request. |
| **`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<T = unknown>(key: string): Promise<T | null | undefined> | T | null | undefined;
set(
key: string,
value: any,
options?: { ex?: number; ttl?: number; nx?: boolean },
): Promise<any> | any;
setex?(key: string, seconds: number, value: any): Promise<any> | 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:<idToken>` 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_<appName>`)**:
- 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_<appName>`)**:
- 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, export your emulator environment variable:

```bash
export FIREBASE_AUTH_EMULATOR_HOST="localhost:9099"
```

The middleware automatically accepts unsigned emulator tokens (`alg: "none"`) and proxies token refresh attempts directly to your local emulator instance.
Loading
Loading