@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +328 -7
  2. package/dist/client-proof.d.ts +10 -1
  3. package/dist/client-proof.js +136 -11
  4. package/dist/client-proof.js.map +1 -1
  5. package/dist/client.d.ts +101 -1
  6. package/dist/client.js +65 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +122 -0
  9. package/dist/config.js +53 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/errors.d.ts +159 -3
  13. package/dist/errors.js +95 -2
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.d.ts +52 -12
  16. package/dist/index.js +104 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
  19. package/dist/nextjs/api.js +329 -37
  20. package/dist/nextjs/api.js.map +1 -1
  21. package/dist/nextjs/server.d.ts +59 -24
  22. package/dist/nextjs/server.js +105 -11
  23. package/dist/nextjs/server.js.map +1 -1
  24. package/dist/server.d.ts +415 -332
  25. package/dist/server.js +2933 -1545
  26. package/dist/server.js.map +1 -1
  27. package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
  28. package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
  29. package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
  30. package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
  31. package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
  32. package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
  33. package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
  34. package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
  35. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/nextjs/server.ts","../../src/nextjs/guards/require-auth.tsx","../../src/nextjs/session-helpers.ts","../../src/server/lib/session.ts","../../src/server/logger.ts","../../src/server/lib/csrf.ts","../../src/server/lib/config.ts","../../src/nextjs/guards/auth-utils.ts","../../src/nextjs/guards/require-role.tsx","../../src/nextjs/guards/require-permission.tsx","../../src/nextjs/cookie-names.ts","../../src/nextjs/oauth-handlers.ts","../../src/lib/return-path.ts","../../src/nextjs/oauth2-authorize-handlers.ts","../../src/nextjs/revoke-all-page-handlers.ts"],"sourcesContent":["import 'server-only';\n\nexport { RequireAuth } from './guards/require-auth';\nexport type { RequireAuthProps } from './guards/require-auth';\n\nexport { RequireRole } from './guards/require-role';\nexport type { RequireRoleProps } from './guards/require-role';\n\nexport { RequirePermission } from './guards/require-permission';\nexport type { RequirePermissionProps } from './guards/require-permission';\n\nexport { getAuthSessionData, getUserRole, getUserPermissions, hasAnyRole, hasAnyPermission } from './guards/auth-utils';\n\n// Session helpers\nexport {\n saveSession,\n getSession,\n clearSession,\n // Pending session (OAuth)\n sealPendingSession,\n unsealPendingSession,\n getPendingSession,\n clearPendingSession,\n type SessionData,\n type PublicSession,\n type SaveSessionOptions,\n type PendingSessionData,\n} from './session-helpers';\n\n// Cookie names — an app that empties the session jar must never spell them\nexport {\n sessionCookieNames,\n clearSessionCookies,\n type SessionCookieNames,\n} from './cookie-names';\n\n// OAuth handlers\nexport {\n createOAuthCallbackHandler,\n type OAuthCallbackOptions,\n} from './oauth-handlers';\n\n// The OAuth 2.1 consent screen — the one half of the authorization server that\n// has to live on the web app, because that is where the session cookie is\nexport {\n createOAuth2AuthorizeHandlers,\n escapeHtml,\n type OAuth2AuthorizeHandlerOptions,\n type OAuth2AuthorizeHandlers,\n type OAuth2ConsentScope,\n type OAuth2ConsentView,\n} from './oauth2-authorize-handlers';\n\n// The sign-out-everywhere page — the mailed link opens a page in the app, and\n// the page has no session to lean on, which is the whole point of the link\nexport {\n createRevokeAllPageHandlers,\n type RevokeAllPageHandlerOptions,\n type RevokeAllPageHandlers,\n type RevokeAllPageView,\n} from './revoke-all-page-handlers';\n\n// The rule every return destination is held to — validate before calling\n// getGoogleOAuthUrl rather than writing a second rule per screen.\nexport { isSafeReturnPath } from '../lib/return-path';\n","/**\n * RequireAuth Guard Component\n *\n * Requires user to be authenticated\n */\n\nimport { redirect } from 'next/navigation';\nimport { getSession } from '../session-helpers';\nimport { getAuthSessionData } from './auth-utils';\nimport type { ReactNode } from 'react';\n\nexport interface RequireAuthProps\n{\n /**\n * Children to render if authenticated\n */\n children: ReactNode;\n\n /**\n * Path to redirect to if not authenticated\n * @default '/login'\n */\n redirectTo?: string;\n\n /**\n * Fallback UI to show instead of redirecting\n */\n fallback?: ReactNode;\n}\n\n/**\n * Require Authentication Guard\n *\n * Ensures user is logged in before rendering children\n *\n * @example\n * ```tsx\n * <RequireAuth redirectTo=\"/login\">\n * <DashboardContent />\n * </RequireAuth>\n * ```\n *\n * @example With fallback\n * ```tsx\n * <RequireAuth fallback={<LoginPrompt />}>\n * <PrivateContent />\n * </RequireAuth>\n * ```\n */\nexport async function RequireAuth({\n children,\n redirectTo = '/auth/login',\n fallback,\n}: RequireAuthProps)\n{\n const session = await getSession();\n\n if (!session)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect(redirectTo);\n }\n\n // Validate server-side session (key expiry, user status, etc.)\n const serverSession = await getAuthSessionData();\n\n if (!serverSession)\n {\n // Note: clearSession() cannot be called in Server Components (Next.js 16+)\n // The RPC proxy interceptor handles session cleanup on 401 responses\n redirect(redirectTo);\n }\n\n return <>{children}</>;\n}\n","/**\n * Session helpers for Next.js\n *\n * Server-side only (uses next/headers)\n */\n\nimport * as jose from 'jose';\nimport { cookies } from 'next/headers.js';\nimport { sealSession, unsealSession, type SessionData } from '../server/lib/session';\nimport { deriveCsrfToken } from '../server/lib/csrf';\nimport { COOKIE_NAMES, getSessionTtl, parseDuration } from '../server/lib/config';\nimport { type KeyAlgorithmType } from '../server/types';\nimport { env } from '@spfn/auth/config';\nimport { logger } from '@spfn/core/logger';\n\nexport type { SessionData };\n\n/**\n * Pending OAuth session data (before user ID is known)\n */\nexport interface PendingSessionData\n{\n privateKey: string;\n keyId: string;\n algorithm: KeyAlgorithmType;\n}\n\n/**\n * Public session information (excludes sensitive data)\n */\nexport interface PublicSession\n{\n /** User ID */\n userId: string;\n}\n\n/**\n * Options for saveSession\n */\nexport interface SaveSessionOptions\n{\n /**\n * Session TTL (time to live)\n *\n * Supports:\n * - Number: seconds (e.g., 2592000)\n * - String: duration format ('30d', '12h', '45m', '3600s')\n *\n * If not provided, uses global configuration:\n * 1. Global config (configureAuth)\n * 2. Environment variable (SPFN_AUTH_SESSION_TTL)\n * 3. Default (7d)\n */\n maxAge?: number | string;\n\n /**\n * Remember me option\n *\n * When true, uses extended session duration (if configured)\n */\n remember?: boolean;\n}\n\n/**\n * Save session to HttpOnly cookie\n *\n * @param data - Session data to save\n * @param options - Session options (maxAge, remember)\n *\n * @example\n * ```typescript\n * // Use global configuration\n * await saveSession(sessionData);\n *\n * // Custom TTL with duration string\n * await saveSession(sessionData, { maxAge: '30d' });\n *\n * // Custom TTL in seconds\n * await saveSession(sessionData, { maxAge: 2592000 });\n *\n * // Remember me\n * await saveSession(sessionData, { remember: true });\n * ```\n */\nexport async function saveSession(\n data: SessionData,\n options?: SaveSessionOptions,\n): Promise<void>\n{\n // Calculate maxAge\n let maxAge: number;\n\n if (options?.maxAge !== undefined)\n {\n // Custom maxAge provided\n maxAge = typeof options.maxAge === 'number'\n ? options.maxAge\n : parseDuration(options.maxAge);\n }\n else\n {\n // Use getSessionTtl for consistent configuration\n maxAge = getSessionTtl();\n }\n\n const token = await sealSession(data, maxAge);\n const cookieStore = await cookies();\n\n cookieStore.set(COOKIE_NAMES.SESSION, token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge,\n });\n\n // Readable companion: the client mirrors it into x-spfn-csrf, and the proxy\n // refuses cookie-session mutations that arrive without it. A session saved\n // here without one would be a session that cannot mutate anything.\n cookieStore.set(COOKIE_NAMES.CSRF, await deriveCsrfToken(data.keyId), {\n httpOnly: false,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge,\n });\n}\n\n/**\n * Get session from HttpOnly cookie\n *\n * Returns public session info only (excludes privateKey, algorithm, keyId)\n */\nexport async function getSession(): Promise<PublicSession | null>\n{\n const cookieStore = await cookies();\n const sessionCookie = cookieStore.get(COOKIE_NAMES.SESSION);\n\n if (!sessionCookie)\n {\n return null;\n }\n\n try\n {\n // Never log the cookie value — it's the sealed session token.\n logger.debug('Validating session cookie', { present: true });\n const session = await unsealSession(sessionCookie.value);\n\n // Return only public information\n return {\n userId: session.userId,\n };\n }\n catch (error)\n {\n // Session expired or invalid\n // Note: Cannot delete cookies in Server Components (read-only)\n // Use validateSessionMiddleware() in Next.js middleware for automatic cleanup\n logger.debug('Session validation failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n\n return null;\n }\n}\n\n/**\n * Clear session cookie\n */\nexport async function clearSession(): Promise<void>\n{\n const cookieStore = await cookies();\n cookieStore.delete(COOKIE_NAMES.SESSION);\n cookieStore.delete(COOKIE_NAMES.SESSION_KEY_ID);\n cookieStore.delete(COOKIE_NAMES.CSRF);\n}\n\n// ============================================================================\n// Pending OAuth Session (for OAuth flow)\n// ============================================================================\n\n/**\n * Get encryption key for pending session\n */\nasync function getPendingSessionKey(): Promise<Uint8Array>\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n const encoder = new TextEncoder();\n const data = encoder.encode(`oauth-pending:${secret}`);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n return new Uint8Array(hashBuffer);\n}\n\n/**\n * Seal pending session data (for OAuth flow)\n *\n * @param data - Pending session data (privateKey, keyId, algorithm)\n * @param ttl - Time to live in seconds (default: 10 minutes)\n */\nexport async function sealPendingSession(\n data: PendingSessionData,\n ttl: number = 600,\n): Promise<string>\n{\n const key = await getPendingSessionKey();\n\n return await new jose.EncryptJWT({ data })\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .setExpirationTime(`${ttl}s`)\n .setIssuer('spfn-auth')\n .setAudience('spfn-oauth')\n .encrypt(key);\n}\n\n/**\n * Unseal pending session data\n *\n * @param jwt - Encrypted pending session token\n */\nexport async function unsealPendingSession(jwt: string): Promise<PendingSessionData>\n{\n const key = await getPendingSessionKey();\n\n const { payload } = await jose.jwtDecrypt(jwt, key, {\n issuer: 'spfn-auth',\n audience: 'spfn-oauth',\n });\n\n return payload.data as PendingSessionData;\n}\n\n/**\n * Get pending session from cookie\n */\nexport async function getPendingSession(): Promise<PendingSessionData | null>\n{\n const cookieStore = await cookies();\n const pendingCookie = cookieStore.get(COOKIE_NAMES.OAUTH_PENDING);\n\n if (!pendingCookie)\n {\n return null;\n }\n\n try\n {\n return await unsealPendingSession(pendingCookie.value);\n }\n catch (error)\n {\n logger.debug('Pending session validation failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n\n return null;\n }\n}\n\n/**\n * Clear pending session cookie\n */\nexport async function clearPendingSession(): Promise<void>\n{\n const cookieStore = await cookies();\n cookieStore.delete(COOKIE_NAMES.OAUTH_PENDING);\n}\n","/**\n * @spfn/auth - Client Session Management\n *\n * Uses Jose JWE (JSON Web Encryption) to securely store session data in cookies\n * More efficient than Iron Session with better Edge Runtime support\n */\n\nimport * as jose from 'jose';\nimport { env } from '@spfn/auth/config';\nimport { env as coreEnv } from '@spfn/core/config';\nimport { authLogger } from '../logger';\n\nimport { type KeyAlgorithmType } from '../types';\n\nexport interface SessionData\n{\n userId: string;\n privateKey: string; // Base64 encoded DER\n keyId: string;\n algorithm: KeyAlgorithmType;\n}\n\n/**\n * Get session secret key derived from environment\n * Must be at least 32 characters (256-bit)\n *\n * Derives a 32-byte key using SHA-256 to ensure compatibility with Jose A256GCM\n */\nasync function getSessionSecretKey(): Promise<Uint8Array>\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n\n // Derive a 32-byte key using SHA-256 for A256GCM compatibility\n // Use Web Crypto API for universal compatibility (browser + Node.js)\n const encoder = new TextEncoder();\n const data = encoder.encode(secret);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n return new Uint8Array(hashBuffer);\n}\n\n/**\n * Get a short fingerprint of the current secret key for debugging\n * Logs only the first 8 hex chars of the SHA-256 hash — safe to expose\n */\nasync function getSecretFingerprint(): Promise<string>\n{\n const key = await getSessionSecretKey();\n const hash = await crypto.subtle.digest('SHA-256', key.buffer as ArrayBuffer);\n const hex = Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n\n return hex.slice(0, 8);\n}\n\n/**\n * Seal session data into encrypted JWT (JWE)\n *\n * @param data - Session data to encrypt\n * @param ttl - Time to live in seconds (default: 7 days)\n * @returns Encrypted JWT string\n */\nexport async function sealSession(\n data: SessionData,\n ttl: number = 60 * 60 * 24 * 7, // 7 days\n): Promise<string>\n{\n const secret = await getSessionSecretKey();\n\n const result = await new jose.EncryptJWT({ data })\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .setExpirationTime(`${ttl}s`)\n .setIssuer('spfn-auth')\n .setAudience('spfn-client')\n .encrypt(secret);\n\n if (coreEnv.NODE_ENV !== 'production')\n {\n const fingerprint = await getSecretFingerprint();\n authLogger.session.debug(`Sealed session`, {\n secretFingerprint: fingerprint,\n resultLength: result.length,\n resultPrefix: result.slice(0, 20),\n });\n }\n\n return result;\n}\n\n/**\n * Unseal encrypted JWT (JWE) to session data\n *\n * @param jwt - Encrypted JWT string\n * @returns Session data\n * @throws Error if session is invalid or expired\n */\nexport async function unsealSession(jwt: string): Promise<SessionData>\n{\n try\n {\n const secret = await getSessionSecretKey();\n\n const { payload } = await jose.jwtDecrypt(jwt, secret, {\n issuer: 'spfn-auth',\n audience: 'spfn-client',\n });\n\n return payload.data as SessionData;\n }\n catch (err)\n {\n if (err instanceof jose.errors.JWTExpired)\n {\n throw new Error('Session expired');\n }\n\n if (err instanceof jose.errors.JWEDecryptionFailed)\n {\n // Log secret fingerprint for debugging cross-process key mismatch\n if (coreEnv.NODE_ENV !== 'production')\n {\n const fingerprint = await getSecretFingerprint();\n authLogger.session.warn(`JWE decryption failed`, {\n secretFingerprint: fingerprint,\n jwtLength: jwt.length,\n jwtPrefix: jwt.slice(0, 20),\n jwtSuffix: jwt.slice(-10),\n });\n }\n\n throw new Error('Invalid session');\n }\n\n if (err instanceof jose.errors.JWTClaimValidationFailed)\n {\n throw new Error('Session validation failed');\n }\n\n throw new Error('Failed to unseal session');\n }\n}\n\n/**\n * Get session metadata without decrypting\n *\n * @param jwt - Encrypted JWT string\n * @returns Session metadata or null if invalid\n */\nexport async function getSessionInfo(jwt: string): Promise<{\n issuedAt: Date;\n expiresAt: Date;\n issuer: string;\n audience: string;\n} | null>\n{\n const secret = await getSessionSecretKey();\n\n try\n {\n const { payload } = await jose.jwtDecrypt(jwt, secret);\n\n return {\n issuedAt: new Date(payload.iat! * 1000),\n expiresAt: new Date(payload.exp! * 1000),\n issuer: payload.iss || '',\n audience: Array.isArray(payload.aud) ? payload.aud[0] : payload.aud || '',\n };\n }\n catch (err)\n {\n // Log error for debugging but return null for graceful handling\n if (coreEnv.NODE_ENV !== 'production')\n {\n authLogger.session.warn('Failed to get session info:', err instanceof Error ? err.message : 'Unknown error');\n }\n\n return null;\n }\n}\n\n/**\n * Check if session is about to expire (within threshold)\n *\n * @param jwt - Encrypted JWT string\n * @param thresholdHours - Hours before expiry to trigger refresh (default: 24)\n * @returns True if session should be refreshed\n */\nexport async function shouldRefreshSession(\n jwt: string,\n thresholdHours: number = 24,\n): Promise<boolean>\n{\n const info = await getSessionInfo(jwt);\n\n if (!info)\n {\n return true;\n }\n\n const hoursRemaining = (info.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60);\n\n return hoursRemaining < thresholdHours;\n}\n","/**\n * @spfn/auth - Centralized Logger\n *\n * All auth package loggers with consistent naming\n */\n\nimport { logger as rootLogger } from '@spfn/core/logger';\n\nexport const authLogger = {\n plugin: rootLogger.child('@spfn/auth:plugin'),\n middleware: rootLogger.child('@spfn/auth:middleware'),\n interceptor: {\n general: rootLogger.child('@spfn/auth:interceptor:general'),\n login: rootLogger.child('@spfn/auth:interceptor:login'),\n keyRotation: rootLogger.child('@spfn/auth:interceptor:key-rotation'),\n oauth: rootLogger.child('@spfn/auth:interceptor:oauth'),\n csrf: rootLogger.child('@spfn/auth:interceptor:csrf'),\n },\n session: rootLogger.child('@spfn/auth:session'),\n service: rootLogger.child('@spfn/auth:service'),\n setup: rootLogger.child('@spfn/auth:setup'),\n email: rootLogger.child('@spfn/auth:email'),\n sms: rootLogger.child('@spfn/auth:sms'),\n};\n","/**\n * @spfn/auth - CSRF token derivation\n *\n * The token is an HMAC of the session's key id under a subkey derived from the\n * session secret. Two properties follow from that shape:\n *\n * - The proxy recomputes it from the session it just unsealed, so a value an\n * attacker planted in the readable cookie (sibling-subdomain cookie tossing)\n * never verifies. Nothing here compares a cookie against a header.\n * - It is bound to the key id, so rotating the session key invalidates it.\n *\n * No new secret: the subkey is a labelled HMAC of SPFN_AUTH_SESSION_SECRET, so\n * the key that encrypts sessions is never used verbatim as the token key.\n */\n\nimport { env } from '@spfn/auth/config';\n\n/** Header the readable CSRF cookie is mirrored into by the client. */\nexport const CSRF_HEADER = 'x-spfn-csrf';\n\n/** Domain-separation label for the CSRF subkey. */\nconst CSRF_SUBKEY_LABEL = 'spfn-auth-csrf-token-v1';\n\n/**\n * Upper bound on candidate values accepted in one header.\n *\n * Deliberately generous, and must stay in step with MAX_CANDIDATES in\n * @spfn/core's client: verification recomputes the expected value once and then\n * only compares fixed-length strings, so an extra candidate costs a few hundred\n * nanoseconds, while a candidate dropped below this line is a user locked out of\n * every mutation. A low cap is not a security control either — accepting a match\n * among many is no weaker than accepting one, because every candidate still has\n * to equal a value recomputed from the session.\n */\nconst MAX_CANDIDATES = 32;\n\n/**\n * Resolve the session secret, refusing to derive anything without one.\n *\n * `env` throws on a missing required variable, but returns undefined when\n * SKIP_ENV_VALIDATION is set — and hashing `undefined` would yield a token every\n * deployment could compute. Fail closed instead.\n */\nfunction sessionSecret(): string\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n\n if (!secret)\n {\n throw new Error(\n 'SPFN_AUTH_SESSION_SECRET is required for CSRF protection. '\n + 'Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off.',\n );\n }\n\n return secret;\n}\n\n/**\n * HMAC-SHA256 over Web Crypto, so this works in the Edge runtime too.\n */\nasync function hmacSha256(key: Uint8Array, message: string): Promise<Uint8Array>\n{\n const cryptoKey = await crypto.subtle.importKey(\n 'raw',\n key.buffer as ArrayBuffer,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(message));\n\n return new Uint8Array(signature);\n}\n\nfunction toHex(bytes: Uint8Array): string\n{\n return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Derive the CSRF token for a session key id.\n *\n * @param keyId - Session key id (`SessionData.keyId`)\n * @returns 64-char hex token — safe to put in a readable cookie, it reveals\n * neither the secret nor the key id\n */\nexport async function deriveCsrfToken(keyId: string): Promise<string>\n{\n const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);\n\n return toHex(await hmacSha256(subkey, keyId));\n}\n\n/**\n * Constant-time string comparison.\n *\n * Named for the string it takes, so it does not collide with node's Buffer-based\n * `timingSafeEqual` — which this package also uses, in the OAuth providers. It is\n * not re-exported from the package barrel for the same reason.\n *\n * Length is compared first and leaks only the length, which is fixed and public.\n */\nexport function timingSafeEqualString(a: string, b: string): boolean\n{\n if (a.length !== b.length)\n {\n return false;\n }\n\n let difference = 0;\n\n for (let i = 0; i < a.length; i++)\n {\n difference |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n\n return difference === 0;\n}\n\n/**\n * Whether a presented header value carries the expected token.\n *\n * The header may carry several comma-separated candidates — a browser sees every\n * `spfn_csrf*` cookie set on the host and cannot tell which dev instance owns\n * which, nor which of two same-named cookies a sibling subdomain tossed in.\n *\n * Every candidate the client is allowed to send is checked — the cap here is the\n * one it selects against — so a genuine value is never evicted by tossed ones\n * that happen to sort ahead of it. A header longer than that can only come from a\n * client that ignored the shared bound, and its surplus is dropped. Accepting any\n * match is no weaker than accepting one: a candidate the attacker chose still has\n * to equal a value recomputed from the session.\n */\nexport function matchesCsrfToken(expected: string, presented: string | null | undefined): boolean\n{\n if (!presented)\n {\n return false;\n }\n\n return presented\n .split(',', MAX_CANDIDATES)\n .some((candidate) => timingSafeEqualString(expected, candidate.trim()));\n}\n","/**\n * @spfn/auth - Global Configuration\n *\n * Manages global auth configuration including session TTL\n */\n\nimport { env } from '@spfn/auth/config';\nimport { PasskeyConfigError } from '@spfn/auth/errors';\n\nimport type { SocialProvider } from '../types';\nimport { normalizeOptionalEmail } from '../helpers/email';\nimport { authLogger } from '../logger';\n\n/**\n * Cookie name suffix derived from the server port, so several local dev\n * instances on the same domain do not overwrite each other's sessions.\n *\n * BREAKING: this read `PORT`, which no longer exists — the framework's port is\n * `SPFN_PORT`, because `PORT` is Next.js's own variable and two processes are\n * started. An app that had `PORT` set gets different cookie names than before\n * and its existing sessions stop resolving; one sign-in fixes it.\n */\nfunction getCookieSuffix(): string\n{\n const port = process.env.SPFN_PORT;\n\n return port ? `_${port}` : '';\n}\n\n/**\n * Cookie names used by SPFN Auth\n *\n * Names include a port-based suffix so that multiple dev instances\n * on different ports don't overwrite each other's cookies.\n */\nexport const COOKIE_NAMES = {\n /** Encrypted session data (userId, privateKey, keyId, algorithm) */\n get SESSION() \n {\n return `spfn_session${getCookieSuffix()}`; \n },\n /** Current key ID (for key rotation) */\n get SESSION_KEY_ID() \n {\n return `spfn_session_key_id${getCookieSuffix()}`; \n },\n /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */\n get OAUTH_PENDING()\n {\n return `spfn_oauth_pending${getCookieSuffix()}`;\n },\n /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */\n get OAUTH_CSRF()\n {\n return `spfn_oauth_csrf${getCookieSuffix()}`;\n },\n /** Password-setup session for verified-email signup — temporary, single-purpose */\n get SIGNUP_SETUP()\n {\n return `spfn_signup_setup${getCookieSuffix()}`;\n },\n /** Password-setup session for a password reset — temporary, single-purpose */\n get PASSWORD_RESET_SETUP()\n {\n return `spfn_password_reset_setup${getCookieSuffix()}`;\n },\n /** CSRF token — the only cookie here the browser can read */\n get CSRF()\n {\n return `spfn_csrf${getCookieSuffix()}`;\n },\n};\n\n/**\n * OAuth CSRF 쿠키를 PORT 접미사와 무관하게 전부 수집한다.\n *\n * 쿠키를 심는 쪽은 Next.js 프로세스, 읽는 쪽은 API 프로세스라 분리 배포에서는\n * 두 프로세스의 PORT가 달라 COOKIE_NAMES.OAUTH_CSRF 정확 일치 조회가 빗나간다.\n * nonce 자체가 랜덤값이고 암호화된 state의 nonce와 대조되므로, 접미사가 다른\n * spfn_oauth_csrf* 후보를 모두 대조 대상으로 넘겨도 안전하다.\n */\nexport function matchOAuthCsrfCookies(\n cookies: Record<string, string>,\n): { name: string; value: string }[]\n{\n return Object.entries(cookies)\n .filter(([name]) => /^spfn_oauth_csrf(_\\d+)?$/.test(name))\n .map(([name, value]) => ({ name, value }));\n}\n\n/**\n * Parse duration string to seconds\n *\n * Supports: '30d', '12h', '45m', '3600s', or plain number\n *\n * @example\n * parseDuration('30d') // 2592000 (30 days in seconds)\n * parseDuration('12h') // 43200\n * parseDuration('45m') // 2700\n * parseDuration('3600') // 3600\n */\nexport function parseDuration(duration: string | number): number\n{\n if (typeof duration === 'number')\n {\n return duration;\n }\n\n const match = duration.match(/^(\\d+)([dhms]?)$/);\n if (!match)\n {\n throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);\n }\n\n const value = parseInt(match[1], 10);\n const unit = match[2] || 's';\n\n switch (unit)\n {\n case 'd':\n return value * 24 * 60 * 60;\n case 'h':\n return value * 60 * 60;\n case 'm':\n return value * 60;\n case 's':\n return value;\n default:\n throw new Error(`Unknown duration unit: ${unit}`);\n }\n}\n\n/**\n * Registration channel passed to the beforeRegister hook\n *\n * - credentials: email/phone + password registration\n * - oauth: new-user signup through a social provider (web or native flow)\n * - invitation: invitation acceptance\n */\nexport type RegisterChannel = 'credentials' | 'oauth' | 'invitation';\n\n/**\n * Context passed to the beforeRegister hook\n *\n * Credentials (password, keys) are intentionally excluded — the hook is a\n * policy gate, not a credential handler.\n */\nexport interface BeforeRegisterContext\n{\n channel: RegisterChannel;\n /** Social provider — only set when channel is 'oauth' */\n provider?: SocialProvider;\n /**\n * Canonical form of the address — trimmed and lower-cased, the same form\n * the account is stored under. A policy keyed on the address (a denylist, a\n * domain allowlist) therefore matches whatever capitalization the person\n * typed, instead of being walked past by `Blocked@Example.com`.\n */\n email?: string;\n /**\n * Whether the email is verified — only set when channel is 'oauth'.\n * OAuth providers may report an unverified (spoofable) email; the created\n * account stores it as null in that case, so email-based policies must\n * check this flag. credentials/invitation emails are already verified.\n */\n emailVerified?: boolean;\n phone?: string;\n /** App-supplied registration metadata (register params / OAuth start params / invitation) */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * How the Next.js proxy treats a cookie-authenticated mutation that arrives\n * without a valid CSRF header.\n *\n * - `off`: no check\n * - `warn`: allow it through, log one line per request that would be refused\n * - `enforce`: refuse it with 403\n */\nexport type CsrfMode = 'off' | 'warn' | 'enforce';\n\n/**\n * CSRF configuration for the Next.js proxy\n */\nexport interface AuthCsrfConfig\n{\n /**\n * @default 'warn' — an existing app gets signal before it gets breakage.\n * `SPFN_AUTH_CSRF` sets it when this is not; new apps scaffolded by\n * `spfn init` are given `enforce`.\n */\n mode?: CsrfMode;\n\n /**\n * Backend paths that skip the check, matched exactly.\n *\n * These are route paths as the backend sees them (`/webhooks/stripe`), not\n * `/api/rpc/...` URLs, with route params already substituted. Intended for\n * endpoints a browser session never calls — webhook receivers and the like.\n * A path listed here is unprotected for cookie callers too, so list only\n * endpoints that carry their own authentication.\n */\n exemptPaths?: string[];\n}\n\n/**\n * Auth configuration\n */\nexport interface AuthConfig\n{\n /**\n * Default session TTL in seconds or duration string\n *\n * Supports:\n * - Number: seconds (e.g., 2592000)\n * - String: '30d', '12h', '45m', '3600s'\n *\n * @default 7d (7 days)\n */\n sessionTtl?: string | number;\n\n /**\n * App-injected validator that runs before a new user row is created,\n * on every registration channel (credentials, oauth, invitation).\n *\n * Throw to reject the registration — RegistrationRejectedError (403) is\n * the recommended error; any HttpError subclass keeps its own status.\n * Runs after built-in checks (verification token, duplicate account),\n * so existing error precedence is unchanged. Not called for admin\n * seeding (initializeAuth) or when linking a social account to an\n * existing user.\n *\n * Runs inside the registration DB transaction on every channel — keep it\n * fast. A slow call (e.g. an external policy API) holds a pooled DB\n * connection open for its full duration on every signup.\n *\n * @example\n * ```typescript\n * configureAuth({\n * beforeRegister: async ({ channel, metadata }) =>\n * {\n * if (channel === 'credentials' && !isOldEnough(metadata?.birthDate))\n * {\n * throw new RegistrationRejectedError({ message: 'Age requirement not met' });\n * }\n * },\n * });\n * ```\n */\n beforeRegister?: (context: BeforeRegisterContext) => void | Promise<void>;\n\n /**\n * CSRF protection for cookie-session mutations, enforced in the Next.js proxy.\n *\n * @example\n * ```typescript\n * configureAuth({\n * csrf: { mode: 'enforce', exemptPaths: ['/webhooks/stripe'] },\n * });\n * ```\n */\n csrf?: AuthCsrfConfig;\n}\n\n/**\n * Global auth configuration state\n */\nlet globalConfig: AuthConfig = {\n sessionTtl: '7d', // Default: 7 days\n};\n\n/**\n * Configure global auth settings\n *\n * @param config - Auth configuration\n *\n * @example\n * ```typescript\n * configureAuth({\n * sessionTtl: '30d', // 30 days\n * });\n * ```\n */\nexport function configureAuth(config: AuthConfig): void\n{\n globalConfig = {\n ...globalConfig,\n ...config,\n };\n}\n\n/**\n * Get current auth configuration\n */\nexport function getAuthConfig(): AuthConfig\n{\n return { ...globalConfig };\n}\n\n/**\n * Run the app-injected beforeRegister hook if configured — throws to reject.\n *\n * Single entry point for every registration channel so a new channel cannot\n * forget the configured-check. Callers invoke this right before creating the\n * user row.\n *\n * The address is folded here rather than at each call site, for the same reason\n * the check itself lives here: three channels supply it, and a policy that sees\n * a different spelling depending on which one the person came through is a\n * policy that can be walked past.\n */\nexport async function runBeforeRegister(context: BeforeRegisterContext): Promise<void>\n{\n const { beforeRegister } = globalConfig;\n\n if (beforeRegister)\n {\n await beforeRegister({ ...context, email: normalizeOptionalEmail(context.email) });\n }\n}\n\n/**\n * Get session TTL in seconds\n *\n * Priority:\n * 1. Runtime override (remember parameter)\n * 2. Global config (configureAuth)\n * 3. Environment variable (SPFN_AUTH_SESSION_TTL) - via config module\n * 4. Default (7 days)\n */\nexport function getSessionTtl(override?: string | number): number\n{\n // 1. Runtime override\n if (override !== undefined)\n {\n return parseDuration(override);\n }\n\n // 2. Global config\n if (globalConfig.sessionTtl !== undefined)\n {\n return parseDuration(globalConfig.sessionTtl);\n }\n\n // 3. Environment variable (from config module)\n const envTtl = env.SPFN_AUTH_SESSION_TTL;\n if (envTtl)\n {\n return parseDuration(envTtl);\n }\n\n // 4. Default: 7 days\n return 7 * 24 * 60 * 60;\n}\n\nconst CSRF_MODES: CsrfMode[] = ['off', 'warn', 'enforce'];\n\n/** The typo notice is a property of the process, not of a request */\nlet unrecognizedCsrfModeReported = false;\n\n/**\n * Get the CSRF mode\n *\n * Priority:\n * 1. Global config (configureAuth)\n * 2. Environment variable (SPFN_AUTH_CSRF)\n * 3. Default ('warn')\n *\n * An unrecognized value is a typo in the one setting that turns the check on;\n * it resolves to `enforce` and says so, rather than quietly leaving mutations\n * unprotected. It says so once per process: this runs on every mutation, so a\n * per-call error would be pure repetition burying the rest of the log.\n */\nexport function getCsrfMode(): CsrfMode\n{\n const configured = globalConfig.csrf?.mode ?? env.SPFN_AUTH_CSRF;\n\n if (!configured)\n {\n return 'warn';\n }\n\n const normalized = String(configured).trim().toLowerCase() as CsrfMode;\n\n if (!CSRF_MODES.includes(normalized))\n {\n if (!unrecognizedCsrfModeReported)\n {\n unrecognizedCsrfModeReported = true;\n authLogger.interceptor.csrf.error(\n `Unrecognized CSRF mode \"${configured}\" — expected off | warn | enforce. Enforcing.`,\n );\n }\n\n return 'enforce';\n }\n\n return normalized;\n}\n\n/**\n * Backend paths this package exempts on its own behalf.\n *\n * All three are endpoints an OAuth client on somebody's laptop calls directly:\n * no cookie, no session, no `x-spfn-csrf` header, and no browser anywhere in\n * the request. The proxy's check already declines to run on them — it fires only\n * after a session cookie has been unsealed, and there is none — so this list\n * changes no outcome today. It is here so that an application which routes them\n * through the proxy while a user happens to be signed in gets a token endpoint\n * that works rather than a 403 nothing in the logs explains.\n *\n * `/_auth/oauth2/authorize` is deliberately absent. That one IS a\n * cookie-session mutation, posted by the consent form on the web app, and it is\n * exactly what the check exists to protect.\n */\nconst PACKAGE_CSRF_EXEMPT_PATHS = [\n '/_auth/oauth2/register',\n '/_auth/oauth2/token',\n '/_auth/oauth2/revoke',\n];\n\n/**\n * Get the paths exempted from the CSRF check (exact match, backend route paths)\n */\nexport function getCsrfExemptPaths(): string[]\n{\n return [...PACKAGE_CSRF_EXEMPT_PATHS, ...(globalConfig.csrf?.exemptPaths ?? [])];\n}\n\n// ============================================================================\n// Passkeys (WebAuthn)\n// ============================================================================\n\n/**\n * The relying party this deployment presents to authenticators, resolved.\n *\n * `rpId` is the domain a credential is bound to and can never change without\n * orphaning every passkey already enrolled under it. `origins` is the closed set\n * of pages allowed to run a ceremony for that rpId.\n */\nexport interface PasskeyConfig\n{\n /** Domain credentials are bound to — a registrable domain, no protocol, no port. */\n rpId: string;\n /** Name shown by the authenticator's own prompt. */\n rpName: string;\n /** Full origins allowed to run a ceremony, e.g. `https://app.example.com`. */\n origins: string[];\n userVerification: PasskeyUserVerification;\n challengeTtlMs: number;\n recentAuthMs: number;\n}\n\n/**\n * How hard the authenticator must work to prove the person is present.\n *\n * `discouraged` is not offered: a passkey here is the whole credential, so an\n * assertion that skipped user verification would sign someone in on possession\n * of an unlocked device alone.\n */\nexport type PasskeyUserVerification = 'preferred' | 'required';\n\nconst PASSKEY_USER_VERIFICATIONS: PasskeyUserVerification[] = ['preferred', 'required'];\n\ntype PasskeyEnvSource = Record<string, string | undefined>;\n\nconst DEFAULT_CHALLENGE_TTL_SECONDS = 300;\nconst DEFAULT_RECENT_AUTH_MINUTES = 10;\n\n/**\n * Every variable this resolution reads, with the one schema default filled in.\n *\n * `SPFN_APP_URL` defaults to `http://localhost:3000` in the validated `env`\n * proxy rather than in `process.env`, so reading the raw environment alone would\n * refuse boot for an app that simply never set it.\n */\nfunction passkeyEnvSource(): PasskeyEnvSource\n{\n return { ...process.env, SPFN_APP_URL: process.env.SPFN_APP_URL || env.SPFN_APP_URL };\n}\n\n/**\n * The app URL every default here is derived from — the same resolution the OAuth\n * callbacks use, so passkeys and OAuth cannot disagree about where the app is.\n */\nfunction passkeyAppUrl(env: PasskeyEnvSource): URL\n{\n const configured = env.NEXT_PUBLIC_SPFN_APP_URL || env.SPFN_APP_URL;\n\n if (!configured)\n {\n throw new PasskeyConfigError({\n message: 'Passkeys need a relying party ID. Set SPFN_AUTH_PASSKEY_RP_ID, or set '\n + 'NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL to the app origin it should be derived from.',\n });\n }\n\n try\n {\n return new URL(configured);\n }\n catch\n {\n throw new PasskeyConfigError({\n message: `Passkeys cannot derive a relying party ID: \"${configured}\" is not a URL. `\n + 'Fix NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL, or set SPFN_AUTH_PASSKEY_RP_ID explicitly.',\n });\n }\n}\n\n/**\n * `localhost` is the one host a browser treats as a secure context over plain\n * http, so it is the one host allowed an `http://` origin here.\n */\nfunction isLocalhost(hostname: string): boolean\n{\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';\n}\n\n/** Whether a ceremony run on this host may claim credentials bound to `rpId`. */\nfunction isUnderRpId(hostname: string, rpId: string): boolean\n{\n return hostname === rpId || hostname.endsWith(`.${rpId}`);\n}\n\n/**\n * One configured origin, checked against the two rules a browser will enforce\n * anyway — better to refuse at boot than to have every ceremony fail with an\n * error that names the browser rather than the env value.\n */\nfunction assertOriginServesRpId(origin: string, rpId: string): void\n{\n let url: URL;\n\n try\n {\n url = new URL(origin);\n }\n catch\n {\n throw new PasskeyConfigError({\n message: `SPFN_AUTH_PASSKEY_ORIGINS contains \"${origin}\", which is not a URL. `\n + 'List full origins, e.g. https://app.example.com.',\n });\n }\n\n if (url.protocol !== 'https:' && !isLocalhost(url.hostname))\n {\n throw new PasskeyConfigError({\n message: `Passkey origin \"${origin}\" is not https. WebAuthn runs only in a secure context, `\n + 'and localhost is the only host a browser treats as one over plain http.',\n });\n }\n\n if (!isUnderRpId(url.hostname, rpId))\n {\n throw new PasskeyConfigError({\n message: `Passkey origin \"${origin}\" is not on relying party ID \"${rpId}\". `\n + 'Each origin must be that host or a subdomain of it, or the browser refuses the ceremony.',\n });\n }\n}\n\nfunction resolveUserVerification(env: PasskeyEnvSource): PasskeyUserVerification\n{\n const configured = env.SPFN_AUTH_PASSKEY_USER_VERIFICATION;\n\n if (!configured)\n {\n return 'preferred';\n }\n\n const normalized = configured.trim().toLowerCase() as PasskeyUserVerification;\n\n if (!PASSKEY_USER_VERIFICATIONS.includes(normalized))\n {\n throw new PasskeyConfigError({\n message: `SPFN_AUTH_PASSKEY_USER_VERIFICATION is \"${configured}\" — expected preferred or required. `\n + 'A passkey is the whole credential here, so an assertion that skipped user verification '\n + 'would sign someone in on an unlocked device alone.',\n });\n }\n\n return normalized;\n}\n\n/** A positive number of the given unit, or the default when unset. */\nfunction resolvePositiveNumber(env: PasskeyEnvSource, variable: string, fallback: number): number\n{\n const configured = env[variable];\n\n if (!configured)\n {\n return fallback;\n }\n\n const parsed = Number(configured);\n\n if (!Number.isFinite(parsed) || parsed <= 0)\n {\n throw new PasskeyConfigError({\n message: `${variable} is \"${configured}\" — expected a positive number.`,\n });\n }\n\n return parsed;\n}\n\n/**\n * Resolve the passkey configuration, refusing anything a ceremony would fail on.\n *\n * Zero-config for a one-origin app: rpId is the app URL's host and the single\n * origin is the app URL's origin. An app on several hosts sets\n * `SPFN_AUTH_PASSKEY_RP_ID` to the registrable domain they share and lists them\n * in `SPFN_AUTH_PASSKEY_ORIGINS`.\n *\n * @param env - Environment to read; defaults to `process.env`.\n * @throws PasskeyConfigError when the configuration cannot be honoured.\n */\nexport function getPasskeyConfig(env: PasskeyEnvSource = passkeyEnvSource()): PasskeyConfig\n{\n const rpId = env.SPFN_AUTH_PASSKEY_RP_ID?.trim() || passkeyAppUrl(env).hostname;\n const configuredOrigins = env.SPFN_AUTH_PASSKEY_ORIGINS\n ?.split(',')\n .map(origin => origin.trim())\n .filter(Boolean);\n const origins = configuredOrigins?.length ? configuredOrigins : [passkeyAppUrl(env).origin];\n\n for (const origin of origins)\n {\n assertOriginServesRpId(origin, rpId);\n }\n\n return {\n rpId,\n rpName: env.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || rpId,\n origins,\n userVerification: resolveUserVerification(env),\n challengeTtlMs: resolvePositiveNumber(\n env, 'SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS', DEFAULT_CHALLENGE_TTL_SECONDS,\n ) * 1000,\n recentAuthMs: resolvePositiveNumber(\n env, 'SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES', DEFAULT_RECENT_AUTH_MINUTES,\n ) * 60_000,\n };\n}\n\n/** The variables whose presence means an operator configured passkeys on purpose. */\nconst PASSKEY_VARS = [\n 'SPFN_AUTH_PASSKEY_RP_ID',\n 'SPFN_AUTH_PASSKEY_RP_NAME',\n 'SPFN_AUTH_PASSKEY_ORIGINS',\n 'SPFN_AUTH_PASSKEY_USER_VERIFICATION',\n 'SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS',\n 'SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES',\n];\n\n/**\n * Refuse boot on a passkey configuration no ceremony could satisfy.\n *\n * Resolution is the check: everything `getPasskeyConfig` refuses would otherwise\n * surface as the browser rejecting every ceremony, long after the deploy that\n * introduced the drift.\n *\n * The refusal is reserved for a configuration an operator actually wrote, which\n * is the posture `assertOAuthRedirectUris` already takes for the same reason. An\n * app that set no passkey variable at all can still resolve to something\n * unusable — `SPFN_APP_URL=http://192.168.1.5:3000` for mobile development, say,\n * which is neither https nor localhost — and refusing to start over a feature\n * nobody asked for would take that app down to fix something it does not use.\n * It is reported instead, once, and the first ceremony (if there ever is one)\n * fails with the same message.\n *\n * @throws PasskeyConfigError when a passkey variable is set and cannot be honoured\n */\nexport function assertPasskeyConfig(env: PasskeyEnvSource = passkeyEnvSource()): void\n{\n if (PASSKEY_VARS.some(variable => env[variable]))\n {\n getPasskeyConfig(env);\n\n return;\n }\n\n try\n {\n getPasskeyConfig(env);\n }\n catch (error)\n {\n authLogger.service.info(\n 'Passkeys cannot be served with the configuration derived from the app URL, and no '\n + `SPFN_AUTH_PASSKEY_* variable is set, so boot continues. ${(error as Error).message}`,\n );\n }\n}\n\n// ============================================================================\n// Second factor (MFA)\n// ============================================================================\n\n/** What the second-factor routes read out of the environment. */\nexport interface MfaConfig\n{\n /** Name the authenticator app files the account under. */\n issuer: string;\n /** How long a device's step-up stays good for a sensitive change. */\n stepUpWindowMs: number;\n}\n\n/** Fallback issuer, for an app that has set neither the MFA nor the passkey name. */\nconst DEFAULT_MFA_ISSUER = 'SPFN';\n\nconst DEFAULT_STEP_UP_MINUTES = 10;\n\n/**\n * Resolve the second-factor configuration.\n *\n * Deliberately reads no passkey setting beyond `SPFN_AUTH_PASSKEY_RP_NAME`,\n * and reads that as a plain string rather than through `getPasskeyConfig()`:\n * an app with no passkeys configured at all must be able to enrol a TOTP and\n * to step up, and `getPasskeyConfig()` refuses to resolve for such an app.\n *\n * Nothing here can fail the way the passkey config can, so there is no boot\n * check to match: a bad step-up window falls back to the default rather than\n * refusing to start, because the value it would refuse over is a number of\n * minutes and the default is the safe one.\n */\nexport function getMfaConfig(): MfaConfig\n{\n const configuredMinutes = Number(process.env.SPFN_AUTH_MFA_STEP_UP_MINUTES);\n const minutes = Number.isFinite(configuredMinutes) && configuredMinutes > 0\n ? configuredMinutes\n : DEFAULT_STEP_UP_MINUTES;\n\n return {\n issuer: process.env.SPFN_AUTH_MFA_ISSUER?.trim()\n || process.env.SPFN_AUTH_PASSKEY_RP_NAME?.trim()\n || mfaIssuerFromAppUrl()\n || DEFAULT_MFA_ISSUER,\n stepUpWindowMs: minutes * 60_000,\n };\n}\n\n/** The app URL's host, when there is one that parses. Display only. */\nfunction mfaIssuerFromAppUrl(): string | null\n{\n const configured = process.env.NEXT_PUBLIC_SPFN_APP_URL || process.env.SPFN_APP_URL;\n\n if (!configured)\n {\n return null;\n }\n\n try\n {\n return new URL(configured).hostname;\n }\n catch\n {\n return null;\n }\n}\n","/**\n * Server-side auth utilities for guards\n *\n * Uses authApi to check permissions in real-time\n */\n\nimport { authApi } from '@spfn/auth';\nimport { authLogger } from '../../server/logger';\n\n/**\n * Get current auth session with roles and permissions via API\n */\nexport async function getAuthSessionData()\n{\n try\n {\n const session = await authApi.getAuthSession.call();\n authLogger.middleware.debug('Auth session retrieved', { name: session.role?.name });\n\n return session;\n }\n catch (error)\n {\n authLogger.middleware.error('Failed to get auth session', { error });\n\n return null;\n }\n}\n\n/**\n * Get user role\n */\nexport async function getUserRole(): Promise<string | null>\n{\n const session = await getAuthSessionData();\n\n return session?.role?.name || null;\n}\n\n/**\n * Get user permissions\n */\nexport async function getUserPermissions(): Promise<string[]>\n{\n const session = await getAuthSessionData();\n\n if (!session)\n {\n return [];\n }\n\n return session.permissions?.map((p: any) => p.name) || [];\n}\n\n/**\n * Check if user has any of the specified roles\n */\nexport async function hasAnyRole(requiredRoles: string[]): Promise<boolean>\n{\n const session = await getAuthSessionData();\n if (!session)\n {\n return false;\n }\n\n return requiredRoles.includes(session.role?.name);\n}\n\n/**\n * Check if user has any of the specified permissions\n */\nexport async function hasAnyPermission(requiredPermissions: string[]): Promise<boolean>\n{\n const session = await getAuthSessionData();\n\n if (!session)\n {\n return false;\n }\n\n const userPermissionNames = session.permissions?.map((p: any) => p.name) || [];\n\n return requiredPermissions.some(permission => userPermissionNames.includes(permission));\n}\n","/**\n * RequireRole Guard Component\n *\n * Requires user to have at least one of the specified roles\n */\n\nimport { redirect } from 'next/navigation';\nimport { getSession } from '../session-helpers';\nimport { hasAnyRole } from './auth-utils';\nimport type { ReactNode } from 'react';\n\nexport interface RequireRoleProps\n{\n /**\n * Required role(s) - user must have at least one\n */\n roles: string | string[];\n\n /**\n * Children to render if user has required role\n */\n children: ReactNode;\n\n /**\n * Path to redirect to if user doesn't have role\n * @default '/unauthorized'\n */\n redirectTo?: string;\n\n /**\n * Fallback UI to show instead of redirecting\n */\n fallback?: ReactNode;\n}\n\n/**\n * Require Role Guard\n *\n * Ensures user has at least one of the specified roles\n *\n * @example Single role\n * ```tsx\n * <RequireRole roles=\"admin\">\n * <AdminPanel />\n * </RequireRole>\n * ```\n *\n * @example Multiple roles (OR condition)\n * ```tsx\n * <RequireRole roles={['admin', 'manager']}>\n * <ManagementDashboard />\n * </RequireRole>\n * ```\n *\n * @example With fallback\n * ```tsx\n * <RequireRole roles=\"admin\" fallback={<AccessDenied />}>\n * <AdminContent />\n * </RequireRole>\n * ```\n */\nexport async function RequireRole({\n roles,\n children,\n redirectTo = '/unauthorized',\n fallback,\n}: RequireRoleProps)\n{\n const session = await getSession();\n\n // Not authenticated\n if (!session)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect('/login');\n }\n\n // Normalize to array\n const requiredRoles = Array.isArray(roles) ? roles : [roles];\n\n // Check if user has any of the required roles\n const hasRole = await hasAnyRole(requiredRoles);\n\n if (!hasRole)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect(redirectTo);\n }\n\n return <>{children}</>;\n}\n","/**\n * RequirePermission Guard Component\n *\n * Requires user to have at least one of the specified permissions\n */\n\nimport { redirect } from 'next/navigation';\nimport { getSession } from '../session-helpers';\nimport { hasAnyPermission } from './auth-utils';\nimport type { ReactNode } from 'react';\n\nexport interface RequirePermissionProps\n{\n /**\n * Required permission(s) - user must have at least one\n */\n permissions: string | string[];\n\n /**\n * Children to render if user has required permission\n */\n children: ReactNode;\n\n /**\n * Path to redirect to if user doesn't have permission\n * @default '/unauthorized'\n */\n redirectTo?: string;\n\n /**\n * Fallback UI to show instead of redirecting\n */\n fallback?: ReactNode;\n}\n\n/**\n * Require Permission Guard\n *\n * Ensures user has at least one of the specified permissions\n *\n * @example Single permission\n * ```tsx\n * <RequirePermission permissions=\"user:delete\">\n * <DeleteUserButton />\n * </RequirePermission>\n * ```\n *\n * @example Multiple permissions (OR condition)\n * ```tsx\n * <RequirePermission permissions={['user:delete', 'user:update']}>\n * <UserManagement />\n * </RequirePermission>\n * ```\n *\n * @example With fallback\n * ```tsx\n * <RequirePermission permissions=\"project:create\" fallback={<UpgradePrompt />}>\n * <CreateProject />\n * </RequirePermission>\n * ```\n */\nexport async function RequirePermission({\n permissions,\n children,\n redirectTo = '/unauthorized',\n fallback,\n}: RequirePermissionProps)\n{\n const session = await getSession();\n\n // Not authenticated\n if (!session)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect('/login');\n }\n\n // Normalize to array\n const requiredPermissions = Array.isArray(permissions) ? permissions : [permissions];\n\n // Check if user has any of the required permissions\n const hasPermission = await hasAnyPermission(requiredPermissions);\n\n if (!hasPermission)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect(redirectTo);\n }\n\n return <>{children}</>;\n}\n","/**\n * Session cookie names for Next.js\n *\n * The names carry the `SPFN_PORT` suffix, so they are only knowable at call\n * time — this module is the one place an app reads them from.\n */\n\nimport { type NextResponse } from 'next/server';\nimport { COOKIE_NAMES } from '../server/lib/config';\n\n/**\n * The cookie names that make up a browser session\n */\nexport interface SessionCookieNames\n{\n /** Encrypted session data */\n session: string;\n /** Current key ID (for key rotation) */\n keyId: string;\n /** Pending OAuth session — present only mid-flow */\n oauthPending: string;\n /** CSRF token — the only one the browser can read */\n csrf: string;\n}\n\n/**\n * Names of the cookies that make up a browser session\n *\n * Read at call time, never at import: the names carry the `SPFN_PORT` suffix,\n * and an app that spells them itself keeps clearing the old name after a\n * release renames one.\n *\n * @example\n * ```typescript\n * const names = sessionCookieNames();\n * const raw = request.cookies.get(names.session);\n * ```\n */\nexport function sessionCookieNames(): SessionCookieNames\n{\n return {\n session: COOKIE_NAMES.SESSION,\n keyId: COOKIE_NAMES.SESSION_KEY_ID,\n oauthPending: COOKIE_NAMES.OAUTH_PENDING,\n csrf: COOKIE_NAMES.CSRF,\n };\n}\n\n/**\n * Expire every session cookie on a response\n *\n * For the route handler or middleware that answers \"the API refused your\n * session\" — it empties the jar so the next request arrives anonymous. The\n * path matches the one the setters use, because a delete under a different\n * path leaves the cookie in place. Absent cookies are not an error.\n *\n * @param response - Response to expire the cookies on\n * @returns The same response, so the call chains\n *\n * @example\n * ```typescript\n * export function GET(): NextResponse\n * {\n * return clearSessionCookies(NextResponse.redirect(new URL('/login', request.url)));\n * }\n * ```\n */\nexport function clearSessionCookies(response: NextResponse): NextResponse\n{\n for (const name of Object.values(sessionCookieNames()))\n {\n response.cookies.delete({ name, path: '/' });\n }\n\n return response;\n}\n","/**\n * OAuth Handlers for Next.js\n *\n * Helper functions to create OAuth callback route handlers\n */\n\nimport { NextRequest, NextResponse } from 'next/server';\nimport { cookies } from 'next/headers.js';\nimport { sealSession } from '../server/lib/session';\nimport { deriveCsrfToken } from '../server/lib/csrf';\nimport { COOKIE_NAMES, getSessionTtl } from '../server/lib/config';\nimport { env } from '@spfn/core/config';\nimport { logger } from '@spfn/core/logger';\nimport { unsealPendingSession } from './session-helpers';\nimport { isSafeReturnPath } from '../lib/return-path';\n\nexport interface OAuthCallbackOptions\n{\n /**\n * Default redirect URL if returnUrl is not provided\n * @default '/'\n */\n defaultRedirectUrl?: string;\n\n /**\n * Error redirect URL\n * @default '/auth/error'\n */\n errorRedirectUrl?: string;\n}\n\n/**\n * The query's `returnUrl`, or the handler's default when it would leave the app.\n *\n * `new URL('https://evil.example.com', request.url)` resolves to the absolute URL,\n * not to a path under the app, so an unchecked value here redirects the browser\n * off-origin after a successful login. Only the destination is replaced — the\n * login stands and the session cookies are still set.\n */\nfunction safeReturnUrl(requested: string | null, defaultRedirect: string): string\n{\n return requested && isSafeReturnPath(requested) ? requested : defaultRedirect;\n}\n\n/**\n * Create OAuth callback handler for Next.js API Route\n *\n * Handles the final step of OAuth flow:\n * 1. Gets userId, keyId from query params (set by backend)\n * 2. Gets privateKey from pending session cookie\n * 3. Creates full session and saves to cookie\n * 4. Redirects to returnUrl\n *\n * @example\n * ```typescript\n * // /api/auth/callback/route.ts\n * import { createOAuthCallbackHandler } from '@spfn/auth/nextjs/server';\n * export const GET = createOAuthCallbackHandler();\n * ```\n */\nexport function createOAuthCallbackHandler(options?: OAuthCallbackOptions)\n{\n const defaultRedirect = options?.defaultRedirectUrl || '/';\n const errorRedirect = options?.errorRedirectUrl || '/auth/error';\n\n return async (request: NextRequest): Promise<NextResponse> =>\n {\n const searchParams = request.nextUrl.searchParams;\n const userId = searchParams.get('userId');\n const keyId = searchParams.get('keyId');\n const returnUrl = safeReturnUrl(searchParams.get('returnUrl'), defaultRedirect);\n const error = searchParams.get('error');\n\n // Handle error from backend\n if (error)\n {\n const errorUrl = new URL(errorRedirect, request.url);\n errorUrl.searchParams.set('error', error);\n\n return NextResponse.redirect(errorUrl);\n }\n\n // Validate required params\n if (!userId || !keyId)\n {\n logger.error('OAuth callback missing required params', { userId: !!userId, keyId: !!keyId });\n const errorUrl = new URL(errorRedirect, request.url);\n errorUrl.searchParams.set('error', 'Missing required parameters');\n\n return NextResponse.redirect(errorUrl);\n }\n\n try\n {\n // Get pending session from cookie\n const cookieStore = await cookies();\n const pendingCookie = cookieStore.get(COOKIE_NAMES.OAUTH_PENDING);\n\n if (!pendingCookie)\n {\n throw new Error('OAuth session expired. Please try again.');\n }\n\n const pendingSession = await unsealPendingSession(pendingCookie.value);\n\n // Verify keyId matches\n if (pendingSession.keyId !== keyId)\n {\n throw new Error('Session mismatch. Please try again.');\n }\n\n // Create full session\n const ttl = getSessionTtl();\n const sessionToken = await sealSession({\n userId,\n privateKey: pendingSession.privateKey,\n keyId: pendingSession.keyId,\n algorithm: pendingSession.algorithm,\n }, ttl);\n\n // Build redirect response\n const redirectUrl = new URL(returnUrl, request.url);\n const response = NextResponse.redirect(redirectUrl);\n\n // Set session cookie\n response.cookies.set(COOKIE_NAMES.SESSION, sessionToken, {\n httpOnly: true,\n secure: env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n });\n\n // Set keyId cookie\n response.cookies.set(COOKIE_NAMES.SESSION_KEY_ID, keyId, {\n httpOnly: true,\n secure: env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n });\n\n // Readable CSRF cookie — the client mirrors it into x-spfn-csrf\n response.cookies.set(COOKIE_NAMES.CSRF, await deriveCsrfToken(keyId), {\n httpOnly: false,\n secure: env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n });\n\n // Clear pending session cookie\n response.cookies.delete(COOKIE_NAMES.OAUTH_PENDING);\n\n logger.debug('OAuth callback completed', { userId, keyId });\n\n return response;\n }\n catch (error)\n {\n const err = error as Error;\n logger.error('OAuth callback failed', { error: err.message });\n\n const errorUrl = new URL(errorRedirect, request.url);\n errorUrl.searchParams.set('error', err.message);\n\n return NextResponse.redirect(errorUrl);\n }\n };\n}\n","/**\n * @spfn/auth - Return-path validation\n *\n * One rule for every flow that hands a caller-supplied destination back to the\n * browser: the verified-email signup link, the password reset link, and the\n * OAuth start/callback seams. Apps that build their own destination before\n * calling an auth route import the same function rather than writing a second\n * rule that drifts from this one.\n *\n * The module imports nothing on purpose — it is part of the client bundle\n * (`@spfn/auth/nextjs/client`), which must not pull server code in behind it.\n */\n\n/**\n * The characters a URL parser deletes from anywhere in its input before it reads\n * the input as a URL: ASCII tab, LF and CR (WHATWG URL, \"remove all ASCII tab or\n * newline\"). The rule below reads the value as written, so a value holding one of\n * them is not the value the browser parses — `/<tab>/evil.com` is read as the\n * protocol-relative `//evil.com` and lands on another origin. Refusing the three\n * outright also keeps a raw CR or LF out of any `Location` header the value\n * reaches, which is what would split that header in two.\n */\nconst URL_STRIPPED_CHARACTER = /[\\t\\n\\r]/;\n\n/**\n * Whether a return path can be handed back to the browser.\n *\n * Only a path within the app is allowed. The rejected shapes are the ones that\n * turn a return path into an open redirect: an absolute URL, a protocol-relative\n * `//host` that a browser reads as another origin, a backslash that some\n * browsers normalize into a slash, any `..` traversal, and any character a URL\n * parser strips before parsing (see above).\n *\n * The value is judged exactly as written: nothing is percent-decoded here. A\n * `/a%0d%0a` is therefore a path containing those six literal characters and is\n * accepted — no decoder downstream turns it back into header bytes.\n */\nexport function isSafeReturnPath(returnPath: string): boolean\n{\n if (!returnPath.startsWith('/'))\n {\n return false;\n }\n\n if (returnPath.startsWith('//') || returnPath.includes('\\\\'))\n {\n return false;\n }\n\n if (returnPath.includes('..') || URL_STRIPPED_CHARACTER.test(returnPath))\n {\n return false;\n }\n\n // A path cannot carry a protocol prefix; `/\\thttps:` and friends are caught\n // above, this catches `/foo:bar` forms that some parsers read as an authority.\n return !/^\\/[^/?#]*:/.test(returnPath);\n}\n","/**\n * @spfn/auth - OAuth 2.1 consent screen (Next.js route handlers)\n *\n * The authorization server lives on the API origin; this is the one piece of it\n * that cannot, because consent is a decision only the signed-in person can make\n * and the session cookie is on the web app. `GET` draws the screen, `POST` takes\n * the answer, and neither of them decides anything: both forward the request to\n * `/_auth/oauth2/authorize`, which validates it against the registration and\n * hands back either what to draw or the refusal to act on.\n *\n * Three rules shape everything below, and each of them is an attack that would\n * otherwise work:\n *\n * - **The only URLs this file redirects to are `loginPath` and a URI the API\n * returned.** The request's own `redirect_uri` is forwarded and never built\n * into a `Location` — an unregistered one is exactly the open redirect the\n * registration check exists to close, and the API is the only side that can\n * tell the two apart.\n * - **Everything interpolated into the page is escaped.** `client_name` arrives\n * from unauthenticated dynamic registration, and `state` and `resource` come\n * from the query string of a link somebody was sent.\n * - **The POST carries its own CSRF token.** The handler's server-side call to\n * the API mints the CSRF header itself and so would always pass; the check\n * that matters is the browser form's, and it is made before the API is called\n * at all.\n */\n\nimport { cookies } from 'next/headers.js';\nimport { NextResponse, type NextRequest } from 'next/server';\n\nimport { authApi } from '@spfn/auth';\nimport type { AuthRouter } from '@spfn/auth';\nimport type { RouterInput } from '@spfn/core/nextjs';\nimport { logger } from '@spfn/core/logger';\n\nimport { sessionCookieNames } from './cookie-names';\nimport { getSession } from './session-helpers';\nimport { matchesCsrfToken } from '../server/lib/csrf';\nimport { isSafeReturnPath } from '../lib/return-path';\n\n/** The authorize parameters, spelled as the protocol spells them. */\nconst AUTHORIZE_PARAMETERS = [\n 'client_id',\n 'redirect_uri',\n 'code_challenge',\n 'code_challenge_method',\n 'resource',\n 'scope',\n 'state',\n] as const;\n\n/**\n * Refusals with no vetted URI to carry them.\n *\n * An unknown client has no registration to read a redirect URI from, and a\n * mismatched `redirect_uri` is the one the request supplied. Both are shown.\n */\nconst NON_REDIRECTABLE = new Set(['unknown_client', 'redirect_uri_mismatch']);\n\n/** Content types a browser form can actually arrive as. */\nconst FORM_CONTENT_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data'];\n\ntype AuthorizeQuery = RouterInput<AuthRouter, 'getOAuth2Authorize'>['query'];\n\ntype DecisionBody = RouterInput<AuthRouter, 'createOAuth2AuthorizationCode'>['body'];\n\n/** One scope, with the sentence the consent screen reads aloud for it. */\nexport interface OAuth2ConsentScope\n{\n name: string;\n description: string;\n}\n\n/**\n * Everything a consent screen needs, raw and unescaped.\n *\n * A custom `render` receives this and owns the whole body, so it must echo\n * `fields` and `csrfToken` back as hidden inputs: the POST is refused without\n * the token, and the API re-validates the request from the fields rather than\n * trusting what the GET was once shown.\n *\n * Every string here is caller-supplied. Put each one through {@link escapeHtml}.\n */\nexport interface OAuth2ConsentView\n{\n /** Registered name of the client asking. Unauthenticated input. */\n clientName: string;\n\n /** Host the code would be sent to — the one fact about the client that is checkable. */\n redirectHost: string;\n\n scopes: OAuth2ConsentScope[];\n\n /** RFC 8707 target the token would be good against. */\n resource: string;\n\n /** Every authorize parameter the request carried, verbatim, to echo as hidden inputs. */\n fields: Record<string, string>;\n\n /** Value the POST's `csrf` field must carry. */\n csrfToken: string;\n}\n\n/**\n * Options for {@link createOAuth2AuthorizeHandlers}\n */\nexport interface OAuth2AuthorizeHandlerOptions\n{\n /**\n * Where to send a visitor with no session, e.g. `/login`\n *\n * The handler appends `?returnUrl=` pointing at this request, so the login\n * lands back on the consent screen with its parameters intact.\n */\n loginPath: string;\n\n /**\n * Replace the default consent page body\n *\n * Status, headers and the field set stay the handler's; this owns the HTML.\n */\n render?: (view: OAuth2ConsentView) => string;\n}\n\n/** The pair a route file re-exports as `export const { GET, POST } = ...`. */\nexport interface OAuth2AuthorizeHandlers\n{\n GET: (request: NextRequest) => Promise<NextResponse>;\n POST: (request: NextRequest) => Promise<NextResponse>;\n}\n\n/**\n * Escape a string for interpolation into HTML text or a quoted attribute.\n *\n * Exported because a custom `render` needs the same escaping the default body\n * applies: `client_name` comes from unauthenticated dynamic registration, and\n * `state` is whatever was in the link the browser followed.\n *\n * @param value - Raw string\n * @returns The same string with `& < > \" '` replaced by entities\n */\nexport function escapeHtml(value: string): string\n{\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n}\n\n/**\n * The authorize parameters that are present, and only those.\n *\n * Reading from a fixed list rather than copying the request is what keeps an\n * extra field somebody appended to the form out of the call to the API.\n */\nfunction authorizeFields(read: (name: string) => string | null): Record<string, string>\n{\n const fields: Record<string, string> = {};\n\n for (const name of AUTHORIZE_PARAMETERS)\n {\n const value = read(name);\n\n if (value)\n {\n fields[name] = value;\n }\n }\n\n return fields;\n}\n\n/** An HTML answer, with the three headers every consent answer carries. */\nfunction screen(status: number, body: string): NextResponse\n{\n return new NextResponse(body, {\n status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Security-Policy': \"frame-ancestors 'none'\",\n 'Cache-Control': 'no-store',\n },\n });\n}\n\n/**\n * A refusal screen.\n *\n * The message is one of the fixed set written below — nothing from the request\n * or from the API's error body reaches the page, because both are\n * attacker-supplied in exactly the cases that produce this screen.\n */\nfunction refusalScreen(status: number, heading: string, message: string): NextResponse\n{\n return screen(status, [\n '<!DOCTYPE html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Authorization request refused</title></head>',\n `<body><h1>${heading}</h1><p>${message}</p></body></html>`,\n ].join('\\n'));\n}\n\nfunction unknownClientScreen(): NextResponse\n{\n return refusalScreen(\n 400,\n 'Unrecognized application',\n 'The application that sent you here is not registered with this service, so the request '\n + 'cannot be completed. Nothing was shared.',\n );\n}\n\nfunction redirectMismatchScreen(): NextResponse\n{\n return refusalScreen(\n 400,\n 'Address not recognized',\n 'The application asked for the authorization to be returned to an address it never '\n + 'registered. Nothing was shared, and you were not sent there.',\n );\n}\n\nfunction unavailableScreen(): NextResponse\n{\n return refusalScreen(\n 500,\n 'Authorization unavailable',\n 'This authorization request could not be checked. Nothing was shared. Please try again.',\n );\n}\n\nfunction noSessionScreen(): NextResponse\n{\n return refusalScreen(\n 403,\n 'Sign-in required',\n 'This authorization request needs a signed-in session and yours is not available. Start '\n + 'the request again from the application.',\n );\n}\n\n/** A 302 that never caches, which is the only kind this file emits. */\nfunction redirect(url: URL): NextResponse\n{\n return NextResponse.redirect(url, { status: 302, headers: { 'Cache-Control': 'no-store' } });\n}\n\n/** Parse an absolute URL, refusing anything that is not one. */\nfunction safeUrl(value: string): URL | null\n{\n try\n {\n return new URL(value);\n }\n catch\n {\n return null;\n }\n}\n\n/**\n * Send an unauthenticated visitor to the login screen, or refuse to.\n *\n * The return destination is this request's own path and query — never an\n * absolute URL — and it is still held to `isSafeReturnPath`, because the query\n * is caller-supplied and a destination that leaves the app is the open redirect\n * every return path in this package is checked against.\n */\nfunction loginRedirect(request: NextRequest, loginPath: string): NextResponse\n{\n const returnPath = `${request.nextUrl.pathname}${request.nextUrl.search}`;\n\n if (!isSafeReturnPath(returnPath))\n {\n return refusalScreen(\n 400,\n 'Malformed authorization request',\n 'This authorization request cannot be signed in to. Start it again from the application.',\n );\n }\n\n const url = new URL(loginPath, request.url);\n url.searchParams.set('returnUrl', returnPath);\n\n return redirect(url);\n}\n\n/** The SPFN error envelope, as an `ApiError.response` carries it. */\ninterface ErrorEnvelope\n{\n error?: { code?: string; message?: string; details?: Record<string, unknown> };\n details?: Record<string, unknown>;\n}\n\n/**\n * The refusal's `details`, whichever shape the thrown value arrived in.\n *\n * A registered error class comes back deserialized and carries `details`\n * directly; anything else is an `ApiError` whose `response` holds the envelope.\n */\nfunction detailsOf(thrown: unknown): Record<string, unknown>\n{\n const error = thrown as { details?: Record<string, unknown>; response?: ErrorEnvelope } | null;\n\n return error?.details ?? error?.response?.error?.details ?? error?.response?.details ?? {};\n}\n\n/** HTTP status of the refusal — an `ApiError.status`, or an `HttpError.statusCode`. */\nfunction statusOf(thrown: unknown): number\n{\n const error = thrown as { status?: unknown; statusCode?: unknown } | null;\n\n return Number(error?.status ?? error?.statusCode ?? 0);\n}\n\n/**\n * The 302 a redirectable refusal earns, or null when it earns a screen.\n *\n * `redirectUri` is read from the API's answer and from nowhere else: it is the\n * value that matched the registration, which is what makes sending a browser\n * there safe. A refusal carrying no such value has no vetted destination, and is\n * shown rather than redirected.\n */\nfunction refusalRedirect(details: Record<string, unknown>): NextResponse | null\n{\n const { error, redirectUri, state } = details;\n\n if (typeof error !== 'string' || NON_REDIRECTABLE.has(error) || typeof redirectUri !== 'string')\n {\n return null;\n }\n\n const url = safeUrl(redirectUri);\n\n if (!url)\n {\n return null;\n }\n\n url.searchParams.set('error', error);\n\n if (typeof state === 'string')\n {\n url.searchParams.set('state', state);\n }\n\n return redirect(url);\n}\n\n/**\n * Turn an API refusal into the answer it earns.\n *\n * @param thrown - Whatever the typed client threw\n * @param onStaleSession - What a 401 means here: the GET redirects to the login\n * once, the POST has no form left to resume and refuses\n */\nfunction answerRefusal(thrown: unknown, onStaleSession: () => NextResponse): NextResponse\n{\n const status = statusOf(thrown);\n\n if (status === 401)\n {\n return onStaleSession();\n }\n\n const details = detailsOf(thrown);\n\n if (details.error === 'unknown_client')\n {\n return unknownClientScreen();\n }\n\n if (details.error === 'redirect_uri_mismatch')\n {\n return redirectMismatchScreen();\n }\n\n const redirectable = refusalRedirect(details);\n\n if (redirectable)\n {\n return redirectable;\n }\n\n // The status and nothing else: the error body was written for a request\n // somebody else composed, and the screen echoes none of it either.\n logger.error('OAuth2 consent request could not be answered', { status });\n\n return unavailableScreen();\n}\n\n/** One hidden input per authorize parameter, values escaped for an attribute. */\nfunction hiddenFields(fields: Record<string, string>, csrfToken: string): string\n{\n return [...Object.entries(fields), ['csrf', csrfToken]]\n .map(([name, value]) => `<input type=\"hidden\" name=\"${escapeHtml(name)}\" value=\"${escapeHtml(value)}\">`)\n .join('\\n ');\n}\n\n/**\n * The default consent page.\n *\n * Deliberately unstyled: an application that wants its own design system passes\n * `render`, and a page shipping CSS of its own would have to be undone first.\n */\nfunction defaultRender(view: OAuth2ConsentView): string\n{\n const scopes = view.scopes\n .map(scope => `<li><strong>${escapeHtml(scope.name)}</strong> — ${escapeHtml(scope.description)}</li>`)\n .join('\\n ');\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>Authorize ${escapeHtml(view.clientName)}</title></head>\n<body>\n <h1>Authorize ${escapeHtml(view.clientName)}</h1>\n <p><strong>${escapeHtml(view.clientName)}</strong> is asking to act on your behalf at\n <code>${escapeHtml(view.resource)}</code>. The authorization would be returned to\n <code>${escapeHtml(view.redirectHost)}</code>.</p>\n <h2>It is asking for</h2>\n <ul>\n ${scopes}\n </ul>\n <form method=\"post\">\n ${hiddenFields(view.fields, view.csrfToken)}\n <button type=\"submit\" name=\"decision\" value=\"approve\">Approve</button>\n <button type=\"submit\" name=\"decision\" value=\"deny\">Deny</button>\n </form>\n</body>\n</html>`;\n}\n\n/** The readable CSRF cookie's value, which the form has to echo back. */\nasync function csrfCookie(): Promise<string | null>\n{\n const cookieStore = await cookies();\n\n return cookieStore.get(sessionCookieNames().csrf)?.value ?? null;\n}\n\n/**\n * Draw the consent screen for an authorize request.\n *\n * The API decides whether there is anything to draw; this turns its answer into\n * a page. A session whose readable CSRF cookie is gone is treated as no session:\n * the form it would render could never be submitted, and signing in again is\n * what puts the cookie back.\n */\nasync function renderConsent(\n request: NextRequest,\n options: OAuth2AuthorizeHandlerOptions,\n): Promise<NextResponse>\n{\n const csrfToken = await csrfCookie();\n\n if (!csrfToken)\n {\n return loginRedirect(request, options.loginPath);\n }\n\n const fields = authorizeFields(name => request.nextUrl.searchParams.get(name));\n\n // The isomorphic client forwards this request's cookie jar and mirrors the\n // readable CSRF cookie into the header, so the call arrives as this user.\n const described = await authApi.getOAuth2Authorize.call({ query: fields as AuthorizeQuery });\n const render = options.render ?? defaultRender;\n\n return screen(200, render({ ...described, fields, csrfToken }));\n}\n\n/**\n * The form's own CSRF token, checked before the API is called at all.\n *\n * It belongs here rather than after the call: the handler's own call to the API\n * carries a CSRF header it mints itself and would always pass, so a cross-site\n * form POST would otherwise consent on the user's behalf. The comparison is\n * `matchesCsrfToken`, which is constant-time.\n *\n * @returns The refusal, or null when the POST may proceed\n */\nasync function refuseUnverifiedPost(form: FormData): Promise<NextResponse | null>\n{\n const presented = form.get('csrf');\n const expected = await csrfCookie();\n\n if (!expected || !matchesCsrfToken(expected, typeof presented === 'string' ? presented : null))\n {\n return refusalScreen(\n 403,\n 'Request could not be verified',\n 'This form did not carry a valid token for your session. Start the authorization again '\n + 'from the application.',\n );\n }\n\n return null;\n}\n\n/** Whether the body is a form at all, which is the only thing this POST reads. */\nfunction isFormPost(request: NextRequest): boolean\n{\n const contentType = request.headers.get('content-type') ?? '';\n\n return FORM_CONTENT_TYPES.some(type => contentType.startsWith(type));\n}\n\n/**\n * The decision, once the form's own CSRF token has been matched.\n *\n * `approve` is the button that was pressed and nothing else — a body with no\n * `decision` is a denial, the safe reading of a form the user did not finish.\n */\nasync function recordDecision(fields: Record<string, string>, decision: string | null): Promise<NextResponse>\n{\n const body = { ...fields, approve: decision === 'approve' } as DecisionBody;\n const issued = await authApi.createOAuth2AuthorizationCode.call({ body });\n const url = safeUrl(issued.redirectUri);\n\n if (!url)\n {\n return unavailableScreen();\n }\n\n url.searchParams.set('code', issued.code);\n\n if (issued.state !== undefined)\n {\n url.searchParams.set('state', issued.state);\n }\n\n return redirect(url);\n}\n\n/**\n * Create the consent screen's route handlers\n *\n * `GET` renders the screen for an `/oauth/authorize` request and `POST` takes\n * the form it submits. Mount both at the path published as\n * `authorization_endpoint` in the authorization server metadata —\n * `/oauth/authorize` unless `authorizationServer.authorizeUrl` says otherwise.\n *\n * Every answer carries `Cache-Control: no-store`; every page also carries\n * `Content-Security-Policy: frame-ancestors 'none'`, because a consent screen\n * that can be framed is a consent screen that can be clickjacked.\n *\n * @param options - Where to send an unauthenticated visitor, and an optional renderer\n * @returns `{ GET, POST }`, ready to re-export from a route file\n *\n * @example\n * ```typescript\n * // app/oauth/authorize/route.ts\n * import { createOAuth2AuthorizeHandlers } from '@spfn/auth/nextjs/server';\n *\n * export const { GET, POST } = createOAuth2AuthorizeHandlers({ loginPath: '/login' });\n * ```\n */\nexport function createOAuth2AuthorizeHandlers(\n options: OAuth2AuthorizeHandlerOptions,\n): OAuth2AuthorizeHandlers\n{\n async function GET(request: NextRequest): Promise<NextResponse>\n {\n if (!await getSession())\n {\n return loginRedirect(request, options.loginPath);\n }\n\n try\n {\n return await renderConsent(request, options);\n }\n catch (error)\n {\n return answerRefusal(error, () => loginRedirect(request, options.loginPath));\n }\n }\n\n async function POST(request: NextRequest): Promise<NextResponse>\n {\n if (!await getSession())\n {\n return noSessionScreen();\n }\n\n if (!isFormPost(request))\n {\n return refusalScreen(\n 415,\n 'Unsupported request',\n 'The consent form is submitted as a form. Start the authorization again from the '\n + 'application.',\n );\n }\n\n const form = await request.formData();\n const refusal = await refuseUnverifiedPost(form);\n\n if (refusal)\n {\n return refusal;\n }\n\n try\n {\n const fields = authorizeFields(name => form.get(name) as string | null);\n\n return await recordDecision(fields, form.get('decision') as string | null);\n }\n catch (error)\n {\n return answerRefusal(error, noSessionScreen);\n }\n }\n\n return { GET, POST };\n}\n","/**\n * @spfn/auth - The sign-out-everywhere page (Next.js route handlers)\n *\n * The page the mailed revoke-all link opens. `GET` describes the link and draws\n * the button, `POST` presses it, and neither decides anything: both forward the\n * token to `/_auth/keys/revoke-all/{confirm,consume}`, which answers either what\n * to draw or the same 404 it answers for every token that names nothing.\n *\n * Four rules shape everything below, and each of them is an attack or a mishap\n * that would otherwise work:\n *\n * - **The token is never anywhere but a hidden field and an API body.** Not in a\n * `Location`, not in a log line, not in the text of the page. It is a bearer\n * capability, and the request logger records the path of every request.\n * - **There is no session here, so the CSRF token cannot come from one.** The\n * whole point of the link is an owner on a device they do not trust. `GET`\n * mints 32 random bytes, sets them in a cookie scoped to this page's path, and\n * mirrors them into the form; `POST` compares the two. A cross-site form has\n * neither half.\n * - **Opening the page signs nobody out.** `GET` calls `confirm`, which the API\n * guarantees changes nothing, so a mail scanner that prefetches the link has\n * done nothing. `consume` is only ever reached from `POST`.\n * - **Every refusal reads the same.** A 404 from either endpoint means unknown,\n * expired, spent, superseded or retired, and the screen says none of them:\n * telling them apart would tell whoever holds a random value that it named\n * something real.\n */\n\nimport { cookies } from 'next/headers.js';\nimport { NextResponse, type NextRequest } from 'next/server';\n\nimport { authApi } from '@spfn/auth';\nimport { logger } from '@spfn/core/logger';\n\nimport { escapeHtml } from './oauth2-authorize-handlers';\nimport { matchesCsrfToken } from '../server/lib/csrf';\n\n/**\n * Cookie holding the value the form has to echo back.\n *\n * `__Host-`-style in every respect a page path allows: `HttpOnly`, `Secure` off\n * localhost, `SameSite=Strict`, and no `Domain`, so no sibling subdomain can\n * write it. Not the literal `__Host-` prefix, which browsers only honour with\n * `Path=/` — and a path of `/` would send this cookie on every request in the\n * app, which is the opposite of what it is for.\n */\nconst CSRF_COOKIE = 'spfn_revoke_all_csrf';\n\n/** How long the minted CSRF value stays good, in seconds. */\nconst CSRF_TTL_SECONDS = 15 * 60;\n\n/** The only content type a browser form arrives as here. */\nconst FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded';\n\n/**\n * Everything the page needs at each of its three stages, raw and unescaped.\n *\n * A custom `render` receives this and owns the whole body, so at `confirm` it\n * must echo `fields` and `csrfToken` back as hidden inputs: the POST is refused\n * without the token, and the API re-reads the link from the field rather than\n * trusting what the GET was once shown.\n *\n * `fields` and `csrfToken` are empty at the other two stages — there is no form\n * left to submit once the link has been spent or found invalid.\n *\n * Every string here goes through {@link escapeHtml} before it reaches the page,\n * `fields.token` above all: it is whatever was in the query of a link somebody\n * was sent.\n */\nexport interface RevokeAllPageView\n{\n /** `confirm` draws the button, `done` reports the sign-out, `invalid` refuses. */\n stage: 'confirm' | 'done' | 'invalid';\n\n /** ISO instant the link stops working. `confirm` only. */\n expiresAt?: string;\n\n /** Devices the link would sign out. `confirm` only. */\n activeKeyCount?: number;\n\n /** Devices the link did sign out. `done` only. */\n revokedCount?: number;\n\n /** The form's hidden inputs — `token` at `confirm`, empty otherwise. */\n fields: Record<string, string>;\n\n /** Value the POST's `csrf` field must carry. Empty outside `confirm`. */\n csrfToken: string;\n}\n\n/**\n * Options for {@link createRevokeAllPageHandlers}\n */\nexport interface RevokeAllPageHandlerOptions\n{\n /**\n * Replace the default page body\n *\n * Status, headers, the cookie and the field set stay the handler's; this\n * owns the HTML, at all three stages.\n */\n render?: (view: RevokeAllPageView) => string;\n}\n\n/** The pair a route file re-exports as `export const { GET, POST } = ...`. */\nexport interface RevokeAllPageHandlers\n{\n GET: (request: NextRequest) => Promise<NextResponse>;\n POST: (request: NextRequest) => Promise<NextResponse>;\n}\n\n/** An HTML answer, with the three headers every answer from this page carries. */\nfunction screen(status: number, body: string): NextResponse\n{\n return new NextResponse(body, {\n status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Security-Policy': \"frame-ancestors 'none'\",\n 'Cache-Control': 'no-store',\n },\n });\n}\n\n/**\n * A refusal screen.\n *\n * The message is one of the fixed set written below — nothing from the request\n * or from the API's error body reaches the page, because in every case that\n * produces this screen both are attacker-supplied.\n */\nfunction refusalScreen(status: number, heading: string, message: string): NextResponse\n{\n return screen(status, [\n '<!DOCTYPE html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Sign out everywhere</title></head>',\n `<body><h1>${heading}</h1><p>${message}</p></body></html>`,\n ].join('\\n'));\n}\n\nfunction missingTokenScreen(): NextResponse\n{\n return refusalScreen(\n 400,\n 'Incomplete link',\n 'This address is missing the part that identifies the request. Open the link from your '\n + 'email again, in full.',\n );\n}\n\nfunction unavailableScreen(): NextResponse\n{\n return refusalScreen(\n 500,\n 'Sign-out unavailable',\n 'This link could not be checked. Nothing was changed. Please try again.',\n );\n}\n\nfunction unverifiedScreen(): NextResponse\n{\n return refusalScreen(\n 403,\n 'Request could not be verified',\n 'This form did not carry the token the page set for it. Open the link from your email '\n + 'again and press the button on the page it opens.',\n );\n}\n\nfunction unsupportedScreen(): NextResponse\n{\n return refusalScreen(\n 415,\n 'Unsupported request',\n 'This page is answered for a browser form. Open the link from your email again.',\n );\n}\n\n/** One hidden input per field, values escaped for an attribute. */\nfunction hiddenFields(fields: Record<string, string>, csrfToken: string): string\n{\n return [...Object.entries(fields), ['csrf', csrfToken]]\n .map(([name, value]) => `<input type=\"hidden\" name=\"${escapeHtml(name)}\" value=\"${escapeHtml(value)}\">`)\n .join('\\n ');\n}\n\n/** The confirm stage: what the link would do, and the one button that does it. */\nfunction confirmBody(view: RevokeAllPageView): string\n{\n return `<h1>Sign out everywhere</h1>\n <p>This will sign out <strong>${view.activeKeyCount}</strong> signed-in device(s), including\n this one. You will need to sign in again afterwards.</p>\n <p>The link stops working at <time datetime=\"${escapeHtml(view.expiresAt ?? '')}\"\n >${escapeHtml(view.expiresAt ?? '')}</time>.</p>\n <form method=\"post\">\n ${hiddenFields(view.fields, view.csrfToken)}\n <button type=\"submit\">Sign out every device</button>\n </form>`;\n}\n\n/** The done stage, and the invalid stage that says nothing about why. */\nfunction stageBody(view: RevokeAllPageView): string\n{\n if (view.stage === 'confirm')\n {\n return confirmBody(view);\n }\n\n if (view.stage === 'done')\n {\n return `<h1>Signed out</h1>\n <p>${view.revokedCount} device(s) signed out. Sign in again to carry on.</p>`;\n }\n\n return `<h1>Link no longer valid</h1>\n <p>This link cannot be used. Ask for a new one, and open the most recent email you were sent.</p>`;\n}\n\n/**\n * The default page, at whichever stage it was reached.\n *\n * Deliberately unstyled: an application that wants its own design system passes\n * `render`, and a page shipping CSS of its own would have to be undone first.\n * The token is in the form and nowhere in the text — the stages say what\n * happened, never which link it happened to.\n */\nfunction defaultRender(view: RevokeAllPageView): string\n{\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>Sign out everywhere</title></head>\n<body>\n ${stageBody(view)}\n</body>\n</html>`;\n}\n\n/** The view every stage but `confirm` is drawn from: no form, so no form fields. */\nfunction stageView(stage: 'done' | 'invalid', revokedCount?: number): RevokeAllPageView\n{\n return { stage, revokedCount, fields: {}, csrfToken: '' };\n}\n\n/** HTTP status of the refusal — an `ApiError.status`, or an `HttpError.statusCode`. */\nfunction statusOf(thrown: unknown): number\n{\n const error = thrown as { status?: unknown; statusCode?: unknown } | null;\n\n return Number(error?.status ?? error?.statusCode ?? 0);\n}\n\n/**\n * Turn an API refusal into the answer it earns.\n *\n * The 404 is every reason a link can fail and is shown as one screen. Anything\n * else is this deployment's problem rather than the visitor's, and is logged as\n * a status and nothing else: the error body was written about a token, and a\n * token belongs in no log.\n */\nfunction answerRefusal(thrown: unknown, render: (view: RevokeAllPageView) => string): NextResponse\n{\n if (statusOf(thrown) === 404)\n {\n return screen(404, render(stageView('invalid')));\n }\n\n logger.error('Revoke-all link could not be answered', { status: statusOf(thrown) });\n\n return unavailableScreen();\n}\n\n/** 32 random bytes as hex — a value only the browser that was served it holds. */\nfunction mintCsrfToken(): string\n{\n return Array.from(crypto.getRandomValues(new Uint8Array(32)))\n .map(byte => byte.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Scope the minted CSRF cookie to this page and nothing else.\n *\n * The path is the page's own, so the cookie is not sent with any other request\n * the app makes, and `SameSite=Strict` keeps it off requests another site\n * caused. Fifteen minutes is long enough to read the page and shorter than the\n * link itself.\n */\nfunction setCsrfCookie(response: NextResponse, csrfToken: string, path: string): NextResponse\n{\n response.cookies.set(CSRF_COOKIE, csrfToken, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'strict',\n path,\n maxAge: CSRF_TTL_SECONDS,\n });\n\n return response;\n}\n\n/** Expire the cookie once its form has been submitted, so it is good for one POST. */\nfunction clearCsrfCookie(response: NextResponse, path: string): NextResponse\n{\n response.cookies.delete({ name: CSRF_COOKIE, path });\n\n return response;\n}\n\n/**\n * Draw the button, having asked the API what pressing it would do.\n *\n * `confirm` is the only call this handler makes, and it changes nothing — the\n * page is safe to prefetch, which a mail scanner will do.\n */\nasync function renderConfirm(\n request: NextRequest,\n token: string,\n render: (view: RevokeAllPageView) => string,\n): Promise<NextResponse>\n{\n const described = await authApi.confirmRevokeAllLink.call({ body: { token } });\n const csrfToken = mintCsrfToken();\n\n const response = screen(200, render({\n stage: 'confirm',\n expiresAt: described.expiresAt,\n activeKeyCount: described.activeKeyCount,\n fields: { token },\n csrfToken,\n }));\n\n return setCsrfCookie(response, csrfToken, request.nextUrl.pathname);\n}\n\n/**\n * The form's own CSRF token, checked before the API is called at all.\n *\n * It belongs here rather than after the call: the token in the form is the whole\n * credential, so a cross-site POST that could reach `consume` would sign an\n * account out on the strength of a link the attacker already read somewhere.\n * They cannot read this cookie, and `matchesCsrfToken` compares in constant time.\n *\n * @returns The refusal, or null when the POST may proceed\n */\nasync function refuseUnverifiedPost(form: FormData): Promise<NextResponse | null>\n{\n const presented = form.get('csrf');\n const expected = (await cookies()).get(CSRF_COOKIE)?.value;\n\n if (!expected || !matchesCsrfToken(expected, typeof presented === 'string' ? presented : null))\n {\n return unverifiedScreen();\n }\n\n return null;\n}\n\n/** Whether the body is the form this POST reads, which is the only thing it reads. */\nfunction isFormPost(request: NextRequest): boolean\n{\n return (request.headers.get('content-type') ?? '').startsWith(FORM_CONTENT_TYPE);\n}\n\n/**\n * Press the button.\n *\n * The token comes from the form's hidden field and the count from the answer;\n * every other field the body carried is ignored, because the API is told the one\n * thing it asks for.\n */\nasync function consume(token: string, render: (view: RevokeAllPageView) => string): Promise<NextResponse>\n{\n const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });\n\n return screen(200, render(stageView('done', revokedCount)));\n}\n\n/**\n * Create the sign-out-everywhere page's route handlers\n *\n * `GET` draws the page the mailed link opens and `POST` takes the form it\n * submits. Mount both at `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` —\n * `/account/revoke-all` unless that variable says otherwise — which is the path\n * `createRevokeAllLink` builds its URL on.\n *\n * There is no session on this page and none is wanted: an owner who no longer\n * trusts the device in front of them is exactly who the link is for. What stands\n * in for the session is the token in the query, and what stands in for a\n * session-derived CSRF token is a random value `GET` sets in a path-scoped\n * cookie and mirrors into the form.\n *\n * Every answer carries `Cache-Control: no-store` and\n * `Content-Security-Policy: frame-ancestors 'none'`: a page whose one button\n * signs out every device is a page worth clickjacking, and a copy of it in a\n * shared cache is a copy of the token.\n *\n * @param options - An optional renderer; the defaults need nothing else\n * @returns `{ GET, POST }`, ready to re-export from a route file\n *\n * @example\n * ```typescript\n * // app/account/revoke-all/route.ts\n * import { createRevokeAllPageHandlers } from '@spfn/auth/nextjs/server';\n *\n * export const { GET, POST } = createRevokeAllPageHandlers();\n * ```\n */\nexport function createRevokeAllPageHandlers(\n options: RevokeAllPageHandlerOptions = {},\n): RevokeAllPageHandlers\n{\n const render = options.render ?? defaultRender;\n\n async function GET(request: NextRequest): Promise<NextResponse>\n {\n const token = request.nextUrl.searchParams.get('token');\n\n if (!token)\n {\n return missingTokenScreen();\n }\n\n try\n {\n return await renderConfirm(request, token, render);\n }\n catch (error)\n {\n return answerRefusal(error, render);\n }\n }\n\n async function POST(request: NextRequest): Promise<NextResponse>\n {\n if (!isFormPost(request))\n {\n return unsupportedScreen();\n }\n\n const form = await request.formData();\n const refusal = await refuseUnverifiedPost(form);\n\n if (refusal)\n {\n return refusal;\n }\n\n const path = request.nextUrl.pathname;\n const token = form.get('token');\n\n if (typeof token !== 'string' || !token)\n {\n return clearCsrfCookie(missingTokenScreen(), path);\n }\n\n try\n {\n return clearCsrfCookie(await consume(token, render), path);\n }\n catch (error)\n {\n return clearCsrfCookie(answerRefusal(error, render), path);\n }\n }\n\n return { GET, POST };\n}\n"],"mappings":"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","names":["jose","env","env","env","env","env","redirect","Fragment","jsx","redirect","redirect","Fragment","jsx","redirect","cookies","env","logger","logger","cookies","env","error","cookies","NextResponse","authApi","logger","NextResponse","redirect","logger","cookies","authApi","cookies","NextResponse","authApi","logger","screen","NextResponse","refusalScreen","unavailableScreen","hiddenFields","defaultRender","statusOf","answerRefusal","logger","authApi","refuseUnverifiedPost","cookies","isFormPost"]}
