diff --git a/examples/with-anonymous-sessions/.env.example b/examples/with-anonymous-sessions/.env.example new file mode 100644 index 000000000..f438dfaef --- /dev/null +++ b/examples/with-anonymous-sessions/.env.example @@ -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 diff --git a/examples/with-anonymous-sessions/.gitignore b/examples/with-anonymous-sessions/.gitignore new file mode 100644 index 000000000..7b7b24f1d --- /dev/null +++ b/examples/with-anonymous-sessions/.gitignore @@ -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 diff --git a/examples/with-anonymous-sessions/.npmrc b/examples/with-anonymous-sessions/.npmrc new file mode 100644 index 000000000..214c29d13 --- /dev/null +++ b/examples/with-anonymous-sessions/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/examples/with-anonymous-sessions/COVERAGE.md b/examples/with-anonymous-sessions/COVERAGE.md new file mode 100644 index 000000000..29bfef874 --- /dev/null +++ b/examples/with-anonymous-sessions/COVERAGE.md @@ -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. diff --git a/examples/with-anonymous-sessions/README.md b/examples/with-anonymous-sessions/README.md new file mode 100644 index 000000000..9b203d107 --- /dev/null +++ b/examples/with-anonymous-sessions/README.md @@ -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) diff --git a/examples/with-anonymous-sessions/app/api/anon/create/route.ts b/examples/with-anonymous-sessions/app/api/anon/create/route.ts new file mode 100644 index 000000000..67b38b36a --- /dev/null +++ b/examples/with-anonymous-sessions/app/api/anon/create/route.ts @@ -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 } + ); + } +} diff --git a/examples/with-anonymous-sessions/app/api/products/route.ts b/examples/with-anonymous-sessions/app/api/products/route.ts new file mode 100644 index 000000000..48b44c766 --- /dev/null +++ b/examples/with-anonymous-sessions/app/api/products/route.ts @@ -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 } + ); + } +} diff --git a/examples/with-anonymous-sessions/app/api/test/mock-scenario/route.ts b/examples/with-anonymous-sessions/app/api/test/mock-scenario/route.ts new file mode 100644 index 000000000..e86fde063 --- /dev/null +++ b/examples/with-anonymous-sessions/app/api/test/mock-scenario/route.ts @@ -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() }); +} diff --git a/examples/with-anonymous-sessions/app/components/AnonymousSessionPanel.tsx b/examples/with-anonymous-sessions/app/components/AnonymousSessionPanel.tsx new file mode 100644 index 000000000..53ee76931 --- /dev/null +++ b/examples/with-anonymous-sessions/app/components/AnonymousSessionPanel.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useAnonymousSession } from "@auth0/nextjs-auth0/client"; + +export default function AnonymousSessionPanel() { + const { anonymous, isLoading, error, invalidate } = useAnonymousSession(); + + if (isLoading) { + return

Loading...

; + } + + if (error) { + return ( +
+ Error: {error.message} +
+ ); + } + + if (!anonymous) { + return

No anonymous session found (client-side hook).

; + } + + const logout = async () => { + await fetch("/auth/anonymous-session/logout", { method: "POST" }); + invalidate(); + window.location.href = "/"; + }; + + const renew = async () => { + // Trigger renewal by invalidating SWR cache and refetching + invalidate(); + }; + + return ( +
+

+ ID: {anonymous.id} +

+

+ Access Token (first 20 chars):{" "} + {anonymous.accessToken.substring(0, 20)}... +

+

+ Expires At:{" "} + {new Date(anonymous.expiresAt * 1000).toISOString()} +

+ {anonymous.metadata && ( + <> +

+ Metadata: +

+
{JSON.stringify(anonymous.metadata, null, 2)}
+ + )} + +
+ + + +
+
+ ); +} diff --git a/examples/with-anonymous-sessions/app/components/LoginToLinkButton.tsx b/examples/with-anonymous-sessions/app/components/LoginToLinkButton.tsx new file mode 100644 index 000000000..b4eb67cba --- /dev/null +++ b/examples/with-anonymous-sessions/app/components/LoginToLinkButton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +export default function LoginToLinkButton() { + const searchParams = useSearchParams(); + const [linked, setLinked] = useState(false); + + useEffect(() => { + // Detect ?linked=true query param after callback redirect + if (searchParams.get("linked") === "true") { + setLinked(true); + } + }, [searchParams]); + + const handleLogin = () => { + // SEC-1: NO session_token param in authorizationParameters. + // SDK injects session_token from cookie automatically (3-layer fixation protection). + window.location.href = "/auth/login?returnTo=/demo"; + }; + + return ( +
+ + {linked && ( +
+ ✓ Anonymous session successfully linked to your account! +
+ )} +
+ ); +} diff --git a/examples/with-anonymous-sessions/app/demo/page.tsx b/examples/with-anonymous-sessions/app/demo/page.tsx new file mode 100644 index 000000000..f00709100 --- /dev/null +++ b/examples/with-anonymous-sessions/app/demo/page.tsx @@ -0,0 +1,76 @@ +import Link from "next/link"; + +import { auth0 } from "@/lib/auth0"; +import AnonymousSessionPanel from "@/app/components/AnonymousSessionPanel"; +import LoginToLinkButton from "@/app/components/LoginToLinkButton"; + +export default async function DemoPage() { + // Server-side anonymous session read (may be stale if access token renewed client-side) + const anonymousSession = await auth0.getAnonymousSession(); + + return ( +
+

Anonymous Session Demo

+

+ ← Back to Home +

+ +

Server-Side Session (SSR)

+ {anonymousSession ? ( +
+

+ ID: {anonymousSession.id} +

+

+ Access Token (first 20 chars):{" "} + {anonymousSession.accessToken.substring(0, 20)}... +

+

+ Expires At:{" "} + {new Date(anonymousSession.expiresAt * 1000).toISOString()} +

+ {anonymousSession.metadata && ( + <> +

+ Metadata: +

+
{JSON.stringify(anonymousSession.metadata, null, 2)}
+ + )} +

+ Note: SSR data may be stale (D7). Access token renewal is deferred + to route handlers. Use the client-side panel below for live state. +

+
+ ) : ( +

No anonymous session found (server-side read).

+ )} + +

Client-Side Session (Live)

+ + +

Login to Link Session

+

+ Log in to convert this anonymous session into a permanent authenticated + account. The SDK automatically links the session during the callback. +

+ + +

Fetch Protected Resource

+

+ Use the anonymous session access token to call a protected API + (audience: https://api.customers, scope:{" "} + read:customers). +

+ +
+ ); +} + +function FetchProductsButton() { + return ( +
+ +
+ ); +} diff --git a/examples/with-anonymous-sessions/app/globals.css b/examples/with-anonymous-sessions/app/globals.css new file mode 100644 index 000000000..1e0fee248 --- /dev/null +++ b/examples/with-anonymous-sessions/app/globals.css @@ -0,0 +1,61 @@ +body { + font-family: + system-ui, + -apple-system, + sans-serif; + max-width: 900px; + margin: 40px auto; + padding: 0 20px; + line-height: 1.6; +} + +button { + background: #0070f3; + color: white; + border: none; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + margin: 5px; +} + +button:hover { + background: #0051cc; +} + +button:disabled { + background: #ccc; + cursor: not-allowed; +} + +.error-banner { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 5px; + padding: 15px; + margin: 20px 0; + color: #856404; +} + +.session-panel { + background: #f7f7f7; + border: 1px solid #ddd; + border-radius: 5px; + padding: 15px; + margin: 20px 0; +} + +.session-panel pre { + background: white; + padding: 10px; + border-radius: 3px; + overflow-x: auto; +} + +code { + background: #f4f4f4; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; +} diff --git a/examples/with-anonymous-sessions/app/layout.tsx b/examples/with-anonymous-sessions/app/layout.tsx new file mode 100644 index 000000000..6903ea409 --- /dev/null +++ b/examples/with-anonymous-sessions/app/layout.tsx @@ -0,0 +1,27 @@ +import { Auth0Provider } from "@auth0/nextjs-auth0/client"; + +import { auth0 } from "@/lib/auth0"; + +import "./globals.css"; + +export default async function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + // Server-side anonymous session fetch for SSR seed (prevents loading flash) + const anonymousSession = await auth0.getAnonymousSession(); + + return ( + + + + {children} + + + + ); +} diff --git a/examples/with-anonymous-sessions/app/page.tsx b/examples/with-anonymous-sessions/app/page.tsx new file mode 100644 index 000000000..72b31a3dd --- /dev/null +++ b/examples/with-anonymous-sessions/app/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +export default function HomePage() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [metadata, setMetadata] = useState( + '{"cart_id": "cart_123", "items": 3}' + ); + + // Creates an anonymous session by POSTing to the app's own route handler, + // which calls auth0.createAnonymousSession() and sets the encrypted + // `auth0_anon` cookie. Pass metadata to seed set-once metadata, or omit it + // for a plain guest session. NOTE: GET /auth/anonymous-session (the SDK + // route) only READS the current session (200/204) — it never creates one, + // so it cannot be used to "enter as guest". + const createSession = async (metadataObj?: Record) => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/anon/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(metadataObj ? { metadata: metadataObj } : {}) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + + // Fail-loud diagnostic for tenant misconfiguration + if ( + res.status === 403 && + (body.code === "feature_not_enabled" || + body.code === "unauthorized_client") + ) { + setError( + "Tenant not configured for anonymous sessions. Prerequisites: " + + "(1) oidc_conformant: true on client, " + + "(2) anonymous_sessions.active: true on client, " + + "(3) client grant with subject_type: anonymous_user for the audience. " + + "See TENANT-SETUP.md for details." + ); + } else { + setError( + `Failed to create session: ${body.message || res.statusText}` + ); + } + return; + } + + // Success: redirect to demo page + window.location.href = "/demo"; + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + }; + + const enterAsGuest = () => createSession(); + + const createWithMetadata = () => { + let parsed: Record; + try { + parsed = JSON.parse(metadata); + } catch { + setError("Metadata is not valid JSON."); + return; + } + return createSession(parsed); + }; + + return ( +
+

Anonymous Sessions Demo

+

+ This example demonstrates anonymous sessions (EA feature) in + @auth0/nextjs-auth0. Anonymous sessions provide pre-login identity with + 1KB metadata storage. +

+ +

Quick Start

+

+ + + Creates an anonymous session (no metadata) via POST /api/anon/create, + then redirects to /demo + +

+ +

Create with Metadata

+

+ Create an anonymous session with custom metadata (e.g., shopping cart + state). +

+