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
15 changes: 15 additions & 0 deletions examples/with-anonymous-sessions/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Auth0 Tenant Configuration
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=your_client_id
AUTH0_CLIENT_SECRET=your_client_secret
AUTH0_SECRET=use_openssl_rand_hex_32_to_generate_a_32_bytes_secret

# Anonymous Sessions
AUTH0_AUDIENCE=https://api.customers

# Application URL
APP_BASE_URL=http://localhost:3000

# TEST HARNESS ONLY — do NOT set in dev/staging/production.
# When "1", the app injects a mock Auth0 fetch (see lib/mock/). Used only by pnpm test:e2e:offline.
# E2E_ANON_MOCK=0
38 changes: 38 additions & 0 deletions examples/with-anonymous-sessions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage
/test-results/
/playwright-report/
/playwright/.cache/

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
1 change: 1 addition & 0 deletions examples/with-anonymous-sessions/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
64 changes: 64 additions & 0 deletions examples/with-anonymous-sessions/COVERAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Test Coverage Matrix

This example ships a tiered test suite. The browser (Playwright) tier is intentionally a thin slice over the SDK's unit and integration coverage, not a replacement for it.

## Coverage by Tier

| Behavior | Unit (src/) | Live (Tier1) | MSW (Tier2) | Offline e2e (mock) | Browser e2e | Notes |
| ------------------------------- | --------------------------------- | ------------------------------ | ---------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------- |
| Create success | `POST /api/anon/create` (T1.1) | L1 (happy path) | T2.1 (mock AS success) | O1, O2 | — | O1/O2 prove example wiring + SDK persist; unit + live prove full invariant |
| Create errors (AS rejects) | T1.2 (mock tenant error) | L2 (live tenant unreachable) | T2.2 (mock AS 500) | O9-O13 | — | O9-O13 cover 403/400/500; unit + MSW prove SDK error-mapping |
| Get/read | T1.3 (unit flow.test) | L3 (live GET after create) | — | O1, O3 | — | O1/O3 prove example wiring + SDK decrypt; unit + live prove full invariant |
| Renew (silent) | T1.4-1.6 (unit + integration) | L4 (live renewal) | T2.3 (mock AS renewal) | O15 | — | O15 proves example wiring + SDK renew; unit + live prove full invariant |
| Logout | T1.7 (unit flow.test) | L5 (live logout) | — | O-logout | L11 (logout UI) | O-logout + L11 exercise UI reset + navigation; unit proves server invariant |
| SEC-1 strip+inject | flow.test.ts:451-490, :561 | — | — | — | L9a/L9b/L9c (strip) | L9 proves end-to-end HTTP layer; unit proves server logic |
| SEC-1 bind/tamper | flow.test.ts:565-642 | — | — | — | — | Unit coverage sufficient (callback tamper) |
| Callback link (happy) | flow.test.ts (unit callback flow) | — | — | — | L10a (live link) | L10a live-gated; unit proves server logic |
| Callback link (tamper negative) | flow.test.ts:565-642 | — | — | — | — | Unit proves anonymousSessionLinked=false on digest mismatch |
| Cookie chunking >4KB | cookies.test.ts (unit) | — | — | O16 (single-cookie doc) | — | O16 asserts single-cookie for typical payloads; unit proves chunk logic |
| Metadata set-once | T1.8 (unit, POST with metadata) | L6 (live set-once enforcement) | — | O17 | — | O17 proves metadata retained across renewal; unit + live prove set-once invariant |
| Metadata 1KB cap | T1.9 (unit, POST with oversized) | — | — | O14 | — | O14 proves client-side cap; unit coverage sufficient |
| Hook loading/error/data states | — | — | — | O3, O4, O5, O7 | L12 (error banner) | O3/O4/O5/O7 + L12 prove loading/error/data states; L11 exercises data state |
| Error banner UI | — | — | — | O9 | L12 (error banner) | O9 + L12 prove error banner rendering |
| Logout UI | — | — | — | O-logout | L11 (logout UI) | O-logout + L11 prove logout UI flow |

## Offline Mock Tier

The offline tier (17 tests in `tests/e2e/offline/`) runs against a mock Auth0 tenant at the `customFetch` layer (`lib/mock/anon-mock-fetch.ts`). The mock intercepts `POST /anonymous/token` and `POST /anonymous/logout` requests and returns configurable responses (success, expired, 403/400/500 errors), controlled via the scenario route `app/api/test/mock-scenario`. The SDK's real encrypt/persist/decrypt/renew logic executes normally — only the network hop is faked.

