Skip to content

Commit c58504c

Browse files
test(examples): add runnable anonymous-sessions example with tiered tests
Add examples/with-anonymous-sessions/, a runnable example consuming the local SDK that demonstrates the full anonymous-session flow: guest session creation with metadata, Server Component and client-hook reads, login-to-link, and logout. Tiered test suite: - Unit/MSW: wire-contract and client-hook coverage. - Browser (Playwright): the session-fixation strip end-to-end and the logout and error-banner UI run without credentials; the live login-to-link callback runs against a tenant test user. - Offline mock tier (pnpm test:e2e:offline): 17 deterministic tests covering the full lifecycle, error-code mappings, renewal, and set-once retention through the real SDK against a mocked Auth0 network hop, no tenant required. COVERAGE.md documents the tier-by-tier matrix and disclosed gaps. The mock, its scenario-control route, and the gated seam are marked test-only with a production checklist in the README. No real secrets; synthetic tokens only. Not wired into CI. Depends on the anonymous-sessions SDK implementation (stacked base). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ef6a5f commit c58504c

37 files changed

Lines changed: 8788 additions & 0 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Auth0 Tenant Configuration
2+
AUTH0_DOMAIN=your-tenant.auth0.com
3+
AUTH0_CLIENT_ID=your_client_id
4+
AUTH0_CLIENT_SECRET=your_client_secret
5+
AUTH0_SECRET=use_openssl_rand_hex_32_to_generate_a_32_bytes_secret
6+
7+
# Anonymous Sessions
8+
AUTH0_AUDIENCE=https://api.customers
9+
10+
# Application URL
11+
APP_BASE_URL=http://localhost:3000
12+
13+
# TEST HARNESS ONLY — do NOT set in dev/staging/production.
14+
# When "1", the app injects a mock Auth0 fetch (see lib/mock/). Used only by pnpm test:e2e:offline.
15+
# E2E_ANON_MOCK=0
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
/test-results/
11+
/playwright-report/
12+
/playwright/.cache/
13+
14+
# next.js
15+
/.next/
16+
/out/
17+
18+
# production
19+
/build
20+
21+
# misc
22+
.DS_Store
23+
*.pem
24+
25+
# debug
26+
npm-debug.log*
27+
yarn-debug.log*
28+
yarn-error.log*
29+
30+
# local env files
31+
.env*.local
32+
33+
# vercel
34+
.vercel
35+
36+
# typescript
37+
*.tsbuildinfo
38+
next-env.d.ts
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
registry=https://registry.npmjs.org/
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Test Coverage Matrix
2+
3+
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.
4+
5+
## Coverage by Tier
6+
7+
| Behavior | Unit (src/) | Live (Tier1) | MSW (Tier2) | Offline e2e (mock) | Browser e2e | Notes |
8+
| ------------------------------- | --------------------------------- | ------------------------------ | ---------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------- |
9+
| 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 |
10+
| 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 |
11+
| 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 |
12+
| 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 |
13+
| 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 |
14+
| 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 |
15+
| SEC-1 bind/tamper | flow.test.ts:565-642 ||||| Unit coverage sufficient (callback tamper) |
16+
| Callback link (happy) | flow.test.ts (unit callback flow) |||| L10a (live link) | L10a live-gated; unit proves server logic |
17+
| Callback link (tamper negative) | flow.test.ts:565-642 ||||| Unit proves anonymousSessionLinked=false on digest mismatch |
18+
| Cookie chunking >4KB | cookies.test.ts (unit) ||| O16 (single-cookie doc) || O16 asserts single-cookie for typical payloads; unit proves chunk logic |
19+
| 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 |
20+
| Metadata 1KB cap | T1.9 (unit, POST with oversized) ||| O14 || O14 proves client-side cap; unit coverage sufficient |
21+
| 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 |
22+
| Error banner UI |||| O9 | L12 (error banner) | O9 + L12 prove error banner rendering |
23+
| Logout UI |||| O-logout | L11 (logout UI) | O-logout + L11 prove logout UI flow |
24+
25+
## Offline Mock Tier
26+
27+
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.
28+
29+
**What it tests:**
30+
31+
- Example wiring (create/get/renew/logout routes, error-mapping, UI state)
32+
- SDK read/persist/renew/error-handling paths
33+
- Deterministic error scenarios (feature_not_enabled, unauthorized_client, invalid_target, invalid_scope, server_error, metadata_too_large)
34+
35+
**What it does NOT test:**
36+
37+
- The actual `/anonymous/token` wire contract (request/response shape, server-side validation, token issuance)
38+
- Live tenant configuration (client grants, `anonymous_sessions.active`, audience setup)
39+
- 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
40+
41+
Run: `pnpm test:e2e:offline`. Needs no tenant credentials. Boots a test server on `:3001` with `E2E_ANON_MOCK=1`.
42+
43+
**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.
44+
45+
## Known Gaps at the Browser Tier (By Design)
46+
47+
The following behaviors are NOT covered at the live browser (Playwright) tier, but are proven at lower test tiers:
48+
49+
- **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.
50+
51+
- **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.
52+
53+
- **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`.
54+
55+
## Default Run vs Full Run
56+
57+
**Offline tier (`pnpm test:e2e:offline`):** Runs fully unconditionally (17/17 specs). No credentials required.
58+
59+
**Live tier (`pnpm test:e2e`):** Without tenant credentials (`AUTH0_DOMAIN`, `AUTH0_CLIENT_SECRET`, `AUTH0_CLIENT_ID`):
60+
61+
- **Run unconditionally:** L9a, L9c (strip), L11 (logout UI), L12 (error banner)
62+
- **Skip (gated on live tenant):** L9b (strip with legit cookie), L10a (callback link happy)
63+
64+
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.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Anonymous Sessions Example
2+
3+
This example demonstrates anonymous sessions (EA feature) in @auth0/nextjs-auth0.
4+
5+
## Features
6+
7+
- **Create Anonymous Sessions**: Generate anonymous identity before login with optional metadata
8+
- **SSR Seed**: Server-side anonymous session fetch prevents loading flash
9+
- **Login-to-Link**: Convert anonymous session to authenticated account
10+
- **Protected API**: Use anonymous access token to call audience-protected resources
11+
- **Client Hook**: `useAnonymousSession()` for live session state
12+
- **Security Best Practices**: SEC-1 (no session_token in authorizationParameters), metadata set-once
13+
14+
## Prerequisites
15+
16+
1. **Build SDK First**: Run `pnpm build` at the repository root to compile the SDK (required for `file:../..` dependency)
17+
2. **Tenant Configuration**: See `../../.forge/features/anonymous-sessions/poc/lite/TENANT-SETUP.md` for required Auth0 tenant setup:
18+
- `oidc_conformant: true` on client
19+
- `anonymous_sessions.active: true` on client
20+
- Client grant with `subject_type: anonymous_user` for audience `https://api.customers`
21+
3. **Environment Variables**: Copy `.env.example` to `.env.local` and fill in your Auth0 tenant credentials
22+
23+
## Setup
24+
25+
```bash
26+
# 1. Install dependencies (from example directory)
27+
pnpm install
28+
29+
# 2. Configure .env.local
30+
cp .env.example .env.local
31+
# Edit .env.local with your Auth0 tenant credentials
32+
33+
# 3. Run development server
34+
pnpm dev
35+
```
36+
37+
Visit http://localhost:3000
38+
39+
## Demo Flow
40+
41+
1. **Home Page** (`/`):
42+
- "Enter as Guest" → creates anonymous session via GET route
43+
- "Create with Metadata" → creates session with custom metadata (e.g., cart state)
44+
- Fail-loud diagnostic if tenant misconfigured (403 unauthorized_client)
45+
46+
2. **Demo Page** (`/demo`):
47+
- SSR session display (may be stale, D7)
48+
- Client-side live session panel (`useAnonymousSession()`)
49+
- Logout/Renew/Invalidate buttons
50+
- "Login to Link" → converts anonymous session to authenticated account (SEC-1: SDK injects session_token from cookie, no param)
51+
- "Fetch Products" → calls protected API with anonymous access token
52+
53+
## API Routes
54+
55+
- `GET /auth/anonymous-session` — SDK route: creates session, redirects to returnTo
56+
- `POST /auth/anonymous-session/logout` — SDK route: clears cookie (no server-side revocation)
57+
- `POST /api/anon/create` — App route: creates session with metadata
58+
- `GET /api/products` — App route: uses access token to call protected API (stub)
59+
60+
## Security Notes
61+
62+
- **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.
63+
- **Metadata Set-Once**: Metadata can only be set at creation time, cannot be updated after (CASCADE-v2 M2).
64+
- **No Server Revocation**: Logout clears the client cookie but does NOT revoke the session server-side; tokens remain valid until expiry.
65+
- **SSR Staleness (D7)**: Server Component `getAnonymousSession()` reads may be stale; renewal is deferred to route handlers.
66+
67+
## Scripts
68+
69+
- `pnpm dev` — Start development server
70+
- `pnpm build` — Build for production
71+
- `pnpm start` — Run production build
72+
- `pnpm lint` — Run ESLint
73+
- `pnpm test` — Unit + MSW tests (Vitest)
74+
- `pnpm test:e2e:l9` — SEC-1 session_token-strip browser test (Playwright, creds-free)
75+
- `pnpm test:e2e` — All Playwright specs
76+
77+
## Testing
78+
79+
Unit/integration (Vitest): `pnpm test`. Deterministic MSW-backed and live-tenant
80+
tiers; the live server tier skips without tenant creds.
81+
82+
Browser (Playwright, `tests/e2e/`):
83+
84+
> **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.
85+
86+
**Offline Mock Tier**
87+
88+
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.
89+
90+
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).
91+
92+
**Live Tier**
93+
94+
- **L9 — SEC-1 strip** (`sec1-strip.spec.ts`): proves a caller-supplied
95+
`session_token` on `/auth/login` never reaches `/authorize`. L9a/L9c are
96+
creds-free (CI-safe); L9b mints a real `auth0_anon` cookie and needs
97+
`.env.local`. Run: `pnpm test:e2e:l9`.
98+
- **L10 — callback link** (`link-callback.spec.ts`): drives a real Universal
99+
Login and asserts the callback links the anonymous session (`?linked=true`
100+
banner). Gated; skips unless a tenant test user is supplied:
101+
102+
```bash
103+
TEST_USER_EMAIL=you@example.com TEST_USER_PASSWORD=... \
104+
pnpm exec playwright test link-callback
105+
```
106+
107+
First run installs the browser: `pnpm exec playwright install chromium`.
108+
109+
## Production Checklist
110+
111+
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:
112+
113+
- `lib/mock/` — offline mock fetch for E2E_ANON_MOCK mode
114+
- `app/api/test/` — scenario injection route for test harness
115+
- `playwright.offline.config.ts` and `tests/e2e/offline/`
116+
- The `E2E_ANON_MOCK` gated `customFetch` line in `lib/auth0.ts`
117+
118+
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.
119+
120+
## Dependencies
121+
122+
- `@auth0/nextjs-auth0`: `file:../..` (local SDK build)
123+
- `next`: 16.2.5
124+
- `react`: 19.2.1
125+
126+
## References
127+
128+
- [Anonymous Sessions Spec](../../.forge/features/anonymous-sessions/poc/lite/BUILD-SPEC.md)
129+
- [Tenant Setup](../../.forge/features/anonymous-sessions/poc/lite/TENANT-SETUP.md)
130+
- [Wire Contract](../../.forge/features/anonymous-sessions/poc/lite/WIRE-CONTRACT-LIVE.md)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import {
3+
AnonymousSessionError,
4+
getStatusForAnonymousError
5+
} from "@auth0/nextjs-auth0/errors";
6+
7+
import { auth0 } from "@/lib/auth0";
8+
9+
export async function POST(req: NextRequest) {
10+
try {
11+
// Read the request body from a CLONE. Under the Next.js 16 proxy runtime the
12+
// handler receives a plain `Request` (not a `NextRequest`), and the SDK
13+
// internally rebuilds a `NextRequest` from it, reusing the original body
14+
// stream. If we consume `req.body` here via `req.json()`, that rebuild fails
15+
// with "Response body object should not be disturbed or locked". Cloning
16+
// leaves `req`'s stream intact for the SDK.
17+
const body = await req.clone().json();
18+
const metadata = body.metadata || undefined;
19+
20+
// The SDK writes the encrypted `auth0_anon` cookie onto `res.cookies`. We
21+
// must return that SAME response so the Set-Cookie header reaches the
22+
// client. Passing `res` as the second arg to NextResponse.json() copies its
23+
// headers (incl. Set-Cookie) and status onto the JSON response.
24+
const res = NextResponse.json(null, { status: 201 });
25+
const session = await auth0.createAnonymousSession(req, res, { metadata });
26+
27+
return NextResponse.json(session, res);
28+
} catch (err: any) {
29+
console.error("[POST /api/anon/create] Error:", err);
30+
if (err instanceof AnonymousSessionError) {
31+
return NextResponse.json(
32+
{
33+
code: err.code,
34+
message: err.message
35+
},
36+
{ status: getStatusForAnonymousError(err.code) }
37+
);
38+
}
39+
return NextResponse.json(
40+
{
41+
code: "internal_error",
42+
message: err.message || "Failed to create anonymous session"
43+
},
44+
{ status: 500 }
45+
);
46+
}
47+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
import { auth0 } from "@/lib/auth0";
4+
5+
export async function GET(req: NextRequest) {
6+
try {
7+
const anonymousSession = await auth0.getAnonymousSession(req);
8+
9+
if (!anonymousSession) {
10+
return NextResponse.json(
11+
{ error: "No anonymous session found" },
12+
{ status: 401 }
13+
);
14+
}
15+
16+
// Use access token to call protected API (stub/echo since api.customers may not have real endpoints)
17+
// In production, this would be: fetch(`${process.env.AUTH0_AUDIENCE}/products`, { headers: { Authorization: `Bearer ${anonymousSession.accessToken}` } })
18+
19+
// For demo: echo token info and simulate success
20+
const response = {
21+
message:
22+
"Successfully fetched products using anonymous session access token",
23+
audience: process.env.AUTH0_AUDIENCE,
24+
scope: "read:customers",
25+
tokenPreview: anonymousSession.accessToken.substring(0, 30) + "...",
26+
expiresAt: new Date(anonymousSession.expiresAt * 1000).toISOString(),
27+
// Stub product data
28+
products: [
29+
{ id: 1, name: "Product A", price: 29.99 },
30+
{ id: 2, name: "Product B", price: 49.99 }
31+
]
32+
};
33+
34+
return NextResponse.json(response, { status: 200 });
35+
} catch (err: any) {
36+
console.error("[GET /api/products] Error:", err);
37+
return NextResponse.json(
38+
{ error: err.message || "Failed to fetch products" },
39+
{ status: 500 }
40+
);
41+
}
42+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* TEST-ONLY — DO NOT COPY TO PRODUCTION.
3+
*
4+
* Part of the offline e2e mock harness (playwright.offline.config.ts, E2E_ANON_MOCK=1).
5+
* This route injects test scenarios to control mock Auth0 responses. It must never run in a
6+
* real deployment. Delete app/api/test/ before using this example in production.
7+
*/
8+
import { NextResponse } from "next/server";
9+
10+
import { getScenario, setScenario } from "@/lib/mock/anon-mock-fetch";
11+
12+
export async function POST(req: Request) {
13+
if (process.env.E2E_ANON_MOCK !== "1") {
14+
return NextResponse.json({ error: "not_found" }, { status: 404 });
15+
}
16+
17+
const { scenario } = await req.json();
18+
setScenario(scenario);
19+
return NextResponse.json({ ok: true, scenario });
20+
}
21+
22+
export async function GET() {
23+
if (process.env.E2E_ANON_MOCK !== "1") {
24+
return NextResponse.json({ error: "not_found" }, { status: 404 });
25+
}
26+
27+
return NextResponse.json({ scenario: getScenario() });
28+
}

0 commit comments

Comments
 (0)