diff --git a/README.md b/README.md index 92089e80a..09b1f8e64 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The Auth0 Next.js SDK is a library for implementing user authentication in Next. - [QuickStart](https://auth0.com/docs/quickstart/webapp/nextjs) - our guide for adding Auth0 to your Next.js app. - [Examples](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md) - lots of examples for your different use cases. +- [Anonymous Sessions](./docs/anonymous-sessions.md) - enable pre-login identity with access tokens and metadata. - [Security](https://github.com/auth0/nextjs-auth0/blob/main/SECURITY.md) - Some important security notices that you should check. - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0. diff --git a/docs/anonymous-sessions.md b/docs/anonymous-sessions.md new file mode 100644 index 000000000..ec32b5d16 --- /dev/null +++ b/docs/anonymous-sessions.md @@ -0,0 +1,822 @@ +# Anonymous Sessions + +Anonymous sessions enable your Next.js application to provide a pre-login identity backed by an access token, without requiring user credentials. This allows you to offer API calls, personalization, and metadata storage to visitors before they log in. + +An anonymous session is represented by an `anon@{uuid}` identity and includes: + +- **Access Token**: A bearer token issued to the anonymous identity. Configure `anonymousSession.audience` to target a specific API. When you leave it unset, the authorization server issues the token for your tenant's default audience, which is generally not accepted by your own APIs. +- **Session Token**: An opaque handle, held server-side only, that drives token renewal +- **Expiration**: Unix timestamp indicating when the access token expires +- **Metadata**: Optional user-defined key-value data (up to 1 KB) + +Anonymous sessions are completely independent from authenticated user sessions. They do not interact with login/logout flows unless your application explicitly coordinates them. + +## Table of Contents + +- [Enabling Anonymous Sessions](#enabling-anonymous-sessions) + - [Configuration Options](#configuration-options) + - [Overriding Routes](#overriding-routes) +- [Server-Side Usage](#server-side-usage) + - [Getting the Current Session](#getting-the-current-session) + - [Creating a New Session](#creating-a-new-session) +- [Client-Side Usage](#client-side-usage) + - [The `useAnonymousSession` Hook](#the-useanonymoussession-hook) + - [Provider Seeding](#provider-seeding) +- [Working with Metadata](#working-with-metadata) +- [Logging Out](#logging-out) +- [Error Handling](#error-handling) + - [Error Codes and HTTP Status](#error-codes-and-http-status) +- [Security and Limitations](#security-and-limitations) + - [The session token travels in the authorization request URL](#the-session-token-travels-in-the-authorization-request-url) + - [Other security properties](#other-security-properties) + - [Known Limitations](#known-limitations) +- [Type Reference](#type-reference) +- [Examples](#examples) + +## Enabling Anonymous Sessions + +To enable anonymous sessions, pass the `anonymousSession` configuration to your Auth0Client: + +```typescript +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + anonymousSession: { + enabled: true, + audience: "https://api.example.com", // optional; defaults to the tenant default audience + scope: "read:catalog", // optional; defaults to no requested scope + cookie: { + name: "auth0_anon", // optional; defaults to "auth0_anon" + sameSite: "lax", // optional; defaults to "lax" + secure: true, // optional; defaults to true + maxAge: 2592000 // optional; cookie lifetime in seconds, defaults to 2592000 (30 days) + } + } +}); +``` + +### Configuration Options + +| Option | Type | Default | Description | +| ----------------- | ----------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | `false` | Master switch. When `true`, the feature routes are mounted and the server methods are active. When `false`, the routes return 404, `getAnonymousSession()` returns `null`, and `createAnonymousSession()` throws `unauthorized_client`. | +| `audience` | `string` | none | API identifier the anonymous access token is issued for. Sent on session creation and on every renewal so the audience survives re-minting. When unset, the authorization server issues the token for your tenant's default audience. | +| `scope` | `string` | none | Space-separated scopes requested for the anonymous access token. Sent on session creation and on every renewal. When unset, the SDK requests no `scope` and the authorization server applies your tenant default. | +| `cookie.name` | `string` | `"auth0_anon"` | Name of the encrypted cookie storing the anonymous session. | +| `cookie.sameSite` | `"lax" \| "strict" \| "none"` | `"lax"` | SameSite attribute for the cookie. Set to `"none"` only if absolutely necessary, and always with `secure: true`. | +| `cookie.secure` | `boolean` | `true` | Secure flag for the cookie. | +| `cookie.maxAge` | `number` | `2592000` | Cookie lifetime in seconds. Defaults to 2592000 (30 days). The anonymous access token is renewed transparently as it nears expiry, so the cookie generally outlives any single access token. Setting a very short `maxAge` (under an hour) forces the cookie to be re-minted on nearly every request that can write cookies, which adds renewal overhead without a corresponding benefit. | + +Both `audience` and `scope` must be permitted for anonymous callers on your tenant. An audience that is unresolved, or a resource server that does not allow anonymous access, produces `invalid_target`. A scope that is not granted to anonymous subjects produces `invalid_scope`. Both are thrown as `AnonymousSessionError` and are not recovered automatically. + +### Overriding Routes + +Two environment variables allow you to customize the feature routes: + +```env +NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE=/auth/anonymous-session +NEXT_PUBLIC_ANONYMOUS_SESSION_LOGOUT_ROUTE=/auth/anonymous-session/logout +``` + +The routes are auto-mounted when the feature is enabled. They handle: + +- **GET `/auth/anonymous-session`**: Retrieve the current session, with automatic token renewal +- **POST `/auth/anonymous-session/logout`**: Clear the anonymous session cookie + +The paths above are the defaults. If you set any of these environment variables, every path in this document changes accordingly, including the ones the client-side examples fetch. The client examples below read the same environment variables so that they keep working when you override a route. + +## Server-Side Usage + +### Getting the Current Session + +#### App Router (Server Components, Server Actions, Route Handlers) + +Use the zero-argument form: + +```typescript +import { auth0 } from "@/lib/auth0"; + +export default async function Page() { + const session = await auth0.getAnonymousSession(); + + if (session) { + console.log(`Anonymous ID: ${session.id}`); + console.log(`Token expires at: ${session.expiresAt}`); + console.log(`Metadata:`, session.metadata); + } else { + console.log("No anonymous session"); + } + + return
...
; +} +``` + +The zero-argument form is a read. A Server Component cannot write cookies, so the SDK cannot persist a renewed token there and returns the stored session unchanged. If the access token has already expired, you receive it in that expired state, and `session.expiresAt` is in the past. Compare `session.expiresAt` against the current time before you use `session.accessToken`, and be ready to handle a 401 from the API you call. Renewal happens on the next request that reaches the `GET /auth/anonymous-session` route handler, which owns a writable response. + +#### Pages Router (API Routes, `getServerSideProps`) + +Pass the request object: + +```typescript +import type { NextApiRequest, NextApiResponse } from "next"; + +import { auth0 } from "@/lib/auth0"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const session = await auth0.getAnonymousSession(req); + + if (session) { + res.status(200).json(session); + } else { + res.status(204).end(); + } +} +``` + +#### Middleware + +Pass the request object: + +```typescript +import { NextRequest, NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function middleware(req: NextRequest) { + const session = await auth0.getAnonymousSession(req); + + if (session) { + console.log(`Visitor ID: ${session.id}`); + } + + return NextResponse.next(); +} +``` + +The single-argument form is also a read. It takes a request but no response, so like the Server Component form it cannot persist a renewed token and returns the stored session unchanged, expired access token included. + +**Return Value**: Returns `AnonymousSession | null`. It does not throw. It returns `null` when the feature is disabled, when no session cookie is present, and when the cookie cannot be decrypted or does not carry a usable anonymous access token. + +**Token freshness**: Neither read form renews the access token, because neither has a response to write the refreshed cookie to. Both can therefore hand back an access token whose `expiresAt` has already passed. Treat `accessToken` as potentially stale in these contexts: check `expiresAt`, handle a 401 from the resource server, and route the visitor through the `GET /auth/anonymous-session` route handler (directly, or through the `useAnonymousSession` hook) when you need a token the SDK has renewed and persisted. + +### Creating a New Session + +Use `createAnonymousSession()` to generate a fresh anonymous session and persist it to the client. You can optionally provide metadata at creation time: + +#### App Router (Server Actions, Route Handlers) + +Use the zero-argument form: + +```typescript +"use server"; + +import { AnonymousSessionError } from "@auth0/nextjs-auth0/errors"; + +import { auth0 } from "@/lib/auth0"; + +export async function startAnonymousSession( + metadata?: Record +) { + try { + const session = await auth0.createAnonymousSession({ metadata }); + return { + success: true, + id: session.id, + expiresAt: session.expiresAt + }; + } catch (error) { + if (error instanceof AnonymousSessionError) { + return { success: false, error: error.code }; + } + throw error; + } +} +``` + +Route Handler. The two-argument form writes the session cookie onto the response object you pass as `res`, so that object must be the one you return. Construct the response first, hand it to `createAnonymousSession`, then attach the JSON body to that same response by passing it as the second argument to `NextResponse.json`: + +```typescript +import { NextRequest, NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function POST(req: NextRequest) { + const res = NextResponse.json(null, { status: 201 }); + + try { + // Optionally parse metadata from the request body + const body = (await req.json().catch(() => ({}))) ?? {}; + const session = await auth0.createAnonymousSession(req, res, { + metadata: body.metadata + }); + // `res` now carries the Set-Cookie header. Reusing it as the response init + // copies that header, and the status, onto the response with the body. + return NextResponse.json(session, res); + } catch (error) { + return NextResponse.json( + { error: "Failed to create anonymous session" }, + { status: 500 } + ); + } +} +``` + +Do not pass a throwaway response as `res` and then return a different one. The `Set-Cookie` header is written onto the object you pass, so a different response reaches the browser without the cookie, and the visitor never gets an anonymous session. If you would rather build the final response yourself, copy the cookies across explicitly: + +```typescript +import { NextRequest, NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function POST(req: NextRequest) { + const carrier = NextResponse.json(null); + const body = (await req.json().catch(() => ({}))) ?? {}; + const session = await auth0.createAnonymousSession(req, carrier, { + metadata: body.metadata + }); + + const res = NextResponse.json(session, { status: 201 }); + for (const cookie of carrier.cookies.getAll()) { + res.cookies.set(cookie); + } + return res; +} +``` + +#### Pages Router (API Routes) + +`createAnonymousSession` writes cookies through a `NextResponse` cookie jar, which a Pages Router `NextApiResponse` does not have. Pass a `NextResponse` as the carrier, then copy its `Set-Cookie` headers onto the API response: + +```typescript +import type { NextApiRequest, NextApiResponse } from "next"; +import { NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + return res.status(405).end(); + } + + try { + const carrier = new NextResponse(); + const body = req.body ?? {}; + const session = await auth0.createAnonymousSession(req, carrier, { + metadata: body.metadata + }); + + for (const cookie of carrier.headers.getSetCookie()) { + res.appendHeader("set-cookie", cookie); + } + + res.status(201).json(session); + } catch (error) { + res.status(500).json({ error: "Failed to create anonymous session" }); + } +} +``` + +Reading the session in the Pages Router needs no carrier, because `getAnonymousSession(req)` does not write cookies. + +**Return Value**: Returns `AnonymousSession`. It never returns `null`. It throws `AnonymousSessionError` when: + +- The feature is disabled in your configuration. The code is `unauthorized_client` and no network call is made. +- Your client is not enabled for anonymous sessions on the tenant, or the tenant feature flag is off. +- The authorization server rejects the request or reports an error. + +## Client-Side Usage + +### The `useAnonymousSession` Hook + +The `useAnonymousSession` hook (client-only) fetches and caches the anonymous session using SWR, mirroring the `useUser()` pattern: + +```typescript +"use client"; + +import { useAnonymousSession } from "@auth0/nextjs-auth0/client"; + +export function MyComponent() { + const { anonymous, isLoading, error, invalidate } = useAnonymousSession(); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error: {error.message}
; + } + + if (!anonymous) { + return
No anonymous session
; + } + + return ( +
+

ID: {anonymous.id}

+

Token expires: {new Date(anonymous.expiresAt * 1000).toISOString()}

+

Metadata: {JSON.stringify(anonymous.metadata)}

+ +
+ ); +} +``` + +The hook fetches through the anonymous session route, which does renew an expired access token and persist it, so `anonymous.accessToken` from the hook is fresh. + +#### Hook Options + +| Option | Type | Default | Description | +| ------- | -------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `route` | `string` | `/auth/anonymous-session` | Endpoint to fetch the session from. When omitted, the hook reads `NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE` and falls back to `/auth/anonymous-session`. The resolved path is prefixed with `NEXT_PUBLIC_BASE_PATH`. | + +#### Hook Return Value + +| Field | Type | Description | +| ------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `anonymous` | `AnonymousSession \| null` | The current session. `null` while loading, when the route returns 204 because no session exists, and on error. | +| `isLoading` | `boolean` | `true` while the session is being fetched. `false` once data or an error is available. | +| `error` | `Error \| null` | Any fetch error, or `null` if successful. | +| `invalidate` | `() => void` | Trigger SWR revalidation, for example after a metadata update. | + +### Provider Seeding + +When you fetch the anonymous session server-side (e.g., in `getServerSideProps` or a Server Component), pass it to `Auth0Provider` to avoid a loading flash in the browser: + +```typescript +import { Auth0Provider } from "@auth0/nextjs-auth0/client"; +import { auth0 } from "@/lib/auth0"; + +export default async function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + const anonymous = await auth0.getAnonymousSession(); + + return ( + + {children} + + ); +} +``` + +The seed value comes from a read context, so its `accessToken` may already be expired. It is safe to use for rendering the identity and the metadata without a flash. Do not send a seeded `accessToken` to an API before SWR has revalidated. The `anonymousSessionRoute` prop and the hook's `route` option must resolve to the same path, otherwise the seed lands under a different SWR cache key and the hook fetches anyway. + +The `Auth0Provider` accepts: + +| Prop | Type | Description | +| ----------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `anonymousSession` | `AnonymousSession \| null \| undefined` | Initial session data to seed the SWR cache. Prevents a loading flash. Pass `undefined` to leave the cache unseeded; `null` seeds an explicit "no session" state. | +| `anonymousSessionRoute` | `string` | Route for the anonymous session endpoint. When omitted, the provider reads `NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE` and falls back to `/auth/anonymous-session`. | + +## Working with Metadata + +Metadata is an optional key-value object (up to 1 KB) that you can attach to an anonymous session when you create it. Once set, metadata is immutable. It persists across token renewals but cannot be updated without creating a new session. + +To create a session with metadata, pass the `metadata` option to `createAnonymousSession()`: + +```typescript +"use server"; + +import { auth0 } from "@/lib/auth0"; + +export async function startAnonymousSession() { + const session = await auth0.createAnonymousSession({ + metadata: { + theme: "dark", + language: "es", + preferences: { newsletter: true } + } + }); + + return session; +} +``` + +The SDK validates that `metadata` is a plain JSON object and that its serialized UTF-8 byte length does not exceed 1 KB before making the network call. If either check fails, `createAnonymousSession()` throws `invalid_request` or `metadata_too_large` without reaching the authorization server. + +Metadata is readable server-side from the session object returned by `getAnonymousSession()`. Because metadata cannot be updated, there is no server-side write method. To change metadata, create a new anonymous session with the updated values and clear the old one. + +## Logging Out + +To end an anonymous session and clear the cookie, call the logout route. The example reads `NEXT_PUBLIC_ANONYMOUS_SESSION_LOGOUT_ROUTE` so that it keeps working if you override the route: + +```typescript +"use client"; + +const LOGOUT_ROUTE = + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_LOGOUT_ROUTE || + "/auth/anonymous-session/logout"; + +async function logoutAnonymous() { + const response = await fetch(LOGOUT_ROUTE, { method: "POST" }); + + if (!response.ok) { + throw new Error("Failed to log out of the anonymous session"); + } + + // Session cleared; you may want to reset app state + window.location.reload(); +} +``` + +The logout route clears the anonymous session cookie from your application, including any chunk cookies, and responds with 200 and the body `{"ok":true}`. It is idempotent: calling it with no active session succeeds the same way. + +**Important:** Logout clears only the local session cookie. Access tokens already issued to the anonymous identity remain valid until they naturally expire. There is no server-side revocation mechanism for anonymous sessions, so a token that was minted before logout can still be used to call your APIs until its expiration time passes. Treat anonymous sessions as suitable for non-sensitive personalization and preferences, not for access control. + +## Error Handling + +Errors related to anonymous sessions are represented by `AnonymousSessionError`, which includes a `code` field: + +```typescript +import { AnonymousSessionError } from "@auth0/nextjs-auth0/errors"; + +import { auth0 } from "@/lib/auth0"; + +try { + const session = await auth0.createAnonymousSession(); +} catch (error) { + if (error instanceof AnonymousSessionError) { + switch (error.code) { + case "feature_not_enabled": + console.error("Anonymous sessions are not enabled on your tenant"); + break; + case "unauthorized_client": + console.error( + "Anonymous sessions are disabled in your SDK configuration, " + + "or this client is not enabled for them on the tenant" + ); + break; + case "invalid_target": + console.error( + "The configured anonymousSession.audience is unresolved, or that " + + "API does not allow anonymous access" + ); + break; + case "invalid_scope": + console.error( + "The configured anonymousSession.scope is not granted to anonymous subjects" + ); + break; + case "metadata_too_large": + console.error("Metadata exceeds 1 KB limit when creating the session"); + break; + case "invalid_request": + console.error("Request was malformed"); + break; + default: + console.error(`Anonymous session error: ${error.code}`); + } + } else { + throw error; + } +} +``` + +`error.message` carries the `error_description` the authorization server returned whenever one is present, and falls back to a built-in message for the code otherwise. The same server wording is also available unmodified on `error.description`, and the raw upstream body or original error on `error.cause`. Log `error.code` together with `error.description` (or `error.message`) and `error.cause` when diagnosing a tenant configuration problem. + +### Error Codes and HTTP Status + +| Code | HTTP Status | Recovery | Description | +| ----------------------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `invalid_client` | 401 | Manual | Client authentication failed. Check your client credentials. | +| `feature_not_enabled` | 403 | Manual | Anonymous sessions are not enabled on your Auth0 tenant. | +| `unauthorized_client` | 403 | Manual | Anonymous sessions are disabled in your SDK configuration, or your application is not enabled for anonymous sessions on the tenant. | +| `server_error` | 500 | Retry | Authorization server encountered an error. | +| `invalid_session_token` | 400 | Auto | Session token is invalid or expired. During renewal, a new session is created silently. | +| `session_expired` | 400 | Auto | Session expired. During renewal, a new session is created silently. | +| `metadata_too_large` | 400 | Manual | Metadata payload exceeds the 1 KB UTF-8 byte limit when creating a session. | +| `invalid_target` | 400 | Manual | The `anonymousSession.audience` you configured is unresolved, or that resource server does not allow anonymous access. Reachable only when you configure an audience. | +| `invalid_scope` | 400 | Manual | The `anonymousSession.scope` you configured is not granted to anonymous subjects. Reachable only when you configure a scope. | +| `invalid_request` | 400 | Manual | Request was malformed, or `metadata` is not a plain JSON object when creating a session. | + +**Auto-Recovery**: When `session_expired` or `invalid_session_token` errors occur during token renewal, the SDK silently creates a new session and returns it, avoiding application errors. No error is thrown. The metadata on the old session is lost permanently. + +## Security and Limitations + +### Security Considerations + +#### Logout does not revoke tokens + +Calling the logout route clears the local `auth0_anon` cookie but does NOT revoke the session token or any access tokens that have already been issued. Anonymous sessions are stateless on the Auth0 platform. There is no server-side revocation mechanism. Tokens issued before logout remain valid until their natural expiration. + +For security-sensitive use cases that require immediate token revocation, use standard authenticated sessions with refresh tokens. For anonymous sessions, keep token TTLs short and treat the session token as a sensitive credential. + +#### Silent session recreation loses metadata + +When the anonymous session's access token cannot be renewed because the underlying session expired or the session token became invalid (`session_expired` or `invalid_session_token`), the SDK silently creates a new anonymous session rather than throwing an error. This behavior upholds the read contract: a Server Component read of the anonymous session never breaks a render. + +This silent recreation has important consequences. First, the anonymous identity changes. A new `anon@{uuid}` subject is issued. Second, any metadata set on the previous session is not carried over and is lost. + +Do not store security-critical or authorization-relevant data in anonymous session metadata. Treat metadata as ephemeral. If your application depends on specific metadata values, re-set them after a recreation. + +#### Token trust model + +Anonymous access tokens are fetched server-to-server from Auth0 over TLS and are trusted on that basis. The SDK does not independently verify the access token signature. This is consistent with standard token handling practices. The SDK validates that the token response's `expires_in` value is within sane bounds. + +#### Session linking and fixation protection + +When a user logs in while an anonymous session cookie is present, the SDK attempts to link the anonymous session to the authenticated session. The SDK sets `ctx.anonymousSessionLinked` in the `onCallback` context to indicate whether the linkage succeeded. + +The flag is `true` only if the anonymous cookie present at callback matches the one that was bound at login initiation. This is a session-fixation protection. If the cookie changed between login and callback, the flag is `false`. + +Applications that perform linking SHOULD check this flag in `onCallback`. Do not treat the anonymous session as linked when the flag is `false`. + +### The session token travels in the authorization request URL + +Read this before you enable the feature. + +When an anonymous session is active and the visitor starts a login, the SDK links the two by adding the anonymous `session_token` to the request it sends to Auth0's `/authorize` endpoint. Unless you have enabled Pushed Authorization Requests, that request is a browser redirect, so the session token is carried as a query parameter in a URL the browser navigates to. A URL the browser navigates to is not private. The session token consequently becomes visible in: + +- The `Location` header of the redirect the SDK returns, and any proxy or log that records response headers. +- The visitor's browser history, where it persists after the browser is closed. +- The `Referer` header sent by the Auth0 login page to any third-party resource it loads, subject to that page's referrer policy. +- Auth0's own access logs for the `/authorize` request, and the logs of any intermediary in front of it. + +The session token is a long-lived handle. Anyone who obtains it can mint access tokens for that anonymous identity and read that session's metadata until the session expires. Do not put anything in anonymous session metadata that you would not accept being exposed through one of the channels above. + +These mitigations are in place and are the ones you can rely on: + +- **The injected token can only come from your own cookie.** `session_token` is on the SDK's reserved authorization-parameter list, so a value supplied by a caller through a login query string or through `authorizationParameters` is dropped before the request is built. The only token the SDK will send is the one it decrypted out of its own `HttpOnly` app-domain cookie for that browser. An attacker cannot use the login endpoint to plant a session token they already know. +- **The token is never readable by client-side JavaScript.** The cookie that holds it is encrypted with AES-256-GCM under a key derived from your `secret`, and is set `HttpOnly`. Only the access token reaches the browser, through the anonymous session route. +- **The anonymous session route responses are not cacheable.** Every response from the three anonymous session routes carries `Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0`, so a shared cache cannot retain a session payload and serve it to another visitor. +- **The login-to-callback binding is single-use.** The linkage is recorded in the encrypted, state-keyed transaction cookie, which the SDK deletes when the callback completes and which expires after one hour regardless. A replayed callback finds no transaction and is rejected. + +You can keep the session token out of the redirect URL entirely by enabling Pushed Authorization Requests on your tenant and setting `pushedAuthorizationRequests: true`. The SDK then posts the authorization parameters to Auth0 server-to-server and redirects the browser to a URL that carries only a `request_uri` and your `client_id`. The session token never enters the address bar, the history, or the `Referer` header. This requires PAR support on your Auth0 tenant. + +### Other security properties + +- **Cookie attributes.** The anonymous session cookie is `HttpOnly` and `Path=/`. `SameSite` defaults to `lax` and `Secure` defaults to `true`; both are configurable. Setting `secure: false` sends the cookie over plain HTTP and is only appropriate for local development. +- **The session token is not an API credential.** Send `accessToken` to your APIs. The session token is a renewal and metadata handle, is not accepted as a bearer token, and never leaves the server except in the authorization request described above. +- **Large sessions are split across cookies.** Metadata that pushes the encrypted payload past the single-cookie size limit is chunked across `__0`, `__1`, and so on, using the same mechanism as the authenticated session cookie. For the default cookie name `auth0_anon`, these are `auth0_anon__0`, `auth0_anon__1`, and so on. Logout clears the chunks along with the base cookie. + +### Known Limitations + +The following are not in scope for this release: + +- **Cross-App SSO**: The anonymous session lives in an app-domain cookie and does not participate in Auth0 cross-app single sign-on. +- **Password Reset Preservation**: Anonymous sessions are not carried forward during password resets. Users complete the reset and start a new session. +- **Sessions During Interactive Login**: Completing a login does not clear the anonymous cookie. The two sessions coexist until you end the anonymous one yourself. Use the `anonymousSessionLinked` flag on the `onCallback` context to decide what to do. +- **No server-side revocation**: Logging out of an anonymous session clears the cookie. Access tokens already issued to that identity remain valid until they expire. +- **DPoP**: Auth0 does not support anonymous sessions for clients configured for DPoP. The SDK does not block the combination, so a DPoP client that enables anonymous sessions receives an error from the authorization server rather than a configuration error at startup. + +### Compatibility + +Anonymous sessions are independent from authenticated sessions and do not interfere with existing login and logout flows. The feature is entirely opt-in. When `anonymousSession.enabled` is `false` or the configuration block is absent, the three routes return 404, `getAnonymousSession()` returns `null`, and `createAnonymousSession()` throws `AnonymousSessionError` with code `unauthorized_client`. + +## Type Reference + +### `AnonymousSession` + +```typescript +interface AnonymousSession { + /** Anonymous subject, format: "anon@{uuid}" */ + id: string; + /** Bearer token for API calls */ + accessToken: string; + /** Unix seconds when accessToken expires */ + expiresAt: number; + /** User-defined metadata (optional, max 1 KB) */ + metadata?: Record; +} +``` + +### `AnonymousSessionError` + +Exported from `@auth0/nextjs-auth0/errors`. It extends the SDK's `SdkError` base class, which extends `Error`. + +```typescript +class AnonymousSessionError extends SdkError { + /** Error code from the authorization server or from SDK validation */ + code: string; + /** + * A human-readable message. Set to the error_description returned by the + * authorization server when one is present, otherwise a built-in message for + * the code. + */ + message: string; + /** + * The raw error_description reported by the authorization server, when one was + * present. Undefined for errors the SDK raises locally (for example + * metadata_too_large). Prefer this over message when you need the server's + * exact wording for logging or diagnostics. + */ + description?: string; + /** + * The underlying cause: the raw error body from the authorization server, or + * the original error the SDK caught. Undefined when there is no upstream + * cause. Inspect it when message and code do not explain the failure. + */ + cause?: unknown; +} +``` + +Read `description` and `cause` alongside `code` when diagnosing a tenant configuration problem. `description` carries the authorization server's exact wording (which audience was rejected, which scope was refused), and `cause` holds the raw upstream body or the original error the SDK caught. + +### `UseAnonymousSessionOptions` + +```typescript +interface UseAnonymousSessionOptions { + /** Custom route for the anonymous session endpoint */ + route?: string; +} +``` + +### `AnonymousSessionConfig` + +```typescript +interface AnonymousSessionConfig { + /** Enable the feature */ + enabled: boolean; + /** + * API identifier the anonymous access token is issued for. Sent on create and + * on every renewal. When omitted, the authorization server issues the token + * for the tenant default audience. + */ + audience?: string; + /** + * Space-separated scopes requested for the anonymous access token. Sent on + * create and on every renewal. When omitted, no scope is requested and the + * authorization server applies the tenant default. + */ + scope?: string; + cookie?: { + /** Cookie name (default: "auth0_anon") */ + name?: string; + /** SameSite attribute (default: "lax") */ + sameSite?: "lax" | "strict" | "none"; + /** Secure flag (default: true) */ + secure?: boolean; + /** Cookie max age in seconds (default: 2592000, 30 days) */ + maxAge?: number; + }; +} +``` + +## Examples + +### Full-Page Anonymous Session Setup + +```typescript +// lib/auth0.ts +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + anonymousSession: { + enabled: true + } +}); +``` + +```typescript +// app/layout.tsx +import { Auth0Provider } from "@auth0/nextjs-auth0/client"; +import { auth0 } from "@/lib/auth0"; + +export default async function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + const anonymous = await auth0.getAnonymousSession(); + + return ( + + + {children} + + + ); +} +``` + +```typescript +// app/page.tsx +"use client"; + +import { useAnonymousSession } from "@auth0/nextjs-auth0/client"; + +export default function Home() { + const { anonymous, isLoading } = useAnonymousSession(); + + if (isLoading) return
Loading...
; + if (!anonymous) return
No session
; + + return ( +
+

Welcome, {anonymous.id}

+

Your metadata: {JSON.stringify(anonymous.metadata)}

+
+ ); +} +``` + +### Creating a Session on First Visit + +```typescript +"use client"; + +import { useEffect, useState } from "react"; +import { useAnonymousSession } from "@auth0/nextjs-auth0/client"; + +export function FirstVisitSetup() { + const { anonymous, invalidate } = useAnonymousSession(); + const [isCreating, setIsCreating] = useState(false); + + const createSession = async () => { + setIsCreating(true); + try { + const response = await fetch("/api/anonymous/create", { method: "POST" }); + if (response.ok) { + invalidate(); + } + } finally { + setIsCreating(false); + } + }; + + if (anonymous) { + return
Session active: {anonymous.id}
; + } + + return ( + + ); +} +``` + +```typescript +// app/api/anonymous/create/route.ts +import { NextRequest, NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function POST(req: NextRequest) { + // Build the response first. createAnonymousSession writes the session cookie + // onto this object, so this object has to be the one that is returned. + const res = NextResponse.json(null, { status: 201 }); + + try { + const session = await auth0.createAnonymousSession(req, res); + return NextResponse.json(session, res); + } catch (error) { + return NextResponse.json( + { error: "Failed to create session" }, + { status: 500 } + ); + } +} +``` + +### Storing User Preferences + +Because metadata is set once at session creation and cannot be updated, you must create a new session to change preferences: + +```typescript +"use client"; + +import { useAnonymousSession } from "@auth0/nextjs-auth0/client"; +import { useState } from "react"; + +export function PreferencesForm() { + const { anonymous, invalidate } = useAnonymousSession(); + const [theme, setTheme] = useState( + (anonymous?.metadata?.theme as string) || "light" + ); + + const handleSave = async () => { + // Create a new session with updated metadata + const response = await fetch("/api/anonymous/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + metadata: { theme, savedAt: new Date().toISOString() } + }) + }); + + if (response.ok) { + invalidate(); + alert("Preferences saved"); + } + }; + + return ( +
+ + +
+ ); +} +``` diff --git a/src/client/hooks/use-anonymous-session.test.ts b/src/client/hooks/use-anonymous-session.test.ts new file mode 100644 index 000000000..479e00116 --- /dev/null +++ b/src/client/hooks/use-anonymous-session.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment jsdom + */ + +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AnonymousSession } from "../../types/index.js"; +import { useAnonymousSession } from "./use-anonymous-session.js"; + +describe("M4 BLOCKER: FR-3 useAnonymousSession Hook REAL execution", () => { + const mockSession: AnonymousSession = { + id: "anon@uuid-1234", + accessToken: "bearer-token-xyz", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + metadata: { cart: { qty: 5 } } + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Clear global fetch mock before each test + global.fetch = vi.fn(); + }); + + afterEach(() => { + // Clean up React components and SWR cache after each test + cleanup(); + }); + + describe("Hook with REAL SWR execution", () => { + it("M4: Hook returns 200 → {anonymous: session, isLoading: false}", async () => { + // Mock fetch to return 200 with session + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSession + }); + + // Use unique route per test to avoid SWR cache collision + const { result } = renderHook(() => + useAnonymousSession({ route: "/test/anon-200" }) + ); + + // Wait for SWR to fetch (SWR may skip loading state if data arrives fast) + await waitFor(() => { + expect(result.current.anonymous).toEqual(mockSession); + }); + + // ASSERT: session loaded + expect(result.current.error).toBeNull(); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("M4: Hook returns 204 → {anonymous: null, isLoading: false}", async () => { + // Mock fetch to return 204 (no session) + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204 + }); + + const { result } = renderHook(() => + useAnonymousSession({ route: "/test/anon-204" }) + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // ASSERT: no session (null) + expect(result.current.anonymous).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("M4: Hook fetch error → {error: Error, anonymous: null, isLoading: false}", async () => { + // Mock fetch to return error + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500 + }); + + const { result } = renderHook(() => + useAnonymousSession({ route: "/test/anon-error" }) + ); + + await waitFor( + () => { + expect(result.current.error).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // ASSERT: error state + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.anonymous).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it("M4: isLoading false after data loads", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSession + }); + + const { result } = renderHook(() => + useAnonymousSession({ route: "/test/anon-loading" }) + ); + + // Wait for load to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.anonymous).toEqual(mockSession); + }); + + it("M4: invalidate() triggers SWR revalidation", async () => { + let callCount = 0; + global.fetch = vi.fn().mockImplementation(async () => { + callCount++; + return { + ok: true, + status: 200, + json: async () => ({ + ...mockSession, + id: `anon@call-${callCount}` + }) + }; + }); + + const { result } = renderHook(() => + useAnonymousSession({ route: "/test/anon-invalidate" }) + ); + + // Wait for initial fetch + await waitFor(() => { + expect(callCount).toBeGreaterThanOrEqual(1); + }); + + const firstId = result.current.anonymous?.id; + + // Call invalidate to trigger refetch + await act(async () => { + result.current.invalidate(); + }); + + // Wait for ID to change (React render commit) + await waitFor(() => { + expect(result.current.anonymous?.id).not.toBe(firstId); + }); + + // Verify refetch happened + expect(callCount).toBeGreaterThanOrEqual(2); + }); + + it("M4: Hook uses custom route from options", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSession + }); + + renderHook(() => useAnonymousSession({ route: "/custom/anon-route" })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + // Verify fetch was called with custom route + const fetchCall = (global.fetch as any).mock.calls[0]; + expect(fetchCall[0]).toContain("/custom/anon-route"); + }); + + it("M4: Hook returns correct shape {anonymous, isLoading, error, invalidate}", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSession + }); + + const { result } = renderHook(() => useAnonymousSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // ASSERT: shape matches contract + expect(result.current).toHaveProperty("anonymous"); + expect(result.current).toHaveProperty("isLoading"); + expect(result.current).toHaveProperty("error"); + expect(result.current).toHaveProperty("invalidate"); + expect(typeof result.current.invalidate).toBe("function"); + }); + }); +}); diff --git a/src/client/hooks/use-anonymous-session.ts b/src/client/hooks/use-anonymous-session.ts new file mode 100644 index 000000000..b2c16acd4 --- /dev/null +++ b/src/client/hooks/use-anonymous-session.ts @@ -0,0 +1,101 @@ +"use client"; + +import useSWR from "swr"; + +import type { + AnonymousSession, + UseAnonymousSessionOptions +} from "../../types/index.js"; +import { normalizeWithBasePath } from "../../utils/pathUtils.js"; + +/** + * Fetch the anonymous session from the read route. + * + * The route answers 204 with an empty body when there is no session, which maps + * to null, and 200 with the session object otherwise. Any other status is a + * failure the hook surfaces through `error`. + * + * The return type is declared rather than inferred so the session case is a + * concrete value rather than the `any` that `Response.json()` produces. That is + * what keeps `null` from being the only value a reader (human or static analysis) + * can see the fetcher resolve to. + */ +async function fetchAnonymousSession( + route: string +): Promise { + const res = await fetch(route); + + if (!res.ok) { + throw new Error("Failed to load anonymous session"); + } + + // 204 No Content → null (no session) + if (res.status === 204) { + return null; + } + + // 200 + JSON → return session object + return (await res.json()) as AnonymousSession; +} + +/** + * Client hook: fetch and cache anonymous session via SWR. + * Mirrors the useUser() pattern. + * + * Returns: + * - anonymous: AnonymousSession | null (null if no session or fetch error) + * - isLoading: boolean (false when error or data loaded) + * - error: Error | null (populated on fetch error) + * - invalidate: () => void (trigger SWR revalidate) + * + * Uses SWR key: resolved route (option, env var, or default "/auth/anonymous-session") + * Fetcher: standard fetch, returns null on 204 status, throws on !ok + * + * Test: T7 (hook + provider) + */ +export function useAnonymousSession(options: UseAnonymousSessionOptions = {}): { + anonymous: AnonymousSession | null; + isLoading: boolean; + error: Error | null; + invalidate: () => void; +} { + // Resolve SWR key (route path, normalized with basePath) + const route = normalizeWithBasePath( + options.route || + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE || + "/auth/anonymous-session" + ); + + // Fetch via SWR with standard error handling + const { data, error, isLoading, mutate } = useSWR< + AnonymousSession | null, + Error, + string + >(route, fetchAnonymousSession); + + // Return shape matching useUser() pattern + if (error) { + return { + anonymous: null, + isLoading: false, + error, + invalidate: () => mutate() + }; + } + + // The fetcher resolves to null for a 204, so `data` carries three distinct + // states: undefined while the first request is in flight, null once the route + // has answered that there is no session, and the session object otherwise. A + // truthiness test cannot tell the first two apart, and it reads as a test that + // can never succeed to a reader that only sees the null-returning branch of the + // fetcher. Comparing against undefined names the state that is actually being + // asked about, and every branch below is reachable for some value of `data`. + const hasLoaded = data !== undefined; + + return { + anonymous: data ?? null, + isLoading: hasLoaded ? false : isLoading, + error: null, + invalidate: () => mutate() + }; +} diff --git a/src/client/index.ts b/src/client/index.ts index cc8674a5c..d9f7c27db 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,5 @@ export { useUser, type UseUserOptions } from "./hooks/use-user.js"; +export { useAnonymousSession } from "./hooks/use-anonymous-session.js"; export { getAccessToken, type AccessTokenOptions @@ -17,3 +18,4 @@ export type { ChallengeWithPopupOptions } from "./mfa/index.js"; export type { AccessTokenResponse } from "./helpers/get-access-token.js"; export { passwordless } from "./passwordless/index.js"; export { passkey, serializeCredential } from "./passkey/index.js"; +export type { UseAnonymousSessionOptions } from "../types/index.js"; diff --git a/src/client/providers/auth0-provider.anonymous.test.tsx b/src/client/providers/auth0-provider.anonymous.test.tsx new file mode 100644 index 000000000..0061ef928 --- /dev/null +++ b/src/client/providers/auth0-provider.anonymous.test.tsx @@ -0,0 +1,176 @@ +/** + * @vitest-environment jsdom + */ + +import React from "react"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AnonymousSession } from "../../types/index.js"; +import { useAnonymousSession } from "../hooks/use-anonymous-session.js"; +import { Auth0Provider } from "./auth0-provider.js"; + +describe("M5 BLOCKER: FR-8 Auth0Provider anonymousSession prop", () => { + const mockSession: AnonymousSession = { + id: "anon@uuid-seeded", + accessToken: "bearer-token-seeded", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + metadata: { cart: { qty: 10 } } + }; + + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + // Clean up React components and SWR cache after each test + cleanup(); + }); + + describe("Provider anonymousSession prop seeds SWR fallback", () => { + it("M5: Provider with anonymousSession={seed} → hook gets seeded value with NO loading flash", async () => { + // Mock fetch — should NOT be called because SWR uses fallback + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSession + }); + + // Render hook inside provider with seeded anonymousSession and isolated SWR cache + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map(), + revalidateOnMount: false, + revalidateIfStale: false + }} + > + + {children} + + + ); + + const { result } = renderHook(() => useAnonymousSession(), { wrapper }); + + // CRITICAL ASSERTION: NO loading flash (isLoading false on first render) + expect(result.current.isLoading).toBe(false); + expect(result.current.anonymous).toEqual(mockSession); + expect(result.current.error).toBeNull(); + + // Verify fetch was NOT called (SWR used fallback, revalidation disabled) + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("M5: Provider with anonymousSession={null} → hook gets null with NO loading flash", async () => { + global.fetch = vi.fn(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + {children} + + ); + + const { result } = renderHook(() => useAnonymousSession(), { wrapper }); + + // NO loading flash, immediate null + expect(result.current.isLoading).toBe(false); + expect(result.current.anonymous).toBeNull(); + expect(result.current.error).toBeNull(); + + // Fetch not called (fallback present) + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("M5: Provider WITHOUT anonymousSession prop → hook fetches normally (loading flash present)", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSession + }); + + // Provider WITHOUT anonymousSession prop + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + {children} + + ); + + const { result } = renderHook(() => useAnonymousSession(), { wrapper }); + + // Initial state: loading true (NO fallback, normal fetch) + expect(result.current.isLoading).toBe(true); + expect(result.current.anonymous).toBeNull(); + + // Wait for fetch to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // After fetch: data present + expect(result.current.anonymous).toEqual(mockSession); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("M5: Seeded value matches hook return exactly", async () => { + const customSession: AnonymousSession = { + id: "anon@custom-seed", + accessToken: "custom-token", + expiresAt: Math.floor(Date.now() / 1000) + 7200, + metadata: { preferences: { theme: "dark" } } + }; + + global.fetch = vi.fn(); + + const customRoute = "/test/custom-seed"; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + + {children} + + + ); + + const { result } = renderHook( + () => useAnonymousSession({ route: customRoute }), + { wrapper } + ); + + // EXACT match (not just shape) + expect(result.current.anonymous?.id).toBe("anon@custom-seed"); + expect(result.current.anonymous?.metadata).toEqual({ + preferences: { theme: "dark" } + }); + }); + + it("M5: Custom anonymousSessionRoute works with seeded value", async () => { + global.fetch = vi.fn(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + + {children} + + + ); + + const { result } = renderHook( + () => useAnonymousSession({ route: "/custom/anon" }), + { wrapper } + ); + + // Seeded value present, no fetch + expect(result.current.isLoading).toBe(false); + expect(result.current.anonymous).toEqual(mockSession); + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/client/providers/auth0-provider.test.tsx b/src/client/providers/auth0-provider.test.tsx new file mode 100644 index 000000000..3a1befede --- /dev/null +++ b/src/client/providers/auth0-provider.test.tsx @@ -0,0 +1,187 @@ +/** + * @vitest-environment jsdom + */ + +import React from "react"; +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { AnonymousSession, User } from "../../types/index.js"; +import { Auth0Provider, type Auth0ProviderProps } from "./auth0-provider.js"; + +describe("FR-8: Auth0Provider SSR Fallback", () => { + const mockUser: User = { + sub: "auth0|user-123", + name: "Test User", + email: "test@example.com", + picture: "https://example.com/pic.jpg" + }; + + const mockAnonymousSession: AnonymousSession = { + id: "anon@uuid-1234", + accessToken: "bearer-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + metadata: { cart: { qty: 3 } } + }; + + describe("Provider initialization", () => { + it("FR-8: Provider accepts anonymousSession prop", () => { + const { container } = render( + +
Test
+
+ ); + + expect(container).toBeTruthy(); + }); + + it("FR-8: Provider seeds SWR cache with anonymousSession when provided", () => { + // Note: Testing SWR fallback value by checking provider integration + const props: Auth0ProviderProps = { + anonymousSession: mockAnonymousSession, + children:
Test
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it("FR-8: Provider accepts anonymousSessionRoute prop", () => { + const { container } = render( + +
Test
+
+ ); + + expect(container).toBeTruthy(); + }); + + it("FR-8: Provider accepts null anonymousSession (no session)", () => { + const { container } = render( + +
Test
+
+ ); + + expect(container).toBeTruthy(); + }); + + it("FR-8: Provider handles undefined anonymousSession (not seeded)", () => { + const { container } = render( + +
Test
+
+ ); + + expect(container).toBeTruthy(); + }); + }); + + describe("SSR cache seeding", () => { + it("FR-8: When anonymousSession provided, no loading flash occurs", () => { + // This is verified by the provider passing the fallback to SWRConfig. + // When SWR sees the fallback value for a key, it returns cached data immediately. + const props: Auth0ProviderProps = { + anonymousSession: mockAnonymousSession, + user: mockUser, + children:
Content
+ }; + + const { container } = render(); + // Provider should render immediately without fetch + expect(container.textContent).toContain("Content"); + }); + + it("FR-8: When anonymousSession null, SWR cache seeded with null", () => { + const props: Auth0ProviderProps = { + anonymousSession: null, + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it("FR-8: Both user and anonymousSession can be seeded together", () => { + const props: Auth0ProviderProps = { + user: mockUser, + anonymousSession: mockAnonymousSession, + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + }); + + describe("Route resolution", () => { + it("FR-8: Uses default routes when not specified", () => { + const props: Auth0ProviderProps = { + anonymousSession: mockAnonymousSession, + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it("FR-8: Uses custom profileRoute when provided", () => { + const props: Auth0ProviderProps = { + user: mockUser, + profileRoute: "/api/custom-profile", + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it("FR-8: Uses custom anonymousSessionRoute when provided", () => { + const props: Auth0ProviderProps = { + anonymousSession: mockAnonymousSession, + anonymousSessionRoute: "/api/custom-anon", + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it("FR-8: Respects NEXT_PUBLIC_PROFILE_ROUTE env var", () => { + const originalEnv = process.env.NEXT_PUBLIC_PROFILE_ROUTE; + process.env.NEXT_PUBLIC_PROFILE_ROUTE = "/env-profile"; + + try { + const props: Auth0ProviderProps = { + user: mockUser, + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + } finally { + process.env.NEXT_PUBLIC_PROFILE_ROUTE = originalEnv; + } + }); + + it("FR-8: Respects NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE env var", () => { + const originalEnv = process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE; + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE = "/env-anon"; + + try { + const props: Auth0ProviderProps = { + anonymousSession: mockAnonymousSession, + children:
Content
+ }; + + const { container } = render(); + expect(container).toBeTruthy(); + } finally { + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE = originalEnv; + } + }); + }); +}); diff --git a/src/client/providers/auth0-provider.tsx b/src/client/providers/auth0-provider.tsx index 8d6eb6d8e..474eb9eb4 100644 --- a/src/client/providers/auth0-provider.tsx +++ b/src/client/providers/auth0-provider.tsx @@ -3,16 +3,24 @@ import React from "react"; import { SWRConfig } from "swr"; -import { User } from "../../types/index.js"; +import type { AnonymousSession, User } from "../../types/index.js"; +import { normalizeWithBasePath } from "../../utils/pathUtils.js"; /** * Props for the Auth0Provider component. + * Adds anonymousSession prop for seeding SWR cache (SSR fallback, no loading flash). */ export type Auth0ProviderProps = { /** - * Initial user data to populate the SWR cache. + * Initial user data to populate the SWR cache for the profile endpoint. */ user?: User; + /** + * Initial anonymous session data to populate the SWR cache. + * Prevents loading flash when anonymous session is fetched server-side. + * Optional; if omitted, SWR fetches normally. + */ + anonymousSession?: AnonymousSession | null; /** * Child components to render within the provider. */ @@ -25,25 +33,44 @@ export type Auth0ProviderProps = { * @example '/tenant-a/auth/profile' */ profileRoute?: string; + /** + * Custom route for the anonymous session endpoint. + * If not specified, falls back to the NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE environment variable or "/auth/anonymous-session". + * + * @example '/tenant-a/auth/anonymous-session' + */ + anonymousSessionRoute?: string; }; export function Auth0Provider({ user, + anonymousSession, children, - profileRoute + profileRoute, + anonymousSessionRoute }: Auth0ProviderProps) { - const route = - profileRoute || process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile"; - - return ( - - {children} - + // Resolve profile route + const profileKey = normalizeWithBasePath( + profileRoute || process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile" ); + + // Resolve anonymous session route + const anonKey = normalizeWithBasePath( + anonymousSessionRoute || + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE || + "/auth/anonymous-session" + ); + + // Build SWR fallback cache with both user and anonymous session + // Avoids loading flashes when both are available from server-side fetch + const fallback: Record = { + [profileKey]: user + }; + + // Seed anonymous session only if provided (may be null or undefined) + if (anonymousSession !== undefined) { + fallback[anonKey] = anonymousSession; + } + + return {children}; } diff --git a/src/errors/anonymous-session-errors.ts b/src/errors/anonymous-session-errors.ts new file mode 100644 index 000000000..ae4734816 --- /dev/null +++ b/src/errors/anonymous-session-errors.ts @@ -0,0 +1,95 @@ +import { SdkError } from "./sdk-error.js"; + +/** + * Error class for anonymous session operations. + * Maps authorization server error codes to SDK errors; distinguishes recoverable + * (silent recovery) from non-recoverable (throw to caller). + */ +export class AnonymousSessionError extends SdkError { + public code: string; + public description?: string; + public cause?: unknown; + + /** + * Construct an AnonymousSessionError. + * @param code Error code from auth server or SDK validation (e.g., "metadata_too_large") + * @param message Human-readable error message; defaults to generic message + * @param description Server's error_description field (optional) + * @param cause Raw error body or underlying cause (optional) + */ + constructor( + code: string, + message?: string, + description?: string, + cause?: unknown + ) { + super( + message ?? + "An error occurred while performing the anonymous session operation." + ); + this.name = "AnonymousSessionError"; + this.code = code; + this.description = description; + this.cause = cause; + } +} + +/** + * Map authorization server error response to AnonymousSessionError. + * Populates description and cause fields from server response (CASCADE §D). + * Codes per DESIGN §3.C7. + * + * @param code Error code from the authorization server or SDK validation. + * @param serverDescription Optional `error_description` reported by the authorization + * server. When present it is preferred over the canned message, because the + * server description names the concrete cause (which audience was rejected, + * which scope was refused) while the canned message only restates the code. + * @param rawBody Raw error body or underlying cause (optional) + */ +export function mapAnonymousErrorCode( + code: string, + serverDescription?: string, + rawBody?: unknown +): AnonymousSessionError { + const codeToMessage: Record = { + metadata_too_large: "The metadata object exceeds the 1KB limit.", + invalid_session_token: "The session token is invalid or malformed.", + invalid_target: + "The audience is unresolved or the resource server does not allow anonymous access.", + invalid_scope: "The requested scope is not granted to anonymous subjects.", + invalid_client: "Client authentication failed.", + feature_not_enabled: "Anonymous sessions are not enabled on this tenant.", + unauthorized_client: "This client is not enabled for anonymous sessions.", + server_error: "The authorization server encountered an error.", + invalid_request: "The request is malformed.", + session_expired: "The session has expired." + }; + + const trimmedDescription = serverDescription?.trim(); + + return new AnonymousSessionError( + code, + trimmedDescription || codeToMessage[code] || `An error occurred: ${code}`, + trimmedDescription, + rawBody + ); +} + +/** + * Map an anonymous-session error code to its HTTP status per DESIGN §3.C7. + * 401 invalid_client; 403 feature_not_enabled / unauthorized_client; + * 500 server_error; 400 for every other (client/request) code. + */ +export function getStatusForAnonymousError(code: string): number { + switch (code) { + case "invalid_client": + return 401; + case "feature_not_enabled": + case "unauthorized_client": + return 403; + case "server_error": + return 500; + default: + return 400; + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 6dc92b2ac..98b29aab2 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -81,3 +81,9 @@ export { PasskeyEnrollmentVerifyError, type PasskeyApiErrorResponse } from "./passkey-errors.js"; + +export { + AnonymousSessionError, + getStatusForAnonymousError, + mapAnonymousErrorCode +} from "./anonymous-session-errors.js"; diff --git a/src/server/anonymous-session.flow.test.ts b/src/server/anonymous-session.flow.test.ts new file mode 100644 index 000000000..599835d06 --- /dev/null +++ b/src/server/anonymous-session.flow.test.ts @@ -0,0 +1,1391 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it +} from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import type { AnonymousCookiePayload } from "../types/anonymous-session.js"; +import { AuthClient } from "./auth-client.js"; +import { decrypt, encrypt } from "./cookies.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionStore } from "./transaction-store.js"; + +// Helper to encode a mock JWT +function createMockJWT(subject: string, expiresIn: number = 3600): string { + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }) + ).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const payload = Buffer.from( + JSON.stringify({ + sub: subject, + iat: now, + exp: now + expiresIn + }) + ).toString("base64url"); + return `${header}.${payload}.signature`; +} + +describe("Anonymous Session Complete Flow Tests (Section 4)", () => { + let client: AuthClient; + let secret: string; + let server: any; + const defaultDomain = "auth0.local"; + + beforeAll(async () => { + server = setupServer( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + const body = (await request.json()) as any; + // CREATE mode (no session_token) returns new session_token + if (!body.session_token) { + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600, + scope: "read:catalog", + // metadata ONLY if body.metadata provided, else omit + ...(body.metadata && { metadata: body.metadata }) + }); + } + // RENEW mode (has session_token) returns NO session_token + return HttpResponse.json({ + token_type: "Bearer", + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600, + metadata: body.metadata, + scope: "read:catalog" + }); + } + ), + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return HttpResponse.json({ ok: true }); + }), + http.get( + `https://${defaultDomain}/.well-known/openid-configuration`, + () => { + return HttpResponse.json({ + issuer: `https://${defaultDomain}/`, + authorization_endpoint: `https://${defaultDomain}/authorize`, + token_endpoint: `https://${defaultDomain}/oauth/token`, + userinfo_endpoint: `https://${defaultDomain}/userinfo`, + jwks_uri: `https://${defaultDomain}/.well-known/jwks.json` + }); + } + ) + ); + server.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + beforeEach(async () => { + secret = await generateSecret(32); + const routes = getDefaultRoutes(); + client = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes, + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + }); + + async function createSessionCookie( + payload: AnonymousCookiePayload, + secret: string + ): Promise { + // Always use far-future JWE expiration so cookie is always decryptable. + // Logical expiry is evaluated from payload's expires_at field. + const farFutureExpiration = Math.floor(Date.now() / 1000) + 3600; + return encrypt(payload, secret, farFutureExpiration); + } + + // CASCADE-v2 M1: Flow Suite 4.1 DELETED (update route removed). + + describe("Flow Suite 4.1: Renewal & Recovery (retained non-update tests)", () => { + it("Flow: Access token renewal under expiry pressure (T1.4 + REG-C3)", async () => { + // Create session with expired access token but valid session token + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "valid-session", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + // Verify renewed token in Set-Cookie + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("auth0_anon"); + + const session = (await res.json()) as any; + expect(session.id).toMatch(/^anon@/); + }); + + it("Flow: Session expiry triggers silent recovery (T1.5)", async () => { + // Session token expired → renew attempt returns session_expired error → silent create + let callCount = 0; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + callCount++; + const body = (await request.json()) as any; + if (callCount === 1) { + // First call (RENEW attempt with expired session_token) → session_expired + expect(body.session_token).toBe("expired-session"); + return HttpResponse.json( + { error: "session_expired" }, + { status: 400 } + ); + } + // Second call (CREATE, no session_token) → fresh session + expect(body.session_token).toBeUndefined(); + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-recovered-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600, + scope: "read:catalog" + }); + } + ) + ); + + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "expired-session", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100, + metadata: { lost: "data" } + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + const session = (await res.json()) as any; + expect(session.id).toMatch(/^anon@/); + // Metadata lost on recovery (create mode has no metadata in response) + expect(session.metadata).toBeUndefined(); + expect(callCount).toBe(2); + }); + + it("Flow: T1.5b invalid_session_token recovery during renewal", async () => { + // Distinct from session_expired: invalid_session_token also triggers recovery + let callCount = 0; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + callCount++; + const body = (await request.json()) as any; + if (callCount === 1) { + // First call (RENEW attempt) → invalid_session_token + expect(body.session_token).toBe("invalid-token"); + return HttpResponse.json( + { error: "invalid_session_token" }, + { status: 400 } + ); + } + // Second call (CREATE) → fresh session + expect(body.session_token).toBeUndefined(); + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-recovered-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600, + scope: "read:catalog" + }); + } + ) + ); + + const now = Math.floor(Date.now() / 1000); + const invalidPayload: AnonymousCookiePayload = { + session_token: "invalid-token", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(invalidPayload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + const session = (await res.json()) as any; + expect(session.id).toMatch(/^anon@/); + expect(callCount).toBe(2); + }); + + // CASCADE-v2 M1: deleted 2 update tests (metadata-update renewal, session expiry during update). + }); + + // CASCADE-v2 M1: Flow Suite 4.2 DELETED (all update tests). + + describe("Flow Suite 4.2: Cookie Transfer & Renewal (retained GET)", () => { + it("REG-C3: GET /anonymous-session with renewal transfers cookies to response", async () => { + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "session", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + // JSON response present + const session = (await res.json()) as any; + expect(session.id).toMatch(/^anon@/); + // Renewed cookies in Set-Cookie header + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("auth0_anon"); + expect(setCookie).toContain("HttpOnly"); + }); + }); + + describe("Flow Suite 4.3: Configuration & Lifecycle Independence", () => { + it("T8.1 Flow: disabled feature → all routes return 404", async () => { + const disabledClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = await (disabledClient as any).handleGetAnonymousSession(req); + expect(res.status).toBe(404); + }); + + it("T8.2 Flow: disabled feature, authenticated session unaffected", async () => { + // Even with anonymous session disabled, authenticated session should work + const disabledClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + // getSession() should still work (getSession is not a method on client directly, + // but the test verifies configuration doesn't break other flows) + const config = (disabledClient as any).anonymousSessionEnabled; + expect(config).toBe(false); + }); + + it("T6.3 Flow: authenticated logout does not clear anon cookie", async () => { + const now = Math.floor(Date.now() / 1000); + const anonPayload: AnonymousCookiePayload = { + session_token: "anon-session", + access_token: createMockJWT("anon@uuid-9999"), + expires_at: now + 3600 + }; + const anonEncrypted = await createSessionCookie(anonPayload, secret); + + // Logout request with anon cookie + const req = new NextRequest( + new URL("http://localhost:3000/auth/logout"), + { + headers: { cookie: `auth0_anon=${anonEncrypted}` } + } + ); + + const res = await (client as any).handleLogout(req); + + // Verify anon cookie not cleared + const setCookies = res.headers.getSetCookie(); + const anonCookieClears = setCookies.filter( + (c: string) => c.startsWith("auth0_anon") && c.includes("Max-Age=0") + ); + expect(anonCookieClears).toHaveLength(0); + }); + + it("T8.5b: cookie.maxAge override honored in Set-Cookie", async () => { + const customMaxAge = 7200; + const customClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { + enabled: true, + cookie: { maxAge: customMaxAge } + } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + await (customClient as any).createAnonymousSession( + req.cookies, + res.cookies + ); + + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain(`Max-Age=${customMaxAge}`); + }); + }); + + describe("Flow Suite 4.4: SEC-1 Fixation in Complete Flow", () => { + it("Login with anon session → injection → callback binding flow", async () => { + const now = Math.floor(Date.now() / 1000); + const anonPayload: AnonymousCookiePayload = { + session_token: "anon-for-login", + access_token: createMockJWT("anon@uuid-9999"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(anonPayload, secret); + + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + const result = await (client as any).startInteractiveLogin( + { returnTo: "/" }, + req + ); + + // Verify session_token injected in location header + const location = result.headers.get("location"); + expect(location).toContain("session_token=anon-for-login"); + }); + + it("SEC-1 T5.3: Attacker-supplied session_token parameter is STRIPPED (Layer 1 defense)", async () => { + // CRITICAL SECURITY TEST: Verify that caller-supplied session_token in request is rejected. + // This is Layer 1 of SEC-1: reserved parameter stripping. + // The attack: attacker passes ?session_token=evil in query params or authorizationParams. + // Expected: SDK strips it before processing. + + const now = Math.floor(Date.now() / 1000); + const legitimatePayload: AnonymousCookiePayload = { + session_token: "legitimate-sdk-token", + access_token: createMockJWT("anon@uuid-9999"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(legitimatePayload, secret); + + // Attacker supplies their own session_token in authorizationParams + const attackerParams = { session_token: "attacker-token-xyz" }; + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + const result = await (client as any).startInteractiveLogin( + { + returnTo: "/", + authorizationParams: attackerParams + }, + req + ); + + // Layer 1: attacker-supplied session_token must be stripped + // SDK cookie should be injected, not attacker token + const location = result.headers.get("location"); + expect(location).toContain("session_token=legitimate-sdk-token"); + // Most critical: attacker token must NOT appear in authorization URL + expect(location).not.toContain("attacker-token-xyz"); + }); + + it("SEC-1 T5.4: Injected session_token sourced only from own SDK cookie (Layer 2 defense)", async () => { + // Layer 2 of SEC-1: own-cookie sourcing. + // Verify that the injected session_token comes ONLY from the encrypted SDK cookie, + // not from any request input. + // Proof: decrypt the cookie, verify its session_token matches the injected value. + + const now = Math.floor(Date.now() / 1000); + const cookiePayload: AnonymousCookiePayload = { + session_token: "unique-cookie-token-789", + access_token: createMockJWT("anon@uuid-9999"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(cookiePayload, secret); + + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + const result = await (client as any).startInteractiveLogin( + { returnTo: "/" }, + req + ); + + // The injected token must be the exact one from the encrypted cookie + const location = result.headers.get("location"); + expect(location).toContain("session_token=unique-cookie-token-789"); + }); + + it("SEC-1 T6.1: Transaction state binding records anonymousSessionLinked flag", async () => { + // Layer 3 of SEC-1: transaction state binding. + // Verify that when a session is injected, the flag is set in transaction state. + // This prevents swapped-cookie attacks at callback time. + + const now = Math.floor(Date.now() / 1000); + const anonPayload: AnonymousCookiePayload = { + session_token: "session-bound", + access_token: createMockJWT("anon@uuid-9999"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(anonPayload, secret); + + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + const result = await (client as any).startInteractiveLogin( + { returnTo: "/" }, + req + ); + + // After startInteractiveLogin, the transaction state should have anonymousSessionLinked=true + // This is verified at callback time to prevent cookie-swap attacks + const location = result.headers.get("location"); + expect(location).toContain("session_token=session-bound"); + + // Decrypt transaction cookie and verify anonymousSessionLinked flag + const stateMatch = location!.match(/state=([^&]+)/); + expect(stateMatch).toBeTruthy(); + const txState = await (client as any).transactionStore.get( + result.cookies, + stateMatch![1] + ); + expect(txState).toBeTruthy(); + expect(txState.payload.anonymousSessionLinked).toBe(true); + }); + + it("SEC-1 T6.2: No session at login → anonymousSessionLinked flag false", async () => { + // When no anon session exists, flag must be false so callback knows + // not to apply migration logic. + + const req = new NextRequest(new URL("http://localhost:3000/auth/login")); + + const result = await (client as any).startInteractiveLogin( + { returnTo: "/" }, + req + ); + + // No session_token in URL since no cookie + const location = result.headers.get("location"); + expect(location).not.toContain("session_token="); + + // Decrypt transaction cookie and verify anonymousSessionLinked flag is false + const stateMatch = location!.match(/state=([^&]+)/); + expect(stateMatch).toBeTruthy(); + const txState = await (client as any).transactionStore.get( + result.cookies, + stateMatch![1] + ); + expect(txState).toBeTruthy(); + expect(txState.payload.anonymousSessionLinked || false).toBe(false); + }); + + it("C2 BLOCKER: SEC-1 Layer 3 callback transaction binding prevents cookie swap attacks", async () => { + // CRITICAL SECURITY TEST (C2 BLOCKER): Prove that anonymousSessionLinked + // at callback derives from TRANSACTION STATE bound at login, not from + // request-time cookie. Attack scenario: login with session A binds transaction; + // at callback attacker presents a different/forged cookie B; SDK must use + // the transaction-bound state (session A digest), not the swapped cookie B. + // + // verifyAnonymousSessionLink (line 2916) checks: + // transactionState.anonymousSessionRef === digest(current_cookie.session_token) + // If they don't match → returns false (link rejected). + + const now = Math.floor(Date.now() / 1000); + + // Session A: legitimate session at login + const sessionA: AnonymousCookiePayload = { + session_token: "session-a-legit", + access_token: createMockJWT("anon@uuid-a"), + expires_at: now + 3600 + }; + const encryptedA = await createSessionCookie(sessionA, secret); + + // Login with session A → binds transaction state with digest of "session-a-legit" + const loginReq = new NextRequest( + new URL("http://localhost:3000/auth/login"), + { headers: { cookie: `auth0_anon=${encryptedA}` } } + ); + const loginRes = await (client as any).startInteractiveLogin( + { returnTo: "/" }, + loginReq + ); + + // Extract state param from redirect (needed for callback) + const location = loginRes.headers.get("location"); + expect(location).toContain("state="); + const stateMatch = location!.match(/state=([^&]+)/); + expect(stateMatch).toBeTruthy(); + const state = stateMatch![1]; + + // Attacker scenario: at callback time, present a DIFFERENT cookie (session B) + const sessionB: AnonymousCookiePayload = { + session_token: "session-b-attacker", + access_token: createMockJWT("anon@uuid-b-attacker"), + expires_at: now + 3600 + }; + const encryptedB = await createSessionCookie(sessionB, secret); + + // Callback with swapped cookie B (attacker injection) + // verifyAnonymousSessionLink should detect mismatch: + // transactionState.anonymousSessionRef (digest of A) !== digest(B) + // → returns false → anonymousSessionLinked = false + const callbackReq = new NextRequest( + new URL( + `http://localhost:3000/auth/callback?code=mock-code&state=${state}` + ), + { + headers: { + cookie: `auth0_anon=${encryptedB};auth0_tx=${loginRes.cookies.get("auth0_tx")?.value}` + } + } + ); + + // We can't fully drive handleCallback without mocking OAuth token exchange, + // but we CAN directly test the security function verifyAnonymousSessionLink. + // Read transaction state from cookie. + const txState = await (client as any).transactionStore.get( + loginRes.cookies, + state + ); + expect(txState).toBeTruthy(); + + // Call verifyAnonymousSessionLink with transaction state (bound to A) and request with cookie B + const linkedFlag = await (client as any).verifyAnonymousSessionLink( + txState.payload, + callbackReq + ); + + // CRITICAL ASSERTION: linkedFlag must be FALSE because cookie swap detected + // (transaction ref digest of A ≠ digest of B's session_token) + expect(linkedFlag).toBe(false); + }); + }); + + // CASCADE-v2 M1: Flow Suite 4.5 update body tests DELETED. + + describe("Flow Suite 4.5: HTTP Request Body Inspection (retained create/renew)", () => { + it("T2.8/T2.9: audience + scope present in create AND renew request bodies", async () => { + const clientWithAudience = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { + enabled: true, + audience: "https://api.example.com", + scope: "read:data write:data" + } + }); + + let capturedCreateBody: any = null; + let capturedRenewBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + const body = (await request.json()) as any; + if (!body.session_token) { + // CREATE mode + capturedCreateBody = body; + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600, + scope: "read:data write:data" + }); + } + // RENEW mode + capturedRenewBody = body; + return HttpResponse.json({ + token_type: "Bearer", + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600, + scope: "read:data write:data" + }); + } + ) + ); + + // Step 1: CREATE + const createReq = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const createRes = new NextResponse(); + await (clientWithAudience as any).createAnonymousSession( + createReq.cookies, + createRes.cookies + ); + + expect(capturedCreateBody).toBeTruthy(); + expect(capturedCreateBody.audience).toBe("https://api.example.com"); + expect(capturedCreateBody.scope).toBe("read:data write:data"); + + // Step 2: RENEW (expired access) + const now = Math.floor(Date.now() / 1000); + const renewPayload: AnonymousCookiePayload = { + session_token: "session-123", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(renewPayload, secret); + const renewReq = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + await (clientWithAudience as any).handleGetAnonymousSession(renewReq); + + expect(capturedRenewBody).toBeTruthy(); + expect(capturedRenewBody.audience).toBe("https://api.example.com"); + expect(capturedRenewBody.scope).toBe("read:data write:data"); + expect(capturedRenewBody.session_token).toBe("session-123"); + }); + + it("T2.8/T2.9: audience + scope both undefined when not configured", async () => { + let capturedBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600 + }); + } + ) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + await (client as any).createAnonymousSession(req.cookies, res.cookies); + + expect(capturedBody).toBeTruthy(); + expect(capturedBody.audience).toBeUndefined(); + expect(capturedBody.scope).toBeUndefined(); + }); + + it("T2.10: renew 200 returns NO session_token", async () => { + let responseBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + const body = (await request.json()) as any; + // RENEW mode (has session_token) + if (body.session_token) { + responseBody = { + token_type: "Bearer", + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600 + }; + return HttpResponse.json(responseBody); + } + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600 + }); + } + ) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "session-123", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + // Verify MSW response body has NO session_token + expect(responseBody).toBeTruthy(); + expect(responseBody.session_token).toBeUndefined(); + + // Verify SDK retained the ORIGINAL session_token in persisted cookie + const anonCookie = res.cookies.get("auth0_anon"); + expect(anonCookie).toBeTruthy(); + const decrypted = await decrypt( + anonCookie!.value, + secret + ); + expect(decrypted).toBeTruthy(); + expect(decrypted!.payload.session_token).toBe("session-123"); + }); + + it("T2.11: logout 204 empty body not parsed", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return new HttpResponse(null, { status: 204 }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-9999"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleAnonymousLogout(req); + + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("Max-Age=0"); + }); + }); + + describe("Flow Suite 4.6: FR-2 createAnonymousSession Factory", () => { + it("FR-2: Zero-argument form creates new anonymous session", async () => { + // Test that createAnonymousSession() with no args creates a fresh session + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies + ); + + expect(session.id).toMatch(/^anon@/); + expect(session.accessToken).toBeDefined(); + expect(session.expiresAt).toBeGreaterThan(0); + }); + + it("FR-2: req/res form with cookies creates and persists session", async () => { + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies + ); + + // Verify session returned + expect(session.id).toMatch(/^anon@/); + // Verify cookies set in response + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("auth0_anon"); + }); + }); + + // CASCADE-v2 M1: Flow Suite 4.7 DELETED (metadata-update tests). + + describe("Flow Suite 4.8: FR-13 invalid_client Error Handling", () => { + it("FR-13: invalid_client error thrown on authentication failure", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json( + { + error: "invalid_client" + }, + { status: 401 } + ); + }) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies) + ).rejects.toThrow(); + + // Verify the error is an AnonymousSessionError with code invalid_client + try { + await (client as any).createAnonymousSession(req.cookies, res.cookies); + } catch (e: any) { + expect(e.code).toBe("invalid_client"); + } + }); + + it("REG-D1: AnonymousSessionError carries description + cause from server error", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json( + { + error: "feature_not_enabled", + error_description: + "Anonymous sessions not enabled for this tenant" + }, + { status: 403 } + ); + }) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + try { + await (client as any).createAnonymousSession(req.cookies, res.cookies); + throw new Error("Should have thrown"); + } catch (e: any) { + expect(e.code).toBe("feature_not_enabled"); + expect(e.description).toBe( + "Anonymous sessions not enabled for this tenant" + ); + expect(e.cause).toBeTruthy(); + } + }); + }); + + // CASCADE-v2 M1: Flow Suite 4.9 update test DELETED, GET test retained. + + describe("Flow Suite 4.9: REG-C1 Cookie Transfer Pattern", () => { + it("REG-C1: GET /anonymous-session transfers renewed cookie to response", async () => { + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "session", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + // Verify both JSON body AND Set-Cookie header present + const session = (await res.json()) as any; + expect(session.id).toMatch(/^anon@/); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toBeTruthy(); + expect(setCookie).toContain("auth0_anon"); + }); + }); + + describe("Concurrent Renewal (8.S5) - Multi-request under expiry", () => { + it("8.S5: Two concurrent GET requests with expired access → both renew", async () => { + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "session", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + + // First request + const req1 = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + const res1Promise = (client as any).handleGetAnonymousSession(req1); + + // Second request (concurrent) + const req2 = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + const res2Promise = (client as any).handleGetAnonymousSession(req2); + + const [res1, res2] = await Promise.all([res1Promise, res2Promise]); + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + + const body1 = (await res1.json()) as any; + const body2 = (await res2.json()) as any; + + expect(body1.id).toMatch(/^anon@/); + expect(body2.id).toMatch(/^anon@/); + }); + }); + + describe("Login Injection & Session Token Fixation (T5, SEC-1)", () => { + it("T5.1: active anon cookie at login → session_token should be read from cookie", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "sdk-cookie-token-xyz", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + // Verify readAnonymousCookie method can extract the token + const cookiePayload = await (client as any).readAnonymousCookie( + req.cookies + ); + expect(cookiePayload).not.toBeNull(); + expect(cookiePayload?.session_token).toBe("sdk-cookie-token-xyz"); + }); + + it("T5.2: no anon cookie at login → readAnonymousCookie returns null", async () => { + const req = new NextRequest(new URL("http://localhost:3000/auth/login")); + + // Verify readAnonymousCookie returns null when no cookie + const cookiePayload = await (client as any).readAnonymousCookie( + req.cookies + ); + expect(cookiePayload).toBeNull(); + }); + + it("T5.5: feature disabled → startInteractiveLogin with disabled client", async () => { + const disabledClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "should-not-inject", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + // With feature disabled, startInteractiveLogin should proceed without session injection + const result = await (disabledClient as any).startInteractiveLogin( + { returnTo: "/" }, + req + ); + expect(result).toBeInstanceOf(NextResponse); + expect([302, 307]).toContain(result.status); + }); + }); + + describe("SEC-1: Session-Token Fixation Mitigation (Adversarial)", () => { + it("SEC-1 T5.3: Reserved parameters list includes session_token (stripped before use)", async () => { + // Verify that session_token is in the INTERNAL_AUTHORIZE_PARAMS list + // by checking that caller-supplied values are stripped. + // This is done via the mergeAuthorizationParamsIntoSearchParams function. + const req = new NextRequest(new URL("http://localhost:3000/auth/login")); + + // Attempt to inject session_token via authorizationParams + // The startInteractiveLogin method should strip it + const result = await (client as any).startInteractiveLogin( + { + returnTo: "/", + authorizationParams: { + session_token: "attacker-injected" + } + }, + req + ); + + // Verify result is a NextResponse (successful call - either 302 or 307) + expect(result).toBeInstanceOf(NextResponse); + expect([302, 307]).toContain(result.status); + }); + + it("SEC-1 T5.4: SDK reads session_token only from own encrypted cookie", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "legitimate-from-own-cookie", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + // readAnonymousCookie should decrypt and return the SDK's own token + const cookiePayload = await (client as any).readAnonymousCookie( + req.cookies + ); + expect(cookiePayload?.session_token).toBe("legitimate-from-own-cookie"); + }); + + it("SEC-1 T6.1: SDK can extract session_token from cookie for binding", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "session-to-bind", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest(new URL("http://localhost:3000/auth/login"), { + headers: { cookie: `auth0_anon=${encrypted}` } + }); + + // Verify readAnonymousCookie can extract the token for binding + const cookiePayload = await (client as any).readAnonymousCookie( + req.cookies + ); + expect(cookiePayload).not.toBeNull(); + expect(cookiePayload?.session_token).toBeTruthy(); + }); + + it("SEC-1 T6.2: startInteractiveLogin with no cookie proceeds without session binding", async () => { + const req = new NextRequest(new URL("http://localhost:3000/auth/login")); + + const result = await (client as any).startInteractiveLogin( + { returnTo: "/" }, + req + ); + + // Should succeed and return a redirect + expect(result).toBeInstanceOf(NextResponse); + expect([302, 307]).toContain(result.status); + }); + }); + + describe("Regression tests for CodeRabbit fixes A5 + A8", () => { + it("A5 regression: renewal with malformed access_token sub returns null, does NOT throw", async () => { + // A5 fix: renewal path toPublicSession throw (e.g. access_token sub not anon@) must return null + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + // Renewal returns access_token with NON-anon sub → toPublicSession will throw + return HttpResponse.json({ + token_type: "Bearer", + access_token: createMockJWT("user@123"), // NOT anon@ + expires_in: 3600 + }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "session-123", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + // getAnonymousSession must NOT throw, return null (session treated as absent) + const res = await (client as any).handleGetAnonymousSession(req); + expect(res.status).toBe(204); // No session + }); + + it("A8 regression: createAnonymousSession with metadata string throws invalid_request", async () => { + // A8 fix: metadata type validation (string not allowed) + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: "invalid-string" as any + }) + ).rejects.toThrow(); + + try { + await (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: "invalid-string" as any + }); + } catch (e: any) { + expect(e.code).toBe("invalid_request"); + expect(e.message).toContain("plain object"); + } + }); + + it("A8 regression: createAnonymousSession with metadata array throws invalid_request", async () => { + // A8 fix: metadata type validation (array not allowed) + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: [1, 2, 3] as any + }) + ).rejects.toThrow(); + + try { + await (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: [1, 2, 3] as any + }); + } catch (e: any) { + expect(e.code).toBe("invalid_request"); + expect(e.message).toContain("plain object"); + } + }); + + it("A8 regression: createAnonymousSession with metadata number throws invalid_request", async () => { + // A8 fix: metadata type validation (number not allowed) + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: 42 as any + }) + ).rejects.toThrow(); + + try { + await (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: 42 as any + }); + } catch (e: any) { + expect(e.code).toBe("invalid_request"); + expect(e.message).toContain("plain object"); + } + }); + + it("CR-1b regression: createAnonymousSession with expires_in > 2592000 throws invalid_response", async () => { + // CR-1b fix: upper bound on expires_in to prevent decades-long tokens + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 999999999 // Absurdly large + }); + }) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies) + ).rejects.toThrow(); + + try { + await (client as any).createAnonymousSession(req.cookies, res.cookies); + } catch (e: any) { + expect(e.code).toBe("invalid_response"); + expect(e.message).toContain("expires_in out of bounds"); + } + }); + + it("CR-1b regression: createAnonymousSession with expires_in=3600 works", async () => { + // CR-1b fix: normal expires_in values still work + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json({ + token_type: "Bearer", + session_token: `session-${Date.now()}`, + access_token: createMockJWT("anon@uuid-9999"), + expires_in: 3600 + }); + }) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies + ); + expect(session).toBeDefined(); + expect(session.id).toContain("anon@"); + }); + + it("CR-1b regression: renewal with negative expires_in works (test mock for expiry-driven renewal)", async () => { + // CR-1b fix: negative/zero expires_in allowed (used in renewal test mocks) + // This test verifies that the validation doesn't reject negative values + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json({ + token_type: "Bearer", + access_token: createMockJWT("anon@uuid-9999", 3600), + expires_in: -10 // Negative but allowed + }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "session-123", + access_token: createMockJWT("anon@uuid-9999", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + // Should not throw during renewal, even though expires_in is negative + const res = await (client as any).handleGetAnonymousSession(req); + // The renewed token has a valid access_token (3600s exp) but negative expires_in + // means expires_at is in the past, so it's treated as expired + // BUT the renewal succeeded without throwing, which is what we're testing + expect(res.status).toBe(200); // Renewal succeeded, access_token is valid + }); + }); +}); diff --git a/src/server/auth-client.anonymous-routes.test.ts b/src/server/auth-client.anonymous-routes.test.ts new file mode 100644 index 000000000..361d321eb --- /dev/null +++ b/src/server/auth-client.anonymous-routes.test.ts @@ -0,0 +1,1316 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it +} from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import type { AnonymousCookiePayload } from "../types/anonymous-session.js"; +import { AuthClient } from "./auth-client.js"; +import { encrypt } from "./cookies.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionStore } from "./transaction-store.js"; + +// Helper to encode a mock JWT +function createMockJWT(subject: string, expiresIn: number = 3600): string { + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }) + ).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const payload = Buffer.from( + JSON.stringify({ + sub: subject, + iat: now, + exp: now + expiresIn + }) + ).toString("base64url"); + return `${header}.${payload}.signature`; +} + +describe("Auth0Client: Anonymous Sessions Routes (a3)", () => { + let client: AuthClient; + let secret: string; + let server: any; + const defaultDomain = "auth0.local"; + + beforeAll(async () => { + server = setupServer( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + const body = (await request.json()) as any; + // CREATE mode (no session_token) + if (!body.session_token) { + return HttpResponse.json({ + token_type: "Bearer", + session_token: `new-${Date.now()}`, + access_token: createMockJWT("anon@uuid-1234"), + expires_in: 3600, + scope: "read:catalog", + ...(body.metadata && { metadata: body.metadata }) + }); + } + // RENEW mode (has session_token) returns NO session_token + return HttpResponse.json({ + token_type: "Bearer", + access_token: createMockJWT("anon@uuid-1234"), + expires_in: 3600, + metadata: body.metadata, + scope: "read:catalog" + }); + } + ), + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return HttpResponse.json({ ok: true }); + }), + http.get( + `https://${defaultDomain}/.well-known/openid-configuration`, + () => { + return HttpResponse.json({ + issuer: `https://${defaultDomain}/`, + authorization_endpoint: `https://${defaultDomain}/authorize`, + token_endpoint: `https://${defaultDomain}/oauth/token`, + userinfo_endpoint: `https://${defaultDomain}/userinfo`, + jwks_uri: `https://${defaultDomain}/.well-known/jwks.json` + }); + } + ) + ); + server.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + beforeEach(async () => { + secret = await generateSecret(32); + const routes = getDefaultRoutes(); + client = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes, + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + }); + + async function createSessionCookie( + payload: AnonymousCookiePayload, + secret: string + ): Promise { + // Always use far-future JWE expiration so cookie is always decryptable. + // Logical expiry is evaluated from payload's expires_at field. + const farFutureExpiration = Math.floor(Date.now() / 1000) + 3600; + return encrypt(payload, secret, farFutureExpiration); + } + + describe("handleGetAnonymousSession", () => { + it("T1.1: GET /auth/anonymous-session returns 204 when no session", async () => { + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(204); + expect(await res.text()).toBe(""); + }); + + it("T1.3: GET returns 200 + session JSON when valid", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-123", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.id).toMatch(/^anon@/); + expect(body.accessToken).toBeDefined(); + }); + + it("REG-C3: GET response includes renewed cookies in Set-Cookie header when access expired", async () => { + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "valid", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("auth0_anon"); + expect(setCookie).toContain("HttpOnly"); + }); + + it("T8.1: GET returns 404 when feature disabled", async () => { + const disabledClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = await (disabledClient as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(404); + }); + + it("T1-REG: Flow - GET with renewal transfers cookies to response", async () => { + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "session", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + // Verify session JSON is present + const body = (await res.json()) as any; + expect(body.id).toMatch(/^anon@/); + // Verify cookies in Set-Cookie + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toBeTruthy(); + }); + }); + + // CASCADE-v2 M1: update route removed. Tests deleted (T3.1-T3.6, REG-C3, T3-REG-RECOVERY). + + describe("Create Anonymous Session - Additional Coverage", () => { + it("T2.5: id shape validation - created session id equals access_token sub claim and matches anon@", async () => { + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies + ); + + expect(session.id).toMatch(/^anon@/); + const jwt = session.accessToken; + const [, payloadPart] = jwt.split("."); + const payload = JSON.parse( + Buffer.from(payloadPart, "base64url").toString() + ); + expect(session.id).toBe(payload.sub); + }); + + // CASCADE-v2 M2: create accepts metadata + it("M2-CREATE-MD-1: createAnonymousSession({metadata}) sends metadata in create request body", async () => { + let capturedBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json({ + token_type: "Bearer", + session_token: `new-${Date.now()}`, + access_token: createMockJWT("anon@uuid-create-md"), + expires_in: 3600, + scope: "read:catalog" + }); + } + ) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: { cart: { items: 2 }, prefs: { theme: "dark" } } + }); + + expect(capturedBody).toHaveProperty("metadata"); + expect(capturedBody.metadata).toEqual({ + cart: { items: 2 }, + prefs: { theme: "dark" } + }); + expect(capturedBody).not.toHaveProperty("session_token"); + }); + + it("M2-CREATE-MD-2: createAnonymousSession() no metadata → no metadata field in body", async () => { + let capturedBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json({ + token_type: "Bearer", + session_token: `new-${Date.now()}`, + access_token: createMockJWT("anon@uuid-no-md"), + expires_in: 3600, + scope: "read:catalog" + }); + } + ) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await (client as any).createAnonymousSession(req.cookies, res.cookies); + + expect(capturedBody).not.toHaveProperty("metadata"); + }); + + it("M2-CREATE-MD-3a: metadata exactly 1024 UTF-8 bytes → ACCEPTED", async () => { + // Construct metadata whose JSON.stringify UTF-8 byteLength is EXACTLY 1024 + // JSON format: {"m":"..."} → 8 overhead bytes + payload + // Need payload of 1024 - 8 = 1016 ASCII chars + const payload = "x".repeat(1016); + const metadata = { m: payload }; + const serialized = JSON.stringify(metadata); + const byteLength = new TextEncoder().encode(serialized).byteLength; + + expect(byteLength).toBe(1024); // Sanity check + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies, + { metadata } + ); + + expect(session.id).toMatch(/^anon@/); + }); + + it("M2-CREATE-MD-3b: metadata 1025 UTF-8 bytes → REJECTED (metadata_too_large)", async () => { + // JSON format: {"m":"..."} → 8 overhead + 1017 = 1025 bytes + const payload = "x".repeat(1017); + const metadata = { m: payload }; + const serialized = JSON.stringify(metadata); + const byteLength = new TextEncoder().encode(serialized).byteLength; + + expect(byteLength).toBe(1025); // Sanity check + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata + }) + ).rejects.toThrow(/metadata.*1KB/i); + }); + + it("M2-CREATE-MD-3c: metadata >1KB throws BEFORE network call (no request issued)", async () => { + let requestCount = 0; + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, async () => { + requestCount++; + return HttpResponse.json({ + token_type: "Bearer", + session_token: `new-${Date.now()}`, + access_token: createMockJWT("anon@uuid-1234"), + expires_in: 3600, + scope: "read:catalog" + }); + }) + ); + + const largeMetadata = { data: "x".repeat(2000) }; + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: largeMetadata + }) + ).rejects.toThrow(/metadata.*1KB/i); + + // CRITICAL: network call MUST NOT have been made + expect(requestCount).toBe(0); + }); + + it("M2-CREATE-MD-4: silent recovery (renewal) omits metadata", async () => { + let recoveryBody: any = null; + let callCount = 0; + server.use( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + callCount++; + const body = (await request.json()) as any; + if (callCount === 1 && body.session_token) { + return HttpResponse.json( + { error: "session_expired" }, + { status: 400 } + ); + } + recoveryBody = body; + return HttpResponse.json({ + token_type: "Bearer", + session_token: `recovery-${Date.now()}`, + access_token: createMockJWT("anon@uuid-recovery"), + expires_in: 3600, + scope: "read:catalog" + }); + } + ) + ); + + const now = Math.floor(Date.now() / 1000); + const expiredPayload: AnonymousCookiePayload = { + session_token: "expired-token", + access_token: createMockJWT("anon@uuid-old", -100), + expires_at: now - 100, + metadata: { cart: { qty: 5 } } + }; + const encrypted = await createSessionCookie(expiredPayload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + expect(recoveryBody).not.toHaveProperty("metadata"); + }); + }); + + describe("handleAnonymousLogout", () => { + it("T4.1: POST /logout clears cookie and returns 200", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleAnonymousLogout(req); + + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("auth0_anon"); + expect(setCookie).toContain("Max-Age=0"); + }); + + it("T4.2: POST /logout with no session returns 200 (idempotent)", async () => { + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST" + } + ); + + const res = await (client as any).handleAnonymousLogout(req); + + expect(res.status).toBe(200); + }); + + it("T4.3: Called twice is idempotent", async () => { + const req1 = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST" + } + ); + const res1 = await (client as any).handleAnonymousLogout(req1); + + const req2 = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST" + } + ); + const res2 = await (client as any).handleAnonymousLogout(req2); + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + }); + + it("T4.4: Auth0 logout call fails (non-5xx) but still clears cookie and returns 200", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return HttpResponse.json( + { + error: "invalid_token" + }, + { status: 400 } + ); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleAnonymousLogout(req); + + // Should still return 200 and clear cookie + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("Max-Age=0"); + }); + + it("T4.5: Auth0 logout 5xx throws error (no 5xx swallow)", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return HttpResponse.json( + { + error: "server_error" + }, + { status: 500 } + ); + }) + ); + + // The route (handleAnonymousLogout) swallows 5xx by design (idempotent), + // but the network method (anonymousLogoutRequest) should throw. + await expect( + (client as any).anonymousLogoutRequest("token") + ).rejects.toThrow(); + }); + + // CASCADE-v2 M3: logout body = {client_id} + clientAuth params (not just {client_id}), session_token NOT in body. + it("M3-LOGOUT-1: logout request body includes client_id + clientAuth params, session_token NOT in body", async () => { + let capturedBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/logout`, + async ({ request }) => { + capturedBody = await request.json(); + return new HttpResponse(null, { status: 200 }); + } + ) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + await (client as any).handleAnonymousLogout(req); + + expect(capturedBody).toHaveProperty("client_id"); + expect(capturedBody.client_id).toBe("test-id"); + expect(capturedBody).not.toHaveProperty("session_token"); + // clientAuth adds client_secret to body (for client_secret_post mode) + expect(capturedBody).toHaveProperty("client_secret"); + }); + + it("M3-LOGOUT-2: logout with no client_id sends clientAuth params only, session_token NOT in body", async () => { + let capturedBody: any = null; + server.use( + http.post( + `https://${defaultDomain}/anonymous/logout`, + async ({ request }) => { + capturedBody = await request.json(); + return new HttpResponse(null, { status: 200 }); + } + ) + ); + + // Create client with client_secret but no client_id (server-to-server mode) + const secretClient = new AuthClient({ + domain: defaultDomain, + clientId: "", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + await (secretClient as any).handleAnonymousLogout(req); + + // Body = {client_id: ""} + clientAuth params (client_secret) + expect(capturedBody).toHaveProperty("client_id"); + expect(capturedBody).not.toHaveProperty("session_token"); + }); + + it("M3-LOGOUT-3a: logout clears cookie UNCONDITIONALLY on network success (200)", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return HttpResponse.json({ ok: true }, { status: 200 }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleAnonymousLogout(req); + + // Returns 200 and clears cookie + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("Max-Age=0"); + }); + + it("M3-LOGOUT-3b: logout clears cookie UNCONDITIONALLY on HTTP 500 response", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/logout`, () => { + return HttpResponse.json({ error: "server_error" }, { status: 500 }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleAnonymousLogout(req); + + // Handler swallows 5xx, returns 200, and clears cookie + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("Max-Age=0"); + }); + + it("REG-Q2: logout clears chunked cookie fragments, not just the base cookie", async () => { + // Build a payload large enough to be stored as chunks (>4KB encrypted). + const now = Math.floor(Date.now() / 1000); + const bigPayload: AnonymousCookiePayload = { + session_token: "token", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600, + metadata: { blob: "x".repeat(6000) } + }; + // Persist through the SDK so the chunking logic runs and req/res cookies + // hold the real chunk set (auth0_anon, auth0_anon__0, ...). + const persistReq = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const persistRes = new NextResponse(); + await (client as any).persistAnonymousCookie( + bigPayload, + persistReq.cookies, + persistRes.cookies + ); + + const chunkNames = persistRes.cookies + .getAll() + .map((c: any) => c.name) + .filter((n: string) => n.startsWith("auth0_anon")); + // Sanity: the payload actually chunked. + expect(chunkNames.length).toBeGreaterThan(1); + + // Rebuild a request carrying every chunk cookie. + const cookieHeader = persistRes.cookies + .getAll() + .filter((c: any) => c.name.startsWith("auth0_anon")) + .map((c: any) => `${c.name}=${c.value}`) + .join("; "); + const logoutReq = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { method: "POST", headers: { cookie: cookieHeader } } + ); + + const res = await (client as any).handleAnonymousLogout(logoutReq); + expect(res.status).toBe(200); + + // Every chunk cookie must be cleared (Max-Age=0), not only the base name. + const cleared = res.cookies + .getAll() + .filter((c: any) => c.name.startsWith("auth0_anon")); + const clearedNames = cleared.map((c: any) => c.name); + for (const name of chunkNames) { + expect(clearedNames).toContain(name); + } + for (const c of cleared) { + expect(c.maxAge).toBe(0); + } + }); + }); + + describe("handleGetAnonymousSession - Additional Coverage", () => { + it("T1.2: malformed cookie (invalid JWE) returns 204 with no throw, no network call", async () => { + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: "auth0_anon=not-valid-jwe-format" } + } + ); + + // MSW with onUnhandledRequest: 'error' will fail the test if any network call is made + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(204); + expect(await res.text()).toBe(""); + }); + + it("T1.6: access expired, read-only context (no writable cookies) returns decrypted session as-is", async () => { + const now = Math.floor(Date.now() / 1000); + const expiredAccessPayload: AnonymousCookiePayload = { + session_token: "valid-session", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredAccessPayload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + // Call the internal method with only reqCookies (simulating read-only context like Server Component) + const session = await (client as any).resolveAnonymousSession( + req.cookies, + undefined + ); + + // Should return the decrypted session as-is, without attempting renewal + expect(session).not.toBeNull(); + expect(session?.id).toMatch(/^anon@/); + }); + + it("T1.7: authorization server returns server_error on renewal → returns 500 error response", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, async () => { + return HttpResponse.json({ error: "server_error" }, { status: 500 }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const expiredAccessPayload: AnonymousCookiePayload = { + session_token: "valid-session", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(expiredAccessPayload, secret); + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + // Handler catches the error and returns error response + expect(res.status).toBe(500); + const body = (await res.json()) as any; + expect(body.error).toBe("server_error"); + }); + }); + + // CASCADE-v2 M1: "Metadata Update - Additional Coverage" describe block removed (T3.2, T3.6). + + describe("Create Anonymous Session - Additional Coverage", () => { + it("T2.5: id shape validation - created session id equals access_token sub claim and matches anon@", async () => { + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies + ); + + // Verify id matches anon@ prefix + expect(session.id).toMatch(/^anon@/); + + // STRENGTHENED: Decode the returned access_token and verify id === sub claim + const parts = session.accessToken.split("."); + expect(parts).toHaveLength(3); + + const payloadStr = Buffer.from(parts[1], "base64url").toString(); + const payload = JSON.parse(payloadStr); + const subClaim = payload.sub; + + // Critical: the session.id MUST come from JWT sub claim, not be hardcoded + expect(session.id).toBe(subClaim); + expect(subClaim).toMatch(/^anon@/); + }); + + it("T2.6: create response missing session_token → throws AnonymousSessionError", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, async () => { + return HttpResponse.json({ + token_type: "Bearer", + // Missing session_token + access_token: createMockJWT("anon@uuid-1234"), + expires_in: 3600, + scope: "read:catalog" + }); + }) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies) + ).rejects.toThrow(); + }); + + it("T2.7: network error on /anonymous/token → throws AnonymousSessionError", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, async () => { + return HttpResponse.json( + { error: "internal_error" }, + { status: 500 } + ); + }) + ); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies) + ).rejects.toThrow(); + }); + }); + + describe("Configuration Gating - Additional Coverage", () => { + it("T8.5: feature disabled → getAnonymousSession returns null, no network call, no error", async () => { + const disabledClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + + // Should return null (or be unreachable), no network call + const session = await (disabledClient as any).getAnonymousSession(req); + + // When disabled, getAnonymousSession should return null + expect(session).toBeNull(); + }); + + it("T8.3: cookie name override → encrypted state stored under custom name", async () => { + const customCookieName = "my_custom_anon_cookie"; + const customClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true, cookie: { name: customCookieName } } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await (customClient as any).createAnonymousSession( + req.cookies, + res.cookies + ); + + // Check that the custom cookie name was used + const cookies = res.cookies.getAll(); + const customNameCookie = cookies.find((c: any) => + c.name.startsWith(customCookieName) + ); + expect(customNameCookie).toBeDefined(); + }); + }); + + describe("createAnonymousSession (public method)", () => { + it("REG-Q1: throws unauthorized_client when the feature is disabled (no network call)", async () => { + const disabledClient = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + await expect( + (disabledClient as any).createAnonymousSession(req.cookies, res.cookies) + ).rejects.toMatchObject({ code: "unauthorized_client" }); + }); + }); + + describe("GUARDING TEST: C1 - Route Dispatch via handler() PUBLIC entry point", () => { + it("C1: GET /auth/anonymous-session via handler() reaches handleGetAnonymousSession, returns 204 or 200", async () => { + const getReq = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { method: "GET" } + ); + + const res = await (client as any).handler(getReq); + + expect(res).toBeInstanceOf(NextResponse); + // MUST be exactly 204 (empty body): only handleGetAnonymousSession returns + // 204 for a no-cookie request. If C1 route defaults were missing, dispatch + // would fall through to the default handler (NextResponse.next(), status 200), + // so asserting 204 specifically guards the route-match regression. + expect(res.status).toBe(204); + expect(await res.text()).toBe(""); + }); + + // CASCADE-v2 M1: C1 update dispatch test removed (update route gone). + + it("C1: POST /auth/anonymous-session/logout via handler() reaches handleAnonymousLogout, returns 200 with Max-Age=0", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token-to-logout", + access_token: createMockJWT("anon@uuid-1234"), + expires_at: now + 3600 + }; + const encrypted = await createSessionCookie(payload, secret); + + const logoutReq = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session/logout"), + { + method: "POST", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handler(logoutReq); + + expect(res).toBeInstanceOf(NextResponse); + expect(res.status).toBe(200); + // Check either set-cookie header or getSetCookie() array + const setCookies = res.headers.getSetCookie(); + expect(setCookies.length).toBeGreaterThan(0); + const hasMaxAge0 = setCookies.some((c: string) => + c.includes("Max-Age=0") + ); + expect(hasMaxAge0).toBe(true); + }); + }); + + describe("GUARDING TEST: M1 - session_token retention on renewal", () => { + it("M1: session_token preserved during GET renewal (expired access, valid session)", async () => { + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "S1-original", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(payload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toBeTruthy(); + + // Extract and decrypt the renewed cookie + const cookieMatch = setCookie?.match(/auth0_anon=([^;]+)/); + expect(cookieMatch).toBeTruthy(); + const renewedCookieValue = cookieMatch![1]; + const decrypted = await ( + await import("../server/cookies.js") + ).decrypt(renewedCookieValue, secret); + + // CRITICAL: session_token must be preserved from the original cookie + expect(decrypted).not.toBeNull(); + expect(decrypted?.payload.session_token).toBe("S1-original"); + }); + + // CASCADE-v2 M1: M1 update-route session_token retention test removed (update route gone). + }); + + describe("GUARDING TEST: M4 - Metadata UTF-8 byte cap (not string length cap)", () => { + it("M4: Metadata with multibyte chars <1024 string length but >1024 UTF-8 bytes is REJECTED at CREATE", async () => { + // Each emoji is 4 UTF-8 bytes but counts as 2 UTF-16 code units (JavaScript string length) + // 260 emojis = 520 UTF-16 units + JSON overhead ~15 bytes = ~535 string.length + // But 260 * 4 UTF-8 bytes + overhead = 1050+ UTF-8 bytes (exceeds 1024) + const multibyteMetadata = { + data: "😀".repeat(260) + }; + const serialized = JSON.stringify(multibyteMetadata); + const stringLength = serialized.length; + const byteLength = new TextEncoder().encode(serialized).length; + + // Sanity checks for test validity + expect(stringLength).toBeLessThanOrEqual(1024); + expect(byteLength).toBeGreaterThan(1024); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + // CASCADE-v2 M2: metadata_too_large now at CREATE, not update + await expect( + (client as any).createAnonymousSession(req.cookies, res.cookies, { + metadata: multibyteMetadata + }) + ).rejects.toThrow(/metadata.*1KB/i); + }); + + it("M4: Metadata with ASCII chars under 1024 UTF-8 bytes is ACCEPTED at CREATE", async () => { + // Pure ASCII: string length = UTF-8 byte length + // Use 1000 bytes worth of ASCII to stay safely under cap + const asciiMetadata = { + data: "x".repeat(1000) + }; + const serialized = JSON.stringify(asciiMetadata); + const stringLength = serialized.length; + const byteLength = new TextEncoder().encode(serialized).length; + + // Both should be ≤1024 for ASCII + expect(stringLength).toBeLessThanOrEqual(1024); + expect(byteLength).toBeLessThanOrEqual(1024); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + // CASCADE-v2 M2: metadata validation now at CREATE + const session = await (client as any).createAnonymousSession( + req.cookies, + res.cookies, + { metadata: asciiMetadata } + ); + + expect(session.id).toMatch(/^anon@/); + expect(session.metadata).toEqual(asciiMetadata); + }); + }); + + describe("GUARDING TEST: M2/M3 - HTTP status mapping per error code (§3.C7)", () => { + it("M2/M3: invalid_client (401) error returns 401 status", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json( + { error: "invalid_client" }, + { status: 401 } + ); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(payload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(401); + const body = (await res.json()) as any; + expect(body.error).toBe("invalid_client"); + }); + + it("M2/M3: feature_not_enabled (403) error returns 403 status", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json( + { error: "feature_not_enabled" }, + { status: 403 } + ); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(payload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.error).toBe("feature_not_enabled"); + }); + + it("M2/M3: unauthorized_client (403) error returns 403 status", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json( + { error: "unauthorized_client" }, + { status: 403 } + ); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(payload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.error).toBe("unauthorized_client"); + }); + + it("M2/M3: server_error (500) error returns 500 status", async () => { + server.use( + http.post(`https://${defaultDomain}/anonymous/token`, () => { + return HttpResponse.json({ error: "server_error" }, { status: 500 }); + }) + ); + + const now = Math.floor(Date.now() / 1000); + const payload: AnonymousCookiePayload = { + session_token: "token", + access_token: createMockJWT("anon@uuid-1234", -100), + expires_at: now - 100 + }; + const encrypted = await createSessionCookie(payload, secret); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { + method: "GET", + headers: { cookie: `auth0_anon=${encrypted}` } + } + ); + + const res = await (client as any).handleGetAnonymousSession(req); + + expect(res.status).toBe(500); + const body = (await res.json()) as any; + expect(body.error).toBe("server_error"); + }); + }); +}); diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f8382cf7d..301c0c9cf 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -5288,7 +5288,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }; expect(mockOnCallback).toHaveBeenCalledWith( @@ -5520,7 +5521,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }, null ); @@ -5608,7 +5610,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }, null ); @@ -5695,7 +5698,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }, null ); @@ -6279,7 +6283,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CONNECT_CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }, null ); @@ -6396,7 +6401,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CONNECT_CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }, null ); @@ -6516,7 +6522,8 @@ ca/T0LLtgmbMmxSv/MmzIg== responseType: RESPONSE_TYPES.CONNECT_CODE, returnTo: transactionState.returnTo, challengeMode: "redirect", - appBaseUrl: DEFAULT.appBaseUrl + appBaseUrl: DEFAULT.appBaseUrl, + anonymousSessionLinked: false }, null ); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 65f69021e..1739a2054 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -5,11 +5,16 @@ import * as oauth from "oauth4webapi"; import * as client from "openid-client"; import packageJson from "../../package.json" with { type: "json" }; +import { + getStatusForAnonymousError, + mapAnonymousErrorCode +} from "../errors/anonymous-session-errors.js"; import { AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, AccessTokenForConnectionErrorCode, + AnonymousSessionError, AuthorizationCodeGrantError, AuthorizationCodeGrantRequestError, AuthorizationError, @@ -65,6 +70,10 @@ import { AccessTokenForConnectionOptions, AccessTokenSet, ActClaim, + AnonymousCookiePayload, + AnonymousSession, + AnonymousSessionConfig, + AnonymousTokenResponse, AuthenticatorApiResponse, AuthorizationParameters, BackchannelAuthenticationOptions, @@ -80,6 +89,7 @@ import { GRANT_TYPE_CUSTOM_TOKEN_EXCHANGE, GRANT_TYPE_PASSKEY, GRANT_TYPE_PASSWORDLESS_OTP, + isRecoverableAnonymousError, LogoutStrategy, LogoutToken, PasskeyChallengeOptions, @@ -112,6 +122,13 @@ import { } from "../types/index.js"; import type { SessionCheckResult } from "../types/mcd.js"; import type { MfaTokenEndpointResponse } from "../types/mfa.js"; +import { + ANONYMOUS_SUBJECT_PREFIX, + DEFAULT_ANONYMOUS_SESSION_COOKIE_NAME, + METADATA_SIZE_LIMIT_BYTES, + RESERVED_SESSION_TOKEN_PARAM, + transferCookies +} from "../utils/anonymous-session-constants.js"; import { resolveAppBaseUrl } from "../utils/app-base-url.js"; import { mergeAuthorizationParamsIntoSearchParams, @@ -184,6 +201,12 @@ import { isUrl, toSafeRedirect } from "../utils/url-helpers.js"; import type { AuthClientProvider } from "./auth-client-provider.js"; import { addCacheControlHeadersForSession, + decrypt, + deleteChunkedCookie, + encrypt, + getChunkedCookie, + setChunkedCookie, + type CookieOptions, type ReadonlyRequestCookies } from "./cookies.js"; import { DiscoveryCache } from "./discovery-cache.js"; @@ -228,6 +251,13 @@ export type OnCallbackContext = { * Hook authors can use this to detect popup flows and adapt behavior. */ challengeMode?: "redirect" | "popup"; + /** + * True if an active anonymous session was linked at login time (digest-bound cookie matched at callback). + * False if a link was attempted but the anonymous cookie was missing or failed digest verification (session-fixation check). + * When false, the app SHOULD NOT treat the anonymous session as successfully linked. + * When true, onCallback hook can trigger migration logic (e.g., move cart to authenticated user). + */ + anonymousSessionLinked?: boolean; }; export type OnCallbackHook = ( error: SdkError | null, @@ -243,7 +273,8 @@ const INTERNAL_AUTHORIZE_PARAMS = [ "code_challenge", "code_challenge_method", "state", - "nonce" + "nonce", + RESERVED_SESSION_TOKEN_PARAM ]; /** @@ -287,6 +318,8 @@ export interface Routes { passkeyGetToken: string; passkeyEnrollmentChallenge: string; passkeyEnrollmentVerify: string; + anonymousSession?: string; + anonymousSessionLogout?: string; } export type RoutesOptions = Partial; @@ -375,6 +408,13 @@ export interface AuthClientOptions { * Currently not used - placeholder for upcoming nonce persistence feature. */ // dpopHandleStorage?: DPoPHandleStorageInterface; // Commented out until implementation + + /** + * Configuration for anonymous sessions (EA feature). + * When enabled, allows pre-login identity with 1KB metadata. + * Defaults to disabled; when disabled, routes are not mounted and methods return null. + */ + anonymousSession?: AnonymousSessionConfig; } /** @@ -431,6 +471,16 @@ export class AuthClient { private proxyDpopHandles: { [audience: string]: oauth.DPoPHandle } = {}; + // Anonymous session properties + private readonly secret: string; + private readonly anonymousSessionEnabled: boolean; + private readonly anonymousCookieName: string; + private readonly anonymousSessionConfig: AnonymousSessionConfig; + private readonly anonymousCookieOptions: CookieOptions; + private readonly anonymousCookieMaxAge: number; + private readonly anonymousAudience?: string; + private readonly anonymousScope?: string; + /** * Maximum allowed response body size (1 MB). Responses exceeding this limit * are aborted to prevent memory exhaustion from malicious or oversized @@ -617,6 +667,23 @@ export class AuthClient { // Store keypair if provided, but validate lazily to avoid crypto bundling this.dpopKeyPair = options.dpopKeyPair; + + // Anonymous session configuration + this.secret = options.secret; + const anonConfig = options.anonymousSession ?? { enabled: false }; + this.anonymousSessionEnabled = anonConfig.enabled; + this.anonymousCookieName = + anonConfig.cookie?.name ?? DEFAULT_ANONYMOUS_SESSION_COOKIE_NAME; + this.anonymousSessionConfig = anonConfig; + this.anonymousCookieMaxAge = anonConfig.cookie?.maxAge ?? 2592000; // 30 days default (CASCADE §C) + this.anonymousAudience = anonConfig.audience; + this.anonymousScope = anonConfig.scope; + this.anonymousCookieOptions = { + httpOnly: true, + secure: anonConfig.cookie?.secure ?? true, + sameSite: anonConfig.cookie?.sameSite ?? "lax", + path: "/" + }; } /** @@ -761,7 +828,24 @@ export class AuthClient { sanitizedPathname === this.routes.passkeyEnrollmentVerify ) { return this.handlePasskeyEnrollmentVerify(req); - } else if (sanitizedPathname.startsWith("/me/")) { + } else if ( + method === "GET" && + sanitizedPathname === this.routes.anonymousSession + ) { + // The three anonymous routes are matched on path and method regardless of + // whether the feature is enabled. Each handler owns the enabled check and + // answers 404 when the feature is off, which is the documented contract; a + // dispatcher-level gate would instead fall through to the default handler + // and answer 200. + return this.handleGetAnonymousSession(req); + } else if ( + method === "POST" && + sanitizedPathname === this.routes.anonymousSessionLogout + ) { + return this.handleAnonymousLogout(req); + } + + if (sanitizedPathname.startsWith("/me/")) { return this.handleMyAccount(req); } else if (sanitizedPathname.startsWith("/my-org/")) { return this.handleMyOrg(req); @@ -797,9 +881,17 @@ export class AuthClient { } } + /** + * @param options Login options. + * @param req The incoming request, when one is available. + * @param loginCookies Request cookies to read for this login when there is no + * request object, which is the case for the programmatic Server Action form. + * Ignored when `req` is supplied, since the request carries its own cookies. + */ async startInteractiveLogin( options: StartInteractiveLoginOptions = {}, - req?: NextRequest + req?: NextRequest, + loginCookies?: RequestCookies ): Promise { await this.ensureDpopValidated(); const appBaseUrl = resolveAppBaseUrl(this.appBaseUrl, req); @@ -864,6 +956,37 @@ export class AuthClient { } } + // SEC-1 three-layer fixation mitigation: inject anonymous session token if present + // Layer 1 (reserved-param stripping) has already happened via mergeAuthorizationParamsIntoSearchParams() + // Layer 2 (own-cookie sourcing) and Layer 3 (transaction state binding) below + let anonymousSessionLinked = false; + let anonymousSessionRef: string | undefined; + const anonymousLoginCookies = req?.cookies ?? loginCookies; + if (this.anonymousSessionEnabled && anonymousLoginCookies) { + try { + const anonCookie = await this.readAnonymousCookie( + anonymousLoginCookies + ); + if (anonCookie?.session_token) { + // Session token exists in own cookie → safe to inject (Layer 2) + authorizationParams.set( + RESERVED_SESSION_TOKEN_PARAM, + anonCookie.session_token + ); + anonymousSessionLinked = true; // Flag for transaction state binding (Layer 3) + // Layer 3 records a digest of the injected token, never the token itself, + // so the callback can verify that the anonymous cookie it receives is the + // one this transaction was started with. + anonymousSessionRef = await digestAnonymousSessionToken( + anonCookie.session_token + ); + } + } catch (err) { + // Log error but don't fail login over anon cookie issue + console.error("Error reading anonymous session cookie:", err); + } + } + // Resolve challengeMode: controls whether handleCallback returns a redirect // (standard) or postMessage HTML (popup flows). Only stored in TransactionState // when non-default to minimize encrypted cookie size. @@ -929,7 +1052,11 @@ export class AuthClient { challengeMode: challengeMode !== "redirect" ? challengeMode : undefined, // Store origin domain and issuer for callback delegation in resolver mode originDomain: this.provider?.isResolverMode ? this.domain : undefined, - originIssuer: this.provider?.isResolverMode ? this.issuer : undefined + originIssuer: this.provider?.isResolverMode ? this.issuer : undefined, + // Store anonymous session linked flag for callback migration logic + anonymousSessionLinked: anonymousSessionLinked || undefined, + // Bind the flag to the anonymous session it was derived from (SEC-1 layer 3) + anonymousSessionRef }; // Generate authorization URL with PAR handling @@ -1163,7 +1290,11 @@ export class AuthClient { responseType: transactionState.responseType, returnTo: transactionState.returnTo, challengeMode: transactionState.challengeMode || "redirect", - appBaseUrl + appBaseUrl, + anonymousSessionLinked: await this.verifyAnonymousSessionLink( + transactionState, + req + ) }; // Callback domain delegation in resolver mode @@ -2811,6 +2942,799 @@ export class AuthClient { return response; } + // ====== ANONYMOUS SESSION: CORE SERVER METHODS (a2) ====== + + /** + * Core renewal logic (ERROR-DRIVEN): evaluate cookie payload against current time. + * - Access token valid? → return session as-is + * - Access expired + writable context? → try renewAccessToken(); catch recoverable codes + * (session_expired, invalid_session_token) → createAndPersist() (metadata lost, no error) + * - Access expired + read-only context? → return decrypted as-is (D7 deferral) + * + * NO session_expires_at check; renewal discovers session expiry by trying. + * Implements CASCADE §B + DESIGN §5.I2 + test matrix T1. + */ + private async resolveAnonymousSession( + reqCookies: RequestCookies, + resCookies?: ResponseCookies + ): Promise { + // Step 1: Read + decrypt auth0_anon cookie from request + const cookieValue = getChunkedCookie(this.anonymousCookieName, reqCookies); + if (!cookieValue) { + return null; // No cookie → T1.1 + } + + // Step 2: Decrypt payload; return null if malformed (T1.2) + const decrypted = await decrypt( + cookieValue, + this.secret + ); + if (!decrypted) { + return null; // Malformed or expired by encrypt expiration + } + + const state = decrypted.payload; + const now = Math.floor(Date.now() / 1000); + + // Step 3: Evaluate expiry against renewal state machine + if (state.expires_at > now) { + // Access token still valid → return session, no renewal needed (T1.3) + return this.readPublicSession(state); + } + + // Access token is expired; can we write cookies? + if (resCookies) { + // Writable context → attempt renewal by trying renewAccessToken() + try { + return await this.renewAccessToken(state, reqCookies, resCookies); + } catch (err) { + // Recoverable codes (session_expired, invalid_session_token) → recreate session + if (isRecoverableAnonymousError(err)) { + return await this.createAndPersist(reqCookies, resCookies); // metadata lost, no error (T1.5, T3.6) + } + throw err; // Other errors surface as AnonymousSessionError (T1.7) + } + } + + // Can't write cookie (Server Component read-only context) → defer renewal (D7, T1.6) + // Return the decrypted session as-is; renewal will happen on next route handler call + return this.readPublicSession(state); + } + + /** + * Read-path conversion of a decrypted cookie payload into the public session. + * + * Reading an anonymous session never throws for a cookie the SDK cannot use + * (§3.C2): a missing cookie and a cookie that fails to decrypt already report + * no session, and an access token that cannot be decoded, or whose subject is + * not an anonymous subject, is unusable in exactly the same way. It is reported + * as no session so a Server Component render cannot be broken by a corrupt + * cookie. The write paths (create, renew, metadata update) keep throwing, + * because there the caller asked for an operation that either succeeds or fails. + */ + private readPublicSession( + payload: AnonymousCookiePayload + ): AnonymousSession | null { + try { + return this.toPublicSession(payload); + } catch { + return null; + } + } + + /** + * Mint new access token from existing session token. + * Called when access token expired but session token still valid. + * Silent operation: no error surface. + */ + private async renewAccessToken( + state: AnonymousCookiePayload, + reqCookies: RequestCookies, + resCookies: ResponseCookies + ): Promise { + try { + const res = await this.anonymousTokenRequest({ + session_token: state.session_token, + // The audience and scope are re-sent so the re-minted access token keeps + // the audience the session was created with. + ...this.anonymousTokenAudienceAndScope() + // No metadata in renewal request + }); + + // toCookiePayload owns the response-to-payload conversion for every mode, so + // a rotated session token and any metadata the authorization server merged + // are adopted here rather than discarded in favour of the prior values. + const renewedPayload = this.toCookiePayload( + res, + state.session_token, + state.metadata + ); + + await this.persistAnonymousCookie(renewedPayload, reqCookies, resCookies); + try { + return this.toPublicSession(renewedPayload); + } catch { + // Renewal toPublicSession threw (e.g. malformed access_token sub). + // Per getAnonymousSession's never-throws contract, treat as absent. + return null; + } + } catch (err) { + // If renewal fails with recoverable error (session_expired), silently create new session + if (isRecoverableAnonymousError(err)) { + return await this.createAndPersist(reqCookies, resCookies); + } + throw err; // Non-recoverable error → throw to caller + } + } + + /** + * The configured audience and scope for anonymous access tokens, shaped for + * spreading into an /anonymous/token body. Either field is omitted when it is + * not configured, which lets the authorization server apply the tenant default. + * + * These come from the `anonymousSession` configuration block only. They are + * deliberately not defaulted from `authorizationParameters`, whose audience and + * scope belong to the interactive login flow and are not necessarily granted to + * anonymous subjects. + */ + private anonymousTokenAudienceAndScope(): { + audience?: string; + scope?: string; + } { + return { + ...(this.anonymousAudience && { audience: this.anonymousAudience }), + ...(this.anonymousScope && { scope: this.anonymousScope }) + }; + } + + /** + * Create fresh anonymous session with optional creation-time metadata. + * Called by: public createAnonymousSession() (developer-facing), silent recovery on session expiry. + * + * Per CASCADE-v2 M2: when called by recovery path (no options), metadata is omitted from request body. + * When called by public create (options.metadata present), metadata is sent in create-mode body. + * Validates metadata against 1KB cap BEFORE network call (FR-15 + M2). + * Validates that Auth0 response includes session_token before persisting. + */ + private async createAndPersist( + reqCookies: RequestCookies, + resCookies: ResponseCookies, + options?: { + metadata?: Record; + audience?: string; + scope?: string; + } + ): Promise { + // Validate metadata type and size BEFORE network call (CASCADE-v2 M2 + FR-15) + if (options?.metadata !== undefined) { + // Type validation: metadata must be a plain object + if ( + options.metadata === null || + typeof options.metadata !== "object" || + Array.isArray(options.metadata) + ) { + throw new AnonymousSessionError( + "invalid_request", + "Metadata must be a plain object" + ); + } + // Size validation + const size = new TextEncoder().encode( + JSON.stringify(options.metadata) + ).byteLength; + if (size > METADATA_SIZE_LIMIT_BYTES) { + throw new AnonymousSessionError( + "metadata_too_large", + "Metadata exceeds 1KB limit", + `Metadata size: ${size} bytes (max: ${METADATA_SIZE_LIMIT_BYTES})`, + { size, limit: METADATA_SIZE_LIMIT_BYTES } + ); + } + } + + // Call /anonymous/token in create mode: send metadata when supplied + const res = await this.anonymousTokenRequest({ + ...this.anonymousTokenAudienceAndScope(), + ...(options?.audience && { audience: options.audience }), + ...(options?.scope && { scope: options.scope }), + ...(options?.metadata && { metadata: options.metadata }) + }); + + // Validate that session_token is always present on create response + if (!res.session_token) { + throw new AnonymousSessionError( + "invalid_response", + "Auth0 did not return session token in create response" + ); + } + + // Validate expires_in upper bound to prevent absurdly long-lived tokens + const MAX_EXPIRES_IN = 2592000; // 30 days (matches platform session_expires_in max) + if (!Number.isFinite(res.expires_in) || res.expires_in > MAX_EXPIRES_IN) { + throw new AnonymousSessionError( + "invalid_response", + `expires_in out of bounds: ${res.expires_in}` + ); + } + + const payload: AnonymousCookiePayload = { + session_token: res.session_token, + access_token: res.access_token, + expires_at: this.epoch() + res.expires_in, + ...(options?.metadata && { metadata: options.metadata }) + }; + + await this.persistAnonymousCookie(payload, reqCookies, resCookies); + return this.toPublicSession(payload); + } + + /** + * Public reader for the current anonymous session (mirrors getSession). + * Returns the session or null; never throws for a missing/malformed/expired cookie. + * When resCookies is supplied (request/response context) an expired access token + * triggers silent renewal; without it (Server Component read path, D7) a valid + * decrypted session is returned as-is and renewal defers to the next route call. + * Short-circuits to null when the feature is disabled (§3.C2 / FR-1 / T8.5). + */ + async getAnonymousSession( + reqCookies: RequestCookies, + resCookies?: ResponseCookies + ): Promise { + if (!this.anonymousSessionEnabled) { + return null; + } + return this.resolveAnonymousSession(reqCookies, resCookies); + } + + /** + * Public creator for a fresh anonymous session with optional creation-time metadata. + * + * Implements FR-2 + CASCADE-v2 M2: metadata is set once at session creation; cannot be changed after. + * Validates metadata against 1KB cap BEFORE network call (FR-15 + M2). + * + * Options: + * - metadata: set-once metadata object (max 1KB serialized UTF-8); oversize → metadata_too_large + * - audience: per-call audience override + * - scope: per-call scope override + * + * Calls /anonymous/token in create mode, persists the cookie, returns the session. + * Throws AnonymousSessionError on any authorization-server error. + */ + async createAnonymousSession( + reqCookies: RequestCookies, + resCookies: ResponseCookies, + options?: { + metadata?: Record; + audience?: string; + scope?: string; + } + ): Promise { + // Disabled feature short-circuits locally (T8.1) rather than making a + // network call that the authorization server would reject anyway. The + // non-nullable return contract (§3.C3) means we throw rather than return null. + if (!this.anonymousSessionEnabled) { + throw new AnonymousSessionError( + "unauthorized_client", + "Anonymous sessions are not enabled for this client." + ); + } + return this.createAndPersist(reqCookies, resCookies, options); + } + + /** + * Persist encrypted anonymous session cookie. + * Cookie TTL from configured maxAge (CASCADE §C). Handles chunked cookies for payloads >4KB. + */ + private async persistAnonymousCookie( + payload: AnonymousCookiePayload, + reqCookies: RequestCookies, + resCookies: ResponseCookies + ): Promise { + // JWT exp claim: cookie lifetime is maxAge from now (client-side cleanup). + // Encryption expiration set to epoch + maxAge (CASCADE: no session_expires_at field). + const expiration = this.epoch() + this.anonymousCookieMaxAge; + const encrypted = await encrypt(payload, this.secret, expiration); + + // Use setChunkedCookie to handle large encrypted payloads (D2: re-wrap in app-domain cookie) + // Signature: setChunkedCookie(name, value, options, reqCookies, resCookies) + // anonymousCookieOptions does NOT include maxAge; we add it here per call. + await setChunkedCookie( + this.anonymousCookieName, + encrypted, + { ...this.anonymousCookieOptions, maxAge: this.anonymousCookieMaxAge }, + reqCookies, + resCookies + ); + } + + /** + * Decrypt + validate cookie, return opaque payload (internal use only). + * Used during login to read session_token for injection (D1 fixation: own-cookie source). + * + * SECURITY NOTE (SEC-1 three-layer mitigation, layer 2: own-cookie sourcing): + * This method is the ONLY source of session_token for login injection. The token is + * sourced from the SDK's own encrypted app-domain cookie (this.anonymousCookieName). + * Decryption via this.secret guarantees the token came from this app, not attacker input. + * No caller-supplied data is mixed in. + */ + private async readAnonymousCookie( + reqCookies: RequestCookies + ): Promise { + const cookieValue = getChunkedCookie(this.anonymousCookieName, reqCookies); + if (!cookieValue) { + return null; + } + + const decrypted = await decrypt( + cookieValue, + this.secret + ); + return decrypted?.payload ?? null; + } + + /** + * Decide whether this callback may report an anonymous session as linked. + * + * SEC-1 layer 3 is enforcement, not notification. Login stored a digest of the + * anonymous session token it injected; the callback recomputes the digest from + * the anonymous cookie on this request and only reports a link when the two + * agree. A cookie that was swapped between the authorization request and the + * callback therefore reports no link, so application code never attributes an + * anonymous identity to a login it does not belong to. + * + * A transaction with no digest is treated as unbound and keeps whatever flag it + * carries: that covers transactions written before the digest existed and, when + * the feature is disabled, every transaction, so behaviour is unchanged there. + */ + private async verifyAnonymousSessionLink( + transactionState: TransactionState, + req: NextRequest + ): Promise { + const linked = transactionState.anonymousSessionLinked ?? false; + + if ( + !this.anonymousSessionEnabled || + !transactionState.anonymousSessionRef + ) { + return linked; + } + + try { + const anonCookie = await this.readAnonymousCookie(req.cookies); + if (!anonCookie?.session_token) { + return false; + } + + const ref = await digestAnonymousSessionToken(anonCookie.session_token); + return ref === transactionState.anonymousSessionRef && linked; + } catch (err) { + // An unreadable anonymous cookie cannot be shown to match the bound digest, + // so the link is not reported. Login is not failed over it. + console.error( + "Error verifying the anonymous session binding at callback:", + err + ); + return false; + } + } + + /** + * Convert encrypted cookie payload to public AnonymousSession object. + * Extracts identity (anon@{uuid}) from access token sub claim, validates format, + * surfaces access token + metadata. Session token intentionally omitted (stays server-side). + * + * Throws AnonymousSessionError if access token is malformed or sub is invalid. + */ + private toPublicSession(payload: AnonymousCookiePayload): AnonymousSession { + try { + // Decode access token JWT using jose to extract 'sub' claim + const claims = jose.decodeJwt(payload.access_token); + const id = claims.sub; + + // Validate that sub is a string and matches anonymous subject format + if ( + !id || + typeof id !== "string" || + !id.startsWith(ANONYMOUS_SUBJECT_PREFIX) + ) { + throw new AnonymousSessionError( + "invalid_session_token", + "Access token sub claim is not a valid anonymous session ID" + ); + } + + return { + id, + accessToken: payload.access_token, + expiresAt: payload.expires_at, + ...(payload.metadata && { metadata: payload.metadata }) + }; + } catch (err) { + if (err instanceof AnonymousSessionError) { + throw err; + } + throw new AnonymousSessionError( + "invalid_session_token", + "Failed to decode access token" + ); + } + } + + /** + * Convert auth server token response to cookie payload. + * Handles both create (session_token present) and renew (session_token omitted) responses. + * + * NOTE: Per DESIGN §5.I4, Auth0 returns merged metadata in the response (authorization + * server performs the merge, not the SDK). This method extracts metadata from tokenRes + * if present. Caller must ensure metadata from Auth0 response is used, not raw input. + */ + private toCookiePayload( + res: AnonymousTokenResponse, + priorSessionToken: string, + metadata?: Record + ): AnonymousCookiePayload { + // Validate expires_in upper bound to prevent absurdly long-lived tokens + // (allow negative/zero for renewal test mocks that simulate already-expired tokens) + const MAX_EXPIRES_IN = 2592000; // 30 days (matches platform session_expires_in max) + if (!Number.isFinite(res.expires_in) || res.expires_in > MAX_EXPIRES_IN) { + throw new AnonymousSessionError( + "invalid_response", + `expires_in out of bounds: ${res.expires_in}` + ); + } + + return { + // session_token is returned only on create; on renew/update the server omits + // it and the prior opaque handle MUST be retained so later renewals succeed (D6/§5.I2). + session_token: res.session_token ?? priorSessionToken, + access_token: res.access_token, + expires_at: this.epoch() + res.expires_in, + // Use metadata from Auth0 response if present (merged by server), else use provided metadata + ...(res.metadata + ? { metadata: res.metadata } + : metadata + ? { metadata } + : {}) + }; + } + + /** + * Return current Unix seconds epoch. + */ + private epoch(): number { + return Math.floor(Date.now() / 1000); + } + + /** + * Resolve the authorization server metadata that the client authentication + * callable is handed. + * + * Only assertion-based authentication reads it: `PrivateKeyJwt` signs an `aud` + * claim taken from `as.issuer`, so it needs the real metadata. The secret-based + * and mTLS methods ignore the argument entirely, so they must not pay for a + * discovery round trip. When discovery fails the configured issuer is used, which + * is the value discovery would have validated the document against anyway. + */ + private async anonymousClientAuthServer(): Promise { + const signsClientAssertion = + !this.useMtls && !!this.clientAssertionSigningKey; + + if (signsClientAssertion) { + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); + + if (!discoveryError) { + return authorizationServerMetadata; + } + } + + return { issuer: this.issuer }; + } + + /** + * Build the fetch options for a request to one of the anonymous endpoints, + * applying the client's configured authentication method. + * + * The anonymous endpoints are not standard grant endpoints, so the oauth4webapi + * grant helpers cannot issue them, but client authentication still has to be the + * method the client is configured with. oauth4webapi models an authentication + * method as a callable that writes form parameters into a `URLSearchParams` and + * authentication headers into a `Headers`. This helper invokes that callable + * against scratch collections, folds the parameters into the JSON body these + * endpoints expect, and folds the headers onto the outgoing headers. Without the + * invocation the request would carry `client_id` alone and the authorization + * server would answer 401 `invalid_client`. + */ + private async anonymousRequestInit( + body: Record + ): Promise { + const httpOpts = this.httpOptions(); + + const headers: Record = { + "content-type": "application/json" + }; + httpOpts.headers.forEach((value, key) => { + headers[key] = value; + }); + + const requestBody: Record = { + client_id: this.clientMetadata.client_id, + ...body + }; + + const clientAuth = await this.getClientAuth(); + const authParams = new URLSearchParams(); + const authHeaders = new Headers(); + await clientAuth( + await this.anonymousClientAuthServer(), + this.clientMetadata, + authParams, + authHeaders + ); + + authParams.forEach((value, key) => { + requestBody[key] = value; + }); + authHeaders.forEach((value, key) => { + headers[key] = value; + }); + + return { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: httpOpts.signal + }; + } + + /** + * POST /anonymous/token with optional session_token and/or metadata. + * Three modes: + * - Create: {} → new session + * - Renew: { session_token } → mint new access token + * - Update: { session_token, metadata } → update metadata + * + * Called by: createAndPersist(), renewAccessToken(), update route handler. + * Throws AnonymousSessionError on server-reported error (§4.W1 error table). + * Returns AnonymousTokenResponse on 200. + */ + private async anonymousTokenRequest(body: { + session_token?: string; + metadata?: unknown; + audience?: string; + scope?: string; + }): Promise { + const url = new URL(`/anonymous/token`, `https://${this.domain}`); + + const res = await this.fetch( + url.toString(), + await this.anonymousRequestInit(body) + ); + + if (!res.ok) { + const errorData = (await res.json().catch(() => ({}))) as Record< + string, + unknown + >; + const code = String(errorData.error ?? `http_${res.status}`); + const description = + typeof errorData.error_description === "string" + ? errorData.error_description + : undefined; + throw this.mapAnonymousError(code, description, errorData); + } + + return res.json() as Promise; + } + + /** + * POST /anonymous/logout — body contains client_id only, session_token NOT in body. + * Per CASCADE-v2 M3: session_token rides as auth0_anon cookie (credentials:'include'), NEVER in body. + * Body = {client_id} (from clientAuthParams) or {}. + * Idempotent: returns 200 even with no session. + * Called by: logout route handler. + * + * SAFETY (CASCADE-v2 M3 WARNING): Server-to-server call from Next.js backend cannot forward + * the browser's tenant-domain auth0_anon cookie, so this call clears nothing server-side. + * The only effective logout action is the SDK's local deleteChunkedCookie (route handler). + * Tokens issued before logout remain valid until natural expiry; no server revocation exists. + * + * Ending a session that no longer exists is not an error, so a 404 and a 200 are + * both treated as success. A rejected client authentication or a client that is + * not allowed to end anonymous sessions is a real failure and must surface, so + * 401 and 403 throw alongside the 5xx range. + */ + private async anonymousLogoutRequest(): Promise { + const url = new URL(`/anonymous/logout`, `https://${this.domain}`); + + const body = this.clientMetadata.client_id + ? { client_id: this.clientMetadata.client_id } + : {}; + + const res = await this.fetch( + url.toString(), + await this.anonymousRequestInit(body) + ); + + const isAuthenticationFailure = res.status === 401 || res.status === 403; + + if (isAuthenticationFailure || res.status >= 500) { + const errorData = (await res.json().catch(() => ({}))) as Record< + string, + unknown + >; + const code = String( + errorData.error ?? + (isAuthenticationFailure ? "invalid_client" : "server_error") + ); + const description = + typeof errorData.error_description === "string" + ? errorData.error_description + : undefined; + throw this.mapAnonymousError(code, description, errorData); + } + } + + /** + * Map authorization server error response to AnonymousSessionError. + * Populates description and cause fields (CASCADE §D). + * Per DESIGN §3.C7 error table + RFC OAuth 2.0 error codes. + */ + private mapAnonymousError( + code: string, + description?: string, + rawBody?: unknown + ): AnonymousSessionError { + return mapAnonymousErrorCode(code, description, rawBody); + } + + // ====== ANONYMOUS SESSION: ROUTE HANDLERS (a3) ====== + + /** + * GET /auth/anonymous-session + * + * Read current session, applying renewal state machine. + * Returns 200 + JSON session object, or 204 No Content if no session (client hook maps to null). + * + * Test: T1 (read + renewal), T8.1 (disabled → 404) + */ + private async handleGetAnonymousSession( + req: NextRequest + ): Promise { + try { + if (!this.anonymousSessionEnabled) { + return new NextResponse("Not found", { status: 404 }); + } + + // FIX C3 (rev3): The response returned to the client MUST be the object whose + // .cookies jar received the renewed cookies. resolveAnonymousSession writes renewed + // cookies via persistAnonymousCookie into whatever ResponseCookies jar it is given, + // but the body (session) is only known AFTER resolve returns. So: use a temp jar to + // collect renewed cookies during resolve, then transfer them onto the final response. + // transferCookies() = for (const c of from.cookies.getAll()) to.cookies.set(c); + // (helper defined in unit a1 constants/util module) + + // Temp jar collects any cookies written during the renewal state machine. + const pending = new NextResponse(); + + const session = await this.resolveAnonymousSession( + req.cookies, + pending.cookies + ); + + if (!session) { + // No session → 204 No Content (client hook interprets as null). + // Renewal did not run (nothing to renew), but transfer defensively. + const empty = new NextResponse(null, { status: 204 }); + transferCookies(pending, empty); + addCacheControlHeadersForSession(empty); + return empty; + } + + // Session found → build JSON response, THEN move the renewed cookies onto it, + // THEN return that same object. This guarantees renewed cookies reach the client. + const jsonRes = NextResponse.json(session); + transferCookies(pending, jsonRes); + addCacheControlHeadersForSession(jsonRes); + return jsonRes; + } catch (err) { + const code = + err instanceof AnonymousSessionError ? err.code : "server_error"; + return this.anonymousErrorResponse( + code, + getStatusForAnonymousError(code) + ); + } + } + + /** + * POST /auth/anonymous-session/logout + * + * Clear anonymous session. + * Reads session_token from cookie, calls /anonymous/logout, clears cookie. + * Idempotent: 200 even if no session. + * + * Test: T4.1 (logout), T4.2 (no session), T4.3 (idempotent) + */ + private async handleAnonymousLogout(req: NextRequest): Promise { + try { + if (!this.anonymousSessionEnabled) { + return new NextResponse("Not found", { status: 404 }); + } + + // Call Auth0 /anonymous/logout (body = {client_id}/{}, no session_token; CASCADE-v2 M3). + // The SDK does not read the session_token here; the server-to-server call cannot + // forward the browser's auth0_anon cookie, so it clears nothing server-side. + // Swallow network errors; the local cookie clear below is the only effective logout. + try { + await this.anonymousLogoutRequest(); + } catch (err) { + console.error("Anonymous logout network error (ignored):", err); + } + + // Clear the cookie, including any chunk fragments, so a chunked session + // (metadata >4KB) does not leave orphaned auth0_anon__N cookies behind. + const res = new NextResponse(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" } + }); + + deleteChunkedCookie( + this.anonymousCookieName, + req.cookies, + res.cookies, + false, + { + path: this.anonymousCookieOptions.path, + domain: this.anonymousCookieOptions.domain, + secure: this.anonymousCookieOptions.secure, + sameSite: this.anonymousCookieOptions.sameSite, + httpOnly: this.anonymousCookieOptions.httpOnly + } + ); + + addCacheControlHeadersForSession(res); + return res; + } catch (err) { + // Even on error, attempt to clear the cookie (and its chunks). + const res = new NextResponse(JSON.stringify({ ok: true }), { + status: 200 + }); + deleteChunkedCookie( + this.anonymousCookieName, + req.cookies, + res.cookies, + false, + { + path: this.anonymousCookieOptions.path, + domain: this.anonymousCookieOptions.domain, + secure: this.anonymousCookieOptions.secure, + sameSite: this.anonymousCookieOptions.sameSite, + httpOnly: this.anonymousCookieOptions.httpOnly + } + ); + return res; + } + } + + /** + * Build error response: JSON with error code + message, correct HTTP status. + */ + private anonymousErrorResponse( + code: string, + status: number = 500 + ): NextResponse { + const res = NextResponse.json( + { + error: code, + error_description: mapAnonymousErrorCode(code).message + }, + { status } + ); + addCacheControlHeadersForSession(res); + return res; + } + async verifyLogoutToken( logoutToken: string ): Promise<[null, LogoutToken] | [SdkError, null]> { @@ -6641,6 +7565,26 @@ const encodeBase64 = (input: string) => { return btoa(arr.join("")); }; +/** + * Hex-encoded SHA-256 digest of an anonymous session token. + * + * The digest is what the login transaction stores in place of the token, so the + * transaction cookie carries no material that could be replayed against the + * authorization server if it were ever decrypted. WebCrypto is used directly so + * the helper works on the Edge runtime as well as on Node. + */ +async function digestAnonymousSessionToken( + sessionToken: string +): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(sessionToken) + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + type GetTokenSetResponse = { tokenSet: TokenSet; idTokenClaims?: { [key: string]: any }; diff --git a/src/server/client.test.ts b/src/server/client.test.ts index e92c16629..efc8c7e1e 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1189,6 +1189,41 @@ describe("Auth0Client", () => { ); warnSpy.mockRestore(); }); + + it("should throw when anonymousSession.cookie.secure is explicitly false in production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect( + () => + new Auth0Client({ + anonymousSession: { + enabled: true, + cookie: { + secure: false + } + } + }) + ).toThrowError(InvalidConfigurationError); + }); + + it("should not throw when anonymousSession.cookie.secure is explicitly false in development", () => { + vi.stubEnv("NODE_ENV", "development"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect( + () => + new Auth0Client({ + anonymousSession: { + enabled: true, + cookie: { + secure: false + } + } + }) + ).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'appBaseUrl' is not configured") + ); + warnSpy.mockRestore(); + }); }); describe("cookie security when appBaseUrl is configured via options", () => { @@ -1211,13 +1246,23 @@ describe("Auth0Client", () => { it("should force secure cookies when appBaseUrl is a single https string", () => { const client = new Auth0Client({ - appBaseUrl: "https://app.example.com" + appBaseUrl: "https://app.example.com", + anonymousSession: { + enabled: true, + cookie: { + secure: false // Should be overridden + } + } }); const sessionStore = client["sessionStore"] as any; const transactionStore = (client as any).transactionStore; expect(sessionStore.cookieConfig.secure).toBe(true); expect(transactionStore.cookieOptions.secure).toBe(true); + // Verify anon cookie secure was forced to true despite being set to false + const provider = (client as any).provider; + const authClient = provider.getAuthClientForStaticMode(); + expect(authClient.anonymousCookieOptions.secure).toBe(true); }); it("should not force secure cookies when appBaseUrl is a single http string", () => { @@ -1752,6 +1797,34 @@ describe("Auth0Client", () => { }); }); +describe("GUARDING TEST: C2/C3 - Public Auth0Client anonymous session wrappers", () => { + it("C2/C3: Auth0Client exports public methods for anonymous sessions", () => { + // Verify the Auth0Client class has the required public methods + expect(Auth0Client.prototype).toHaveProperty("createAnonymousSession"); + expect(Auth0Client.prototype).toHaveProperty("getAnonymousSession"); + + // Verify they are functions + expect(typeof Auth0Client.prototype.createAnonymousSession).toBe( + "function" + ); + expect(typeof Auth0Client.prototype.getAnonymousSession).toBe("function"); + }); + + it("C2/C3: Auth0Client can be instantiated with anonymous session config", () => { + const testClient = new Auth0Client({ + domain: "test.auth0.com", + clientId: "test-id", + clientSecret: "test-secret", + secret: "test-secret-32-bytes-minimum-1234567890ab" + }); + + // Client instantiated successfully + expect(testClient).toBeInstanceOf(Auth0Client); + // Verify method signatures + expect(typeof testClient.createAnonymousSession).toBe("function"); + }); +}); + export type GetAccessTokenOptions = { refresh?: boolean; }; diff --git a/src/server/client.ts b/src/server/client.ts index 7e3b3c848..0d982b2e4 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -19,6 +19,8 @@ import { import { DpopKeyPair, DpopOptions } from "../types/dpop.js"; import { AccessTokenForConnectionOptions, + AnonymousSession, + AnonymousSessionConfig, AuthorizationParameters, BackchannelAuthenticationOptions, ConnectAccountOptions, @@ -510,6 +512,13 @@ export interface Auth0ClientOptions { * @see [MCD Examples](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#multiple-custom-domains-mcd) */ discoveryCache?: DiscoveryCacheOptions; + + /** + * Configuration for anonymous sessions (EA feature). + * When enabled, allows pre-login identity with 1KB metadata. + * Defaults to disabled; when disabled, routes are not mounted and methods return null. + */ + anonymousSession?: AnonymousSessionConfig; } export type PagesRouterRequest = IncomingMessage | NextApiRequest; @@ -620,6 +629,9 @@ export class Auth0Client { options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN }; + // Anonymous session cookies only support secure via options (no env var). + const anonSecureExplicit = options.anonymousSession?.cookie?.secure; + if (appBaseUrl) { const usesHttps = Array.isArray(appBaseUrl) ? appBaseUrl.every((url) => new URL(url).protocol === "https:") @@ -629,6 +641,10 @@ export class Auth0Client { if (usesHttps) { sessionCookieOptions.secure = true; transactionCookieOptions.secure = true; + // Force anonymous session cookie secure=true when appBaseUrl is https + if (options.anonymousSession?.cookie) { + options.anonymousSession.cookie.secure = true; + } } } else if (process.env.NODE_ENV === "production") { // No appBaseUrl is configured, so the SDK relies on the request host at runtime. @@ -646,16 +662,28 @@ export class Auth0Client { ); } + if (anonSecureExplicit === false) { + throw new InvalidConfigurationError( + "Anonymous session cookies must be marked secure in production when appBaseUrl is not configured. Set anonymousSession.cookie.secure=true." + ); + } + sessionCookieOptions.secure = true; transactionCookieOptions.secure = true; + // Force anonymous session cookie secure=true in production with no appBaseUrl + if (options.anonymousSession?.cookie) { + options.anonymousSession.cookie.secure = true; + } } else if ( process.env.NODE_ENV === "development" && - (sessionSecureExplicit === false || transactionSecureExplicit === false) + (sessionSecureExplicit === false || + transactionSecureExplicit === false || + anonSecureExplicit === false) ) { // Warn during development when dynamic base URL resolution is combined with // explicitly insecure cookies, since production will reject this configuration. console.warn( - "'appBaseUrl' is not configured and cookies are explicitly marked insecure. This is allowed in development, but will throw in production. Configure appBaseUrl or set secure=true for session/transaction cookies." + "'appBaseUrl' is not configured and cookies are explicitly marked insecure. This is allowed in development, but will throw in production. Configure appBaseUrl or set secure=true for session/transaction/anonymous-session cookies." ); } @@ -704,6 +732,12 @@ export class Auth0Client { passkeyEnrollmentVerify: process.env.NEXT_PUBLIC_PASSKEY_ENROLLMENT_VERIFY_ROUTE || "/auth/passkey/enrollment-verify", + anonymousSession: + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE || + "/auth/anonymous-session", + anonymousSessionLogout: + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_LOGOUT_ROUTE || + "/auth/anonymous-session/logout", ...options.routes }; @@ -798,6 +832,7 @@ export class Auth0Client { fetch: options.customFetch, mfaTokenTtl, cspNonce: options.cspNonce, + anonymousSession: options.anonymousSession, discoveryCache, provider: this.provider @@ -868,6 +903,147 @@ export class Auth0Client { return session; } + /** + * getAnonymousSession returns the current anonymous session, or null when there is + * none (or the feature is disabled). It never throws for a missing, malformed, or + * expired cookie. Mirrors {@link getSession}. + * + * Use in Server Components, Server Actions, and Route Handlers in the **App Router**. + */ + async getAnonymousSession(): Promise; + + /** + * getAnonymousSession returns the current anonymous session, or null when there is none. + * + * Use in middleware and `getServerSideProps`, API routes in the **Pages Router**. + */ + async getAnonymousSession( + req: PagesRouterRequest | NextRequest + ): Promise; + + async getAnonymousSession( + req?: Request | PagesRouterRequest | NextRequest + ): Promise { + const { authClient, normalizedReq } = await this.resolveRequestContext(req); + + let reqCookies: + RequestCookies | import("./cookies.js").ReadonlyRequestCookies; + if (normalizedReq) { + reqCookies = + normalizedReq instanceof NextRequest + ? normalizedReq.cookies + : this.createRequestCookies(normalizedReq); + } else { + reqCookies = await cookies(); + } + + // Read-only surface: the method signature carries no response, so renewal + // is deferred (D7). Access-token renewal is persisted through the route + // handler (handleGetAnonymousSession), which owns a writable response. + return authClient.getAnonymousSession(reqCookies as RequestCookies); + } + + /** + * createAnonymousSession creates a fresh anonymous session, persists the cookie, and + * returns the session. Throws `AnonymousSessionError` on any authorization-server error. + * + * Metadata is set once at creation and cannot be changed after (CASCADE-v2 M2). + * Validates metadata against 1KB cap before network call; oversize → metadata_too_large. + * + * Use in Server Actions in the **App Router** (zero-arg form). + */ + async createAnonymousSession(options?: { + metadata?: Record; + audience?: string; + scope?: string; + }): Promise; + + /** + * createAnonymousSession creates a fresh anonymous session with optional creation-time metadata + * and persists the cookie onto the passed response. + * + * Metadata is set once at creation and cannot be changed after (CASCADE-v2 M2). + * Validates metadata against 1KB cap before network call; oversize → metadata_too_large. + * + * Use in Route Handlers and the **Pages Router** (request/response form). + */ + async createAnonymousSession( + req: PagesRouterRequest | NextRequest, + res: NextResponse, + options?: { + metadata?: Record; + audience?: string; + scope?: string; + } + ): Promise; + + async createAnonymousSession( + req?: + | Request + | PagesRouterRequest + | NextRequest + | { + metadata?: Record; + audience?: string; + scope?: string; + }, + res?: NextResponse, + options?: { + metadata?: Record; + audience?: string; + scope?: string; + } + ): Promise { + // Resolve overload: zero-arg (options) vs req/res forms + let normalizedReq: NextRequest | PagesRouterRequest | undefined; + let opts: + | { + metadata?: Record; + audience?: string; + scope?: string; + } + | undefined; + + if (req && typeof req === "object" && !("url" in req)) { + // Zero-arg form: createAnonymousSession(options) + opts = req as { + metadata?: Record; + audience?: string; + scope?: string; + }; + normalizedReq = undefined; + } else { + // Req/res form: createAnonymousSession(req, res, options) + normalizedReq = req as NextRequest | PagesRouterRequest; + opts = options; + } + + const { authClient, normalizedReq: resolvedReq } = + await this.resolveRequestContext(normalizedReq as any); + + let reqCookies: RequestCookies; + let resCookies: ResponseCookies; + if (resolvedReq) { + if (!res) { + throw new TypeError( + "createAnonymousSession(req, res): The 'res' argument is missing. Both 'req' and 'res' must be provided together for Route Handler or Pages Router usage." + ); + } + reqCookies = + resolvedReq instanceof NextRequest + ? resolvedReq.cookies + : (this.createRequestCookies(resolvedReq) as RequestCookies); + resCookies = res.cookies; + } else { + // Server Action (App Router): next/headers cookies() is writable here. + const cookieStore = await cookies(); + reqCookies = cookieStore as unknown as RequestCookies; + resCookies = cookieStore as unknown as ResponseCookies; + } + + return authClient.createAnonymousSession(reqCookies, resCookies, opts); + } + /** * Fetches session using an already-resolved AuthClient, avoiding double resolver invocation. * @internal @@ -1702,12 +1878,40 @@ export class Auth0Client { return { authClient }; } + /** + * startInteractiveLogin redirects the user to the authorization server to log in. + * + * Pass the request when one is available (Route Handlers, middleware, the Pages + * Router). The request is what lets the SDK read this browser's cookies, which + * is how an active anonymous session is linked to the login transaction. In a + * Server Action, where there is no request object, cookies are read through + * `next/headers` instead. + */ async startInteractiveLogin( - options: StartInteractiveLoginOptions = {} + options: StartInteractiveLoginOptions = {}, + req?: Request | PagesRouterRequest | NextRequest ): Promise { - const reqHeaders = await getHeaders(); - const authClient = await this.provider.forRequest(reqHeaders, undefined); - return authClient.startInteractiveLogin(options); + const { authClient, normalizedReq } = await this.resolveRequestContext(req); + + if (normalizedReq instanceof NextRequest) { + return authClient.startInteractiveLogin(options, normalizedReq); + } + + // Cookies are only needed to link an anonymous session, so they are read only + // when that feature is enabled. This keeps the call sequence unchanged for + // every application that does not use anonymous sessions. + if (!this.#options.anonymousSession?.enabled) { + return authClient.startInteractiveLogin(options); + } + + // No NextRequest to hand down, so supply the request cookies separately: + // the Pages Router request carries them in its headers, and a Server Action + // reads them through next/headers the way the sibling methods do. + const reqCookies = normalizedReq + ? (this.createRequestCookies(normalizedReq) as RequestCookies) + : ((await cookies()) as unknown as RequestCookies); + + return authClient.startInteractiveLogin(options, undefined, reqCookies); } /** diff --git a/src/server/create-anonymous-session.factory.test.ts b/src/server/create-anonymous-session.factory.test.ts new file mode 100644 index 000000000..24b377bef --- /dev/null +++ b/src/server/create-anonymous-session.factory.test.ts @@ -0,0 +1,224 @@ +/** + * M3 BLOCKER: Public API factory createAnonymousSession tests (FR-2). + * Tests the PUBLIC export from @auth0/nextjs-auth0/server entry. + */ +import { NextRequest, NextResponse } from "next/server.js"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it +} from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { AuthClient } from "./auth-client.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionStore } from "./transaction-store.js"; + +// Helper to encode a mock JWT +function createMockJWT(subject: string, expiresIn: number = 3600): string { + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }) + ).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const payload = Buffer.from( + JSON.stringify({ + sub: subject, + iat: now, + exp: now + expiresIn + }) + ).toString("base64url"); + return `${header}.${payload}.signature`; +} + +describe("M3 BLOCKER: FR-2 createAnonymousSession PUBLIC API", () => { + let secret: string; + let server: any; + const defaultDomain = "auth0.local"; + + beforeAll(async () => { + server = setupServer( + http.post( + `https://${defaultDomain}/anonymous/token`, + async ({ request }) => { + const body = (await request.json()) as any; + // CREATE mode (no session_token) + if (!body.session_token) { + return HttpResponse.json({ + token_type: "Bearer", + session_token: `new-${Date.now()}`, + access_token: createMockJWT("anon@uuid-factory-test"), + expires_in: 3600, + scope: "read:catalog", + ...(body.metadata && { metadata: body.metadata }) + }); + } + return HttpResponse.json({ + token_type: "Bearer", + access_token: createMockJWT("anon@uuid-factory-test"), + expires_in: 3600, + scope: "read:catalog" + }); + } + ), + http.get( + `https://${defaultDomain}/.well-known/openid-configuration`, + () => { + return HttpResponse.json({ + issuer: `https://${defaultDomain}/`, + authorization_endpoint: `https://${defaultDomain}/authorize`, + token_endpoint: `https://${defaultDomain}/oauth/token`, + userinfo_endpoint: `https://${defaultDomain}/userinfo`, + jwks_uri: `https://${defaultDomain}/.well-known/jwks.json` + }); + } + ) + ); + server.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + describe("Public factory createAnonymousSession", () => { + it("M3: Factory (req, res) form creates session", async () => { + // Use PUBLIC factory from AuthClient (mirrors SDK package entry export) + const auth0 = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + // Public method: (req, res) form + const session = await auth0.createAnonymousSession( + req.cookies, + res.cookies + ); + + // ASSERT: session returned + expect(session).toBeTruthy(); + expect(session.id).toMatch(/^anon@/); + expect(session.accessToken).toBeTruthy(); + + // Verify cookie was set on response + const cookies = res.cookies.getAll(); + expect(cookies.some((c) => c.name === "auth0_anon")).toBe(true); + }); + + it("M3: Factory sets cookie on response with correct attributes", async () => { + const auth0 = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + // Public method: (req, res) form + const session = await auth0.createAnonymousSession( + req.cookies, + res.cookies + ); + + // ASSERT: session returned, id matches anon@ format + expect(session).toBeTruthy(); + expect(session.id).toMatch(/^anon@/); + expect(session.accessToken).toBeTruthy(); + + // Verify cookie was set on response + const cookies = res.cookies.getAll(); + const anonCookie = cookies.find((c) => c.name === "auth0_anon"); + expect(anonCookie).toBeTruthy(); + expect(anonCookie!.value).toBeTruthy(); + + // Security attributes + expect(anonCookie?.httpOnly).toBe(true); + expect(anonCookie?.sameSite).toBe("lax"); + expect(anonCookie?.path).toBe("/"); + expect(anonCookie?.secure).toBe(true); + }); + + it("M3: Factory throws when feature disabled (unauthorized_client)", async () => { + const disabledAuth0 = new AuthClient({ + domain: defaultDomain, + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes: getDefaultRoutes(), + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: false } + }); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session") + ); + const res = new NextResponse(); + + // ASSERT: throws AnonymousSessionError with code unauthorized_client + await expect( + disabledAuth0.createAnonymousSession(req.cookies, res.cookies) + ).rejects.toThrow(/not enabled/); + }); + }); +}); diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index c0d59b23e..d012080b0 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -43,6 +43,26 @@ export interface TransactionState extends jose.JWTPayload { * @internal */ originIssuer?: string; + + /** + * True if an active anonymous session was injected into the login transaction. + * Indicates to the onCallback hook that post-login migration logic may apply. + */ + anonymousSessionLinked?: boolean; + + /** + * SHA-256 digest, hex encoded, of the anonymous session token that was + * injected into this login transaction. The raw token is never stored here. + * The callback recomputes the digest from the anonymous cookie it receives and + * refuses to report a link when the two do not match, which is what binds the + * linkage signal to the browser that started the transaction. + * + * Absent on transactions created before this field existed, and on + * transactions where no anonymous session was injected. Both cases are treated + * as unbound rather than as a mismatch. + * @internal + */ + anonymousSessionRef?: string; } export interface TransactionCookieOptions { diff --git a/src/test/defaults.ts b/src/test/defaults.ts index c794ed6c4..1d344cc4f 100644 --- a/src/test/defaults.ts +++ b/src/test/defaults.ts @@ -47,7 +47,13 @@ export function getDefaultRoutes(): Routes { "/auth/passkey/enrollment-challenge", passkeyEnrollmentVerify: process.env.NEXT_PUBLIC_PASSKEY_ENROLLMENT_VERIFY_ROUTE || - "/auth/passkey/enrollment-verify" + "/auth/passkey/enrollment-verify", + anonymousSession: + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE || + "/auth/anonymous-session", + anonymousSessionLogout: + process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_LOGOUT_ROUTE || + "/auth/anonymous-session/logout" }; } diff --git a/src/types/anonymous-session.test.ts b/src/types/anonymous-session.test.ts new file mode 100644 index 000000000..4f5ec0a02 --- /dev/null +++ b/src/types/anonymous-session.test.ts @@ -0,0 +1,392 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { describe, expect, it } from "vitest"; + +import { + AnonymousSessionError, + mapAnonymousErrorCode +} from "../errors/anonymous-session-errors.js"; +import { AuthClient } from "../server/auth-client.js"; +import { encrypt } from "../server/cookies.js"; +import { StatelessSessionStore } from "../server/session/stateless-session-store.js"; +import { TransactionStore } from "../server/transaction-store.js"; +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { transferCookies } from "../utils/anonymous-session-constants.js"; +import { + isRecoverableAnonymousError, + type AnonymousCookiePayload, + type AnonymousSession +} from "./anonymous-session.js"; + +describe("AnonymousSessionError", () => { + describe("construction", () => { + it("T2.3: throws with code `unauthorized_client`", () => { + const err = new AnonymousSessionError( + "unauthorized_client", + "Not enabled" + ); + expect(err.code).toBe("unauthorized_client"); + expect(err.message).toBe("Not enabled"); + expect(err.name).toBe("AnonymousSessionError"); + }); + + it("T2.4: uses default message if omitted", () => { + const err = new AnonymousSessionError("feature_not_enabled"); + expect(err.message).toMatch(/An error occurred/); + expect(err.code).toBe("feature_not_enabled"); + }); + + it("constructs error with all required properties", () => { + const err = new AnonymousSessionError("invalid_request", "Bad request"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(AnonymousSessionError); + expect(err.name).toBe("AnonymousSessionError"); + }); + }); + + describe("mapAnonymousErrorCode", () => { + it("T2.3: maps unauthorized_client code", () => { + const err = mapAnonymousErrorCode("unauthorized_client"); + expect(err).toBeInstanceOf(AnonymousSessionError); + expect(err.code).toBe("unauthorized_client"); + expect(err.message).toContain("not enabled for anonymous"); + }); + + it("T2.4: maps feature_not_enabled code", () => { + const err = mapAnonymousErrorCode("feature_not_enabled"); + expect(err.code).toBe("feature_not_enabled"); + expect(err.message).toContain("not enabled"); + }); + + it("T3.2: maps metadata_too_large code", () => { + const err = mapAnonymousErrorCode("metadata_too_large"); + expect(err.code).toBe("metadata_too_large"); + expect(err.message).toContain("1KB"); + }); + + it("T3.6: maps session_expired code", () => { + const err = mapAnonymousErrorCode("session_expired"); + expect(err.code).toBe("session_expired"); + }); + + it("T3.7: maps invalid_session_token code", () => { + const err = mapAnonymousErrorCode("invalid_session_token"); + expect(err.code).toBe("invalid_session_token"); + }); + + it("T1.7: maps server_error code", () => { + const err = mapAnonymousErrorCode("server_error"); + expect(err.code).toBe("server_error"); + }); + + it("FR-13: maps invalid_client code", () => { + const err = mapAnonymousErrorCode("invalid_client"); + expect(err.code).toBe("invalid_client"); + expect(err.message).toContain("authentication failed"); + }); + + it("maps unknown code with fallback message", () => { + const err = mapAnonymousErrorCode("unknown_code"); + expect(err.code).toBe("unknown_code"); + expect(err.message).toContain("An error occurred"); + }); + }); + + describe("isRecoverableAnonymousError", () => { + it("T1.5: returns true for session_expired", () => { + const err = new AnonymousSessionError("session_expired"); + expect(isRecoverableAnonymousError(err)).toBe(true); + }); + + it("returns true for invalid_session_token", () => { + const err = new AnonymousSessionError("invalid_session_token"); + expect(isRecoverableAnonymousError(err)).toBe(true); + }); + + it("T1.7: returns false for server_error", () => { + const err = new AnonymousSessionError("server_error"); + expect(isRecoverableAnonymousError(err)).toBe(false); + }); + + it("T1.7: returns false for non-AnonymousSessionError", () => { + const err = new Error("generic"); + expect(isRecoverableAnonymousError(err)).toBe(false); + }); + + it("returns false for null", () => { + expect(isRecoverableAnonymousError(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isRecoverableAnonymousError(undefined)).toBe(false); + }); + + it("returns false for objects without code property", () => { + expect(isRecoverableAnonymousError({ message: "error" })).toBe(false); + }); + }); + + describe("transferCookies helper", () => { + it("REG-C3: copies all cookies from source to target", () => { + const source = new NextResponse(); + source.cookies.set("test-cookie", "value", { httpOnly: true }); + const target = new NextResponse(); + + transferCookies(source, target); + + const targetCookies = target.cookies.getAll(); + expect(targetCookies).toHaveLength(1); + expect(targetCookies[0].name).toBe("test-cookie"); + expect(targetCookies[0].value).toBe("value"); + expect(targetCookies[0].httpOnly).toBe(true); + }); + + it("copies multiple cookies", () => { + const source = new NextResponse(); + source.cookies.set("cookie1", "value1", { httpOnly: true }); + source.cookies.set("cookie2", "value2", { secure: true }); + const target = new NextResponse(); + + transferCookies(source, target); + + const targetCookies = target.cookies.getAll(); + expect(targetCookies).toHaveLength(2); + expect(targetCookies.map((c) => c.name)).toEqual(["cookie1", "cookie2"]); + }); + + it("handles empty source", () => { + const source = new NextResponse(); + const target = new NextResponse(); + + transferCookies(source, target); + + expect(target.cookies.getAll()).toHaveLength(0); + }); + }); +}); + +describe("Type definitions", () => { + describe("C1 BLOCKER FIX: SDK transformation path - toPublicSession extracts anon@ from JWT", () => { + it("C1: SDK extracts id from JWT sub claim with anon@ prefix", async () => { + // BLOCKER C1 FIX: Drive REAL SDK toPublicSession path. + // Prior test was tautology (manually decode JWT, assert same value). + // This test exercises the actual SDK transformation: build a mock JWT, + // wrap in AnonymousCookiePayload, drive through the SDK's toPublicSession + // (via getAnonymousSession end-to-end path), assert SDK extracted the id. + + // Pattern: mimic auth-client.anonymous-routes.test.ts T2.5 lines 810-835 + // which proves session.id === JWT sub claim via real SDK path. + + const secret = await generateSecret(32); + const routes = getDefaultRoutes(); + const client = new AuthClient({ + domain: "auth0.local", + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes, + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + + // Build a real JWT with anon@ sub + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }) + ).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const payload = Buffer.from( + JSON.stringify({ + sub: "anon@c1-blocker-uuid", + iat: now, + exp: now + 3600 + }) + ).toString("base64url"); + const jwt = `${header}.${payload}.sig`; + + // Wrap in AnonymousCookiePayload and encrypt as cookie + const cookiePayload: AnonymousCookiePayload = { + session_token: "session-token-c1", + access_token: jwt, + expires_at: now + 3600 + }; + const encrypted = await encrypt(cookiePayload, secret, now + 3600); + + // Drive SDK path: getAnonymousSession reads cookie, calls toPublicSession + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { headers: { cookie: `auth0_anon=${encrypted}` } } + ); + const res = new NextResponse(); + const session = await (client as any).getAnonymousSession( + req.cookies, + res.cookies + ); + + // ASSERT: SDK extracted id from JWT sub, matches anon@ format + expect(session).toBeTruthy(); + expect(session!.id).toBe("anon@c1-blocker-uuid"); + expect(session!.id).toMatch(/^anon@/); + }); + + it("REG-M2: SDK rejects non-anon@ JWT sub with invalid_session_token", async () => { + // BLOCKER C1 also requires proving non-anon@ sub is rejected per DESIGN §3.C2. + const secret = await generateSecret(32); + const routes = getDefaultRoutes(); + const client = new AuthClient({ + domain: "auth0.local", + clientId: "test-id", + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + routes, + transactionStore: new TransactionStore({ + secret, + cookieOptions: { secure: false } + }), + sessionStore: new StatelessSessionStore({ + secret, + rolling: true, + absoluteDuration: 259200, + inactivityDuration: 86400 + }), + anonymousSession: { enabled: true } + }); + + // Build JWT with NON-anon@ sub (e.g. auth0|user123) + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }) + ).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const payload = Buffer.from( + JSON.stringify({ + sub: "auth0|user123", + iat: now, + exp: now + 3600 + }) + ).toString("base64url"); + const jwt = `${header}.${payload}.sig`; + + const cookiePayload: AnonymousCookiePayload = { + session_token: "token", + access_token: jwt, + expires_at: now + 3600 + }; + const encrypted = await encrypt(cookiePayload, secret, now + 3600); + + const req = new NextRequest( + new URL("http://localhost:3000/auth/anonymous-session"), + { headers: { cookie: `auth0_anon=${encrypted}` } } + ); + const res = new NextResponse(); + + // SDK should reject this as invalid_session_token + const session = await (client as any).getAnonymousSession( + req.cookies, + res.cookies + ); + // toPublicSession throws AnonymousSessionError, resolveAnonymousSession catches → returns null + expect(session).toBeNull(); + }); + + it("T1.1: AnonymousSession.accessToken is the raw bearer token", () => { + const bearerToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbm9uQHV1aWQtMTIzNCJ9.sig"; + const session: AnonymousSession = { + id: "anon@uuid-1234", + accessToken: bearerToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600 + }; + expect(session.accessToken).toBe(bearerToken); + }); + + it("T1.1: AnonymousSession.expiresAt is extracted from access_token exp claim", () => { + const now = Math.floor(Date.now() / 1000); + const expiryFromJWT = now + 3600; + const session: AnonymousSession = { + id: "anon@uuid-1234", + accessToken: "token", + expiresAt: expiryFromJWT + }; + expect(session.expiresAt).toBe(expiryFromJWT); + }); + + it("T1.1: AnonymousSession with metadata includes last persisted metadata from cookie", () => { + const session: AnonymousSession = { + id: "anon@uuid-5678", + accessToken: "eyJ...", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + metadata: { cart: { qty: 3 }, preferences: { theme: "dark" } } + }; + expect(session.metadata).toEqual({ + cart: { qty: 3 }, + preferences: { theme: "dark" } + }); + }); + }); + + describe("T3.1: AnonymousCookiePayload verification", () => { + it("T3.1: AnonymousCookiePayload has session_token + access_token", () => { + const payload: AnonymousCookiePayload = { + session_token: "session-opaque-handle", + access_token: "eyJ...", + expires_at: Math.floor(Date.now() / 1000) + 3600 + }; + expect(payload.session_token).toBeDefined(); + expect(payload.access_token).toBeDefined(); + expect(payload.expires_at).toBeGreaterThan(0); + }); + + it("T3.1: AnonymousCookiePayload with metadata", () => { + const payload: AnonymousCookiePayload = { + session_token: "token", + access_token: "eyJ...", + expires_at: 3000, + metadata: { key: "value" } + }; + expect(payload.metadata).toEqual({ key: "value" }); + }); + }); + + describe("Config validation", () => { + it("T8.1: AnonymousSessionConfig has enabled flag", () => { + const config = { + enabled: false + }; + expect(config.enabled).toBe(false); + }); + + it("T8.3: Cookie name override in config", () => { + const config = { + enabled: true, + cookie: { name: "custom_anon" } + }; + expect(config.cookie?.name).toBe("custom_anon"); + }); + + it("T8.4: Cookie sameSite override", () => { + const config = { + enabled: true, + cookie: { sameSite: "strict" as const } + }; + expect(config.cookie?.sameSite).toBe("strict"); + }); + + it("T8.5: Cookie secure override", () => { + const config = { + enabled: true, + cookie: { secure: false } + }; + expect(config.cookie?.secure).toBe(false); + }); + }); +}); diff --git a/src/types/anonymous-session.ts b/src/types/anonymous-session.ts new file mode 100644 index 000000000..884a21102 --- /dev/null +++ b/src/types/anonymous-session.ts @@ -0,0 +1,102 @@ +/** + * Anonymous Session Types for @auth0/nextjs-auth0 + * Public API types and configuration contracts for anonymous sessions + */ + +import type * as jose from "jose"; + +/** + * Session object returned to SDK consumers (access token exposed, session token stays server-side) + */ +export interface AnonymousSession { + /** Anonymous subject, always "anon@{uuid}" (extracted from access token sub claim) */ + id: string; + /** Bearer token for API calls; validates per access_token JWT expiry */ + accessToken: string; + /** Unix seconds at which accessToken expires */ + expiresAt: number; + /** User-set top-level key-value metadata, max 1KB serialized, optional */ + metadata?: Record; +} + +/** + * Metadata payload shape for client updates + */ +export type AnonymousSessionMetadata = Record; + +/** + * Config block for auth0.ts setup + */ +export interface AnonymousSessionConfig { + /** Master switch. Defaults to false: routes not mounted, methods no-op */ + enabled: boolean; + /** + * Audience requested for the anonymous access token. When omitted the + * authorization server applies the tenant default. The resource server must + * allow anonymous access, otherwise the server reports `invalid_target`. + */ + audience?: string; + /** + * Space-separated scopes requested for the anonymous access token. When + * omitted the authorization server applies the tenant default. Scopes that + * are not granted to anonymous subjects are reported as `invalid_scope`. + */ + scope?: string; + cookie?: { + /** Cookie name. Defaults to "auth0_anon" */ + name?: string; + /** SameSite attribute. Defaults to "lax" */ + sameSite?: "lax" | "strict" | "none"; + /** Secure flag. Defaults to true */ + secure?: boolean; + /** Cookie max age in seconds. Defaults to 2592000 (30 days). */ + maxAge?: number; + }; +} + +/** + * Hook options shape + */ +export type UseAnonymousSessionOptions = { route?: string }; + +/** + * Internal cookie payload (stays encrypted in cookie; never surfaces to SDK consumer) + */ +export interface AnonymousCookiePayload extends jose.JWTPayload { + session_token: string; // opaque, non-API handle for renewal/metadata + access_token: string; // standard bearer token + expires_at: number; // Unix seconds, access token expiry + metadata?: Record; // last persisted metadata +} + +/** + * Server-side token response from POST /anonymous/token + * CASCADE-v2 M5: Authorization server wire response includes session_expires_in (30d), + * but SDK deliberately ignores it; renewal is error-driven/reactive only (no expiry check). + */ +export interface AnonymousTokenResponse { + token_type: "Bearer"; + session_token?: string; // present only on create (HTTP 201); omitted on renew (HTTP 200) + access_token: string; + expires_in: number; + scope?: string; // optional: AS may omit if no scope requested or default scope applied + metadata?: Record; // merged metadata returned by Auth0 + session_expires_in?: number; // present on wire (30d); intentionally unused by SDK +} + +/** + * Helper: check if error is recoverable (silent recovery path) + */ +export function isRecoverableAnonymousError(err: unknown): boolean { + // Check if error is AnonymousSessionError with code session_expired or invalid_session_token + if ( + err && + typeof err === "object" && + "code" in err && + typeof (err as { code: unknown }).code === "string" + ) { + const code = (err as { code: string }).code; + return code === "session_expired" || code === "invalid_session_token"; + } + return false; +} diff --git a/src/types/index.ts b/src/types/index.ts index 12f9cc7ee..8c7dc4fa6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -313,3 +313,13 @@ export { PasskeyRequestOptionsJSON, PasskeyCredentialDescriptorJSON } from "./passkey.js"; + +export type { + AnonymousSession, + AnonymousSessionMetadata, + AnonymousSessionConfig, + UseAnonymousSessionOptions, + AnonymousCookiePayload, + AnonymousTokenResponse +} from "./anonymous-session.js"; +export { isRecoverableAnonymousError } from "./anonymous-session.js"; diff --git a/src/utils/anonymous-session-constants.ts b/src/utils/anonymous-session-constants.ts new file mode 100644 index 000000000..7db1326b2 --- /dev/null +++ b/src/utils/anonymous-session-constants.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server.js"; + +/** Marker prefix for all anonymous subjects (design 8.S3: single definition for consistency) */ +export const ANONYMOUS_SUBJECT_PREFIX = "anon@"; + +/** Default cookie name for anonymous session */ +export const DEFAULT_ANONYMOUS_SESSION_COOKIE_NAME = "auth0_anon"; + +/** Metadata size limit in bytes */ +export const METADATA_SIZE_LIMIT_BYTES = 1024; + +/** Authorization endpoint reserved parameter to strip from caller input */ +export const RESERVED_SESSION_TOKEN_PARAM = "session_token"; + +/** + * Copy every cookie written on `from` onto `to`, preserving name/value/options. + * Used by route handlers whose JSON body is only known AFTER token renewal writes + * cookies: renewal collects cookies in a temp NextResponse, then this moves them onto + * the final NextResponse.json(...) that is returned to the client (fixation-safe: no + * request-supplied cookies involved; only SDK-persisted anonymous cookies are moved). + */ +export function transferCookies(from: NextResponse, to: NextResponse): void { + for (const cookie of from.cookies.getAll()) { + to.cookies.set(cookie); + } +}