**What it tests:**

- Example wiring (create/get/renew/logout routes, error-mapping, UI state)
- SDK read/persist/renew/error-handling paths
- Deterministic error scenarios (feature_not_enabled, unauthorized_client, invalid_target, invalid_scope, server_error, metadata_too_large)

**What it does NOT test:**

- The actual `/anonymous/token` wire contract (request/response shape, server-side validation, token issuance)
- Live tenant configuration (client grants, `anonymous_sessions.active`, audience setup)
- Access token signature verification, aud/iss/exp claims validation, scope enforcement, and session_expires_in handling — the mock uses an unsigned synthetic JWT and omits those fields; only the live Tier1 specs prove the real /anonymous/token wire contract

Run: `pnpm test:e2e:offline`. Needs no tenant credentials. Boots a test server on `:3001` with `E2E_ANON_MOCK=1`.

**Coverage:** O1-O8 (session lifecycle), O9-O14 (error paths), O15-O17 (renewal, chunking constraint, metadata set-once). See matrix above for cross-tier coverage. The live Tier1 specs (L1-L8, L9b, L10a) remain the definitive verification of the Auth0 wire contract.

## Known Gaps at the Browser Tier (By Design)

The following behaviors are NOT covered at the live browser (Playwright) tier, but are proven at lower test tiers:

- **Cookie-tamper negative (SEC-1 bind)** — Covered by unit test `src/server/anonymous-session.flow.test.ts:565-642`, which asserts `anonymousSessionLinked=false` on digest mismatch. A browser test would require a live tenant and manual cookie manipulation; the unit test already proves the invariant.

- **Cookie chunking >4KB** — Covered by unit test `src/cookies.test.ts` (chunk logic). Offline test O16 documents that typical payloads remain single-cookie and that >4KB is unreachable through the public create path (1KB metadata cap). The SDK's cookie-chunking logic is deterministic and does not require live browser validation.

- **Live callback link (negative case)** — L10a proves the happy path (anonymousSessionLinked=true). The tamper negative case is covered by unit test `src/server/anonymous-session.flow.test.ts:565-642`.

## Default Run vs Full Run

**Offline tier (`pnpm test:e2e:offline`):** Runs fully unconditionally (17/17 specs). No credentials required.

**Live tier (`pnpm test:e2e`):** Without tenant credentials (`AUTH0_DOMAIN`, `AUTH0_CLIENT_SECRET`, `AUTH0_CLIENT_ID`):

- **Run unconditionally:** L9a, L9c (strip), L11 (logout UI), L12 (error banner)
- **Skip (gated on live tenant):** L9b (strip with legit cookie), L10a (callback link happy)

When reading green test output, do NOT interpret it as exhaustive security coverage. The browser tier is a thin slice. The SDK's unit and integration tests provide the bulk of coverage.
130 changes: 130 additions & 0 deletions examples/with-anonymous-sessions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Anonymous Sessions Example

This example demonstrates anonymous sessions (EA feature) in @auth0/nextjs-auth0.

## Features

- **Create Anonymous Sessions**: Generate anonymous identity before login with optional metadata
- **SSR Seed**: Server-side anonymous session fetch prevents loading flash
- **Login-to-Link**: Convert anonymous session to authenticated account
- **Protected API**: Use anonymous access token to call audience-protected resources
- **Client Hook**: `useAnonymousSession()` for live session state
- **Security Best Practices**: SEC-1 (no session_token in authorizationParameters), metadata set-once

## Prerequisites

1. **Build SDK First**: Run `pnpm build` at the repository root to compile the SDK (required for `file:../..` dependency)
2. **Tenant Configuration**: See `../../.forge/features/anonymous-sessions/poc/lite/TENANT-SETUP.md` for required Auth0 tenant setup:
- `oidc_conformant: true` on client
- `anonymous_sessions.active: true` on client
- Client grant with `subject_type: anonymous_user` for audience `https://api.customers`
3. **Environment Variables**: Copy `.env.example` to `.env.local` and fill in your Auth0 tenant credentials

## Setup

```bash
# 1. Install dependencies (from example directory)
pnpm install

# 2. Configure .env.local
cp .env.example .env.local
# Edit .env.local with your Auth0 tenant credentials

# 3. Run development server
pnpm dev
```