1
+ {"version":3,"sources":["../../src/nextjs/server.ts","../../src/nextjs/guards/require-auth.tsx","../../src/nextjs/session-helpers.ts","../../src/server/lib/session.ts","../../src/server/logger.ts","../../src/server/lib/csrf.ts","../../src/server/lib/config.ts","../../src/nextjs/guards/auth-utils.ts","../../src/nextjs/guards/require-role.tsx","../../src/nextjs/guards/require-permission.tsx","../../src/nextjs/cookie-names.ts","../../src/nextjs/oauth-handlers.ts","../../src/lib/return-path.ts","../../src/nextjs/interceptors/session-binding.ts","../../src/server/lib/ua-family.ts","../../src/nextjs/interceptors/cookie-options.ts","../../src/nextjs/oauth2-authorize-handlers.ts","../../src/nextjs/revoke-all-page-handlers.ts"],"sourcesContent":["import 'server-only';\n\nexport { RequireAuth } from './guards/require-auth';\nexport type { RequireAuthProps } from './guards/require-auth';\n\nexport { RequireRole } from './guards/require-role';\nexport type { RequireRoleProps } from './guards/require-role';\n\nexport { RequirePermission } from './guards/require-permission';\nexport type { RequirePermissionProps } from './guards/require-permission';\n\nexport { getAuthSessionData, getUserRole, getUserPermissions, hasAnyRole, hasAnyPermission } from './guards/auth-utils';\n\n// Session helpers\nexport {\n saveSession,\n getSession,\n clearSession,\n // Pending session (OAuth)\n sealPendingSession,\n unsealPendingSession,\n getPendingSession,\n clearPendingSession,\n type SessionData,\n type PublicSession,\n type SaveSessionOptions,\n type PendingSessionData,\n} from './session-helpers';\n\n// Cookie names — an app that empties the session jar must never spell them\nexport {\n sessionCookieNames,\n clearSessionCookies,\n type SessionCookieNames,\n} from './cookie-names';\n\n// OAuth handlers\nexport {\n createOAuthCallbackHandler,\n type OAuthCallbackOptions,\n} from './oauth-handlers';\n\n// The OAuth 2.1 consent screen — the one half of the authorization server that\n// has to live on the web app, because that is where the session cookie is\nexport {\n createOAuth2AuthorizeHandlers,\n escapeHtml,\n type OAuth2AuthorizeHandlerOptions,\n type OAuth2AuthorizeHandlers,\n type OAuth2ConsentScope,\n type OAuth2ConsentView,\n} from './oauth2-authorize-handlers';\n\n// The sign-out-everywhere page — the mailed link opens a page in the app, and\n// the page has no session to lean on, which is the whole point of the link\nexport {\n createRevokeAllPageHandlers,\n type RevokeAllPageHandlerOptions,\n type RevokeAllPageHandlers,\n type RevokeAllPageView,\n} from './revoke-all-page-handlers';\n\n// The rule every return destination is held to — validate before calling\n// getGoogleOAuthUrl rather than writing a second rule per screen.\nexport { isSafeReturnPath } from '../lib/return-path';\n","/**\n * RequireAuth Guard Component\n *\n * Requires user to be authenticated\n */\n\nimport { redirect } from 'next/navigation';\nimport { getSession } from '../session-helpers';\nimport { getAuthSessionData, RENEWAL_REQUIRED } from './auth-utils';\nimport { getSessionRenewPath } from '../../server/lib/config';\nimport type { ReactNode } from 'react';\n\nexport interface RequireAuthProps\n{\n /**\n * Children to render if authenticated\n */\n children: ReactNode;\n\n /**\n * Path to redirect to if not authenticated\n * @default '/login'\n */\n redirectTo?: string;\n\n /**\n * Fallback UI to show instead of redirecting\n */\n fallback?: ReactNode;\n\n /**\n * Path to send a bound session whose key has run out (#97)\n *\n * Not the sign-in page: the person is still signed in and one WebAuthn\n * ceremony puts a live key back in the cookie. The page this names is the\n * app's own, and all it has to do is render a client component that calls\n * `renewSession(api)` and returns them to where they were.\n *\n * @default env SPFN_AUTH_SESSION_RENEW_PATH, or '/auth/renew'\n */\n renewalPath?: string;\n}\n\n/**\n * Require Authentication Guard\n *\n * Ensures user is logged in before rendering children\n *\n * @example\n * ```tsx\n * <RequireAuth redirectTo=\"/login\">\n * <DashboardContent />\n * </RequireAuth>\n * ```\n *\n * @example With fallback\n * ```tsx\n * <RequireAuth fallback={<LoginPrompt />}>\n * <PrivateContent />\n * </RequireAuth>\n * ```\n *\n * @example A bound session whose key ran out\n * ```tsx\n * // Sent to /account/renew instead of the sign-in page. That page renders a\n * // client component calling renewSession(api).\n * <RequireAuth renewalPath=\"/account/renew\">\n * <DashboardContent />\n * </RequireAuth>\n * ```\n */\nexport async function RequireAuth({\n children,\n redirectTo = '/auth/login',\n renewalPath,\n fallback,\n}: RequireAuthProps)\n{\n const session = await getSession();\n\n if (!session)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect(redirectTo);\n }\n\n // Validate server-side session (key expiry, user status, etc.)\n const serverSession = await getAuthSessionData();\n\n // A bound session waiting on a passkey ceremony is not a signed-out one. The\n // cookies are intact and the sign-in page would ask for a password the person\n // does not need to give; the renewal page runs the ceremony instead.\n if (serverSession === RENEWAL_REQUIRED)\n {\n redirect(renewalPath ?? getSessionRenewPath());\n }\n\n if (!serverSession)\n {\n // Note: clearSession() cannot be called in Server Components (Next.js 16+)\n // The RPC proxy interceptor handles session cleanup on 401 responses\n redirect(redirectTo);\n }\n\n return <>{children}</>;\n}\n","/**\n * Session helpers for Next.js\n *\n * Server-side only (uses next/headers)\n */\n\nimport * as jose from 'jose';\nimport { cookies } from 'next/headers.js';\nimport { sealSession, unsealSession, type SessionData } from '../server/lib/session';\nimport { deriveCsrfToken } from '../server/lib/csrf';\nimport { COOKIE_NAMES, getSessionTtl, parseDuration } from '../server/lib/config';\nimport { type KeyAlgorithmType } from '../server/types';\nimport { env } from '@spfn/auth/config';\nimport { logger } from '@spfn/core/logger';\n\nexport type { SessionData };\n\n/**\n * Pending OAuth session data (before user ID is known)\n */\nexport interface PendingSessionData\n{\n privateKey: string;\n keyId: string;\n algorithm: KeyAlgorithmType;\n}\n\n/**\n * Pending second-factor session, held between a 202 sign-in and its verify (#95).\n *\n * The same three fields plus `challengeHash`, and sealed under its own audience\n * so it can never be unsealed as an OAuth pending cookie or the other way round.\n * The extra field is the binding: the proxy seals a session only when the\n * verified response names this challenge **and** this key, so a cookie minted\n * for one flow cannot seal a session around another flow's key.\n *\n * The hash and not the secret. The proxy has no use for a spendable challenge —\n * it is comparing, not verifying — and a cookie that carried one would be a\n * second copy of a credential for no gain.\n */\nexport interface PendingMfaSessionData extends PendingSessionData\n{\n challengeHash: string;\n}\n\n/**\n * Public session information (excludes sensitive data)\n */\nexport interface PublicSession\n{\n /** User ID */\n userId: string;\n}\n\n/**\n * Options for saveSession\n */\nexport interface SaveSessionOptions\n{\n /**\n * Session TTL (time to live)\n *\n * Supports:\n * - Number: seconds (e.g., 2592000)\n * - String: duration format ('30d', '12h', '45m', '3600s')\n *\n * If not provided, uses global configuration:\n * 1. Global config (configureAuth)\n * 2. Environment variable (SPFN_AUTH_SESSION_TTL)\n * 3. Default (7d)\n */\n maxAge?: number | string;\n\n /**\n * Remember me option\n *\n * When true, uses extended session duration (if configured)\n */\n remember?: boolean;\n}\n\n/**\n * Save session to HttpOnly cookie\n *\n * @param data - Session data to save\n * @param options - Session options (maxAge, remember)\n *\n * @example\n * ```typescript\n * // Use global configuration\n * await saveSession(sessionData);\n *\n * // Custom TTL with duration string\n * await saveSession(sessionData, { maxAge: '30d' });\n *\n * // Custom TTL in seconds\n * await saveSession(sessionData, { maxAge: 2592000 });\n *\n * // Remember me\n * await saveSession(sessionData, { remember: true });\n * ```\n */\nexport async function saveSession(\n data: SessionData,\n options?: SaveSessionOptions,\n): Promise<void>\n{\n // Calculate maxAge\n let maxAge: number;\n\n if (options?.maxAge !== undefined)\n {\n // Custom maxAge provided\n maxAge = typeof options.maxAge === 'number'\n ? options.maxAge\n : parseDuration(options.maxAge);\n }\n else\n {\n // Use getSessionTtl for consistent configuration\n maxAge = getSessionTtl();\n }\n\n const token = await sealSession(data, maxAge);\n const cookieStore = await cookies();\n\n cookieStore.set(COOKIE_NAMES.SESSION, token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge,\n });\n\n // Readable companion: the client mirrors it into x-spfn-csrf, and the proxy\n // refuses cookie-session mutations that arrive without it. A session saved\n // here without one would be a session that cannot mutate anything.\n cookieStore.set(COOKIE_NAMES.CSRF, await deriveCsrfToken(data.keyId), {\n httpOnly: false,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge,\n });\n}\n\n/**\n * Get session from HttpOnly cookie\n *\n * Returns public session info only (excludes privateKey, algorithm, keyId)\n */\nexport async function getSession(): Promise<PublicSession | null>\n{\n const cookieStore = await cookies();\n const sessionCookie = cookieStore.get(COOKIE_NAMES.SESSION);\n\n if (!sessionCookie)\n {\n return null;\n }\n\n try\n {\n // Never log the cookie value — it's the sealed session token.\n logger.debug('Validating session cookie', { present: true });\n const session = await unsealSession(sessionCookie.value);\n\n // Return only public information\n return {\n userId: session.userId,\n };\n }\n catch (error)\n {\n // Session expired or invalid\n // Note: Cannot delete cookies in Server Components (read-only)\n // Use validateSessionMiddleware() in Next.js middleware for automatic cleanup\n logger.debug('Session validation failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n\n return null;\n }\n}\n\n/**\n * Clear session cookie\n */\nexport async function clearSession(): Promise<void>\n{\n const cookieStore = await cookies();\n cookieStore.delete(COOKIE_NAMES.SESSION);\n cookieStore.delete(COOKIE_NAMES.SESSION_KEY_ID);\n cookieStore.delete(COOKIE_NAMES.CSRF);\n}\n\n// ============================================================================\n// Pending OAuth Session (for OAuth flow)\n// ============================================================================\n\n/**\n * Get encryption key for a pending session, derived per purpose.\n *\n * The purpose is in the derivation as well as in the audience, so the OAuth and\n * second-factor cookies cannot be unsealed as each other even if a caller named\n * the wrong audience: two flows may be live in one browser at once, and the\n * whole point of separating them is that neither can seal a session around the\n * other's key.\n */\nasync function getPendingSessionKey(purpose: 'oauth' | 'mfa'): Promise<Uint8Array>\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n const encoder = new TextEncoder();\n const data = encoder.encode(purpose === 'oauth' ? `oauth-pending:${secret}` : `mfa-pending:${secret}`);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n return new Uint8Array(hashBuffer);\n}\n\n/**\n * Seal pending session data (for OAuth flow)\n *\n * @param data - Pending session data (privateKey, keyId, algorithm)\n * @param ttl - Time to live in seconds (default: 10 minutes)\n */\nexport async function sealPendingSession(\n data: PendingSessionData,\n ttl: number = 600,\n): Promise<string>\n{\n return await sealFor('oauth', data, ttl);\n}\n\n/**\n * Seal the pending second-factor session (#95)\n *\n * Takes its data explicitly rather than reading a cookie: the only caller is an\n * interceptor rule, which does not run inside `next/headers` and reads the jar\n * through `ctx.cookies` instead.\n *\n * @param data - privateKey, keyId, algorithm and the challenge hash they are for\n * @param ttl - Seconds. Ten minutes, matching the challenge's own life\n */\nexport async function sealPendingMfaSession(\n data: PendingMfaSessionData,\n ttl: number = 600,\n): Promise<string>\n{\n return await sealFor('mfa', data, ttl);\n}\n\n/** The one sealer both pending cookies use, parameterized by purpose. */\nasync function sealFor(purpose: 'oauth' | 'mfa', data: PendingSessionData, ttl: number): Promise<string>\n{\n return await new jose.EncryptJWT({ data })\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .setExpirationTime(`${ttl}s`)\n .setIssuer('spfn-auth')\n .setAudience(purpose === 'oauth' ? 'spfn-oauth' : 'spfn-mfa')\n .encrypt(await getPendingSessionKey(purpose));\n}\n\n/**\n * Unseal pending session data\n *\n * @param jwt - Encrypted pending session token\n */\nexport async function unsealPendingSession(jwt: string): Promise<PendingSessionData>\n{\n const { payload } = await jose.jwtDecrypt(jwt, await getPendingSessionKey('oauth'), {\n issuer: 'spfn-auth',\n audience: 'spfn-oauth',\n });\n\n return payload.data as PendingSessionData;\n}\n\n/**\n * Unseal the pending second-factor session (#95)\n *\n * Throws on an OAuth pending cookie presented here, and on anything past its ten\n * minutes — both are the separation this cookie exists for.\n *\n * @param jwt - Encrypted pending token from `COOKIE_NAMES.MFA_PENDING`\n */\nexport async function unsealPendingMfaSession(jwt: string): Promise<PendingMfaSessionData>\n{\n const { payload } = await jose.jwtDecrypt(jwt, await getPendingSessionKey('mfa'), {\n issuer: 'spfn-auth',\n audience: 'spfn-mfa',\n });\n\n return payload.data as PendingMfaSessionData;\n}\n\n/**\n * Get pending session from cookie\n */\nexport async function getPendingSession(): Promise<PendingSessionData | null>\n{\n const cookieStore = await cookies();\n const pendingCookie = cookieStore.get(COOKIE_NAMES.OAUTH_PENDING);\n\n if (!pendingCookie)\n {\n return null;\n }\n\n try\n {\n return await unsealPendingSession(pendingCookie.value);\n }\n catch (error)\n {\n logger.debug('Pending session validation failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n\n return null;\n }\n}\n\n/**\n * Clear pending session cookie\n */\nexport async function clearPendingSession(): Promise<void>\n{\n const cookieStore = await cookies();\n cookieStore.delete(COOKIE_NAMES.OAUTH_PENDING);\n}\n","/**\n * @spfn/auth - Client Session Management\n *\n * Uses Jose JWE (JSON Web Encryption) to securely store session data in cookies\n * More efficient than Iron Session with better Edge Runtime support\n */\n\nimport * as jose from 'jose';\nimport { env } from '@spfn/auth/config';\nimport { env as coreEnv } from '@spfn/core/config';\nimport { authLogger } from '../logger';\n\nimport { type KeyAlgorithmType, type SessionBindingType } from '../types';\nimport type { UaFamily } from './ua-family';\n\n/**\n * What the sealed cookie carries.\n *\n * The first four fields are the session itself and have always been here. The\n * last three are what #97 added, and all three are optional together: a cookie\n * without them is an unbound session, which is every session an account that did\n * not opt in gets and every session an app seals by hand with `saveSession()`.\n * The proxy reads their absence as \"behave exactly as before\".\n */\nexport interface SessionData\n{\n userId: string;\n privateKey: string; // Base64 encoded DER\n keyId: string;\n algorithm: KeyAlgorithmType;\n\n /**\n * `'passkey'` when the key sealed here is bound.\n *\n * The backend is the only party that knows an account opted in — the proxy\n * generated the key but never saw the setting — so this is copied out of the\n * `LoginResult` the sign-in answered with. Absent means unbound.\n */\n binding?: SessionBindingType;\n\n /** Epoch milliseconds the bound key expires at. Only set alongside `binding`. */\n keyExpiresAt?: number;\n\n /**\n * Browser family the session was sealed from, per `uaFamily`.\n *\n * Recorded here rather than read off the key row because the comparison\n * happens in the proxy: it is the only hop that sees the browser's own\n * `user-agent`, and a server component's call to the RPC proxy carries none.\n */\n uaFamily?: UaFamily;\n}\n\n/**\n * Get session secret key derived from environment\n * Must be at least 32 characters (256-bit)\n *\n * Derives a 32-byte key using SHA-256 to ensure compatibility with Jose A256GCM\n */\nasync function getSessionSecretKey(): Promise<Uint8Array>\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n\n // Derive a 32-byte key using SHA-256 for A256GCM compatibility\n // Use Web Crypto API for universal compatibility (browser + Node.js)\n const encoder = new TextEncoder();\n const data = encoder.encode(secret);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n return new Uint8Array(hashBuffer);\n}\n\n/**\n * Get a short fingerprint of the current secret key for debugging\n * Logs only the first 8 hex chars of the SHA-256 hash — safe to expose\n */\nasync function getSecretFingerprint(): Promise<string>\n{\n const key = await getSessionSecretKey();\n const hash = await crypto.subtle.digest('SHA-256', key.buffer as ArrayBuffer);\n const hex = Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n\n return hex.slice(0, 8);\n}\n\n/**\n * Seal session data into encrypted JWT (JWE)\n *\n * @param data - Session data to encrypt\n * @param ttl - Time to live in seconds (default: 7 days)\n * @returns Encrypted JWT string\n */\nexport async function sealSession(\n data: SessionData,\n ttl: number = 60 * 60 * 24 * 7, // 7 days\n): Promise<string>\n{\n const secret = await getSessionSecretKey();\n\n const result = await new jose.EncryptJWT({ data })\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .setExpirationTime(`${ttl}s`)\n .setIssuer('spfn-auth')\n .setAudience('spfn-client')\n .encrypt(secret);\n\n if (coreEnv.NODE_ENV !== 'production')\n {\n const fingerprint = await getSecretFingerprint();\n authLogger.session.debug(`Sealed session`, {\n secretFingerprint: fingerprint,\n resultLength: result.length,\n resultPrefix: result.slice(0, 20),\n });\n }\n\n return result;\n}\n\n/**\n * Unseal encrypted JWT (JWE) to session data\n *\n * @param jwt - Encrypted JWT string\n * @returns Session data\n * @throws Error if session is invalid or expired\n */\nexport async function unsealSession(jwt: string): Promise<SessionData>\n{\n try\n {\n const secret = await getSessionSecretKey();\n\n const { payload } = await jose.jwtDecrypt(jwt, secret, {\n issuer: 'spfn-auth',\n audience: 'spfn-client',\n });\n\n return payload.data as SessionData;\n }\n catch (err)\n {\n if (err instanceof jose.errors.JWTExpired)\n {\n throw new Error('Session expired');\n }\n\n if (err instanceof jose.errors.JWEDecryptionFailed)\n {\n // Log secret fingerprint for debugging cross-process key mismatch\n if (coreEnv.NODE_ENV !== 'production')\n {\n const fingerprint = await getSecretFingerprint();\n authLogger.session.warn(`JWE decryption failed`, {\n secretFingerprint: fingerprint,\n jwtLength: jwt.length,\n jwtPrefix: jwt.slice(0, 20),\n jwtSuffix: jwt.slice(-10),\n });\n }\n\n throw new Error('Invalid session');\n }\n\n if (err instanceof jose.errors.JWTClaimValidationFailed)\n {\n throw new Error('Session validation failed');\n }\n\n throw new Error('Failed to unseal session');\n }\n}\n\n/**\n * Get session metadata without decrypting\n *\n * @param jwt - Encrypted JWT string\n * @returns Session metadata or null if invalid\n */\nexport async function getSessionInfo(jwt: string): Promise<{\n issuedAt: Date;\n expiresAt: Date;\n issuer: string;\n audience: string;\n} | null>\n{\n const secret = await getSessionSecretKey();\n\n try\n {\n const { payload } = await jose.jwtDecrypt(jwt, secret);\n\n return {\n issuedAt: new Date(payload.iat! * 1000),\n expiresAt: new Date(payload.exp! * 1000),\n issuer: payload.iss || '',\n audience: Array.isArray(payload.aud) ? payload.aud[0] : payload.aud || '',\n };\n }\n catch (err)\n {\n // Log error for debugging but return null for graceful handling\n if (coreEnv.NODE_ENV !== 'production')\n {\n authLogger.session.warn('Failed to get session info:', err instanceof Error ? err.message : 'Unknown error');\n }\n\n return null;\n }\n}\n\n/**\n * Check if session is about to expire (within threshold)\n *\n * @param jwt - Encrypted JWT string\n * @param thresholdHours - Hours before expiry to trigger refresh (default: 24)\n * @returns True if session should be refreshed\n */\nexport async function shouldRefreshSession(\n jwt: string,\n thresholdHours: number = 24,\n): Promise<boolean>\n{\n const info = await getSessionInfo(jwt);\n\n if (!info)\n {\n return true;\n }\n\n const hoursRemaining = (info.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60);\n\n return hoursRemaining < thresholdHours;\n}\n","/**\n * @spfn/auth - Centralized Logger\n *\n * All auth package loggers with consistent naming\n */\n\nimport { logger as rootLogger } from '@spfn/core/logger';\n\nexport const authLogger = {\n plugin: rootLogger.child('@spfn/auth:plugin'),\n middleware: rootLogger.child('@spfn/auth:middleware'),\n interceptor: {\n general: rootLogger.child('@spfn/auth:interceptor:general'),\n login: rootLogger.child('@spfn/auth:interceptor:login'),\n keyRotation: rootLogger.child('@spfn/auth:interceptor:key-rotation'),\n oauth: rootLogger.child('@spfn/auth:interceptor:oauth'),\n csrf: rootLogger.child('@spfn/auth:interceptor:csrf'),\n },\n session: rootLogger.child('@spfn/auth:session'),\n service: rootLogger.child('@spfn/auth:service'),\n setup: rootLogger.child('@spfn/auth:setup'),\n email: rootLogger.child('@spfn/auth:email'),\n sms: rootLogger.child('@spfn/auth:sms'),\n};\n","/**\n * @spfn/auth - CSRF token derivation\n *\n * The token is an HMAC of the session's key id under a subkey derived from the\n * session secret. Two properties follow from that shape:\n *\n * - The proxy recomputes it from the session it just unsealed, so a value an\n * attacker planted in the readable cookie (sibling-subdomain cookie tossing)\n * never verifies. Nothing here compares a cookie against a header.\n * - It is bound to the key id, so rotating the session key invalidates it.\n *\n * No new secret: the subkey is a labelled HMAC of SPFN_AUTH_SESSION_SECRET, so\n * the key that encrypts sessions is never used verbatim as the token key.\n */\n\nimport { env } from '@spfn/auth/config';\n\n/** Header the readable CSRF cookie is mirrored into by the client. */\nexport const CSRF_HEADER = 'x-spfn-csrf';\n\n/** Domain-separation label for the CSRF subkey. */\nconst CSRF_SUBKEY_LABEL = 'spfn-auth-csrf-token-v1';\n\n/**\n * Upper bound on candidate values accepted in one header.\n *\n * Deliberately generous, and must stay in step with MAX_CANDIDATES in\n * @spfn/core's client: verification recomputes the expected value once and then\n * only compares fixed-length strings, so an extra candidate costs a few hundred\n * nanoseconds, while a candidate dropped below this line is a user locked out of\n * every mutation. A low cap is not a security control either — accepting a match\n * among many is no weaker than accepting one, because every candidate still has\n * to equal a value recomputed from the session.\n */\nconst MAX_CANDIDATES = 32;\n\n/**\n * Resolve the session secret, refusing to derive anything without one.\n *\n * `env` throws on a missing required variable, but returns undefined when\n * SKIP_ENV_VALIDATION is set — and hashing `undefined` would yield a token every\n * deployment could compute. Fail closed instead.\n */\nfunction sessionSecret(): string\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n\n if (!secret)\n {\n throw new Error(\n 'SPFN_AUTH_SESSION_SECRET is required for CSRF protection. '\n + 'Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off.',\n );\n }\n\n return secret;\n}\n\n/**\n * HMAC-SHA256 over Web Crypto, so this works in the Edge runtime too.\n */\nasync function hmacSha256(key: Uint8Array, message: string): Promise<Uint8Array>\n{\n const cryptoKey = await crypto.subtle.importKey(\n 'raw',\n key.buffer as ArrayBuffer,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(message));\n\n return new Uint8Array(signature);\n}\n\nfunction toHex(bytes: Uint8Array): string\n{\n return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Derive the CSRF token for a session key id.\n *\n * @param keyId - Session key id (`SessionData.keyId`)\n * @returns 64-char hex token — safe to put in a readable cookie, it reveals\n * neither the secret nor the key id\n */\nexport async function deriveCsrfToken(keyId: string): Promise<string>\n{\n const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);\n\n return toHex(await hmacSha256(subkey, keyId));\n}\n\n/**\n * Constant-time string comparison.\n *\n * Named for the string it takes, so it does not collide with node's Buffer-based\n * `timingSafeEqual` — which this package also uses, in the OAuth providers. It is\n * not re-exported from the package barrel for the same reason.\n *\n * Length is compared first and leaks only the length, which is fixed and public.\n */\nexport function timingSafeEqualString(a: string, b: string): boolean\n{\n if (a.length !== b.length)\n {\n return false;\n }\n\n let difference = 0;\n\n for (let i = 0; i < a.length; i++)\n {\n difference |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n\n return difference === 0;\n}\n\n/**\n * Whether a presented header value carries the expected token.\n *\n * The header may carry several comma-separated candidates — a browser sees every\n * `spfn_csrf*` cookie set on the host and cannot tell which dev instance owns\n * which, nor which of two same-named cookies a sibling subdomain tossed in.\n *\n * Every candidate the client is allowed to send is checked — the cap here is the\n * one it selects against — so a genuine value is never evicted by tossed ones\n * that happen to sort ahead of it. A header longer than that can only come from a\n * client that ignored the shared bound, and its surplus is dropped. Accepting any\n * match is no weaker than accepting one: a candidate the attacker chose still has\n * to equal a value recomputed from the session.\n */\nexport function matchesCsrfToken(expected: string, presented: string | null | undefined): boolean\n{\n if (!presented)\n {\n return false;\n }\n\n return presented\n .split(',', MAX_CANDIDATES)\n .some((candidate) => timingSafeEqualString(expected, candidate.trim()));\n}\n","/**\n * @spfn/auth - Global Configuration\n *\n * Manages global auth configuration including session TTL\n */\n\nimport { env } from '@spfn/auth/config';\nimport { PasskeyConfigError } from '@spfn/auth/errors';\n\nimport type { SocialProvider } from '../types';\nimport { BOUND_KEY_RENEW_GRACE_HOURS, BOUND_KEY_TTL_HOURS, CONCURRENT_USE_WINDOW_MS } from './key-policy';\nimport { normalizeOptionalEmail } from '../helpers/email';\nimport { authLogger } from '../logger';\n\n/**\n * Cookie name suffix derived from the server port, so several local dev\n * instances on the same domain do not overwrite each other's sessions.\n *\n * BREAKING: this read `PORT`, which no longer exists — the framework's port is\n * `SPFN_PORT`, because `PORT` is Next.js's own variable and two processes are\n * started. An app that had `PORT` set gets different cookie names than before\n * and its existing sessions stop resolving; one sign-in fixes it.\n */\nfunction getCookieSuffix(): string\n{\n const port = process.env.SPFN_PORT;\n\n return port ? `_${port}` : '';\n}\n\n/**\n * Cookie names used by SPFN Auth\n *\n * Names include a port-based suffix so that multiple dev instances\n * on different ports don't overwrite each other's cookies.\n */\nexport const COOKIE_NAMES = {\n /** Encrypted session data (userId, privateKey, keyId, algorithm) */\n get SESSION() \n {\n return `spfn_session${getCookieSuffix()}`; \n },\n /** Current key ID (for key rotation) */\n get SESSION_KEY_ID() \n {\n return `spfn_session_key_id${getCookieSuffix()}`; \n },\n /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */\n get OAUTH_PENDING()\n {\n return `spfn_oauth_pending${getCookieSuffix()}`;\n },\n /**\n * Pending second-factor session (privateKey, keyId, challengeHash) (#95)\n *\n * Its own name and its own audience, separate from OAUTH_PENDING. The two\n * coexist: a person who starts a social login in one tab while a password\n * step-up is outstanding in another has both flows live, and one name would\n * mean the second overwrote the first — sealing a session with a private key\n * that does not match the key being activated.\n */\n get MFA_PENDING()\n {\n return `spfn_mfa_pending${getCookieSuffix()}`;\n },\n /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */\n get OAUTH_CSRF()\n {\n return `spfn_oauth_csrf${getCookieSuffix()}`;\n },\n /** Password-setup session for verified-email signup — temporary, single-purpose */\n get SIGNUP_SETUP()\n {\n return `spfn_signup_setup${getCookieSuffix()}`;\n },\n /** Password-setup session for a password reset — temporary, single-purpose */\n get PASSWORD_RESET_SETUP()\n {\n return `spfn_password_reset_setup${getCookieSuffix()}`;\n },\n /** CSRF token — the only cookie here the browser can read */\n get CSRF()\n {\n return `spfn_csrf${getCookieSuffix()}`;\n },\n};\n\n/**\n * OAuth CSRF 쿠키를 PORT 접미사와 무관하게 전부 수집한다.\n *\n * 쿠키를 심는 쪽은 Next.js 프로세스, 읽는 쪽은 API 프로세스라 분리 배포에서는\n * 두 프로세스의 PORT가 달라 COOKIE_NAMES.OAUTH_CSRF 정확 일치 조회가 빗나간다.\n * nonce 자체가 랜덤값이고 암호화된 state의 nonce와 대조되므로, 접미사가 다른\n * spfn_oauth_csrf* 후보를 모두 대조 대상으로 넘겨도 안전하다.\n */\nexport function matchOAuthCsrfCookies(\n cookies: Record<string, string>,\n): { name: string; value: string }[]\n{\n return Object.entries(cookies)\n .filter(([name]) => /^spfn_oauth_csrf(_\\d+)?$/.test(name))\n .map(([name, value]) => ({ name, value }));\n}\n\n/**\n * Parse duration string to seconds\n *\n * Supports: '30d', '12h', '45m', '3600s', or plain number\n *\n * @example\n * parseDuration('30d') // 2592000 (30 days in seconds)\n * parseDuration('12h') // 43200\n * parseDuration('45m') // 2700\n * parseDuration('3600') // 3600\n */\nexport function parseDuration(duration: string | number): number\n{\n if (typeof duration === 'number')\n {\n return duration;\n }\n\n const match = duration.match(/^(\\d+)([dhms]?)$/);\n if (!match)\n {\n throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);\n }\n\n const value = parseInt(match[1], 10);\n const unit = match[2] || 's';\n\n switch (unit)\n {\n case 'd':\n return value * 24 * 60 * 60;\n case 'h':\n return value * 60 * 60;\n case 'm':\n return value * 60;\n case 's':\n return value;\n default:\n throw new Error(`Unknown duration unit: ${unit}`);\n }\n}\n\n/**\n * Registration channel passed to the beforeRegister hook\n *\n * - credentials: email/phone + password registration\n * - oauth: new-user signup through a social provider (web or native flow)\n * - invitation: invitation acceptance\n */\nexport type RegisterChannel = 'credentials' | 'oauth' | 'invitation';\n\n/**\n * Context passed to the beforeRegister hook\n *\n * Credentials (password, keys) are intentionally excluded — the hook is a\n * policy gate, not a credential handler.\n */\nexport interface BeforeRegisterContext\n{\n channel: RegisterChannel;\n /** Social provider — only set when channel is 'oauth' */\n provider?: SocialProvider;\n /**\n * Canonical form of the address — trimmed and lower-cased, the same form\n * the account is stored under. A policy keyed on the address (a denylist, a\n * domain allowlist) therefore matches whatever capitalization the person\n * typed, instead of being walked past by `Blocked@Example.com`.\n */\n email?: string;\n /**\n * Whether the email is verified — only set when channel is 'oauth'.\n * OAuth providers may report an unverified (spoofable) email; the created\n * account stores it as null in that case, so email-based policies must\n * check this flag. credentials/invitation emails are already verified.\n */\n emailVerified?: boolean;\n phone?: string;\n /** App-supplied registration metadata (register params / OAuth start params / invitation) */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * How the Next.js proxy treats a cookie-authenticated mutation that arrives\n * without a valid CSRF header.\n *\n * - `off`: no check\n * - `warn`: allow it through, log one line per request that would be refused\n * - `enforce`: refuse it with 403\n */\nexport type CsrfMode = 'off' | 'warn' | 'enforce';\n\n/**\n * CSRF configuration for the Next.js proxy\n */\nexport interface AuthCsrfConfig\n{\n /**\n * @default 'warn' — an existing app gets signal before it gets breakage.\n * `SPFN_AUTH_CSRF` sets it when this is not; new apps scaffolded by\n * `spfn init` are given `enforce`.\n */\n mode?: CsrfMode;\n\n /**\n * Backend paths that skip the check, matched exactly.\n *\n * These are route paths as the backend sees them (`/webhooks/stripe`), not\n * `/api/rpc/...` URLs, with route params already substituted. Intended for\n * endpoints a browser session never calls — webhook receivers and the like.\n * A path listed here is unprotected for cookie callers too, so list only\n * endpoints that carry their own authentication.\n */\n exemptPaths?: string[];\n}\n\n/**\n * Auth configuration\n */\nexport interface AuthConfig\n{\n /**\n * Default session TTL in seconds or duration string\n *\n * Supports:\n * - Number: seconds (e.g., 2592000)\n * - String: '30d', '12h', '45m', '3600s'\n *\n * @default 7d (7 days)\n */\n sessionTtl?: string | number;\n\n /**\n * App-injected validator that runs before a new user row is created,\n * on every registration channel (credentials, oauth, invitation).\n *\n * Throw to reject the registration — RegistrationRejectedError (403) is\n * the recommended error; any HttpError subclass keeps its own status.\n * Runs after built-in checks (verification token, duplicate account),\n * so existing error precedence is unchanged. Not called for admin\n * seeding (initializeAuth) or when linking a social account to an\n * existing user.\n *\n * Runs inside the registration DB transaction on every channel — keep it\n * fast. A slow call (e.g. an external policy API) holds a pooled DB\n * connection open for its full duration on every signup.\n *\n * @example\n * ```typescript\n * configureAuth({\n * beforeRegister: async ({ channel, metadata }) =>\n * {\n * if (channel === 'credentials' && !isOldEnough(metadata?.birthDate))\n * {\n * throw new RegistrationRejectedError({ message: 'Age requirement not met' });\n * }\n * },\n * });\n * ```\n */\n beforeRegister?: (context: BeforeRegisterContext) => void | Promise<void>;\n\n /**\n * CSRF protection for cookie-session mutations, enforced in the Next.js proxy.\n *\n * @example\n * ```typescript\n * configureAuth({\n * csrf: { mode: 'enforce', exemptPaths: ['/webhooks/stripe'] },\n * });\n * ```\n */\n csrf?: AuthCsrfConfig;\n}\n\n/**\n * Global auth configuration state\n */\nlet globalConfig: AuthConfig = {\n sessionTtl: '7d', // Default: 7 days\n};\n\n/**\n * Configure global auth settings\n *\n * @param config - Auth configuration\n *\n * @example\n * ```typescript\n * configureAuth({\n * sessionTtl: '30d', // 30 days\n * });\n * ```\n */\nexport function configureAuth(config: AuthConfig): void\n{\n globalConfig = {\n ...globalConfig,\n ...config,\n };\n}\n\n/**\n * Get current auth configuration\n */\nexport function getAuthConfig(): AuthConfig\n{\n return { ...globalConfig };\n}\n\n/**\n * Run the app-injected beforeRegister hook if configured — throws to reject.\n *\n * Single entry point for every registration channel so a new channel cannot\n * forget the configured-check. Callers invoke this right before creating the\n * user row.\n *\n * The address is folded here rather than at each call site, for the same reason\n * the check itself lives here: three channels supply it, and a policy that sees\n * a different spelling depending on which one the person came through is a\n * policy that can be walked past.\n */\nexport async function runBeforeRegister(context: BeforeRegisterContext): Promise<void>\n{\n const { beforeRegister } = globalConfig;\n\n if (beforeRegister)\n {\n await beforeRegister({ ...context, email: normalizeOptionalEmail(context.email) });\n }\n}\n\n/**\n * Get session TTL in seconds\n *\n * Priority:\n * 1. Runtime override (remember parameter)\n * 2. Global config (configureAuth)\n * 3. Environment variable (SPFN_AUTH_SESSION_TTL) - via config module\n * 4. Default (7 days)\n */\nexport function getSessionTtl(override?: string | number): number\n{\n // 1. Runtime override\n if (override !== undefined)\n {\n return parseDuration(override);\n }\n\n // 2. Global config\n if (globalConfig.sessionTtl !== undefined)\n {\n return parseDuration(globalConfig.sessionTtl);\n }\n\n // 3. Environment variable (from config module)\n const envTtl = env.SPFN_AUTH_SESSION_TTL;\n if (envTtl)\n {\n return parseDuration(envTtl);\n }\n\n // 4. Default: 7 days\n return 7 * 24 * 60 * 60;\n}\n\nconst CSRF_MODES: CsrfMode[] = ['off', 'warn', 'enforce'];\n\n/** The typo notice is a property of the process, not of a request */\nlet unrecognizedCsrfModeReported = false;\n\n/**\n * Get the CSRF mode\n *\n * Priority:\n * 1. Global config (configureAuth)\n * 2. Environment variable (SPFN_AUTH_CSRF)\n * 3. Default ('warn')\n *\n * An unrecognized value is a typo in the one setting that turns the check on;\n * it resolves to `enforce` and says so, rather than quietly leaving mutations\n * unprotected. It says so once per process: this runs on every mutation, so a\n * per-call error would be pure repetition burying the rest of the log.\n */\nexport function getCsrfMode(): CsrfMode\n{\n const configured = globalConfig.csrf?.mode ?? env.SPFN_AUTH_CSRF;\n\n if (!configured)\n {\n return 'warn';\n }\n\n const normalized = String(configured).trim().toLowerCase() as CsrfMode;\n\n if (!CSRF_MODES.includes(normalized))\n {\n if (!unrecognizedCsrfModeReported)\n {\n unrecognizedCsrfModeReported = true;\n authLogger.interceptor.csrf.error(\n `Unrecognized CSRF mode \"${configured}\" — expected off | warn | enforce. Enforcing.`,\n );\n }\n\n return 'enforce';\n }\n\n return normalized;\n}\n\n/**\n * Backend paths this package exempts on its own behalf.\n *\n * All three are endpoints an OAuth client on somebody's laptop calls directly:\n * no cookie, no session, no `x-spfn-csrf` header, and no browser anywhere in\n * the request. The proxy's check already declines to run on them — it fires only\n * after a session cookie has been unsealed, and there is none — so this list\n * changes no outcome today. It is here so that an application which routes them\n * through the proxy while a user happens to be signed in gets a token endpoint\n * that works rather than a 403 nothing in the logs explains.\n *\n * `/_auth/oauth2/authorize` is deliberately absent. That one IS a\n * cookie-session mutation, posted by the consent form on the web app, and it is\n * exactly what the check exists to protect.\n */\nconst PACKAGE_CSRF_EXEMPT_PATHS = [\n '/_auth/oauth2/register',\n '/_auth/oauth2/token',\n '/_auth/oauth2/revoke',\n];\n\n/**\n * Get the paths exempted from the CSRF check (exact match, backend route paths)\n */\nexport function getCsrfExemptPaths(): string[]\n{\n return [...PACKAGE_CSRF_EXEMPT_PATHS, ...(globalConfig.csrf?.exemptPaths ?? [])];\n}\n\n// ============================================================================\n// Session binding (#97)\n// ============================================================================\n\n/**\n * How long a key bound to a passkey lives, in milliseconds.\n *\n * A positive override is honoured; anything else falls back to the policy\n * constant. Nothing refuses boot over it — unlike the passkey relying party, a\n * nonsensical value here does not make every ceremony fail, it just means the\n * default applies.\n */\nexport function getBoundKeyTtlMs(): number\n{\n return positiveOr(env.SPFN_AUTH_BOUND_KEY_TTL_HOURS, BOUND_KEY_TTL_HOURS) * 60 * 60 * 1000;\n}\n\n/** How long past expiry a bound key may still be renewed, in milliseconds. */\nexport function getBoundKeyRenewGraceMs(): number\n{\n return positiveOr(env.SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS, BOUND_KEY_RENEW_GRACE_HOURS) * 60 * 60 * 1000;\n}\n\n/** How close two sightings from two addresses must be to count as concurrent. */\nexport function getConcurrentUseWindowMs(): number\n{\n return positiveOr(env.SPFN_AUTH_CONCURRENT_USE_WINDOW_MS, CONCURRENT_USE_WINDOW_MS);\n}\n\n/**\n * The page a bound session whose key expired is sent to.\n *\n * `RequireAuth` redirects here instead of to the sign-in page; the app renders a\n * client component there that calls `renewSession(api)` and returns the person to\n * where they were.\n */\nexport function getSessionRenewPath(): string\n{\n return env.SPFN_AUTH_SESSION_RENEW_PATH?.trim() || DEFAULT_SESSION_RENEW_PATH;\n}\n\n/** The configured value when it is a usable positive number, the fallback otherwise. */\nfunction positiveOr(configured: number | undefined, fallback: number): number\n{\n return Number.isFinite(configured) && (configured as number) > 0 ? configured as number : fallback;\n}\n\n/** Where the renewal ceremony lives when nothing says otherwise. */\nconst DEFAULT_SESSION_RENEW_PATH = '/auth/renew';\n\n// ============================================================================\n// Passkeys (WebAuthn)\n// ============================================================================\n\n/**\n * The relying party this deployment presents to authenticators, resolved.\n *\n * `rpId` is the domain a credential is bound to and can never change without\n * orphaning every passkey already enrolled under it. `origins` is the closed set\n * of pages allowed to run a ceremony for that rpId.\n */\nexport interface PasskeyConfig\n{\n /** Domain credentials are bound to — a registrable domain, no protocol, no port. */\n rpId: string;\n /** Name shown by the authenticator's own prompt. */\n rpName: string;\n /** Full origins allowed to run a ceremony, e.g. `https://app.example.com`. */\n origins: string[];\n userVerification: PasskeyUserVerification;\n challengeTtlMs: number;\n recentAuthMs: number;\n}\n\n/**\n * How hard the authenticator must work to prove the person is present.\n *\n * `discouraged` is not offered: a passkey here is the whole credential, so an\n * assertion that skipped user verification would sign someone in on possession\n * of an unlocked device alone.\n */\nexport type PasskeyUserVerification = 'preferred' | 'required';\n\nconst PASSKEY_USER_VERIFICATIONS: PasskeyUserVerification[] = ['preferred', 'required'];\n\ntype PasskeyEnvSource = Record<string, string | undefined>;\n\nconst DEFAULT_CHALLENGE_TTL_SECONDS = 300;\nconst DEFAULT_RECENT_AUTH_MINUTES = 10;\n\n/**\n * Every variable this resolution reads, with the one schema default filled in.\n *\n * `SPFN_APP_URL` defaults to `http://localhost:3000` in the validated `env`\n * proxy rather than in `process.env`, so reading the raw environment alone would\n * refuse boot for an app that simply never set it.\n */\nfunction passkeyEnvSource(): PasskeyEnvSource\n{\n return { ...process.env, SPFN_APP_URL: process.env.SPFN_APP_URL || env.SPFN_APP_URL };\n}\n\n/**\n * The app URL every default here is derived from — the same resolution the OAuth\n * callbacks use, so passkeys and OAuth cannot disagree about where the app is.\n */\nfunction passkeyAppUrl(env: PasskeyEnvSource): URL\n{\n const configured = env.NEXT_PUBLIC_SPFN_APP_URL || env.SPFN_APP_URL;\n\n if (!configured)\n {\n throw new PasskeyConfigError({\n message: 'Passkeys need a relying party ID. Set SPFN_AUTH_PASSKEY_RP_ID, or set '\n + 'NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL to the app origin it should be derived from.',\n });\n }\n\n try\n {\n return new URL(configured);\n }\n catch\n {\n throw new PasskeyConfigError({\n message: `Passkeys cannot derive a relying party ID: \"${configured}\" is not a URL. `\n + 'Fix NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL, or set SPFN_AUTH_PASSKEY_RP_ID explicitly.',\n });\n }\n}\n\n/**\n * `localhost` is the one host a browser treats as a secure context over plain\n * http, so it is the one host allowed an `http://` origin here.\n */\nfunction isLocalhost(hostname: string): boolean\n{\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';\n}\n\n/** Whether a ceremony run on this host may claim credentials bound to `rpId`. */\nfunction isUnderRpId(hostname: string, rpId: string): boolean\n{\n return hostname === rpId || hostname.endsWith(`.${rpId}`);\n}\n\n/**\n * One configured origin, checked against the two rules a browser will enforce\n * anyway — better to refuse at boot than to have every ceremony fail with an\n * error that names the browser rather than the env value.\n */\nfunction assertOriginServesRpId(origin: string, rpId: string): void\n{\n let url: URL;\n\n try\n {\n url = new URL(origin);\n }\n catch\n {\n throw new PasskeyConfigError({\n message: `SPFN_AUTH_PASSKEY_ORIGINS contains \"${origin}\", which is not a URL. `\n + 'List full origins, e.g. https://app.example.com.',\n });\n }\n\n if (url.protocol !== 'https:' && !isLocalhost(url.hostname))\n {\n throw new PasskeyConfigError({\n message: `Passkey origin \"${origin}\" is not https. WebAuthn runs only in a secure context, `\n + 'and localhost is the only host a browser treats as one over plain http.',\n });\n }\n\n if (!isUnderRpId(url.hostname, rpId))\n {\n throw new PasskeyConfigError({\n message: `Passkey origin \"${origin}\" is not on relying party ID \"${rpId}\". `\n + 'Each origin must be that host or a subdomain of it, or the browser refuses the ceremony.',\n });\n }\n}\n\nfunction resolveUserVerification(env: PasskeyEnvSource): PasskeyUserVerification\n{\n const configured = env.SPFN_AUTH_PASSKEY_USER_VERIFICATION;\n\n if (!configured)\n {\n return 'preferred';\n }\n\n const normalized = configured.trim().toLowerCase() as PasskeyUserVerification;\n\n if (!PASSKEY_USER_VERIFICATIONS.includes(normalized))\n {\n throw new PasskeyConfigError({\n message: `SPFN_AUTH_PASSKEY_USER_VERIFICATION is \"${configured}\" — expected preferred or required. `\n + 'A passkey is the whole credential here, so an assertion that skipped user verification '\n + 'would sign someone in on an unlocked device alone.',\n });\n }\n\n return normalized;\n}\n\n/** A positive number of the given unit, or the default when unset. */\nfunction resolvePositiveNumber(env: PasskeyEnvSource, variable: string, fallback: number): number\n{\n const configured = env[variable];\n\n if (!configured)\n {\n return fallback;\n }\n\n const parsed = Number(configured);\n\n if (!Number.isFinite(parsed) || parsed <= 0)\n {\n throw new PasskeyConfigError({\n message: `${variable} is \"${configured}\" — expected a positive number.`,\n });\n }\n\n return parsed;\n}\n\n/**\n * Resolve the passkey configuration, refusing anything a ceremony would fail on.\n *\n * Zero-config for a one-origin app: rpId is the app URL's host and the single\n * origin is the app URL's origin. An app on several hosts sets\n * `SPFN_AUTH_PASSKEY_RP_ID` to the registrable domain they share and lists them\n * in `SPFN_AUTH_PASSKEY_ORIGINS`.\n *\n * @param env - Environment to read; defaults to `process.env`.\n * @throws PasskeyConfigError when the configuration cannot be honoured.\n */\nexport function getPasskeyConfig(env: PasskeyEnvSource = passkeyEnvSource()): PasskeyConfig\n{\n const rpId = env.SPFN_AUTH_PASSKEY_RP_ID?.trim() || passkeyAppUrl(env).hostname;\n const configuredOrigins = env.SPFN_AUTH_PASSKEY_ORIGINS\n ?.split(',')\n .map(origin => origin.trim())\n .filter(Boolean);\n const origins = configuredOrigins?.length ? configuredOrigins : [passkeyAppUrl(env).origin];\n\n for (const origin of origins)\n {\n assertOriginServesRpId(origin, rpId);\n }\n\n return {\n rpId,\n rpName: env.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || rpId,\n origins,\n userVerification: resolveUserVerification(env),\n challengeTtlMs: resolvePositiveNumber(\n env, 'SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS', DEFAULT_CHALLENGE_TTL_SECONDS,\n ) * 1000,\n recentAuthMs: resolvePositiveNumber(\n env, 'SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES', DEFAULT_RECENT_AUTH_MINUTES,\n ) * 60_000,\n };\n}\n\n/** The variables whose presence means an operator configured passkeys on purpose. */\nconst PASSKEY_VARS = [\n 'SPFN_AUTH_PASSKEY_RP_ID',\n 'SPFN_AUTH_PASSKEY_RP_NAME',\n 'SPFN_AUTH_PASSKEY_ORIGINS',\n 'SPFN_AUTH_PASSKEY_USER_VERIFICATION',\n 'SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS',\n 'SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES',\n];\n\n/**\n * Refuse boot on a passkey configuration no ceremony could satisfy.\n *\n * Resolution is the check: everything `getPasskeyConfig` refuses would otherwise\n * surface as the browser rejecting every ceremony, long after the deploy that\n * introduced the drift.\n *\n * The refusal is reserved for a configuration an operator actually wrote, which\n * is the posture `assertOAuthRedirectUris` already takes for the same reason. An\n * app that set no passkey variable at all can still resolve to something\n * unusable — `SPFN_APP_URL=http://192.168.1.5:3000` for mobile development, say,\n * which is neither https nor localhost — and refusing to start over a feature\n * nobody asked for would take that app down to fix something it does not use.\n * It is reported instead, once, and the first ceremony (if there ever is one)\n * fails with the same message.\n *\n * @throws PasskeyConfigError when a passkey variable is set and cannot be honoured\n */\nexport function assertPasskeyConfig(env: PasskeyEnvSource = passkeyEnvSource()): void\n{\n if (PASSKEY_VARS.some(variable => env[variable]))\n {\n getPasskeyConfig(env);\n\n return;\n }\n\n try\n {\n getPasskeyConfig(env);\n }\n catch (error)\n {\n authLogger.service.info(\n 'Passkeys cannot be served with the configuration derived from the app URL, and no '\n + `SPFN_AUTH_PASSKEY_* variable is set, so boot continues. ${(error as Error).message}`,\n );\n }\n}\n\n// ============================================================================\n// Second factor (MFA)\n// ============================================================================\n\n/** What the second-factor routes read out of the environment. */\nexport interface MfaConfig\n{\n /** Name the authenticator app files the account under. */\n issuer: string;\n /** How long a device's step-up stays good for a sensitive change. */\n stepUpWindowMs: number;\n /**\n * How long a new-device step-up challenge stays spendable.\n *\n * The window a person has to reach for their authenticator, and the window\n * an attacker who has the password has to get past the second factor. Ten\n * minutes is the same number the OAuth pending cookie and the link flows\n * use, and the proxy's pending cookie is sealed for exactly this long.\n */\n challengeTtlMs: number;\n}\n\n/** Fallback issuer, for an app that has set neither the MFA nor the passkey name. */\nconst DEFAULT_MFA_ISSUER = 'SPFN';\n\nconst DEFAULT_STEP_UP_MINUTES = 10;\n\nconst DEFAULT_CHALLENGE_TTL_MINUTES = 10;\n\n/** A positive whole-or-fractional minute count from the environment, or the default. */\nfunction minutesOr(configured: string | undefined, fallback: number): number\n{\n const minutes = Number(configured);\n\n return Number.isFinite(minutes) && minutes > 0 ? minutes : fallback;\n}\n\n/**\n * Resolve the second-factor configuration.\n *\n * Deliberately reads no passkey setting beyond `SPFN_AUTH_PASSKEY_RP_NAME`,\n * and reads that as a plain string rather than through `getPasskeyConfig()`:\n * an app with no passkeys configured at all must be able to enrol a TOTP and\n * to step up, and `getPasskeyConfig()` refuses to resolve for such an app.\n *\n * Nothing here can fail the way the passkey config can, so there is no boot\n * check to match: a bad step-up window falls back to the default rather than\n * refusing to start, because the value it would refuse over is a number of\n * minutes and the default is the safe one.\n */\nexport function getMfaConfig(): MfaConfig\n{\n return {\n issuer: process.env.SPFN_AUTH_MFA_ISSUER?.trim()\n || process.env.SPFN_AUTH_PASSKEY_RP_NAME?.trim()\n || mfaIssuerFromAppUrl()\n || DEFAULT_MFA_ISSUER,\n stepUpWindowMs: minutesOr(process.env.SPFN_AUTH_MFA_STEP_UP_MINUTES, DEFAULT_STEP_UP_MINUTES) * 60_000,\n challengeTtlMs:\n minutesOr(process.env.SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES, DEFAULT_CHALLENGE_TTL_MINUTES) * 60_000,\n };\n}\n\n/** The app URL's host, when there is one that parses. Display only. */\nfunction mfaIssuerFromAppUrl(): string | null\n{\n const configured = process.env.NEXT_PUBLIC_SPFN_APP_URL || process.env.SPFN_APP_URL;\n\n if (!configured)\n {\n return null;\n }\n\n try\n {\n return new URL(configured).hostname;\n }\n catch\n {\n return null;\n }\n}\n","/**\n * Server-side auth utilities for guards\n *\n * Uses authApi to check permissions in real-time\n */\n\nimport { authApi } from '@spfn/auth';\nimport { SessionRenewalRequiredError } from '@spfn/auth/errors';\nimport { authLogger } from '../../server/logger';\n\n/**\n * A bound session whose key has run out, seen from a server component.\n *\n * The third state `getAuthSessionData` answers with, and the reason it is a\n * sentinel rather than `null`: a server component's `api.` call goes through the\n * same proxy a browser's does, so it meets the same renewal-required refusal —\n * and `null` there would read as \"not signed in\" and send the person to the\n * sign-in page, which is precisely the thing renewal exists to avoid. A server\n * component cannot run a WebAuthn ceremony, so the guard hands the work to a page\n * that can.\n */\nexport const RENEWAL_REQUIRED = 'renewal-required';\n\nexport type AuthSessionData = Awaited<ReturnType<typeof authApi.getAuthSession.call>>;\n\n/** What a guard gets back: the session, the renewal sentinel, or nothing. */\nexport type AuthSessionState = AuthSessionData | typeof RENEWAL_REQUIRED | null;\n\n/**\n * Get current auth session with roles and permissions via API\n */\nexport async function getAuthSessionData(): Promise<AuthSessionState>\n{\n try\n {\n const session = await authApi.getAuthSession.call();\n authLogger.middleware.debug('Auth session retrieved', { name: session.role?.name });\n\n return session;\n }\n catch (error)\n {\n if (isRenewalRequired(error))\n {\n authLogger.middleware.debug('Auth session needs renewing');\n\n return RENEWAL_REQUIRED;\n }\n\n authLogger.middleware.error('Failed to get auth session', { error });\n\n return null;\n }\n}\n\n/**\n * Whether a refusal is the renewal-required one.\n *\n * Matched by name as well as by class. `@spfn/auth/errors` can resolve to two\n * module instances at once — the package entry and the source tree — under a\n * test runner and in dev, and `instanceof` across them is false; core's own\n * `isSerializableError` duck-types for exactly that reason. Getting this wrong\n * fails in the direction that redirects someone to a sign-in page they do not\n * need, which is the failure this whole branch exists to remove.\n */\nfunction isRenewalRequired(error: unknown): boolean\n{\n return error instanceof SessionRenewalRequiredError\n || (error as { name?: unknown } | null)?.name === 'SessionRenewalRequiredError';\n}\n\n/** The session itself, or null for either of the two non-session states. */\nfunction resolvedSession(state: AuthSessionState): AuthSessionData | null\n{\n return state && state !== RENEWAL_REQUIRED ? state : null;\n}\n\n/**\n * Get user role\n */\nexport async function getUserRole(): Promise<string | null>\n{\n const session = resolvedSession(await getAuthSessionData());\n\n return session?.role?.name || null;\n}\n\n/**\n * Get user permissions\n */\nexport async function getUserPermissions(): Promise<string[]>\n{\n const session = resolvedSession(await getAuthSessionData());\n\n if (!session)\n {\n return [];\n }\n\n return session.permissions?.map((p: any) => p.name) || [];\n}\n\n/**\n * Check if user has any of the specified roles\n */\nexport async function hasAnyRole(requiredRoles: string[]): Promise<boolean>\n{\n const session = resolvedSession(await getAuthSessionData());\n if (!session)\n {\n return false;\n }\n\n return requiredRoles.includes(session.role?.name);\n}\n\n/**\n * Check if user has any of the specified permissions\n */\nexport async function hasAnyPermission(requiredPermissions: string[]): Promise<boolean>\n{\n const session = resolvedSession(await getAuthSessionData());\n\n if (!session)\n {\n return false;\n }\n\n const userPermissionNames = session.permissions?.map((p: any) => p.name) || [];\n\n return requiredPermissions.some(permission => userPermissionNames.includes(permission));\n}\n","/**\n * RequireRole Guard Component\n *\n * Requires user to have at least one of the specified roles\n */\n\nimport { redirect } from 'next/navigation';\nimport { getSession } from '../session-helpers';\nimport { hasAnyRole } from './auth-utils';\nimport type { ReactNode } from 'react';\n\nexport interface RequireRoleProps\n{\n /**\n * Required role(s) - user must have at least one\n */\n roles: string | string[];\n\n /**\n * Children to render if user has required role\n */\n children: ReactNode;\n\n /**\n * Path to redirect to if user doesn't have role\n * @default '/unauthorized'\n */\n redirectTo?: string;\n\n /**\n * Fallback UI to show instead of redirecting\n */\n fallback?: ReactNode;\n}\n\n/**\n * Require Role Guard\n *\n * Ensures user has at least one of the specified roles\n *\n * @example Single role\n * ```tsx\n * <RequireRole roles=\"admin\">\n * <AdminPanel />\n * </RequireRole>\n * ```\n *\n * @example Multiple roles (OR condition)\n * ```tsx\n * <RequireRole roles={['admin', 'manager']}>\n * <ManagementDashboard />\n * </RequireRole>\n * ```\n *\n * @example With fallback\n * ```tsx\n * <RequireRole roles=\"admin\" fallback={<AccessDenied />}>\n * <AdminContent />\n * </RequireRole>\n * ```\n */\nexport async function RequireRole({\n roles,\n children,\n redirectTo = '/unauthorized',\n fallback,\n}: RequireRoleProps)\n{\n const session = await getSession();\n\n // Not authenticated\n if (!session)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect('/login');\n }\n\n // Normalize to array\n const requiredRoles = Array.isArray(roles) ? roles : [roles];\n\n // Check if user has any of the required roles\n const hasRole = await hasAnyRole(requiredRoles);\n\n if (!hasRole)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect(redirectTo);\n }\n\n return <>{children}</>;\n}\n","/**\n * RequirePermission Guard Component\n *\n * Requires user to have at least one of the specified permissions\n */\n\nimport { redirect } from 'next/navigation';\nimport { getSession } from '../session-helpers';\nimport { hasAnyPermission } from './auth-utils';\nimport type { ReactNode } from 'react';\n\nexport interface RequirePermissionProps\n{\n /**\n * Required permission(s) - user must have at least one\n */\n permissions: string | string[];\n\n /**\n * Children to render if user has required permission\n */\n children: ReactNode;\n\n /**\n * Path to redirect to if user doesn't have permission\n * @default '/unauthorized'\n */\n redirectTo?: string;\n\n /**\n * Fallback UI to show instead of redirecting\n */\n fallback?: ReactNode;\n}\n\n/**\n * Require Permission Guard\n *\n * Ensures user has at least one of the specified permissions\n *\n * @example Single permission\n * ```tsx\n * <RequirePermission permissions=\"user:delete\">\n * <DeleteUserButton />\n * </RequirePermission>\n * ```\n *\n * @example Multiple permissions (OR condition)\n * ```tsx\n * <RequirePermission permissions={['user:delete', 'user:update']}>\n * <UserManagement />\n * </RequirePermission>\n * ```\n *\n * @example With fallback\n * ```tsx\n * <RequirePermission permissions=\"project:create\" fallback={<UpgradePrompt />}>\n * <CreateProject />\n * </RequirePermission>\n * ```\n */\nexport async function RequirePermission({\n permissions,\n children,\n redirectTo = '/unauthorized',\n fallback,\n}: RequirePermissionProps)\n{\n const session = await getSession();\n\n // Not authenticated\n if (!session)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect('/login');\n }\n\n // Normalize to array\n const requiredPermissions = Array.isArray(permissions) ? permissions : [permissions];\n\n // Check if user has any of the required permissions\n const hasPermission = await hasAnyPermission(requiredPermissions);\n\n if (!hasPermission)\n {\n if (fallback)\n {\n return <>{fallback}</>;\n }\n\n redirect(redirectTo);\n }\n\n return <>{children}</>;\n}\n","/**\n * Session cookie names for Next.js\n *\n * The names carry the `SPFN_PORT` suffix, so they are only knowable at call\n * time — this module is the one place an app reads them from.\n */\n\nimport { type NextResponse } from 'next/server';\nimport { COOKIE_NAMES } from '../server/lib/config';\n\n/**\n * The cookie names that make up a browser session\n */\nexport interface SessionCookieNames\n{\n /** Encrypted session data */\n session: string;\n /** Current key ID (for key rotation) */\n keyId: string;\n /** Pending OAuth session — present only mid-flow */\n oauthPending: string;\n /** CSRF token — the only one the browser can read */\n csrf: string;\n}\n\n/**\n * Names of the cookies that make up a browser session\n *\n * Read at call time, never at import: the names carry the `SPFN_PORT` suffix,\n * and an app that spells them itself keeps clearing the old name after a\n * release renames one.\n *\n * @example\n * ```typescript\n * const names = sessionCookieNames();\n * const raw = request.cookies.get(names.session);\n * ```\n */\nexport function sessionCookieNames(): SessionCookieNames\n{\n return {\n session: COOKIE_NAMES.SESSION,\n keyId: COOKIE_NAMES.SESSION_KEY_ID,\n oauthPending: COOKIE_NAMES.OAUTH_PENDING,\n csrf: COOKIE_NAMES.CSRF,\n };\n}\n\n/**\n * Expire every session cookie on a response\n *\n * For the route handler or middleware that answers \"the API refused your\n * session\" — it empties the jar so the next request arrives anonymous. The\n * path matches the one the setters use, because a delete under a different\n * path leaves the cookie in place. Absent cookies are not an error.\n *\n * @param response - Response to expire the cookies on\n * @returns The same response, so the call chains\n *\n * @example\n * ```typescript\n * export function GET(): NextResponse\n * {\n * return clearSessionCookies(NextResponse.redirect(new URL('/login', request.url)));\n * }\n * ```\n */\nexport function clearSessionCookies(response: NextResponse): NextResponse\n{\n for (const name of Object.values(sessionCookieNames()))\n {\n response.cookies.delete({ name, path: '/' });\n }\n\n return response;\n}\n","/**\n * OAuth Handlers for Next.js\n *\n * Helper functions to create OAuth callback route handlers\n */\n\nimport { NextRequest, NextResponse } from 'next/server';\nimport { cookies } from 'next/headers.js';\nimport { sealSession } from '../server/lib/session';\nimport { deriveCsrfToken } from '../server/lib/csrf';\nimport { COOKIE_NAMES, getSessionTtl } from '../server/lib/config';\nimport { env } from '@spfn/core/config';\nimport { env as authEnv } from '@spfn/auth/config';\nimport { logger } from '@spfn/core/logger';\nimport { unsealPendingSession } from './session-helpers';\nimport { isSafeReturnPath } from '../lib/return-path';\nimport { bindingSessionFields } from './interceptors/session-binding';\n\nexport interface OAuthCallbackOptions\n{\n /**\n * Default redirect URL if returnUrl is not provided\n * @default '/'\n */\n defaultRedirectUrl?: string;\n\n /**\n * Error redirect URL\n * @default '/auth/error'\n */\n errorRedirectUrl?: string;\n\n /**\n * App page that asks for the second factor, when the callback carries a\n * step-up challenge instead of a session (#95).\n *\n * An override for `SPFN_AUTH_MFA_CONFIRM_PATH`, which is where every other\n * app-page path in this package lives; the env var is the one to set, and\n * this exists for an app mounting two handlers on different screens. The\n * handler redirects to it with `?challenge=` and `?returnUrl=`.\n *\n * @default env SPFN_AUTH_MFA_CONFIRM_PATH, then '/auth/mfa'\n */\n mfaPath?: string;\n}\n\n/**\n * The query's `returnUrl`, or the handler's default when it would leave the app.\n *\n * `new URL('https://evil.example.com', request.url)` resolves to the absolute URL,\n * not to a path under the app, so an unchecked value here redirects the browser\n * off-origin after a successful login. Only the destination is replaced — the\n * login stands and the session cookies are still set.\n */\nfunction safeReturnUrl(requested: string | null, defaultRedirect: string): string\n{\n return requested && isSafeReturnPath(requested) ? requested : defaultRedirect;\n}\n\n/**\n * The two binding values the backend put on the callback URL, in the shape the\n * sealing helper reads a sign-in response in.\n *\n * A malformed number comes out as `NaN`, which the helper's `typeof` check\n * accepts — so it is filtered here instead, and a query that cannot be read\n * seals an unbound session rather than one whose expiry is unusable.\n */\nfunction bindingFromQuery(searchParams: URLSearchParams): { sessionBinding?: string; keyExpiresAtMillis?: number }\n{\n const expiresAt = Number(searchParams.get('keyExpiresAtMillis'));\n\n if (searchParams.get('sessionBinding') !== 'passkey' || !Number.isFinite(expiresAt))\n {\n return {};\n }\n\n return { sessionBinding: 'passkey', keyExpiresAtMillis: expiresAt };\n}\n\n/**\n * Send the browser to the second-factor page, carrying the challenge (#95).\n *\n * No session is sealed and no cookie is touched. The OAuth pending cookie stays\n * where it is — it holds the private half of the key the challenge would\n * activate, and `mfaVerifyInterceptor` reads it when the page posts the proof.\n *\n * The challenge rides the query, as `userId` and `keyId` used to. Nothing else\n * is authorized by it: it spends at `POST /_auth/mfa/verify` and at no other\n * route, it is single use, it dies in ten minutes, and the request logger records\n * `pathname` only — so unlike #94's revoke-all link this is not a bearer\n * credential riding a URL.\n */\nfunction mfaRedirect(\n request: NextRequest,\n challenge: string,\n returnUrl: string,\n configured?: string,\n): NextResponse\n{\n const path = configured || authEnv.SPFN_AUTH_MFA_CONFIRM_PATH || DEFAULT_MFA_CONFIRM_PATH;\n const target = new URL(path, request.url);\n\n target.searchParams.set('challenge', challenge);\n target.searchParams.set('returnUrl', returnUrl);\n\n logger.debug('OAuth callback needs a second factor', { path });\n\n return NextResponse.redirect(target);\n}\n\n/** Where the second-factor page lives when nothing says otherwise. */\nconst DEFAULT_MFA_CONFIRM_PATH = '/auth/mfa';\n\n/**\n * Create OAuth callback handler for Next.js API Route\n *\n * Handles the final step of OAuth flow:\n * 1. Gets userId, keyId from query params (set by backend)\n * 2. Gets privateKey from pending session cookie\n * 3. Creates full session and saves to cookie\n * 4. Redirects to returnUrl\n *\n * When the account has a second factor and this device is new to it (#95) the\n * backend sends `mfaChallenge` in place of `userId` and `keyId`. No session is\n * sealed; the browser goes to `SPFN_AUTH_MFA_CONFIRM_PATH` with the challenge,\n * and the session is sealed by `mfaVerifyInterceptor` once the page proves it.\n *\n * @example\n * ```typescript\n * // /api/auth/callback/route.ts\n * import { createOAuthCallbackHandler } from '@spfn/auth/nextjs/server';\n * export const GET = createOAuthCallbackHandler();\n * ```\n */\nexport function createOAuthCallbackHandler(options?: OAuthCallbackOptions)\n{\n const defaultRedirect = options?.defaultRedirectUrl || '/';\n const errorRedirect = options?.errorRedirectUrl || '/auth/error';\n\n return async (request: NextRequest): Promise<NextResponse> =>\n {\n const searchParams = request.nextUrl.searchParams;\n const userId = searchParams.get('userId');\n const keyId = searchParams.get('keyId');\n const returnUrl = safeReturnUrl(searchParams.get('returnUrl'), defaultRedirect);\n const error = searchParams.get('error');\n\n // Handle error from backend\n if (error)\n {\n const errorUrl = new URL(errorRedirect, request.url);\n errorUrl.searchParams.set('error', error);\n\n return NextResponse.redirect(errorUrl);\n }\n\n const mfaChallenge = searchParams.get('mfaChallenge');\n\n if (mfaChallenge)\n {\n return mfaRedirect(request, mfaChallenge, returnUrl, options?.mfaPath);\n }\n\n // Validate required params\n if (!userId || !keyId)\n {\n logger.error('OAuth callback missing required params', { userId: !!userId, keyId: !!keyId });\n const errorUrl = new URL(errorRedirect, request.url);\n errorUrl.searchParams.set('error', 'Missing required parameters');\n\n return NextResponse.redirect(errorUrl);\n }\n\n try\n {\n // Get pending session from cookie\n const cookieStore = await cookies();\n const pendingCookie = cookieStore.get(COOKIE_NAMES.OAUTH_PENDING);\n\n if (!pendingCookie)\n {\n throw new Error('OAuth session expired. Please try again.');\n }\n\n const pendingSession = await unsealPendingSession(pendingCookie.value);\n\n // Verify keyId matches\n if (pendingSession.keyId !== keyId)\n {\n throw new Error('Session mismatch. Please try again.');\n }\n\n // Create full session.\n //\n // The binding fields ride the callback query, put there by the\n // backend that registered the key: this handler runs before any\n // route call, so the redirect is the only thing that can tell it the\n // key is short-lived. Nothing is authorized by them — the key row\n // expires when it says it does whatever the query claims — but a\n // cookie that did not carry them would take the unbound branch at the\n // first expiry and sign the person out instead of renewing.\n const ttl = getSessionTtl();\n const sessionToken = await sealSession({\n userId,\n privateKey: pendingSession.privateKey,\n keyId: pendingSession.keyId,\n algorithm: pendingSession.algorithm,\n ...bindingSessionFields(bindingFromQuery(searchParams), request.headers.get('user-agent')),\n }, ttl);\n\n // Build redirect response\n const redirectUrl = new URL(returnUrl, request.url);\n const response = NextResponse.redirect(redirectUrl);\n\n // Set session cookie\n response.cookies.set(COOKIE_NAMES.SESSION, sessionToken, {\n httpOnly: true,\n secure: env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n });\n\n // Set keyId cookie\n response.cookies.set(COOKIE_NAMES.SESSION_KEY_ID, keyId, {\n httpOnly: true,\n secure: env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n });\n\n // Readable CSRF cookie — the client mirrors it into x-spfn-csrf\n response.cookies.set(COOKIE_NAMES.CSRF, await deriveCsrfToken(keyId), {\n httpOnly: false,\n secure: env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n });\n\n // Clear pending session cookie\n response.cookies.delete(COOKIE_NAMES.OAUTH_PENDING);\n\n logger.debug('OAuth callback completed', { userId, keyId });\n\n return response;\n }\n catch (error)\n {\n const err = error as Error;\n logger.error('OAuth callback failed', { error: err.message });\n\n const errorUrl = new URL(errorRedirect, request.url);\n errorUrl.searchParams.set('error', err.message);\n\n return NextResponse.redirect(errorUrl);\n }\n };\n}\n","/**\n * @spfn/auth - Return-path validation\n *\n * One rule for every flow that hands a caller-supplied destination back to the\n * browser: the verified-email signup link, the password reset link, and the\n * OAuth start/callback seams. Apps that build their own destination before\n * calling an auth route import the same function rather than writing a second\n * rule that drifts from this one.\n *\n * The module imports nothing on purpose — it is part of the client bundle\n * (`@spfn/auth/nextjs/client`), which must not pull server code in behind it.\n */\n\n/**\n * The characters a URL parser deletes from anywhere in its input before it reads\n * the input as a URL: ASCII tab, LF and CR (WHATWG URL, \"remove all ASCII tab or\n * newline\"). The rule below reads the value as written, so a value holding one of\n * them is not the value the browser parses — `/<tab>/evil.com` is read as the\n * protocol-relative `//evil.com` and lands on another origin. Refusing the three\n * outright also keeps a raw CR or LF out of any `Location` header the value\n * reaches, which is what would split that header in two.\n */\nconst URL_STRIPPED_CHARACTER = /[\\t\\n\\r]/;\n\n/**\n * Whether a return path can be handed back to the browser.\n *\n * Only a path within the app is allowed. The rejected shapes are the ones that\n * turn a return path into an open redirect: an absolute URL, a protocol-relative\n * `//host` that a browser reads as another origin, a backslash that some\n * browsers normalize into a slash, any `..` traversal, and any character a URL\n * parser strips before parsing (see above).\n *\n * The value is judged exactly as written: nothing is percent-decoded here. A\n * `/a%0d%0a` is therefore a path containing those six literal characters and is\n * accepted — no decoder downstream turns it back into header bytes.\n */\nexport function isSafeReturnPath(returnPath: string): boolean\n{\n if (!returnPath.startsWith('/'))\n {\n return false;\n }\n\n if (returnPath.startsWith('//') || returnPath.includes('\\\\'))\n {\n return false;\n }\n\n if (returnPath.includes('..') || URL_STRIPPED_CHARACTER.test(returnPath))\n {\n return false;\n }\n\n // A path cannot carry a protocol prefix; `/\\thttps:` and friends are caught\n // above, this catches `/foo:bar` forms that some parsers read as an authority.\n return !/^\\/[^/?#]*:/.test(returnPath);\n}\n","/**\n * Session Binding Interceptor\n *\n * The cookie half of #97.\n *\n * Two things live here. `bindingSessionFields` is what every sealing site copies\n * into `SessionData`, so that the five of them cannot drift: the backend is the\n * only party that knows an account opted in, it says so in the sign-in response,\n * and this turns that answer plus the inbound `user-agent` into the three fields\n * the proxy later reads.\n *\n * `sessionBindingInterceptor` is the other half: turning binding on mutates a key\n * row, and without this the cookie in the browser would go on saying nothing\n * about it. The proxy re-seals only within the last day of the *cookie's* life,\n * so for the rest of the week it would believe the session unbound, and the first\n * time the (now short-lived) key expired the backend's 401 would clear the\n * cookies — signing the person out on the day they turned the protection on,\n * which is the failure the feature exists to prevent. Turning it off has the\n * mirror problem: the cookie would keep an expiry that no longer applies.\n *\n * And it fails closed. A re-seal that did not happen is answered as a failure\n * with the jar emptied, never as the 200 the route wanted to give — see\n * `refuseAsUnsealable`.\n */\n\nimport type { InterceptorRule, ResponseInterceptorContext } from '@spfn/core/nextjs/server';\nimport { SessionResealFailedError } from '@spfn/auth/errors';\nimport { sealSession, unsealSession, type SessionData } from '../../server/lib/session';\nimport { uaFamily } from '../../server/lib/ua-family';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie, pushCsrfCookieRemoval } from './csrf';\nimport { refusalEnvelope } from './error-envelope';\n\n/** The binding half of a `LoginResult`, as it arrives on the wire. */\nexport interface BindingResponseFields\n{\n sessionBinding?: unknown;\n keyExpiresAtMillis?: unknown;\n}\n\n/**\n * The three fields a sealing site adds to `SessionData`, or nothing at all.\n *\n * Nothing at all is the important half. An unbound session is sealed with the\n * same four fields it has always been sealed with — no `uaFamily`, no empty\n * `binding` — so a deployment where nobody opted in produces byte-identical\n * cookies to the one before this change, and every branch downstream that asks\n * \"is this bound\" answers by the absence.\n *\n * `uaFamily` is recorded here, from the request that started the session, because\n * the proxy is the only hop that sees the browser's own `user-agent`: a server\n * component calling the RPC proxy sends none, and the backend would be comparing\n * a family it never received.\n *\n * @param body - the response body of the sign-in, whatever shape it came in\n * @param userAgent - the inbound `user-agent`, absent when the caller sent none\n */\nexport function bindingSessionFields(\n body: BindingResponseFields | null | undefined,\n userAgent: string | null | undefined,\n): Partial<SessionData>\n{\n if (body?.sessionBinding !== 'passkey' || typeof body.keyExpiresAtMillis !== 'number')\n {\n return {};\n }\n\n return {\n binding: 'passkey',\n keyExpiresAt: body.keyExpiresAtMillis,\n ...(userAgent ? { uaFamily: uaFamily(userAgent) } : {}),\n };\n}\n\n/**\n * Session Binding Interceptor\n *\n * Response: re-seal the session cookie from the 200 the binding route answered.\n *\n * Registered after `generalAuthInterceptor` so that its cookie is the later one\n * in `setCookies` — the response phases run in registration order, and the last\n * write of a name is the one the browser keeps. `general-auth` re-seals on this\n * path only in the rare window where the cookie is nearly expired, and that\n * re-seal carries the *old* fields.\n */\nexport const sessionBindingInterceptor: InterceptorRule =\n {\n pathPattern: '/_auth/session/binding',\n method: 'POST',\n\n response: async (ctx, next) =>\n {\n const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);\n\n if (ctx.response.status !== 200 || !sessionCookie)\n {\n await next();\n\n return;\n }\n\n try\n {\n const session = await unsealSession(sessionCookie);\n await pushResealed(ctx.setCookies, applyBinding(session, ctx.response.body, ctx.request.headers));\n }\n catch (error)\n {\n authLogger.interceptor.general.error('Failed to re-seal the session after a binding change', error as Error);\n refuseAsUnsealable(ctx);\n }\n\n await next();\n },\n };\n\n/**\n * Answer the failure instead of the success the route already committed.\n *\n * The change is in the database and the cookie could not be made to agree with\n * it, so answering 200 would hand the browser a session that contradicts the\n * account: an enable whose cookie says unbound skips the user-agent check and is\n * cleared as an ordinary expired session at the first short expiry, and a disable\n * whose cookie still says bound asks for a renewal the backend now refuses. The\n * three session cookies go with the refusal — signing in again is what produces a\n * cookie that agrees — and the caller is told, rather than finding out a day later.\n */\nfunction refuseAsUnsealable(ctx: ResponseInterceptorContext): void\n{\n const refusal = refusalEnvelope(new SessionResealFailedError());\n\n ctx.response.status = refusal.status;\n ctx.response.ok = false;\n ctx.response.body = refusal.body;\n\n for (const name of [COOKIE_NAMES.SESSION, COOKIE_NAMES.SESSION_KEY_ID])\n {\n ctx.setCookies.push({ name, value: '', options: { maxAge: 0, path: '/' } });\n }\n\n pushCsrfCookieRemoval(ctx.setCookies);\n}\n\n/**\n * The session as it should now read, given what the route answered.\n *\n * The binding route speaks its own vocabulary — `{ mode, keyExpiresAtMillis }`,\n * which is what a settings screen reads — so its answer is translated into the\n * sign-in vocabulary the shared helper takes rather than the helper being taught\n * a second shape.\n */\nfunction applyBinding(\n session: SessionData,\n body: { mode?: unknown; keyExpiresAtMillis?: unknown } | null | undefined,\n requestHeaders: Record<string, string>,\n): SessionData\n{\n const { binding, keyExpiresAt, uaFamily: sealedFamily, ...unbound } = session;\n\n if (body?.mode !== 'passkey')\n {\n return unbound;\n }\n\n const fields = bindingSessionFields(\n { sessionBinding: body.mode, keyExpiresAtMillis: body.keyExpiresAtMillis },\n requestHeaders['user-agent'],\n );\n\n // The family the session already carried wins over this request's: a session\n // that moved browsers between being sealed and being bound must not have the\n // check silently re-anchored to where it ended up. This is not a sign-in.\n return { ...unbound, ...fields, ...(sealedFamily ? { uaFamily: sealedFamily } : {}) };\n}\n\n/** Write the session, key-id and CSRF cookies the way every other seal site does. */\nasync function pushResealed(\n setCookies: Parameters<typeof pushCsrfCookie>[0],\n session: SessionData,\n): Promise<void>\n{\n const ttl = getSessionTtl();\n const options = {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax' as const,\n maxAge: ttl,\n path: '/',\n };\n\n setCookies.push({ name: COOKIE_NAMES.SESSION, value: await sealSession(session, ttl), options });\n setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: session.keyId, options });\n await pushCsrfCookie(setCookies, session.keyId, ttl);\n}\n","/**\n * The browser family a `user-agent` names — five badges, and nothing else.\n *\n * A bound session records the family it was sealed from and the Next.js proxy\n * compares every later request against it (#97). The comparison has to be coarse\n * on purpose: a version bump, a minor-version reduction, a platform token that\n * changes when someone taps \"Request desktop site\" must all still be the same\n * browser, or the check signs people out for ordinary acts instead of catching\n * the cookie that moved to another machine.\n *\n * A fixed table rather than a parsing dependency. `package.json` carries no\n * user-agent parser and adding one to answer a five-valued question would be out\n * of proportion; the table below is the whole of what this package needs to know\n * about user-agent strings.\n *\n * @module server/lib/ua-family\n */\n\n/**\n * The five answers, and deliberately no sixth.\n *\n * There is no desktop/mobile axis. Android's \"Request desktop site\" flips the\n * platform token on the same browser in the same cookie jar, and a session that\n * refused after that would be a support ticket for a thing the user did on\n * purpose. What this check is looking for is a cookie that moved to a *different*\n * browser, and the browser is what the badge names.\n */\nexport const UA_FAMILIES = ['edge', 'chrome', 'firefox', 'safari', 'other'] as const;\n\nexport type UaFamily = typeof UA_FAMILIES[number];\n\n/**\n * The markers, in the only order that works.\n *\n * Every entry below is a superstring of the next one's claim, which is why this\n * is a list and not a map: post-reduction Chrome sends `… Chrome/141.0.0.0\n * Safari/537.36`, and Edge sends that plus `Edg/`. Matched the other way round\n * every Edge user reads as chrome and every Chrome user risks reading as safari.\n *\n * iOS has no engines, only badges: `CriOS`, `FxiOS` and `EdgiOS` are the only\n * markers there and everything else on the platform is Safari's engine wearing\n * whatever name the app chose. An in-app `SFSafariViewController` shares the\n * Safari cookie jar and answers `safari`; Chrome on iOS has its own jar and\n * answers `chrome`, so moving a session between the two is a family change. That\n * is the intended reading — the two do not share cookies, so the move cannot\n * happen without someone copying one.\n */\nconst FAMILY_MARKERS: readonly { family: UaFamily; marker: RegExp }[] = [\n { family: 'edge', marker: /\\bEdg(?:A|iOS)?\\// },\n { family: 'chrome', marker: /\\b(?:Chrome|CriOS)\\// },\n { family: 'firefox', marker: /\\b(?:Firefox|FxiOS)\\// },\n { family: 'safari', marker: /\\bSafari\\// },\n];\n\n/**\n * Which family a `user-agent` belongs to.\n *\n * Total: an absent, empty or unrecognised string answers `'other'` rather than\n * throwing or answering null. `'other'` is a family like any other — two requests\n * from two different crawlers both read as `'other'` and compare equal — so a\n * caller that needs \"no signal\" has to check for the header's absence itself\n * rather than read it off this answer. The proxy does exactly that: no inbound\n * `user-agent` means no comparison, because a server component's call to the RPC\n * proxy carries no browser string to compare.\n *\n * @param userAgent - the header as it arrived, or nothing\n * @returns one of `UA_FAMILIES`\n */\nexport function uaFamily(userAgent: string | null | undefined): UaFamily\n{\n if (!userAgent)\n {\n return 'other';\n }\n\n return FAMILY_MARKERS.find(entry => entry.marker.test(userAgent))?.family ?? 'other';\n}\n","/**\n * Shared cookie option helpers for auth interceptors\n *\n * SPFN_AUTH_COOKIE_SECURE env var allows overriding the Secure flag.\n * - unset: defaults to NODE_ENV === 'production'\n * - \"true\" / \"false\": explicit override\n *\n * Useful for HTTP-only staging environments (e.g. bastion over plain HTTP).\n */\n\n/**\n * Resolve whether cookies should have the Secure flag.\n *\n * Priority:\n * 1. SPFN_AUTH_COOKIE_SECURE (explicit override)\n * 2. NODE_ENV === 'production'\n */\nfunction resolveSecure(): boolean\n{\n const override = process.env.SPFN_AUTH_COOKIE_SECURE;\n\n if (override !== undefined)\n {\n return override === 'true';\n }\n\n return process.env.NODE_ENV === 'production';\n}\n\n/**\n * Whether cookies should have the Secure flag.\n * Evaluated once at module load time.\n */\nexport const cookieSecure = resolveSecure();\n","/**\n * @spfn/auth - OAuth 2.1 consent screen (Next.js route handlers)\n *\n * The authorization server lives on the API origin; this is the one piece of it\n * that cannot, because consent is a decision only the signed-in person can make\n * and the session cookie is on the web app. `GET` draws the screen, `POST` takes\n * the answer, and neither of them decides anything: both forward the request to\n * `/_auth/oauth2/authorize`, which validates it against the registration and\n * hands back either what to draw or the refusal to act on.\n *\n * Three rules shape everything below, and each of them is an attack that would\n * otherwise work:\n *\n * - **The only URLs this file redirects to are `loginPath` and a URI the API\n * returned.** The request's own `redirect_uri` is forwarded and never built\n * into a `Location` — an unregistered one is exactly the open redirect the\n * registration check exists to close, and the API is the only side that can\n * tell the two apart.\n * - **Everything interpolated into the page is escaped.** `client_name` arrives\n * from unauthenticated dynamic registration, and `state` and `resource` come\n * from the query string of a link somebody was sent.\n * - **The POST carries its own CSRF token.** The handler's server-side call to\n * the API mints the CSRF header itself and so would always pass; the check\n * that matters is the browser form's, and it is made before the API is called\n * at all.\n */\n\nimport { cookies } from 'next/headers.js';\nimport { NextResponse, type NextRequest } from 'next/server';\n\nimport { authApi } from '@spfn/auth';\nimport type { AuthRouter } from '@spfn/auth';\nimport type { RouterInput } from '@spfn/core/nextjs';\nimport { logger } from '@spfn/core/logger';\n\nimport { sessionCookieNames } from './cookie-names';\nimport { getSession } from './session-helpers';\nimport { matchesCsrfToken } from '../server/lib/csrf';\nimport { isSafeReturnPath } from '../lib/return-path';\n\n/** The authorize parameters, spelled as the protocol spells them. */\nconst AUTHORIZE_PARAMETERS = [\n 'client_id',\n 'redirect_uri',\n 'code_challenge',\n 'code_challenge_method',\n 'resource',\n 'scope',\n 'state',\n] as const;\n\n/**\n * Refusals with no vetted URI to carry them.\n *\n * An unknown client has no registration to read a redirect URI from, and a\n * mismatched `redirect_uri` is the one the request supplied. Both are shown.\n */\nconst NON_REDIRECTABLE = new Set(['unknown_client', 'redirect_uri_mismatch']);\n\n/** Content types a browser form can actually arrive as. */\nconst FORM_CONTENT_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data'];\n\ntype AuthorizeQuery = RouterInput<AuthRouter, 'getOAuth2Authorize'>['query'];\n\ntype DecisionBody = RouterInput<AuthRouter, 'createOAuth2AuthorizationCode'>['body'];\n\n/** One scope, with the sentence the consent screen reads aloud for it. */\nexport interface OAuth2ConsentScope\n{\n name: string;\n description: string;\n}\n\n/**\n * Everything a consent screen needs, raw and unescaped.\n *\n * A custom `render` receives this and owns the whole body, so it must echo\n * `fields` and `csrfToken` back as hidden inputs: the POST is refused without\n * the token, and the API re-validates the request from the fields rather than\n * trusting what the GET was once shown.\n *\n * Every string here is caller-supplied. Put each one through {@link escapeHtml}.\n */\nexport interface OAuth2ConsentView\n{\n /** Registered name of the client asking. Unauthenticated input. */\n clientName: string;\n\n /** Host the code would be sent to — the one fact about the client that is checkable. */\n redirectHost: string;\n\n scopes: OAuth2ConsentScope[];\n\n /** RFC 8707 target the token would be good against. */\n resource: string;\n\n /** Every authorize parameter the request carried, verbatim, to echo as hidden inputs. */\n fields: Record<string, string>;\n\n /** Value the POST's `csrf` field must carry. */\n csrfToken: string;\n}\n\n/**\n * Options for {@link createOAuth2AuthorizeHandlers}\n */\nexport interface OAuth2AuthorizeHandlerOptions\n{\n /**\n * Where to send a visitor with no session, e.g. `/login`\n *\n * The handler appends `?returnUrl=` pointing at this request, so the login\n * lands back on the consent screen with its parameters intact.\n */\n loginPath: string;\n\n /**\n * Replace the default consent page body\n *\n * Status, headers and the field set stay the handler's; this owns the HTML.\n */\n render?: (view: OAuth2ConsentView) => string;\n}\n\n/** The pair a route file re-exports as `export const { GET, POST } = ...`. */\nexport interface OAuth2AuthorizeHandlers\n{\n GET: (request: NextRequest) => Promise<NextResponse>;\n POST: (request: NextRequest) => Promise<NextResponse>;\n}\n\n/**\n * Escape a string for interpolation into HTML text or a quoted attribute.\n *\n * Exported because a custom `render` needs the same escaping the default body\n * applies: `client_name` comes from unauthenticated dynamic registration, and\n * `state` is whatever was in the link the browser followed.\n *\n * @param value - Raw string\n * @returns The same string with `& < > \" '` replaced by entities\n */\nexport function escapeHtml(value: string): string\n{\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n}\n\n/**\n * The authorize parameters that are present, and only those.\n *\n * Reading from a fixed list rather than copying the request is what keeps an\n * extra field somebody appended to the form out of the call to the API.\n */\nfunction authorizeFields(read: (name: string) => string | null): Record<string, string>\n{\n const fields: Record<string, string> = {};\n\n for (const name of AUTHORIZE_PARAMETERS)\n {\n const value = read(name);\n\n if (value)\n {\n fields[name] = value;\n }\n }\n\n return fields;\n}\n\n/** An HTML answer, with the three headers every consent answer carries. */\nfunction screen(status: number, body: string): NextResponse\n{\n return new NextResponse(body, {\n status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Security-Policy': \"frame-ancestors 'none'\",\n 'Cache-Control': 'no-store',\n },\n });\n}\n\n/**\n * A refusal screen.\n *\n * The message is one of the fixed set written below — nothing from the request\n * or from the API's error body reaches the page, because both are\n * attacker-supplied in exactly the cases that produce this screen.\n */\nfunction refusalScreen(status: number, heading: string, message: string): NextResponse\n{\n return screen(status, [\n '<!DOCTYPE html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Authorization request refused</title></head>',\n `<body><h1>${heading}</h1><p>${message}</p></body></html>`,\n ].join('\\n'));\n}\n\nfunction unknownClientScreen(): NextResponse\n{\n return refusalScreen(\n 400,\n 'Unrecognized application',\n 'The application that sent you here is not registered with this service, so the request '\n + 'cannot be completed. Nothing was shared.',\n );\n}\n\nfunction redirectMismatchScreen(): NextResponse\n{\n return refusalScreen(\n 400,\n 'Address not recognized',\n 'The application asked for the authorization to be returned to an address it never '\n + 'registered. Nothing was shared, and you were not sent there.',\n );\n}\n\nfunction unavailableScreen(): NextResponse\n{\n return refusalScreen(\n 500,\n 'Authorization unavailable',\n 'This authorization request could not be checked. Nothing was shared. Please try again.',\n );\n}\n\nfunction noSessionScreen(): NextResponse\n{\n return refusalScreen(\n 403,\n 'Sign-in required',\n 'This authorization request needs a signed-in session and yours is not available. Start '\n + 'the request again from the application.',\n );\n}\n\n/** A 302 that never caches, which is the only kind this file emits. */\nfunction redirect(url: URL): NextResponse\n{\n return NextResponse.redirect(url, { status: 302, headers: { 'Cache-Control': 'no-store' } });\n}\n\n/** Parse an absolute URL, refusing anything that is not one. */\nfunction safeUrl(value: string): URL | null\n{\n try\n {\n return new URL(value);\n }\n catch\n {\n return null;\n }\n}\n\n/**\n * Send an unauthenticated visitor to the login screen, or refuse to.\n *\n * The return destination is this request's own path and query — never an\n * absolute URL — and it is still held to `isSafeReturnPath`, because the query\n * is caller-supplied and a destination that leaves the app is the open redirect\n * every return path in this package is checked against.\n */\nfunction loginRedirect(request: NextRequest, loginPath: string): NextResponse\n{\n const returnPath = `${request.nextUrl.pathname}${request.nextUrl.search}`;\n\n if (!isSafeReturnPath(returnPath))\n {\n return refusalScreen(\n 400,\n 'Malformed authorization request',\n 'This authorization request cannot be signed in to. Start it again from the application.',\n );\n }\n\n const url = new URL(loginPath, request.url);\n url.searchParams.set('returnUrl', returnPath);\n\n return redirect(url);\n}\n\n/** The SPFN error envelope, as an `ApiError.response` carries it. */\ninterface ErrorEnvelope\n{\n error?: { code?: string; message?: string; details?: Record<string, unknown> };\n details?: Record<string, unknown>;\n}\n\n/**\n * The refusal's `details`, whichever shape the thrown value arrived in.\n *\n * A registered error class comes back deserialized and carries `details`\n * directly; anything else is an `ApiError` whose `response` holds the envelope.\n */\nfunction detailsOf(thrown: unknown): Record<string, unknown>\n{\n const error = thrown as { details?: Record<string, unknown>; response?: ErrorEnvelope } | null;\n\n return error?.details ?? error?.response?.error?.details ?? error?.response?.details ?? {};\n}\n\n/** HTTP status of the refusal — an `ApiError.status`, or an `HttpError.statusCode`. */\nfunction statusOf(thrown: unknown): number\n{\n const error = thrown as { status?: unknown; statusCode?: unknown } | null;\n\n return Number(error?.status ?? error?.statusCode ?? 0);\n}\n\n/**\n * The 302 a redirectable refusal earns, or null when it earns a screen.\n *\n * `redirectUri` is read from the API's answer and from nowhere else: it is the\n * value that matched the registration, which is what makes sending a browser\n * there safe. A refusal carrying no such value has no vetted destination, and is\n * shown rather than redirected.\n */\nfunction refusalRedirect(details: Record<string, unknown>): NextResponse | null\n{\n const { error, redirectUri, state } = details;\n\n if (typeof error !== 'string' || NON_REDIRECTABLE.has(error) || typeof redirectUri !== 'string')\n {\n return null;\n }\n\n const url = safeUrl(redirectUri);\n\n if (!url)\n {\n return null;\n }\n\n url.searchParams.set('error', error);\n\n if (typeof state === 'string')\n {\n url.searchParams.set('state', state);\n }\n\n return redirect(url);\n}\n\n/**\n * Turn an API refusal into the answer it earns.\n *\n * @param thrown - Whatever the typed client threw\n * @param onStaleSession - What a 401 means here: the GET redirects to the login\n * once, the POST has no form left to resume and refuses\n */\nfunction answerRefusal(thrown: unknown, onStaleSession: () => NextResponse): NextResponse\n{\n const status = statusOf(thrown);\n\n if (status === 401)\n {\n return onStaleSession();\n }\n\n const details = detailsOf(thrown);\n\n if (details.error === 'unknown_client')\n {\n return unknownClientScreen();\n }\n\n if (details.error === 'redirect_uri_mismatch')\n {\n return redirectMismatchScreen();\n }\n\n const redirectable = refusalRedirect(details);\n\n if (redirectable)\n {\n return redirectable;\n }\n\n // The status and nothing else: the error body was written for a request\n // somebody else composed, and the screen echoes none of it either.\n logger.error('OAuth2 consent request could not be answered', { status });\n\n return unavailableScreen();\n}\n\n/** One hidden input per authorize parameter, values escaped for an attribute. */\nfunction hiddenFields(fields: Record<string, string>, csrfToken: string): string\n{\n return [...Object.entries(fields), ['csrf', csrfToken]]\n .map(([name, value]) => `<input type=\"hidden\" name=\"${escapeHtml(name)}\" value=\"${escapeHtml(value)}\">`)\n .join('\\n ');\n}\n\n/**\n * The default consent page.\n *\n * Deliberately unstyled: an application that wants its own design system passes\n * `render`, and a page shipping CSS of its own would have to be undone first.\n */\nfunction defaultRender(view: OAuth2ConsentView): string\n{\n const scopes = view.scopes\n .map(scope => `<li><strong>${escapeHtml(scope.name)}</strong> — ${escapeHtml(scope.description)}</li>`)\n .join('\\n ');\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>Authorize ${escapeHtml(view.clientName)}</title></head>\n<body>\n <h1>Authorize ${escapeHtml(view.clientName)}</h1>\n <p><strong>${escapeHtml(view.clientName)}</strong> is asking to act on your behalf at\n <code>${escapeHtml(view.resource)}</code>. The authorization would be returned to\n <code>${escapeHtml(view.redirectHost)}</code>.</p>\n <h2>It is asking for</h2>\n <ul>\n ${scopes}\n </ul>\n <form method=\"post\">\n ${hiddenFields(view.fields, view.csrfToken)}\n <button type=\"submit\" name=\"decision\" value=\"approve\">Approve</button>\n <button type=\"submit\" name=\"decision\" value=\"deny\">Deny</button>\n </form>\n</body>\n</html>`;\n}\n\n/** The readable CSRF cookie's value, which the form has to echo back. */\nasync function csrfCookie(): Promise<string | null>\n{\n const cookieStore = await cookies();\n\n return cookieStore.get(sessionCookieNames().csrf)?.value ?? null;\n}\n\n/**\n * Draw the consent screen for an authorize request.\n *\n * The API decides whether there is anything to draw; this turns its answer into\n * a page. A session whose readable CSRF cookie is gone is treated as no session:\n * the form it would render could never be submitted, and signing in again is\n * what puts the cookie back.\n */\nasync function renderConsent(\n request: NextRequest,\n options: OAuth2AuthorizeHandlerOptions,\n): Promise<NextResponse>\n{\n const csrfToken = await csrfCookie();\n\n if (!csrfToken)\n {\n return loginRedirect(request, options.loginPath);\n }\n\n const fields = authorizeFields(name => request.nextUrl.searchParams.get(name));\n\n // The isomorphic client forwards this request's cookie jar and mirrors the\n // readable CSRF cookie into the header, so the call arrives as this user.\n const described = await authApi.getOAuth2Authorize.call({ query: fields as AuthorizeQuery });\n const render = options.render ?? defaultRender;\n\n return screen(200, render({ ...described, fields, csrfToken }));\n}\n\n/**\n * The form's own CSRF token, checked before the API is called at all.\n *\n * It belongs here rather than after the call: the handler's own call to the API\n * carries a CSRF header it mints itself and would always pass, so a cross-site\n * form POST would otherwise consent on the user's behalf. The comparison is\n * `matchesCsrfToken`, which is constant-time.\n *\n * @returns The refusal, or null when the POST may proceed\n */\nasync function refuseUnverifiedPost(form: FormData): Promise<NextResponse | null>\n{\n const presented = form.get('csrf');\n const expected = await csrfCookie();\n\n if (!expected || !matchesCsrfToken(expected, typeof presented === 'string' ? presented : null))\n {\n return refusalScreen(\n 403,\n 'Request could not be verified',\n 'This form did not carry a valid token for your session. Start the authorization again '\n + 'from the application.',\n );\n }\n\n return null;\n}\n\n/** Whether the body is a form at all, which is the only thing this POST reads. */\nfunction isFormPost(request: NextRequest): boolean\n{\n const contentType = request.headers.get('content-type') ?? '';\n\n return FORM_CONTENT_TYPES.some(type => contentType.startsWith(type));\n}\n\n/**\n * The decision, once the form's own CSRF token has been matched.\n *\n * `approve` is the button that was pressed and nothing else — a body with no\n * `decision` is a denial, the safe reading of a form the user did not finish.\n */\nasync function recordDecision(fields: Record<string, string>, decision: string | null): Promise<NextResponse>\n{\n const body = { ...fields, approve: decision === 'approve' } as DecisionBody;\n const issued = await authApi.createOAuth2AuthorizationCode.call({ body });\n const url = safeUrl(issued.redirectUri);\n\n if (!url)\n {\n return unavailableScreen();\n }\n\n url.searchParams.set('code', issued.code);\n\n if (issued.state !== undefined)\n {\n url.searchParams.set('state', issued.state);\n }\n\n return redirect(url);\n}\n\n/**\n * Create the consent screen's route handlers\n *\n * `GET` renders the screen for an `/oauth/authorize` request and `POST` takes\n * the form it submits. Mount both at the path published as\n * `authorization_endpoint` in the authorization server metadata —\n * `/oauth/authorize` unless `authorizationServer.authorizeUrl` says otherwise.\n *\n * Every answer carries `Cache-Control: no-store`; every page also carries\n * `Content-Security-Policy: frame-ancestors 'none'`, because a consent screen\n * that can be framed is a consent screen that can be clickjacked.\n *\n * @param options - Where to send an unauthenticated visitor, and an optional renderer\n * @returns `{ GET, POST }`, ready to re-export from a route file\n *\n * @example\n * ```typescript\n * // app/oauth/authorize/route.ts\n * import { createOAuth2AuthorizeHandlers } from '@spfn/auth/nextjs/server';\n *\n * export const { GET, POST } = createOAuth2AuthorizeHandlers({ loginPath: '/login' });\n * ```\n */\nexport function createOAuth2AuthorizeHandlers(\n options: OAuth2AuthorizeHandlerOptions,\n): OAuth2AuthorizeHandlers\n{\n async function GET(request: NextRequest): Promise<NextResponse>\n {\n if (!await getSession())\n {\n return loginRedirect(request, options.loginPath);\n }\n\n try\n {\n return await renderConsent(request, options);\n }\n catch (error)\n {\n return answerRefusal(error, () => loginRedirect(request, options.loginPath));\n }\n }\n\n async function POST(request: NextRequest): Promise<NextResponse>\n {\n if (!await getSession())\n {\n return noSessionScreen();\n }\n\n if (!isFormPost(request))\n {\n return refusalScreen(\n 415,\n 'Unsupported request',\n 'The consent form is submitted as a form. Start the authorization again from the '\n + 'application.',\n );\n }\n\n const form = await request.formData();\n const refusal = await refuseUnverifiedPost(form);\n\n if (refusal)\n {\n return refusal;\n }\n\n try\n {\n const fields = authorizeFields(name => form.get(name) as string | null);\n\n return await recordDecision(fields, form.get('decision') as string | null);\n }\n catch (error)\n {\n return answerRefusal(error, noSessionScreen);\n }\n }\n\n return { GET, POST };\n}\n","/**\n * @spfn/auth - The sign-out-everywhere page (Next.js route handlers)\n *\n * The page the mailed revoke-all link opens. `GET` describes the link and draws\n * the button, `POST` presses it, and neither decides anything: both forward the\n * token to `/_auth/keys/revoke-all/{confirm,consume}`, which answers either what\n * to draw or the same 404 it answers for every token that names nothing.\n *\n * Four rules shape everything below, and each of them is an attack or a mishap\n * that would otherwise work:\n *\n * - **The token is never anywhere but a hidden field and an API body.** Not in a\n * `Location`, not in a log line, not in the text of the page. It is a bearer\n * capability, and the request logger records the path of every request.\n * - **There is no session here, so the CSRF token cannot come from one.** The\n * whole point of the link is an owner on a device they do not trust. `GET`\n * mints 32 random bytes, sets them in a cookie scoped to this page's path, and\n * mirrors them into the form; `POST` compares the two. A cross-site form has\n * neither half.\n * - **Opening the page signs nobody out.** `GET` calls `confirm`, which the API\n * guarantees changes nothing, so a mail scanner that prefetches the link has\n * done nothing. `consume` is only ever reached from `POST`.\n * - **Every refusal reads the same.** A 404 from either endpoint means unknown,\n * expired, spent, superseded or retired, and the screen says none of them:\n * telling them apart would tell whoever holds a random value that it named\n * something real.\n */\n\nimport { cookies } from 'next/headers.js';\nimport { NextResponse, type NextRequest } from 'next/server';\n\nimport { authApi } from '@spfn/auth';\nimport { logger } from '@spfn/core/logger';\n\nimport { escapeHtml } from './oauth2-authorize-handlers';\nimport { matchesCsrfToken } from '../server/lib/csrf';\n\n/**\n * Cookie holding the value the form has to echo back.\n *\n * `__Host-`-style in every respect a page path allows: `HttpOnly`, `Secure` off\n * localhost, `SameSite=Strict`, and no `Domain`, so no sibling subdomain can\n * write it. Not the literal `__Host-` prefix, which browsers only honour with\n * `Path=/` — and a path of `/` would send this cookie on every request in the\n * app, which is the opposite of what it is for.\n */\nconst CSRF_COOKIE = 'spfn_revoke_all_csrf';\n\n/** How long the minted CSRF value stays good, in seconds. */\nconst CSRF_TTL_SECONDS = 15 * 60;\n\n/** The only content type a browser form arrives as here. */\nconst FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded';\n\n/**\n * Everything the page needs at each of its three stages, raw and unescaped.\n *\n * A custom `render` receives this and owns the whole body, so at `confirm` it\n * must echo `fields` and `csrfToken` back as hidden inputs: the POST is refused\n * without the token, and the API re-reads the link from the field rather than\n * trusting what the GET was once shown.\n *\n * `fields` and `csrfToken` are empty at the other two stages — there is no form\n * left to submit once the link has been spent or found invalid.\n *\n * Every string here goes through {@link escapeHtml} before it reaches the page,\n * `fields.token` above all: it is whatever was in the query of a link somebody\n * was sent.\n */\nexport interface RevokeAllPageView\n{\n /** `confirm` draws the button, `done` reports the sign-out, `invalid` refuses. */\n stage: 'confirm' | 'done' | 'invalid';\n\n /** ISO instant the link stops working. `confirm` only. */\n expiresAt?: string;\n\n /** Devices the link would sign out. `confirm` only. */\n activeKeyCount?: number;\n\n /** Devices the link did sign out. `done` only. */\n revokedCount?: number;\n\n /** The form's hidden inputs — `token` at `confirm`, empty otherwise. */\n fields: Record<string, string>;\n\n /** Value the POST's `csrf` field must carry. Empty outside `confirm`. */\n csrfToken: string;\n}\n\n/**\n * Options for {@link createRevokeAllPageHandlers}\n */\nexport interface RevokeAllPageHandlerOptions\n{\n /**\n * Replace the default page body\n *\n * Status, headers, the cookie and the field set stay the handler's; this\n * owns the HTML, at all three stages.\n */\n render?: (view: RevokeAllPageView) => string;\n}\n\n/** The pair a route file re-exports as `export const { GET, POST } = ...`. */\nexport interface RevokeAllPageHandlers\n{\n GET: (request: NextRequest) => Promise<NextResponse>;\n POST: (request: NextRequest) => Promise<NextResponse>;\n}\n\n/** An HTML answer, with the three headers every answer from this page carries. */\nfunction screen(status: number, body: string): NextResponse\n{\n return new NextResponse(body, {\n status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Security-Policy': \"frame-ancestors 'none'\",\n 'Cache-Control': 'no-store',\n },\n });\n}\n\n/**\n * A refusal screen.\n *\n * The message is one of the fixed set written below — nothing from the request\n * or from the API's error body reaches the page, because in every case that\n * produces this screen both are attacker-supplied.\n */\nfunction refusalScreen(status: number, heading: string, message: string): NextResponse\n{\n return screen(status, [\n '<!DOCTYPE html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Sign out everywhere</title></head>',\n `<body><h1>${heading}</h1><p>${message}</p></body></html>`,\n ].join('\\n'));\n}\n\nfunction missingTokenScreen(): NextResponse\n{\n return refusalScreen(\n 400,\n 'Incomplete link',\n 'This address is missing the part that identifies the request. Open the link from your '\n + 'email again, in full.',\n );\n}\n\nfunction unavailableScreen(): NextResponse\n{\n return refusalScreen(\n 500,\n 'Sign-out unavailable',\n 'This link could not be checked. Nothing was changed. Please try again.',\n );\n}\n\nfunction unverifiedScreen(): NextResponse\n{\n return refusalScreen(\n 403,\n 'Request could not be verified',\n 'This form did not carry the token the page set for it. Open the link from your email '\n + 'again and press the button on the page it opens.',\n );\n}\n\nfunction unsupportedScreen(): NextResponse\n{\n return refusalScreen(\n 415,\n 'Unsupported request',\n 'This page is answered for a browser form. Open the link from your email again.',\n );\n}\n\n/** One hidden input per field, values escaped for an attribute. */\nfunction hiddenFields(fields: Record<string, string>, csrfToken: string): string\n{\n return [...Object.entries(fields), ['csrf', csrfToken]]\n .map(([name, value]) => `<input type=\"hidden\" name=\"${escapeHtml(name)}\" value=\"${escapeHtml(value)}\">`)\n .join('\\n ');\n}\n\n/** The confirm stage: what the link would do, and the one button that does it. */\nfunction confirmBody(view: RevokeAllPageView): string\n{\n return `<h1>Sign out everywhere</h1>\n <p>This will sign out <strong>${view.activeKeyCount}</strong> signed-in device(s), including\n this one. You will need to sign in again afterwards.</p>\n <p>The link stops working at <time datetime=\"${escapeHtml(view.expiresAt ?? '')}\"\n >${escapeHtml(view.expiresAt ?? '')}</time>.</p>\n <form method=\"post\">\n ${hiddenFields(view.fields, view.csrfToken)}\n <button type=\"submit\">Sign out every device</button>\n </form>`;\n}\n\n/** The done stage, and the invalid stage that says nothing about why. */\nfunction stageBody(view: RevokeAllPageView): string\n{\n if (view.stage === 'confirm')\n {\n return confirmBody(view);\n }\n\n if (view.stage === 'done')\n {\n return `<h1>Signed out</h1>\n <p>${view.revokedCount} device(s) signed out. Sign in again to carry on.</p>`;\n }\n\n return `<h1>Link no longer valid</h1>\n <p>This link cannot be used. Ask for a new one, and open the most recent email you were sent.</p>`;\n}\n\n/**\n * The default page, at whichever stage it was reached.\n *\n * Deliberately unstyled: an application that wants its own design system passes\n * `render`, and a page shipping CSS of its own would have to be undone first.\n * The token is in the form and nowhere in the text — the stages say what\n * happened, never which link it happened to.\n */\nfunction defaultRender(view: RevokeAllPageView): string\n{\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>Sign out everywhere</title></head>\n<body>\n ${stageBody(view)}\n</body>\n</html>`;\n}\n\n/** The view every stage but `confirm` is drawn from: no form, so no form fields. */\nfunction stageView(stage: 'done' | 'invalid', revokedCount?: number): RevokeAllPageView\n{\n return { stage, revokedCount, fields: {}, csrfToken: '' };\n}\n\n/** HTTP status of the refusal — an `ApiError.status`, or an `HttpError.statusCode`. */\nfunction statusOf(thrown: unknown): number\n{\n const error = thrown as { status?: unknown; statusCode?: unknown } | null;\n\n return Number(error?.status ?? error?.statusCode ?? 0);\n}\n\n/**\n * Turn an API refusal into the answer it earns.\n *\n * The 404 is every reason a link can fail and is shown as one screen. Anything\n * else is this deployment's problem rather than the visitor's, and is logged as\n * a status and nothing else: the error body was written about a token, and a\n * token belongs in no log.\n */\nfunction answerRefusal(thrown: unknown, render: (view: RevokeAllPageView) => string): NextResponse\n{\n if (statusOf(thrown) === 404)\n {\n return screen(404, render(stageView('invalid')));\n }\n\n logger.error('Revoke-all link could not be answered', { status: statusOf(thrown) });\n\n return unavailableScreen();\n}\n\n/** 32 random bytes as hex — a value only the browser that was served it holds. */\nfunction mintCsrfToken(): string\n{\n return Array.from(crypto.getRandomValues(new Uint8Array(32)))\n .map(byte => byte.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Scope the minted CSRF cookie to this page and nothing else.\n *\n * The path is the page's own, so the cookie is not sent with any other request\n * the app makes, and `SameSite=Strict` keeps it off requests another site\n * caused. Fifteen minutes is long enough to read the page and shorter than the\n * link itself.\n */\nfunction setCsrfCookie(response: NextResponse, csrfToken: string, path: string): NextResponse\n{\n response.cookies.set(CSRF_COOKIE, csrfToken, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'strict',\n path,\n maxAge: CSRF_TTL_SECONDS,\n });\n\n return response;\n}\n\n/** Expire the cookie once its form has been submitted, so it is good for one POST. */\nfunction clearCsrfCookie(response: NextResponse, path: string): NextResponse\n{\n response.cookies.delete({ name: CSRF_COOKIE, path });\n\n return response;\n}\n\n/**\n * Draw the button, having asked the API what pressing it would do.\n *\n * `confirm` is the only call this handler makes, and it changes nothing — the\n * page is safe to prefetch, which a mail scanner will do.\n */\nasync function renderConfirm(\n request: NextRequest,\n token: string,\n render: (view: RevokeAllPageView) => string,\n): Promise<NextResponse>\n{\n const described = await authApi.confirmRevokeAllLink.call({ body: { token } });\n const csrfToken = mintCsrfToken();\n\n const response = screen(200, render({\n stage: 'confirm',\n expiresAt: described.expiresAt,\n activeKeyCount: described.activeKeyCount,\n fields: { token },\n csrfToken,\n }));\n\n return setCsrfCookie(response, csrfToken, request.nextUrl.pathname);\n}\n\n/**\n * The form's own CSRF token, checked before the API is called at all.\n *\n * It belongs here rather than after the call: the token in the form is the whole\n * credential, so a cross-site POST that could reach `consume` would sign an\n * account out on the strength of a link the attacker already read somewhere.\n * They cannot read this cookie, and `matchesCsrfToken` compares in constant time.\n *\n * @returns The refusal, or null when the POST may proceed\n */\nasync function refuseUnverifiedPost(form: FormData): Promise<NextResponse | null>\n{\n const presented = form.get('csrf');\n const expected = (await cookies()).get(CSRF_COOKIE)?.value;\n\n if (!expected || !matchesCsrfToken(expected, typeof presented === 'string' ? presented : null))\n {\n return unverifiedScreen();\n }\n\n return null;\n}\n\n/** Whether the body is the form this POST reads, which is the only thing it reads. */\nfunction isFormPost(request: NextRequest): boolean\n{\n return (request.headers.get('content-type') ?? '').startsWith(FORM_CONTENT_TYPE);\n}\n\n/**\n * Press the button.\n *\n * The token comes from the form's hidden field and the count from the answer;\n * every other field the body carried is ignored, because the API is told the one\n * thing it asks for.\n */\nasync function consume(token: string, render: (view: RevokeAllPageView) => string): Promise<NextResponse>\n{\n const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });\n\n return screen(200, render(stageView('done', revokedCount)));\n}\n\n/**\n * Create the sign-out-everywhere page's route handlers\n *\n * `GET` draws the page the mailed link opens and `POST` takes the form it\n * submits. Mount both at `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` —\n * `/account/revoke-all` unless that variable says otherwise — which is the path\n * `createRevokeAllLink` builds its URL on.\n *\n * There is no session on this page and none is wanted: an owner who no longer\n * trusts the device in front of them is exactly who the link is for. What stands\n * in for the session is the token in the query, and what stands in for a\n * session-derived CSRF token is a random value `GET` sets in a path-scoped\n * cookie and mirrors into the form.\n *\n * Every answer carries `Cache-Control: no-store` and\n * `Content-Security-Policy: frame-ancestors 'none'`: a page whose one button\n * signs out every device is a page worth clickjacking, and a copy of it in a\n * shared cache is a copy of the token.\n *\n * @param options - An optional renderer; the defaults need nothing else\n * @returns `{ GET, POST }`, ready to re-export from a route file\n *\n * @example\n * ```typescript\n * // app/account/revoke-all/route.ts\n * import { createRevokeAllPageHandlers } from '@spfn/auth/nextjs/server';\n *\n * export const { GET, POST } = createRevokeAllPageHandlers();\n * ```\n */\nexport function createRevokeAllPageHandlers(\n options: RevokeAllPageHandlerOptions = {},\n): RevokeAllPageHandlers\n{\n const render = options.render ?? defaultRender;\n\n async function GET(request: NextRequest): Promise<NextResponse>\n {\n const token = request.nextUrl.searchParams.get('token');\n\n if (!token)\n {\n return missingTokenScreen();\n }\n\n try\n {\n return await renderConfirm(request, token, render);\n }\n catch (error)\n {\n return answerRefusal(error, render);\n }\n }\n\n async function POST(request: NextRequest): Promise<NextResponse>\n {\n if (!isFormPost(request))\n {\n return unsupportedScreen();\n }\n\n const form = await request.formData();\n const refusal = await refuseUnverifiedPost(form);\n\n if (refusal)\n {\n return refusal;\n }\n\n const path = request.nextUrl.pathname;\n const token = form.get('token');\n\n if (typeof token !== 'string' || !token)\n {\n return clearCsrfCookie(missingTokenScreen(), path);\n }\n\n try\n {\n return clearCsrfCookie(await consume(token, render), path);\n }\n catch (error)\n {\n return clearCsrfCookie(answerRefusal(error, render), path);\n }\n }\n\n return { GET, POST };\n}\n"],"mappings":"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","names":["jose","env","env","env","env","env","env","redirect","Fragment","jsx","redirect","redirect","Fragment","jsx","redirect","cookies","env","logger","logger","cookies","env","error","cookies","NextResponse","authApi","logger","NextResponse","redirect","logger","cookies","authApi","cookies","NextResponse","authApi","logger","screen","NextResponse","refusalScreen","unavailableScreen","hiddenFields","defaultRender","statusOf","answerRefusal","logger","authApi","refuseUnverifiedPost","cookies","isFormPost"]}