Visit http://localhost:3000

## Demo Flow

1. **Home Page** (`/`):
- "Enter as Guest" → creates anonymous session via GET route
- "Create with Metadata" → creates session with custom metadata (e.g., cart state)
- Fail-loud diagnostic if tenant misconfigured (403 unauthorized_client)

2. **Demo Page** (`/demo`):
- SSR session display (may be stale, D7)
- Client-side live session panel (`useAnonymousSession()`)
- Logout/Renew/Invalidate buttons
- "Login to Link" → converts anonymous session to authenticated account (SEC-1: SDK injects session_token from cookie, no param)
- "Fetch Products" → calls protected API with anonymous access token

## API Routes

- `GET /auth/anonymous-session` — SDK route: creates session, redirects to returnTo
- `POST /auth/anonymous-session/logout` — SDK route: clears cookie (no server-side revocation)
- `POST /api/anon/create` — App route: creates session with metadata
- `GET /api/products` — App route: uses access token to call protected API (stub)

## Security Notes

- **SEC-1 (Session Token Fixation)**: SDK injects session_token from its own cookie during login (3-layer protection). Applications must NOT allow session_token in authorizationParameters.
- **Metadata Set-Once**: Metadata can only be set at creation time, cannot be updated after (CASCADE-v2 M2).
- **No Server Revocation**: Logout clears the client cookie but does NOT revoke the session server-side; tokens remain valid until expiry.
- **SSR Staleness (D7)**: Server Component `getAnonymousSession()` reads may be stale; renewal is deferred to route handlers.

## Scripts

- `pnpm dev` — Start development server
- `pnpm build` — Build for production
- `pnpm start` — Run production build
- `pnpm lint` — Run ESLint
- `pnpm test` — Unit + MSW tests (Vitest)
- `pnpm test:e2e:l9` — SEC-1 session_token-strip browser test (Playwright, creds-free)
- `pnpm test:e2e` — All Playwright specs

## Testing

Unit/integration (Vitest): `pnpm test`. Deterministic MSW-backed and live-tenant
tiers; the live server tier skips without tenant creds.

Browser (Playwright, `tests/e2e/`):

> **Note:** These browser tests use an example-local Playwright harness. When the repo-wide e2e suite (`feat/e2e-test-suite`, root `e2e/`) lands in main, this coverage will fold into that centralized harness and adopt its shared login helper (`loginWithAuth0`) and `injectSession` conventions.

**Offline Mock Tier**

The offline tier tests the example wiring and SDK logic against a mock Auth0 tenant. The mock (injected via `customFetch` at `lib/mock/anon-mock-fetch.ts`) intercepts `POST /anonymous/token` and `POST /anonymous/logout` requests and returns configurable responses (success, expired, 403/400/500 errors). The SDK's real encrypt/persist/decrypt/renew logic executes normally. No tenant credentials required.

Run: `pnpm test:e2e:offline`. Runs 17 specs covering session lifecycle (create, get, renew, logout), error paths (feature_not_enabled, unauthorized_client, invalid_target, invalid_scope, server_error, metadata_too_large), and UI states (loading, error banner, metadata display).

**Live Tier**

- **L9 — SEC-1 strip** (`sec1-strip.spec.ts`): proves a caller-supplied
`session_token` on `/auth/login` never reaches `/authorize`. L9a/L9c are
creds-free (CI-safe); L9b mints a real `auth0_anon` cookie and needs
`.env.local`. Run: `pnpm test:e2e:l9`.
- **L10 — callback link** (`link-callback.spec.ts`): drives a real Universal
Login and asserts the callback links the anonymous session (`?linked=true`
banner). Gated; skips unless a tenant test user is supplied:

```bash
TEST_USER_EMAIL=you@example.com TEST_USER_PASSWORD=... \
pnpm exec playwright test link-callback
```

First run installs the browser: `pnpm exec playwright install chromium`.

## Production Checklist

This example includes test-only files for the offline e2e harness. Before deploying to production or using this example as a template for a real application, remove the following directories and files:

- `lib/mock/` — offline mock fetch for E2E_ANON_MOCK mode
- `app/api/test/` — scenario injection route for test harness
- `playwright.offline.config.ts` and `tests/e2e/offline/`
- The `E2E_ANON_MOCK` gated `customFetch` line in `lib/auth0.ts`

These files exist solely to support offline testing without Auth0 tenant credentials. They have no role in a production application and should never be deployed to dev, staging, or production environments.

## Dependencies

- `@auth0/nextjs-auth0`: `file:../..` (local SDK build)
- `next`: 16.2.5
- `react`: 19.2.1

## References

- [Anonymous Sessions Spec](../../.forge/features/anonymous-sessions/poc/lite/BUILD-SPEC.md)
- [Tenant Setup](../../.forge/features/anonymous-sessions/poc/lite/TENANT-SETUP.md)
- [Wire Contract](../../.forge/features/anonymous-sessions/poc/lite/WIRE-CONTRACT-LIVE.md)
47 changes: 47 additions & 0 deletions examples/with-anonymous-sessions/app/api/anon/create/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
AnonymousSessionError,
getStatusForAnonymousError
} from "@auth0/nextjs-auth0/errors";

import { auth0 } from "@/lib/auth0";

export async function POST(req: NextRequest) {
try {
// Read the request body from a CLONE. Under the Next.js 16 proxy runtime the
// handler receives a plain `Request` (not a `NextRequest`), and the SDK
// internally rebuilds a `NextRequest` from it, reusing the original body
// stream. If we consume `req.body` here via `req.json()`, that rebuild fails
// with "Response body object should not be disturbed or locked". Cloning
// leaves `req`'s stream intact for the SDK.
const body = await req.clone().json();
const metadata = body.metadata || undefined;

// The SDK writes the encrypted `auth0_anon` cookie onto `res.cookies`. We
// must return that SAME response so the Set-Cookie header reaches the
// client. Passing `res` as the second arg to NextResponse.json() copies its
// headers (incl. Set-Cookie) and status onto the JSON response.
const res = NextResponse.json(null, { status: 201 });
const session = await auth0.createAnonymousSession(req, res, { metadata });

return NextResponse.json(session, res);
} catch (err: any) {
console.error("[POST /api/anon/create] Error:", err);
if (err instanceof AnonymousSessionError) {
return NextResponse.json(
{
code: err.code,
message: err.message
},
{ status: getStatusForAnonymousError(err.code) }
);
}
return NextResponse.json(
{
code: "internal_error",
message: err.message || "Failed to create anonymous session"
},
{ status: 500 }
);
}
}
42 changes: 42 additions & 0 deletions examples/with-anonymous-sessions/app/api/products/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";

import { auth0 } from "@/lib/auth0";

export async function GET(req: NextRequest) {
try {
const anonymousSession = await auth0.getAnonymousSession(req);

if (!anonymousSession) {
return NextResponse.json(
{ error: "No anonymous session found" },
{ status: 401 }
);
}

// Use access token to call protected API (stub/echo since api.customers may not have real endpoints)
// In production, this would be: fetch(`${process.env.AUTH0_AUDIENCE}/products`, { headers: { Authorization: `Bearer ${anonymousSession.accessToken}` } })

// For demo: echo token info and simulate success
const response = {
message:
"Successfully fetched products using anonymous session access token",
audience: process.env.AUTH0_AUDIENCE,
scope: "read:customers",
tokenPreview: anonymousSession.accessToken.substring(0, 30) + "...",
expiresAt: new Date(anonymousSession.expiresAt * 1000).toISOString(),
// Stub product data
products: [
{ id: 1, name: "Product A", price: 29.99 },
{ id: 2, name: "Product B", price: 49.99 }
]
};

return NextResponse.json(response, { status: 200 });
} catch (err: any) {
console.error("[GET /api/products] Error:", err);
return NextResponse.json(
{ error: err.message || "Failed to fetch products" },
{ status: 500 }
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* TEST-ONLY — DO NOT COPY TO PRODUCTION.
*
* Part of the offline e2e mock harness (playwright.offline.config.ts, E2E_ANON_MOCK=1).
* This route injects test scenarios to control mock Auth0 responses. It must never run in a
* real deployment. Delete app/api/test/ before using this example in production.
*/
import { NextResponse } from "next/server";

import { getScenario, setScenario } from "@/lib/mock/anon-mock-fetch";

export async function POST(req: Request) {
if (process.env.E2E_ANON_MOCK !== "1") {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}

const { scenario } = await req.json();
setScenario(scenario);
return NextResponse.json({ ok: true, scenario });
}

export async function GET() {
if (process.env.E2E_ANON_MOCK !== "1") {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}

return NextResponse.json({ scenario: getScenario() });
}
Loading
Loading