@spfn/auth 0.3.0-beta.24 → 0.3.0-beta.26
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.
- package/README.md +163 -5
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +126 -9
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +50 -1
- package/dist/client.js +25 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +48 -2
- package/dist/errors.js +27 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +23 -16
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-ZJd9anVT.d.ts → machine-principals-CdEgxOB1.d.ts} +1397 -886
- package/dist/nextjs/api.js +194 -49
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +17 -0
- package/dist/nextjs/server.js +33 -6
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +256 -96
- package/dist/server.js +1328 -676
- package/dist/server.js.map +1 -1
- package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
- package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
- package/package.json +1 -1
package/dist/nextjs/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/nextjs/api.ts","../../src/server/lib/crypto.ts","../../src/server/lib/session.ts","../../src/server/logger.ts","../../src/server/lib/config.ts","../../src/nextjs/interceptors/cookie-options.ts","../../src/server/lib/csrf.ts","../../src/nextjs/interceptors/csrf.ts","../../src/nextjs/interceptors/session-binding.ts","../../src/server/lib/ua-family.ts","../../src/nextjs/interceptors/error-envelope.ts","../../src/nextjs/interceptors/login-register.ts","../../src/nextjs/interceptors/general-auth.ts","../../src/nextjs/interceptors/session-renew.ts","../../src/nextjs/interceptors/key-rotation.ts","../../src/server/lib/oauth/state.ts","../../src/lib/return-path.ts","../../src/nextjs/session-helpers.ts","../../src/nextjs/interceptors/oauth.ts","../../src/nextjs/interceptors/signup-link.ts","../../src/nextjs/interceptors/password-reset.ts","../../src/nextjs/interceptors/index.ts"],"sourcesContent":["/**\n * @spfn/auth/adapters/nextjs/api\n *\n * Next.js Adapter for SPFN Auth\n *\n * Provides automatic interceptor registration for seamless auth flow:\n * - Session management (HttpOnly cookies)\n * - JWT generation and signing\n * - Public key encryption\n *\n * @requires next >= 13.0.0\n *\n * @example\n * ```typescript\n * // Just import to auto-register interceptors\n * import '@spfn/auth/nextjs/api';\n * ```\n */\n\n// Re-export interceptors for advanced usage\nimport { registerInterceptors } from '@spfn/core/nextjs/server';\nimport { authInterceptors } from './interceptors';\n\n// Auto-register interceptors on import\nregisterInterceptors('auth', authInterceptors);\n","/**\n * @spfn/auth - Client Crypto Helpers\n *\n * ES256 (ECDSA P-256) key generation and JWT signing for Next.js\n * Keys are stored in DER format (Base64 encoded) for efficiency\n *\n * Key Sizes:\n * - ES256 (ECDSA P-256): ~91 bytes (Base64: ~120 chars)\n * - RS256 (RSA 2048): ~294 bytes (Base64: ~392 chars)\n */\n\nimport { type KeyAlgorithmType } from '../types';\nimport crypto from 'crypto';\nimport jwt, { type Algorithm, type SignOptions } from 'jsonwebtoken';\n\ntype Unit =\n | 'Years'\n | 'Year'\n | 'Yrs'\n | 'Yr'\n | 'Y'\n | 'Weeks'\n | 'Week'\n | 'W'\n | 'Days'\n | 'Day'\n | 'D'\n | 'Hours'\n | 'Hour'\n | 'Hrs'\n | 'Hr'\n | 'H'\n | 'Minutes'\n | 'Minute'\n | 'Mins'\n | 'Min'\n | 'M'\n | 'Seconds'\n | 'Second'\n | 'Secs'\n | 'Sec'\n | 's'\n | 'Milliseconds'\n | 'Millisecond'\n | 'Msecs'\n | 'Msec'\n | 'Ms';\n\ntype UnitAnyCase = Unit | Uppercase<Unit> | Lowercase<Unit>;\n\ntype StringValue =\n | `${number}`\n | `${number}${UnitAnyCase}`\n | `${number} ${UnitAnyCase}`;\n\nexport interface KeyPair\n{\n privateKey: string; // Base64 encoded DER\n publicKey: string; // Base64 encoded DER\n keyId: string; // UUID\n fingerprint: string; // SHA-256 hash\n algorithm: KeyAlgorithmType;\n}\n\n/**\n * Generate ECDSA P-256 key pair (ES256)\n * Recommended for optimal size and performance\n */\nexport function generateKeyPairES256(): KeyPair\n{\n const keyId = crypto.randomUUID();\n\n const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {\n namedCurve: 'P-256', // ES256\n publicKeyEncoding: {\n type: 'spki',\n format: 'der',\n },\n privateKeyEncoding: {\n type: 'pkcs8',\n format: 'der',\n },\n });\n\n // Convert Buffer to Base64\n const privateKeyB64 = privateKey.toString('base64');\n const publicKeyB64 = publicKey.toString('base64');\n\n // Generate fingerprint (SHA-256 of public key)\n const fingerprint = crypto\n .createHash('sha256')\n .update(publicKey)\n .digest('hex');\n\n return {\n privateKey: privateKeyB64,\n publicKey: publicKeyB64,\n keyId,\n fingerprint,\n algorithm: 'ES256',\n };\n}\n\n/**\n * Generate RSA 2048 key pair (RS256)\n * Fallback option, larger size but wider compatibility\n */\nexport function generateKeyPairRS256(): KeyPair\n{\n const keyId = crypto.randomUUID();\n\n const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {\n modulusLength: 2048,\n publicKeyEncoding: {\n type: 'spki',\n format: 'der',\n },\n privateKeyEncoding: {\n type: 'pkcs8',\n format: 'der',\n },\n });\n\n const privateKeyB64 = privateKey.toString('base64');\n const publicKeyB64 = publicKey.toString('base64');\n\n const fingerprint = crypto\n .createHash('sha256')\n .update(publicKey)\n .digest('hex');\n\n return {\n privateKey: privateKeyB64,\n publicKey: publicKeyB64,\n keyId,\n fingerprint,\n algorithm: 'RS256',\n };\n}\n\n/**\n * Generate key pair (defaults to ES256)\n */\nexport function generateKeyPair(\n algorithm: KeyAlgorithmType = 'ES256',\n): KeyPair\n{\n return algorithm === 'ES256'\n ? generateKeyPairES256()\n : generateKeyPairRS256();\n}\n\n/**\n * Generate JWT signed with client private key (DER format)\n */\nexport function generateClientToken(\n payload: Record<string, any>,\n privateKeyB64: string,\n algorithm: Algorithm,\n options?: {\n expiresIn?: StringValue | number;\n issuer?: string;\n },\n): string\n{\n try\n {\n // Convert Base64 back to Buffer\n const privateKeyDER = Buffer.from(privateKeyB64, 'base64');\n\n // Create key object for signing\n const privateKeyObject = crypto.createPrivateKey({\n key: privateKeyDER,\n format: 'der',\n type: 'pkcs8',\n });\n\n // Export as PEM for jwt.sign\n const privateKeyPEM = privateKeyObject.export({\n type: 'pkcs8',\n format: 'pem',\n });\n\n const signOptions: SignOptions = {\n algorithm,\n issuer: options?.issuer || 'spfn-client',\n expiresIn: options?.expiresIn ?? '15m', // Default to 15 minutes\n };\n\n return jwt.sign(payload, privateKeyPEM, signOptions);\n }\n catch (error)\n {\n throw new Error(\n `Failed to generate client token: ${error instanceof Error ? error.message : 'Unknown error'}`,\n );\n }\n}\n\n/**\n * Get key size information\n */\nexport function getKeySize(publicKeyB64: string): {\n bytes: number;\n base64Length: number;\n}\n{\n const keyDER = Buffer.from(publicKeyB64, 'base64');\n\n return {\n bytes: keyDER.length,\n base64Length: publicKeyB64.length,\n };\n}\n\n/**\n * Check if key should be rotated based on creation date\n */\nexport function shouldRotateKey(\n createdAt: Date,\n rotationDays: number = 90,\n): {\n shouldRotate: boolean;\n daysRemaining: number;\n}\n{\n const now = new Date();\n const ageInDays = Math.floor(\n (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24),\n );\n const daysRemaining = Math.max(0, rotationDays - ageInDays);\n\n return {\n shouldRotate: daysRemaining <= 7, // Warn 7 days before expiry\n daysRemaining,\n };\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 - 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 /** 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\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 * 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 - 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 * CSRF check for cookie-authenticated mutations\n *\n * Enforced here, in the Next.js proxy, because this is the only layer that knows\n * a request's credential was ambient: the browser sends an encrypted HttpOnly\n * session cookie, generalAuthInterceptor turns it into a short-lived bearer JWT,\n * and the backend then sees `scheme:'bearer'` for cookie callers and genuine\n * bearer callers alike. Requests the proxy does not authenticate from the session\n * cookie — no session, direct-to-backend bearer, clientProofV1, machine and ops\n * tokens — never reach this code and are unaffected.\n *\n * The threat model is in the README: the session cookie is SameSite=Lax, so this\n * covers what Lax does not (sibling-subdomain pivots, legacy browsers, domain\n * layout drift) and covers nothing an XSS on your own origin could not do anyway.\n */\n\nimport type { ProxyAbort, RequestInterceptorContext } from '@spfn/core/nextjs/server';\nimport type { SetCookie } from '@spfn/core/nextjs';\n\nimport { getCsrfMode, getCsrfExemptPaths, getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { CSRF_HEADER, deriveCsrfToken, matchesCsrfToken, timingSafeEqualString } from '../../server/lib/csrf';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\n\n/**\n * Methods that cannot mutate, so they need no token.\n *\n * Compared against the resolved *route* method, not the method the browser used\n * to reach the proxy: `GET /api/rpc/deleteAccount?input=…` is forwarded to the\n * backend as the route's DELETE, and SameSite=Lax does send the session cookie on\n * a cross-site top-level GET navigation. Gating on the wire method would leave\n * every mutation reachable that way.\n */\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);\n\n/**\n * The readable CSRF cookie, as every place that issues one builds it.\n *\n * Mirrors the session cookie's attributes minus HttpOnly — the client has to read\n * it — and carries only the HMAC, never the key id it derives from.\n */\nfunction csrfCookie(token: string, ttl: number): SetCookie\n{\n return {\n name: COOKIE_NAMES.CSRF,\n value: token,\n options: {\n httpOnly: false,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n };\n}\n\n/**\n * The 403 a failed check answers with.\n *\n * The body is the same whatever the reason: a caller learns that the request was\n * refused, never whether the header was absent, wrong, or impossible to check.\n *\n * It carries no cookie. The caller attaches one afterwards where there is a value\n * to attach — see refuseInvalidCsrf for why that order matters.\n */\nfunction refusal(): ProxyAbort\n{\n return {\n status: 403,\n body: {\n error: 'Forbidden',\n message: 'CSRF token missing or invalid',\n },\n setCookies: [],\n };\n}\n\n/**\n * Refuse a cookie-authenticated mutation that has no valid CSRF header.\n *\n * Call only once the session has been unsealed — a refusal must never be the\n * answer to a request that had no session, or the status alone would tell an\n * unauthenticated caller whether someone is signed in. Unauthenticated requests\n * keep taking the existing path (the backend answers 401).\n *\n * The refusal carries a fresh CSRF cookie. A refused request skips the backend\n * and every response interceptor, so this is the only chance to repair a browser\n * whose token cookie went missing or stale — without it the client would be told\n * \"wrong token\" while holding nothing better to try, and the documented recovery\n * would need an unrelated GET to happen first.\n *\n * @param ctx - Request interceptor context, mutated with `abort` on refusal\n * @param keyId - Key id of the session that authenticated this request\n * @returns True when the request was refused and the caller must stop\n */\nexport async function refuseInvalidCsrf(\n ctx: RequestInterceptorContext,\n keyId: string,\n): Promise<boolean>\n{\n const mode = getCsrfMode();\n\n if (mode === 'off' || SAFE_METHODS.has(ctx.method.toUpperCase()))\n {\n return false;\n }\n\n if (getCsrfExemptPaths().includes(ctx.path))\n {\n authLogger.interceptor.csrf.debug('Path is CSRF-exempt', { path: ctx.path });\n\n return false;\n }\n\n let expected: string;\n\n try\n {\n expected = await deriveCsrfToken(keyId);\n }\n catch (error)\n {\n // Fail closed, in every mode including the default `warn`. A deployment\n // that cannot derive the token is misconfigured, not unprotected, and\n // letting warn wave these through would leave a broken install silently\n // open while its logs looked like a healthy one's. No cookie either —\n // there is no value to issue.\n authLogger.interceptor.csrf.error(\n 'Cannot derive the CSRF token — refusing regardless of mode',\n error as Error,\n );\n\n ctx.abort = refusal();\n\n return true;\n }\n\n // Read the header off the ORIGINAL browser request. ctx.headers is the set\n // buildProxyHeaders() forwards to the backend, and that is a fixed allowlist\n // — the CSRF header is not on it, deliberately: it is proxy-terminated and\n // has no meaning past this point. Reading ctx.headers here would refuse\n // every request instead.\n const presented = ctx.request.headers.get(CSRF_HEADER);\n\n if (matchesCsrfToken(expected, presented))\n {\n return false;\n }\n\n // One line per request that fails the check, in either mode. Never the\n // expected token or the key id — the log would otherwise hand out what the\n // check exists to withhold.\n const detail = {\n method: ctx.method,\n path: ctx.path,\n headerPresent: !!presented,\n };\n\n if (mode === 'warn')\n {\n authLogger.interceptor.csrf.warn('CSRF check would refuse this request (mode=warn)', detail);\n\n return false;\n }\n\n authLogger.interceptor.csrf.warn('CSRF check refused this request', detail);\n\n ctx.abort = refusal();\n\n // The repair is attached after the refusal is already in place. Building the\n // cookie needs the session TTL, which parses configuration and throws on a\n // malformed value — and a throw on the way out of here is swallowed upstream,\n // which would hand the caller the backend's 401 instead of this 403. Refusing\n // without the repair costs one user one extra page load; refusing with the\n // wrong status changes what the check means.\n ctx.abort.setCookies = [csrfCookie(expected, getSessionTtl())];\n\n return true;\n}\n\n/**\n * Queue the readable CSRF cookie for a session.\n *\n * Set wherever a session is established or renewed.\n *\n * Queued in every mode, including `off`, so that turning enforcement on later\n * does not require everyone to sign in again.\n */\nexport async function pushCsrfCookie(\n setCookies: SetCookie[],\n keyId: string,\n ttl: number,\n): Promise<void>\n{\n setCookies.push(csrfCookie(await deriveCsrfToken(keyId), ttl));\n}\n\n/**\n * Queue the readable CSRF cookie when the request's copy is missing or stale.\n *\n * The other half of the recovery path: a session that predates this feature\n * carries no cookie, and one whose value stopped matching — a key rotated down a\n * path that did not reissue, a jar half-cleared by an extension — would\n * otherwise sit broken until the session came within a day of expiry. A cookie\n * that is present but no longer matches is reissued; presence alone is not\n * proof. Any authenticated response repairs it, which is what makes one page\n * load enough.\n *\n * A derivation failure is logged rather than thrown: the request-side check\n * already refuses those, and turning every authenticated response into a 500 on\n * top of that helps nobody diagnose it.\n */\nexport async function pushCsrfCookieIfStale(\n setCookies: SetCookie[],\n presented: string | undefined,\n keyId: string,\n): Promise<void>\n{\n try\n {\n const token = await deriveCsrfToken(keyId);\n\n if (presented && timingSafeEqualString(token, presented))\n {\n return;\n }\n\n setCookies.push(csrfCookie(token, getSessionTtl()));\n }\n catch (error)\n {\n authLogger.interceptor.csrf.error('Cannot reissue the CSRF cookie', error as Error);\n }\n}\n\n/**\n * Queue removal of the readable CSRF cookie (logout, expired session).\n */\nexport function pushCsrfCookieRemoval(setCookies: SetCookie[]): void\n{\n setCookies.push({\n name: COOKIE_NAMES.CSRF,\n value: '',\n options: { maxAge: 0, path: '/' },\n });\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 * The body a proxy-minted refusal carries.\n *\n * Exactly the shape a backend refusal has: `__type` and `message` at the top\n * level, plus the `{ code, message, requestId }` envelope `ErrorHandler` attaches\n * beside them. That is what makes an interceptor's 401 arrive at the app as the\n * error class it names — `handleErrorResponse` restores a class only when the\n * body has `__type` and `authErrorRegistry` knows it — so `err instanceof\n * SessionRenewalRequiredError` reads the same whether the refusal came from here\n * or from a route.\n *\n * `interceptors/csrf.ts` mints a refusal that is *not* this shape. It predates\n * this helper and its 403 carries a deliberately uninformative `{ error, message }`\n * body; it is not the precedent to follow, and it is named here so that the\n * difference reads as a decision rather than as drift.\n */\n\nimport type { ProxyAbort } from '@spfn/core/nextjs/server';\nimport type { HttpError } from '@spfn/core/errors';\n\n/**\n * Serialize a registered error as the refusal an interceptor aborts with.\n *\n * @param error - an error class listed in `authErrorRegistry`; anything else\n * reaches the client as a bare `ApiError`, which is the thing this avoids\n * @param setCookies - cookies to put on the refusal itself. A refusal skips the\n * backend and every response interceptor, so this is the only chance to touch\n * the browser's jar — and leaving it empty is how a refusal keeps the cookies\n * the caller already had.\n */\nexport function refusalEnvelope(error: HttpError, setCookies: ProxyAbort['setCookies'] = []): ProxyAbort\n{\n const body = error.toJSON() as { __type: string; message: string };\n\n return {\n status: error.statusCode,\n body: {\n ...body,\n error: {\n code: body.__type,\n message: body.message,\n requestId: mintRequestId(),\n },\n },\n setCookies,\n };\n}\n\n/**\n * A request id for a response no request logger ever saw.\n *\n * The backend's own envelope carries the id `RequestLogger` set, or mints one for\n * that response alone when there is none. A refusal minted here never reached the\n * backend, so there is nothing to correlate with and the same fallback applies —\n * 16 random bytes as hex, so a person reading one out to support is reading the\n * same shape of value either way.\n */\nfunction mintRequestId(): string\n{\n const bytes = crypto.getRandomValues(new Uint8Array(16));\n\n return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n","/**\n * Login/Register Interceptor\n *\n * Automatically handles key generation and session management\n * for login, register, and invitation-accept endpoints.\n * (Invitation acceptance creates the user account + key pair and\n * logs the new user in, so it follows the same key/session flow.)\n *\n * `session/renew/verify` is on the list too (#97). Renewing a bound session key\n * needs exactly what a sign-in needs — a fresh pair generated here, the public\n * half in the body, the private half sealed into the cookie — so it is served by\n * this interceptor rather than by a second copy of it. `keyId` below means the\n * new key on that path exactly as it does on every other; the key being replaced\n * is not in the body at all, it is the one `general-auth` signs the request with.\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { generateKeyPair } from '../../server/lib/crypto';\nimport { sealSession } from '../../server/lib/session';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\nimport { bindingSessionFields } from './session-binding';\n\n/**\n * The sign-in paths that replace a key the browser already holds.\n *\n * Register, invitation-accept and signup/password create the account, so there\n * is nothing to rotate; the two sign-ins can each arrive at a browser that is\n * already carrying a session key.\n */\nconst ROTATING_SIGN_IN_PATHS = new Set(['/_auth/login', '/_auth/passkeys/login/verify']);\n\n/**\n * Login, Register, and Invitation-Accept Interceptor\n *\n * Request: Generates key pair and adds publicKey to request body\n * Response: Saves privateKey to HttpOnly cookie\n */\nexport const loginRegisterInterceptor: InterceptorRule =\n {\n pathPattern: /^\\/_auth\\/(login|register|invitations\\/accept|signup\\/password|password\\/reset\\/complete|passkeys\\/login\\/verify|session\\/renew\\/verify)$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n // Get old session if exists (for key rotation on login)\n const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);\n\n // Extract remember option from request body (if provided)\n const remember = ctx.body?.remember;\n\n // Generate new key pair\n const keyPair = generateKeyPair('ES256');\n\n // Add publicKey data to request body\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.publicKey = keyPair.publicKey;\n ctx.body.keyId = keyPair.keyId;\n ctx.body.fingerprint = keyPair.fingerprint;\n ctx.body.algorithm = keyPair.algorithm;\n ctx.body.keySize = Buffer.from(keyPair.publicKey, 'base64').length;\n\n // Add oldKeyId for a sign-in (key rotation). Both sign-in paths:\n // a passkey assertion starts a session exactly as a password login\n // does, so the key the browser was already carrying has to be\n // retired by the same request that replaces it.\n if (ROTATING_SIGN_IN_PATHS.has(ctx.path) && oldKeyId)\n {\n ctx.body.oldKeyId = oldKeyId;\n }\n\n // Remove remember from body (not part of contract)\n delete ctx.body.remember;\n\n // Store privateKey and remember in metadata for response interceptor\n ctx.metadata.privateKey = keyPair.privateKey;\n ctx.metadata.keyId = keyPair.keyId;\n ctx.metadata.algorithm = keyPair.algorithm;\n ctx.metadata.remember = remember;\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // Only process successful responses\n if (ctx.response.status !== 200)\n {\n await next();\n\n return;\n }\n\n // Handle both wrapped ({ data: { userId } }) and direct ({ userId }) responses\n const userData = ctx.response.body?.data || ctx.response.body;\n if (!userData?.userId)\n {\n authLogger.interceptor.login.error('No userId in response');\n await next();\n\n return;\n }\n\n try\n {\n // Get session TTL (priority: runtime > global > env > default)\n const ttl = getSessionTtl(ctx.metadata.remember);\n\n // Encrypt session data. The binding fields ride along when the\n // sign-in said the account asked for a bound session; without\n // them this is the same four-field literal it has always been.\n const sessionData =\n {\n userId: userData.userId,\n privateKey: ctx.metadata.privateKey,\n keyId: ctx.metadata.keyId,\n algorithm: ctx.metadata.algorithm,\n ...bindingSessionFields(userData, ctx.request.headers['user-agent']),\n };\n\n const sealed = await sealSession(sessionData, ttl);\n\n // Set HttpOnly session cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Set keyId cookie (for oldKeyId lookup)\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: ctx.metadata.keyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Set the readable CSRF cookie the client mirrors into a header\n await pushCsrfCookie(ctx.setCookies, ctx.metadata.keyId, ttl);\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.login.error('Failed to save session', err);\n }\n\n await next();\n },\n };\n","/**\n * General Authentication Interceptor\n *\n * Handles authentication for all API requests except login/register\n * - Session validation and renewal\n * - JWT generation and signing\n * - Expired session cleanup\n */\n\nimport type { InterceptorRule, RequestInterceptorContext } from '@spfn/core/nextjs/server';\nimport { SessionContextChangedError, SessionRenewalRequiredError } from '@spfn/auth/errors';\nimport { unsealSession, sealSession, shouldRefreshSession, type SessionData } from '../../server/lib/session';\nimport { generateClientToken } from '../../server/lib/crypto';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { uaFamily } from '../../server/lib/ua-family';\nimport { refuseInvalidCsrf, pushCsrfCookie, pushCsrfCookieIfStale, pushCsrfCookieRemoval } from './csrf';\nimport { refusalEnvelope } from './error-envelope';\nimport { SESSION_RENEW_PATH_PATTERN } from './session-renew';\n\n/**\n * Check if path requires authentication\n */\nfunction requiresAuth(path: string): boolean\n{\n // Paths that don't require auth\n const publicPaths = [\n /^\\/_auth\\/login$/,\n /^\\/_auth\\/register$/,\n /^\\/_auth\\/codes$/, // Send verification code\n /^\\/_auth\\/codes\\/verify$/, // Verify code\n /^\\/_auth\\/exists$/, // Check account exists\n ];\n\n return !publicPaths.some((pattern) => pattern.test(path));\n}\n\n/**\n * Whether a bound session arrived from a different browser than it was sealed in.\n *\n * Three terms, and each one is a rule.\n *\n * Bound only. An unbound session is not checked and nothing is logged for it —\n * the check would be a warn line per request for every account that did not opt\n * in, which is the noisy half of a protection they did not ask for.\n *\n * A `user-agent` has to be present. Absent is no signal, not a different family:\n * a server component's `api.` call reaches this proxy as Node `fetch` and the\n * isomorphic client sets `Content-Type`, `Cookie` and the CSRF header and nothing\n * else, so fail-closed on absence would refuse every server-rendered page view.\n *\n * And the comparison is between families rather than strings, so a version bump,\n * a user-agent reduction, or \"Request desktop site\" on Android are all the same\n * browser. What is left is a session presented from a different cookie jar, which\n * is a thing that does not happen without a copy.\n */\nfunction contextChanged(session: SessionData, userAgent: string | null): boolean\n{\n return session.binding === 'passkey'\n && Boolean(session.uaFamily)\n && Boolean(userAgent)\n && uaFamily(userAgent) !== session.uaFamily;\n}\n\n/**\n * Refuse a bound session presented from another browser, and empty the jar.\n *\n * The opposite of the renewal refusal the response phase mints: this session is\n * not waiting for a prompt, it is one whose cookie is somewhere it was never\n * sealed. The three cookies go with the refusal, which is the only moment a\n * refused request can touch them — and it is the one check this layer makes\n * alone, because the backend never sees the browser's `user-agent`.\n */\nfunction refuseAsContextChanged(ctx: RequestInterceptorContext): void\n{\n authLogger.interceptor.general.warn('Bound session presented from a different browser family', {\n path: ctx.path,\n sealed: ctx.metadata.sealedUaFamily,\n presented: ctx.metadata.presentedUaFamily,\n });\n\n const cleared = [\n { name: COOKIE_NAMES.SESSION, value: '', options: { maxAge: 0, path: '/' } },\n { name: COOKIE_NAMES.SESSION_KEY_ID, value: '', options: { maxAge: 0, path: '/' } },\n { name: COOKIE_NAMES.CSRF, value: '', options: { maxAge: 0, path: '/' } },\n ];\n\n ctx.abort = refusalEnvelope(new SessionContextChangedError(), cleared);\n}\n\n/**\n * Whether a backend 401 is the one that says the device key has expired.\n *\n * Read off `__type`, which is what the error envelope classifies by; the string\n * is the class name, and it is compared rather than imported because the body\n * here is JSON off the wire rather than an error instance.\n */\nfunction isKeyExpiredRefusal(body: unknown): boolean\n{\n return (body as { __type?: unknown } | null)?.__type === 'KeyExpiredError';\n}\n\n/**\n * General Authentication Interceptor\n *\n * Applies to all paths except login/register/codes\n * - Validates session\n * - Generates JWT token\n * - Refreshes session if needed\n * - Clears expired sessions\n */\nexport const generalAuthInterceptor: InterceptorRule =\n {\n pathPattern: '*', // Match all paths, filter by requiresAuth()\n method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],\n\n request: async (ctx, next) =>\n {\n // Skip if path doesn't require auth\n if (!requiresAuth(ctx.path))\n {\n authLogger.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);\n await next();\n\n return;\n }\n\n // Log available cookies\n const cookieNames = Array.from(ctx.cookies.keys());\n authLogger.interceptor.general.debug('Available cookies:', {\n cookieNames,\n totalCount: cookieNames.length,\n lookingFor: COOKIE_NAMES.SESSION,\n });\n\n const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);\n\n authLogger.interceptor.general.debug('Request', {\n method: ctx.method,\n path: ctx.path,\n hasSession: !!sessionCookie,\n sessionLength: sessionCookie?.length ?? 0,\n sessionPrefix: sessionCookie?.slice(0, 20) ?? '',\n sessionSuffix: sessionCookie?.slice(-10) ?? '',\n });\n\n // No session cookie\n if (!sessionCookie)\n {\n authLogger.interceptor.general.debug('No session cookie, proceeding without auth');\n // Let request proceed - server will return 401\n await next();\n\n return;\n }\n\n try\n {\n // Decrypt and validate session\n const session = await unsealSession(sessionCookie);\n\n authLogger.interceptor.general.debug('Session valid', {\n userId: session.userId,\n keyId: session.keyId,\n });\n\n // The request is authenticated from the session cookie — the one\n // fact only this layer knows, and the whole reason the CSRF check\n // lives here. Refusals stop before the backend is called.\n if (await refuseInvalidCsrf(ctx, session.keyId))\n {\n return;\n }\n\n // Context before expiry. A session whose cookie has moved to\n // another browser is finished whether or not its key is still\n // live, and answering it \"renew with your passkey\" would keep\n // exactly the cookies that need to go.\n const presented = ctx.request.headers.get('user-agent');\n\n if (contextChanged(session, presented))\n {\n ctx.metadata.sealedUaFamily = session.uaFamily;\n ctx.metadata.presentedUaFamily = uaFamily(presented);\n refuseAsContextChanged(ctx);\n\n return;\n }\n\n // Check if session should be refreshed (within 24h of expiry)\n const needsRefresh = await shouldRefreshSession(sessionCookie, 24);\n\n if (needsRefresh)\n {\n authLogger.interceptor.general.debug('Session needs refresh (within 24h of expiry)');\n // Mark for session renewal in response interceptor\n ctx.metadata.refreshSession = true;\n ctx.metadata.sessionData = session;\n }\n\n // Generate JWT token\n const token = generateClientToken(\n {\n userId: session.userId,\n keyId: session.keyId,\n timestamp: Date.now(),\n },\n session.privateKey,\n session.algorithm,\n { expiresIn: '15m' },\n );\n\n authLogger.interceptor.general.debug('Generated JWT token (expires in 15m)');\n\n // Add authentication headers\n ctx.headers['Authorization'] = `Bearer ${token}`;\n ctx.headers['X-Key-Id'] = session.keyId;\n\n // Store session info in metadata\n ctx.metadata.userId = session.userId;\n ctx.metadata.keyId = session.keyId;\n ctx.metadata.sessionValid = true;\n ctx.metadata.sessionBound = session.binding === 'passkey';\n }\n catch (error)\n {\n const err = error as Error;\n const msg = err.message.toLowerCase();\n\n // Session expired or invalid\n if (msg.includes('expired') || msg.includes('invalid'))\n {\n authLogger.interceptor.general.warn('Session expired or invalid', {\n message: err.message,\n cookieLength: sessionCookie.length,\n cookiePrefix: sessionCookie.slice(0, 20),\n cookieSuffix: sessionCookie.slice(-10),\n });\n authLogger.interceptor.general.debug('Marking session for cleanup');\n\n // Mark for cleanup in response interceptor\n ctx.metadata.clearSession = true;\n ctx.metadata.sessionValid = false;\n }\n else\n {\n authLogger.interceptor.general.error('Failed to process session', err);\n }\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // A bound session the backend refused as expired. This is the only place\n // the renewal prompt is minted, and deliberately so: the cookie's copy of\n // the expiry is a hint, the key row is the fact, and a proxy that refused\n // on the hint alone would strand a session whose key was made long-lived\n // again on another device. The cookies stay — the session is renewable,\n // not finished.\n if (ctx.response.status === 401\n && ctx.metadata.sessionValid\n && ctx.metadata.sessionBound\n && isKeyExpiredRefusal(ctx.response.body))\n {\n ctx.response.body = refusalEnvelope(new SessionRenewalRequiredError()).body;\n\n await next();\n\n return;\n }\n\n // Backend returned 401 with a valid session — server rejected it.\n //\n // Never on the renewal paths. They are signed like any other path, so\n // `sessionValid` is set there and this branch would otherwise fire on\n // the refusal a renewal answers with — emptying the cookie jar, and\n // with it the session the person was in the middle of repairing.\n if (ctx.response.status === 401\n && ctx.metadata.sessionValid\n && !SESSION_RENEW_PATH_PATTERN.test(ctx.path))\n {\n authLogger.interceptor.general.warn('Backend returned 401, clearing session');\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: '',\n options: { maxAge: 0, path: '/' },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: '',\n options: { maxAge: 0, path: '/' },\n });\n\n pushCsrfCookieRemoval(ctx.setCookies);\n\n await next();\n\n return;\n }\n\n // Clear expired/invalid session\n if (ctx.metadata.clearSession)\n {\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: '',\n options: {\n maxAge: 0,\n path: '/',\n },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: '',\n options: {\n maxAge: 0,\n path: '/',\n },\n });\n\n pushCsrfCookieRemoval(ctx.setCookies);\n }\n // Refresh session if needed and request was successful\n else if (ctx.metadata.refreshSession && ctx.response.status === 200)\n {\n try\n {\n const sessionData = ctx.metadata.sessionData;\n const ttl = getSessionTtl();\n\n // Re-encrypt session with new TTL. The object is the one\n // unsealed on the way in, so a bound session's `binding`,\n // `keyExpiresAt` and `uaFamily` survive the refresh — this is\n // the one sealing site that carries them for free, and the\n // reason it must go on re-sealing the whole object rather\n // than rebuilding the four-field literal.\n const sealed = await sealSession(sessionData, ttl);\n\n // Update session cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Update keyId cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: sessionData.keyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Renewed session, renewed CSRF cookie — same lifetime, so the\n // readable value never outlives the session it belongs to.\n await pushCsrfCookie(ctx.setCookies, sessionData.keyId, ttl);\n\n authLogger.interceptor.general.info('Session refreshed', {\n userId: sessionData.userId,\n sealedLength: sealed.length,\n sealedPrefix: sealed.slice(0, 20),\n });\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.general.error('Failed to refresh session', err);\n }\n }\n // Handle logout (clear session)\n else if (ctx.path === '/_auth/logout' && ctx.response.ok)\n {\n const base = {\n httpOnly: true,\n secure: cookieSecure,\n maxAge: 0,\n path: '/',\n };\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: '',\n options: { ...base, sameSite: 'lax' },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: '',\n options: { ...base, sameSite: 'lax' },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: '',\n options: { ...base, sameSite: 'lax' },\n });\n\n pushCsrfCookieRemoval(ctx.setCookies);\n }\n\n // A session that predates CSRF protection carries no readable cookie,\n // and renewal only happens near expiry — an app switching to enforce\n // would otherwise refuse every mutation from everyone already signed\n // in, for days. Issue it on any authenticated response whose cookie is\n // missing or no longer matches; reads pass the check, so a page load\n // is enough to heal.\n const csrfQueued = ctx.setCookies.some(cookie => cookie.name === COOKIE_NAMES.CSRF);\n\n if (ctx.metadata.sessionValid && !csrfQueued)\n {\n await pushCsrfCookieIfStale(\n ctx.setCookies,\n ctx.cookies.get(COOKIE_NAMES.CSRF),\n ctx.metadata.keyId,\n );\n }\n\n await next();\n },\n };\n","/**\n * The two renewal paths, named once.\n *\n * There is no interceptor here any more, and the reason is the point of the\n * file. Renewal used to be told which key to renew by a body field the proxy\n * injected from the HttpOnly key-id cookie; now the backend reads it off the\n * `keyId` of the bearer JWT the request is signed with\n * (`authenticateForRenewal`), so there is nothing left to inject and nothing a\n * direct caller can name that they do not already hold the private key for.\n *\n * What the proxy still has to do for these two paths, `general-auth` does: they\n * are authenticated paths like any other, so the session cookie is unsealed, the\n * CSRF header checked, and a JWT signed with the private half of the expiring\n * key — `generateClientToken` signs with the key material in the cookie and never\n * consults the row's expiry, which is what makes an expired key still able to\n * speak for itself. The one thing that path must not do is clear the jar when\n * one of these answers 401, and the pattern below is how it knows.\n *\n * `renew/verify` is also on `loginRegisterInterceptor`'s path list, which is\n * where the *new* key pair is generated and the replacement session sealed.\n */\n\n/** The two public renewal paths, as one pattern the proxy layers agree on. */\nexport const SESSION_RENEW_PATH_PATTERN = /^\\/_auth\\/session\\/renew\\/(options|verify)$/;\n","/**\n * Key Rotation Interceptor\n *\n * Handles key rotation with new key generation and session update\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { generateKeyPair, generateClientToken } from '../../server/lib/crypto';\nimport { unsealSession, sealSession } from '../../server/lib/session';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\n\n/**\n * Key Rotation Interceptor\n *\n * Request: Generates new key pair and adds to body, authenticates with current key\n * Response: Updates session with new privateKey\n */\nexport const keyRotationInterceptor: InterceptorRule =\n {\n pathPattern: '/_auth/keys/rotate',\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);\n\n if (!sessionCookie)\n {\n await next();\n\n return;\n }\n\n try\n {\n // Get current session\n const currentSession = await unsealSession(sessionCookie);\n\n // Generate new key pair\n const newKeyPair = generateKeyPair('ES256');\n\n // Add new publicKey to request body\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.publicKey = newKeyPair.publicKey;\n ctx.body.keyId = newKeyPair.keyId;\n ctx.body.fingerprint = newKeyPair.fingerprint;\n ctx.body.algorithm = newKeyPair.algorithm;\n ctx.body.keySize = Buffer.from(newKeyPair.publicKey, 'base64').length;\n\n // Identity of the new key only — the pair carries `privateKey`,\n // the credential the session is sealed around.\n authLogger.interceptor.keyRotation.debug('Generated a new key pair', {\n keyId: newKeyPair.keyId,\n fingerprint: newKeyPair.fingerprint,\n algorithm: newKeyPair.algorithm,\n });\n\n // Authenticate with CURRENT key\n const token = generateClientToken(\n {\n userId: currentSession.userId,\n keyId: currentSession.keyId,\n action: 'rotate_key',\n timestamp: Date.now(),\n },\n currentSession.privateKey,\n currentSession.algorithm,\n {expiresIn: '15m'},\n );\n\n ctx.headers['Authorization'] = `Bearer ${token}`;\n ctx.headers['X-Key-Id'] = currentSession.keyId;\n\n // Store new key and userId in metadata\n ctx.metadata.newPrivateKey = newKeyPair.privateKey;\n ctx.metadata.newKeyId = newKeyPair.keyId;\n ctx.metadata.newAlgorithm = newKeyPair.algorithm;\n ctx.metadata.userId = currentSession.userId;\n ctx.metadata.bindingFields = currentSession.binding\n ? {\n binding: currentSession.binding,\n keyExpiresAt: currentSession.keyExpiresAt,\n ...(currentSession.uaFamily ? { uaFamily: currentSession.uaFamily } : {}),\n }\n : {};\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.keyRotation.error('Failed to prepare key rotation', err);\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // Only update session on successful rotation\n if (ctx.response.status !== 200)\n {\n await next();\n\n return;\n }\n\n if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId)\n {\n authLogger.interceptor.keyRotation.error('Missing key rotation metadata');\n await next();\n\n return;\n }\n\n try\n {\n // Get session TTL\n const ttl = getSessionTtl();\n\n // Create new session with rotated key.\n //\n // The binding fields come from the session being replaced, not\n // from the response: rotation registers a key that inherits the\n // replaced key's binding and its expiry verbatim, so the cookie\n // must inherit them too. Re-deriving would be wrong twice over —\n // the rotate response says nothing about binding, and a fresh\n // expiry is exactly what rotation must not hand a bound key.\n const newSessionData =\n {\n userId: ctx.metadata.userId,\n privateKey: ctx.metadata.newPrivateKey,\n keyId: ctx.metadata.newKeyId,\n algorithm: ctx.metadata.newAlgorithm,\n ...ctx.metadata.bindingFields,\n };\n\n const sealed = await sealSession(newSessionData, ttl);\n\n // Update session cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Update keyId cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: ctx.metadata.newKeyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Rotation changes the key id the token is derived from, so the\n // old CSRF value stops verifying — reissue it in the same response.\n await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.keyRotation.error('Failed to update session after rotation', err);\n }\n\n await next();\n },\n };\n","/**\n * OAuth State Management\n *\n * CSRF 방지를 위한 state 파라미터 암호화/복호화\n * - returnUrl: OAuth 성공 후 리다이렉트할 URL\n * - nonce: CSRF 방지용 일회용 토큰\n * - provider: OAuth provider (google, github 등)\n * - publicKey, keyId, fingerprint, algorithm: 클라이언트 키 정보\n * - expiresAt: state 만료 시간\n */\n\nimport * as jose from 'jose';\nimport { env } from '@spfn/auth/config';\nimport { type KeyAlgorithmType } from '../../types';\n\nexport interface OAuthState\n{\n returnUrl: string;\n nonce: string;\n provider: string;\n publicKey: string;\n keyId: string;\n fingerprint: string;\n algorithm: KeyAlgorithmType;\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Get encryption key derived from session secret\n */\nasync function getStateKey(): Promise<Uint8Array>\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n const encoder = new TextEncoder();\n const data = encoder.encode(`oauth-state:${secret}`);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n return new Uint8Array(hashBuffer);\n}\n\n/**\n * Generate random nonce\n */\nfunction generateNonce(): string\n{\n const array = new Uint8Array(16);\n crypto.getRandomValues(array);\n\n return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Generate a CSRF nonce for the OAuth flow. The caller passes it to\n * createOAuthState AND sets it as the oauth_csrf cookie, so the callback can\n * double-submit-verify the flow was initiated by this same browser.\n */\nexport function generateOAuthNonce(): string\n{\n return generateNonce();\n}\n\nexport interface CreateOAuthStateParams\n{\n provider: string;\n returnUrl: string;\n publicKey: string;\n keyId: string;\n fingerprint: string;\n algorithm: KeyAlgorithmType;\n metadata?: Record<string, unknown>;\n /**\n * CSRF nonce bound into the state. Pass the same value as the oauth_csrf\n * cookie. Defaults to a fresh nonce (unbound — legacy/no-CSRF callers).\n */\n nonce?: string;\n}\n\n/**\n * OAuth state 생성 및 암호화\n *\n * @param params - state 생성에 필요한 파라미터\n * @returns 암호화된 state 문자열\n */\nexport async function createOAuthState(params: CreateOAuthStateParams): Promise<string>\n{\n const key = await getStateKey();\n\n const state: OAuthState = {\n returnUrl: params.returnUrl,\n nonce: params.nonce ?? generateNonce(),\n provider: params.provider,\n publicKey: params.publicKey,\n keyId: params.keyId,\n fingerprint: params.fingerprint,\n algorithm: params.algorithm,\n metadata: params.metadata,\n };\n\n const jwe = await new jose.EncryptJWT({ state })\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .setExpirationTime('10m')\n .encrypt(key);\n\n // URL-safe base64 encoding\n return encodeURIComponent(jwe);\n}\n\n/**\n * OAuth state 복호화 및 검증\n *\n * @param encryptedState - 암호화된 state 문자열\n * @returns 복호화된 state 객체\n * @throws Error if state is invalid or expired (JWE exp claim으로 자동 검증)\n */\nexport async function verifyOAuthState(encryptedState: string): Promise<OAuthState>\n{\n const key = await getStateKey();\n\n const jwe = decodeURIComponent(encryptedState);\n const { payload } = await jose.jwtDecrypt(jwe, key);\n\n return payload.state as OAuthState;\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 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 * OAuth Interceptors\n *\n * 1. oauthUrlInterceptor: OAuth URL 요청 시 키쌍 생성 및 state 주입\n * 2. oauthFinalizeInterceptor: OAuth 완료 시 pending session에서 세션 저장\n */\n\nimport type { InterceptorRule, ProxyAbort, ResponseInterceptorContext } from '@spfn/core/nextjs/server';\nimport { generateKeyPair } from '../../server/lib/crypto';\nimport { createOAuthState, generateOAuthNonce } from '../../server/lib/oauth/state';\nimport { sealSession } from '../../server/lib/session';\nimport { COOKIE_NAMES, getSessionTtl } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { isSafeReturnPath } from '../../lib/return-path';\nimport { sealPendingSession, unsealPendingSession } from '../session-helpers';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\nimport { bindingSessionFields } from './session-binding';\n\nconst UNSAFE_RETURN_URL_MESSAGE = 'returnUrl must be a relative path within the app';\n\n/**\n * Refuse an OAuth start whose `returnUrl` would leave the app.\n *\n * This is where the value has to be checked: the interceptor seals it into the\n * encrypted state, and every layer after this one — the backend `/url` routes,\n * the provider, the callback — sees only the sealed state and cannot recover\n * what the caller asked for. An unchecked value comes back as a redirect after\n * a real login, which is what turns a forgotten screen into an open redirect.\n *\n * Refused the same way the signup-link route refuses `returnPath`: 400 carrying\n * a ValidationError, so the typed client restores the same error class whether\n * the refusal came from here or from the backend.\n */\nfunction refuseUnsafeReturnUrl(): ProxyAbort\n{\n return {\n status: 400,\n body: {\n __type: 'ValidationError',\n message: UNSAFE_RETURN_URL_MESSAGE,\n error: {\n code: 'ValidationError',\n message: UNSAFE_RETURN_URL_MESSAGE,\n },\n },\n };\n}\n\n/**\n * OAuth URL Interceptor\n *\n * POST /_auth/oauth/:provider/url 요청을 가로채서\n * 키쌍 생성 및 state 주입 처리\n */\nexport const oauthUrlInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/oauth\\/\\w+\\/url$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n const provider = ctx.path.split('/')[3]; // google, github, etc.\n const returnUrl = ctx.body?.returnUrl || '/';\n const metadata = ctx.body?.metadata as Record<string, unknown> | undefined;\n\n // `ctx.body` is whatever the caller posted, so the value reaching the rule\n // is not a string just because the route's schema says it is — the schema\n // runs at the backend, one hop after this. A non-string is refused here\n // rather than left to throw out of `isSafeReturnPath` as a 500.\n if (typeof returnUrl !== 'string' || !isSafeReturnPath(returnUrl))\n {\n authLogger.interceptor.oauth?.warn?.('OAuth start refused: returnUrl is not a path within the app', {\n provider,\n });\n ctx.abort = refuseUnsafeReturnUrl();\n\n return;\n }\n\n // 키쌍 생성\n const keyPair = generateKeyPair('ES256');\n\n // CSRF nonce: bound into the state AND set as the oauth_csrf cookie below,\n // so the backend callback can confirm the flow started in THIS browser.\n const csrfNonce = generateOAuthNonce();\n\n // state 생성 (publicKey 포함)\n const state = await createOAuthState({\n provider,\n returnUrl,\n publicKey: keyPair.publicKey,\n keyId: keyPair.keyId,\n fingerprint: keyPair.fingerprint,\n algorithm: keyPair.algorithm,\n nonce: csrfNonce,\n metadata,\n });\n\n // body에 state 주입\n if (!ctx.body)\n {\n ctx.body = {};\n }\n ctx.body.state = state;\n\n // pending session 저장용 metadata\n ctx.metadata.pendingSession = {\n privateKey: keyPair.privateKey,\n keyId: keyPair.keyId,\n algorithm: keyPair.algorithm,\n };\n ctx.metadata.oauthCsrf = csrfNonce;\n\n authLogger.interceptor.oauth?.debug?.('OAuth state created', {\n provider,\n keyId: keyPair.keyId,\n });\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // 성공 응답이고 pending session이 있으면 쿠키 설정\n if (ctx.response.ok && ctx.metadata.pendingSession)\n {\n try\n {\n const sealed = await sealPendingSession(ctx.metadata.pendingSession);\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax', // OAuth 리다이렉트 허용\n maxAge: 600, // 10분\n path: '/',\n },\n });\n\n // CSRF nonce cookie (double-submit against the state.nonce at callback)\n if (ctx.metadata.oauthCsrf)\n {\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_CSRF,\n value: ctx.metadata.oauthCsrf,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: 600,\n path: '/',\n },\n });\n }\n\n authLogger.interceptor.oauth?.debug?.('Pending session cookie set', {\n keyId: ctx.metadata.pendingSession.keyId,\n });\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.oauth?.error?.('Failed to set pending session', err);\n }\n }\n\n await next();\n },\n};\n\n/**\n * Finalize 실패 시 에러 응답 설정 + pending 쿠키 정리\n */\nfunction setFinalizeError(ctx: ResponseInterceptorContext, message: string): void\n{\n ctx.response.ok = false;\n ctx.response.status = 401;\n ctx.response.statusText = 'Unauthorized';\n ctx.response.body = { success: false, message };\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: '',\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: 0,\n path: '/',\n },\n });\n}\n\n/**\n * OAuth Finalize Interceptor\n *\n * POST /_auth/oauth/finalize 요청을 가로채서\n * pending session에서 세션 저장\n */\nexport const oauthFinalizeInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/oauth\\/finalize$/,\n method: 'POST',\n\n response: async (ctx, next) =>\n {\n // 성공 응답일 때만 처리\n if (!ctx.response.ok)\n {\n await next();\n\n return;\n }\n\n const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);\n if (!pendingCookie)\n {\n authLogger.interceptor.oauth?.warn?.('No pending session cookie found');\n setFinalizeError(ctx, 'OAuth session expired. Please try again.');\n await next();\n\n return;\n }\n\n try\n {\n // pending session에서 privateKey 복원\n const pendingSession = await unsealPendingSession(pendingCookie);\n\n // body에서 userId, keyId 추출\n const { userId, keyId } = ctx.response.body || {};\n\n if (!userId || !keyId)\n {\n authLogger.interceptor.oauth?.error?.('Missing userId or keyId in response');\n setFinalizeError(ctx, 'OAuth finalize failed: missing credentials');\n await next();\n\n return;\n }\n\n // keyId 일치 확인\n if (pendingSession.keyId !== keyId)\n {\n authLogger.interceptor.oauth?.error?.('KeyId mismatch', {\n expected: pendingSession.keyId,\n received: keyId,\n });\n setFinalizeError(ctx, 'OAuth session mismatch. Please try again.');\n await next();\n\n return;\n }\n\n // 세션 생성.\n // `userId` here is the value reflected by /_auth/oauth/finalize (a UI\n // convenience, not a trust anchor). It's safe to seal: keyId was matched\n // against the pending cookie above, the session is sealed, and the\n // backend re-derives identity from keyId on every request — see the\n // SECURITY note on the oauthFinalize route handler.\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(ctx.response.body, ctx.request.headers['user-agent']),\n }, ttl);\n\n // 세션 쿠키 설정\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sessionToken,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // keyId 쿠키 설정\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: keyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Set the readable CSRF cookie the client mirrors into a header\n await pushCsrfCookie(ctx.setCookies, keyId, ttl);\n\n // pending session 쿠키 삭제 (maxAge: 0)\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: '',\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: 0,\n path: '/',\n },\n });\n\n authLogger.interceptor.oauth?.debug?.('OAuth session finalized', {\n userId,\n keyId,\n });\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.oauth?.error?.('Failed to finalize OAuth session', err);\n setFinalizeError(ctx, err.message);\n }\n\n await next();\n },\n};\n","/**\n * Verified-Email Signup Interceptor\n *\n * Carries the password-setup session between the two browser-facing steps of the\n * verified-email signup, so the secret that authorizes password setup lives in an\n * HttpOnly cookie and never in page script.\n *\n * On the confirm response it moves `setupSecret` out of the body and into the\n * cookie. On the password request it puts the cookie back into the body, the same\n * way `loginRegisterInterceptor` injects a device key. Both interceptors match\n * `/_auth/signup/password` and both run — matching rules execute as a chain in\n * registration order, they do not compete — so the password request arrives with\n * the setup secret and a freshly generated key.\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\n\n/**\n * Setup-session cookie lifetime, in seconds.\n *\n * Deliberately a little longer than the server-side setup session: the server\n * decides when the session dies, and a cookie that expired first would turn an\n * expired-session refusal into a missing-cookie one, which reads as a different\n * bug to whoever is looking.\n */\nconst SETUP_COOKIE_TTL_SECONDS = 60 * 60;\n\n/**\n * Cookie carrying the password-setup session.\n */\nfunction setupCookie(value: string, maxAge: number)\n{\n return {\n name: COOKIE_NAMES.SIGNUP_SETUP,\n value,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax' as const,\n maxAge,\n path: '/',\n },\n };\n}\n\nexport const signupLinkInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/signup\\/(email\\/confirm|password)$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n if (ctx.path === '/_auth/signup/password')\n {\n const cookie = ctx.cookies.get(COOKIE_NAMES.SIGNUP_SETUP);\n\n if (cookie)\n {\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.setupSecret = cookie;\n }\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n if (!ctx.response.ok)\n {\n // A refusal here is often the user's to fix — a password that fails\n // the strength policy, an app policy that rejected the signup. The\n // setup session survives those on the server, so the cookie has to\n // survive them too, or the retry has nothing to present.\n await next();\n\n return;\n }\n\n if (ctx.path === '/_auth/signup/email/confirm')\n {\n const secret = ctx.response.body?.setupSecret;\n\n if (!secret)\n {\n authLogger.interceptor.oauth?.error?.('Signup confirm response carried no setup secret');\n await next();\n\n return;\n }\n\n ctx.setCookies.push(setupCookie(secret, SETUP_COOKIE_TTL_SECONDS));\n\n // The browser must never see it — an HttpOnly cookie that page\n // script can also read out of the JSON body is not HttpOnly.\n delete ctx.response.body.setupSecret;\n }\n\n if (ctx.path === '/_auth/signup/password')\n {\n // Spent. Clearing it stops a stale cookie from being presented to a\n // session the server has already marked used.\n ctx.setCookies.push(setupCookie('', 0));\n }\n\n await next();\n },\n};\n","/**\n * Password Reset Interceptor\n *\n * Carries the password-setup session between the two browser-facing steps of a\n * password reset, so the secret that authorizes setting a new password lives in\n * an HttpOnly cookie and never in page script.\n *\n * On the confirm response it moves `setupSecret` out of the body and into the\n * cookie. On the complete request it puts the cookie back into the body, the\n * same way `loginRegisterInterceptor` injects a device key. Both interceptors\n * match `/_auth/password/reset/complete` and both run — matching rules execute\n * as a chain in registration order, they do not compete — so the request arrives\n * with the setup secret and a freshly generated key.\n *\n * A cookie of its own rather than the signup one: the two secrets address\n * different tables, and a browser that abandoned a signup mid-flow must not\n * present its leftover secret to a reset.\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\n\n/**\n * Setup-session cookie lifetime, in seconds.\n *\n * Deliberately a little longer than the server-side setup session: the server\n * decides when the session dies, and a cookie that expired first would turn an\n * expired-session refusal into a missing-cookie one, which reads as a different\n * bug to whoever is looking.\n */\nconst SETUP_COOKIE_TTL_SECONDS = 60 * 60;\n\n/**\n * Cookie carrying the password-setup session.\n */\nfunction setupCookie(value: string, maxAge: number)\n{\n return {\n name: COOKIE_NAMES.PASSWORD_RESET_SETUP,\n value,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax' as const,\n maxAge,\n path: '/',\n },\n };\n}\n\nexport const passwordResetInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/password\\/reset\\/(confirm|complete)$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n if (ctx.path === '/_auth/password/reset/complete')\n {\n const cookie = ctx.cookies.get(COOKIE_NAMES.PASSWORD_RESET_SETUP);\n\n if (cookie)\n {\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.setupSecret = cookie;\n }\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n if (!ctx.response.ok)\n {\n // A refusal here is often the user's to fix — a password that fails\n // the strength policy. The setup session survives those on the\n // server, so the cookie has to survive them too, or the retry has\n // nothing to present.\n await next();\n\n return;\n }\n\n if (ctx.path === '/_auth/password/reset/confirm')\n {\n const secret = ctx.response.body?.setupSecret;\n\n if (!secret)\n {\n authLogger.interceptor.oauth?.error?.('Password reset confirm response carried no setup secret');\n await next();\n\n return;\n }\n\n ctx.setCookies.push(setupCookie(secret, SETUP_COOKIE_TTL_SECONDS));\n\n // The browser must never see it — an HttpOnly cookie that page\n // script can also read out of the JSON body is not HttpOnly.\n delete ctx.response.body.setupSecret;\n }\n\n if (ctx.path === '/_auth/password/reset/complete')\n {\n // Spent. Clearing it stops a stale cookie from being presented to a\n // session the server has already marked used.\n ctx.setCookies.push(setupCookie('', 0));\n }\n\n await next();\n },\n};\n","/**\n * Auth Interceptors for Next.js Proxy\n *\n * Automatically registers interceptors for authentication flow\n *\n * Every rule whose path and method match runs, as a chain in this order — they\n * do not compete for a single match. Two of them share /_auth/signup/password on\n * purpose: signupLinkInterceptor supplies the setup secret and\n * loginRegisterInterceptor supplies the device key.\n *\n * Order matters - more specific interceptors first:\n * 1. signupLinkInterceptor - Most specific (verified-email signup only)\n * 2. passwordResetInterceptor - Most specific (password reset only)\n * 3. loginRegisterInterceptor - Specific (login/register/signup password/reset complete)\n * 4. keyRotationInterceptor - Specific (key rotation only)\n * 5. oauthUrlInterceptor - OAuth URL generation (key generation + state injection)\n * 6. generalAuthInterceptor - General (all authenticated requests)\n * 7. sessionBindingInterceptor - Last, so its re-sealed cookie wins over the\n * general one: response phases run in this order and the later write of a\n * cookie name is the one the browser keeps.\n */\n\nimport { loginRegisterInterceptor } from './login-register';\nimport { generalAuthInterceptor } from './general-auth';\nimport { keyRotationInterceptor } from './key-rotation';\nimport { oauthUrlInterceptor, oauthFinalizeInterceptor } from './oauth';\nimport { signupLinkInterceptor } from './signup-link';\nimport { passwordResetInterceptor } from './password-reset';\nimport { sessionBindingInterceptor } from './session-binding';\n\n/**\n * All auth interceptors\n *\n * Execution order:\n * 1. signupLinkInterceptor - Handles verified-email signup (setup secret ↔ HttpOnly cookie)\n * 2. passwordResetInterceptor - Handles password reset (setup secret ↔ HttpOnly cookie)\n * 3. loginRegisterInterceptor - Handles login/register/signup password/reset complete/session renew (key generation + session save)\n * 4. keyRotationInterceptor - Handles key rotation (new key generation + session update)\n * 5. oauthUrlInterceptor - Handles OAuth URL requests (key generation + state injection + pending session)\n * 6. oauthFinalizeInterceptor - Handles OAuth finalize (pending session → full session)\n * 7. generalAuthInterceptor - Handles all authenticated requests (session validation + JWT injection + session renewal)\n * 8. sessionBindingInterceptor - Re-seals the session cookie when the binding setting changes\n */\nexport const authInterceptors = [\n signupLinkInterceptor,\n passwordResetInterceptor,\n loginRegisterInterceptor,\n keyRotationInterceptor,\n oauthUrlInterceptor,\n oauthFinalizeInterceptor,\n generalAuthInterceptor,\n sessionBindingInterceptor,\n];\n\nexport { loginRegisterInterceptor } from './login-register';\nexport { generalAuthInterceptor } from './general-auth';\nexport { keyRotationInterceptor } from './key-rotation';\nexport { oauthUrlInterceptor, oauthFinalizeInterceptor } from './oauth';\nexport { signupLinkInterceptor } from './signup-link';\nexport { passwordResetInterceptor } from './password-reset';\nexport { sessionBindingInterceptor, bindingSessionFields } from './session-binding';\nexport { SESSION_RENEW_PATH_PATTERN } from './session-renew';\n\n// Deprecated: use generalAuthInterceptor instead\nexport { generalAuthInterceptor as authenticationInterceptor };\n"],"mappings":"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","names":["crypto","jwt","env","env","env","refusal","jose","env","jose","env","env","jwt","SETUP_COOKIE_TTL_SECONDS","setupCookie"]}
|
|
1
|
+
{"version":3,"sources":["../../src/nextjs/api.ts","../../src/server/lib/crypto.ts","../../src/server/lib/session.ts","../../src/server/logger.ts","../../src/server/lib/config.ts","../../src/nextjs/interceptors/cookie-options.ts","../../src/server/lib/csrf.ts","../../src/nextjs/interceptors/csrf.ts","../../src/nextjs/interceptors/session-binding.ts","../../src/server/lib/ua-family.ts","../../src/nextjs/interceptors/error-envelope.ts","../../src/nextjs/interceptors/login-register.ts","../../src/nextjs/interceptors/mfa-verify.ts","../../src/server/lib/link-credentials.ts","../../src/nextjs/session-helpers.ts","../../src/nextjs/interceptors/general-auth.ts","../../src/nextjs/interceptors/session-renew.ts","../../src/nextjs/interceptors/key-rotation.ts","../../src/server/lib/oauth/state.ts","../../src/lib/return-path.ts","../../src/nextjs/interceptors/oauth.ts","../../src/nextjs/interceptors/signup-link.ts","../../src/nextjs/interceptors/password-reset.ts","../../src/nextjs/interceptors/index.ts"],"sourcesContent":["/**\n * @spfn/auth/adapters/nextjs/api\n *\n * Next.js Adapter for SPFN Auth\n *\n * Provides automatic interceptor registration for seamless auth flow:\n * - Session management (HttpOnly cookies)\n * - JWT generation and signing\n * - Public key encryption\n *\n * @requires next >= 13.0.0\n *\n * @example\n * ```typescript\n * // Just import to auto-register interceptors\n * import '@spfn/auth/nextjs/api';\n * ```\n */\n\n// Re-export interceptors for advanced usage\nimport { registerInterceptors } from '@spfn/core/nextjs/server';\nimport { authInterceptors } from './interceptors';\n\n// Auto-register interceptors on import\nregisterInterceptors('auth', authInterceptors);\n","/**\n * @spfn/auth - Client Crypto Helpers\n *\n * ES256 (ECDSA P-256) key generation and JWT signing for Next.js\n * Keys are stored in DER format (Base64 encoded) for efficiency\n *\n * Key Sizes:\n * - ES256 (ECDSA P-256): ~91 bytes (Base64: ~120 chars)\n * - RS256 (RSA 2048): ~294 bytes (Base64: ~392 chars)\n */\n\nimport { type KeyAlgorithmType } from '../types';\nimport crypto from 'crypto';\nimport jwt, { type Algorithm, type SignOptions } from 'jsonwebtoken';\n\ntype Unit =\n | 'Years'\n | 'Year'\n | 'Yrs'\n | 'Yr'\n | 'Y'\n | 'Weeks'\n | 'Week'\n | 'W'\n | 'Days'\n | 'Day'\n | 'D'\n | 'Hours'\n | 'Hour'\n | 'Hrs'\n | 'Hr'\n | 'H'\n | 'Minutes'\n | 'Minute'\n | 'Mins'\n | 'Min'\n | 'M'\n | 'Seconds'\n | 'Second'\n | 'Secs'\n | 'Sec'\n | 's'\n | 'Milliseconds'\n | 'Millisecond'\n | 'Msecs'\n | 'Msec'\n | 'Ms';\n\ntype UnitAnyCase = Unit | Uppercase<Unit> | Lowercase<Unit>;\n\ntype StringValue =\n | `${number}`\n | `${number}${UnitAnyCase}`\n | `${number} ${UnitAnyCase}`;\n\nexport interface KeyPair\n{\n privateKey: string; // Base64 encoded DER\n publicKey: string; // Base64 encoded DER\n keyId: string; // UUID\n fingerprint: string; // SHA-256 hash\n algorithm: KeyAlgorithmType;\n}\n\n/**\n * Generate ECDSA P-256 key pair (ES256)\n * Recommended for optimal size and performance\n */\nexport function generateKeyPairES256(): KeyPair\n{\n const keyId = crypto.randomUUID();\n\n const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {\n namedCurve: 'P-256', // ES256\n publicKeyEncoding: {\n type: 'spki',\n format: 'der',\n },\n privateKeyEncoding: {\n type: 'pkcs8',\n format: 'der',\n },\n });\n\n // Convert Buffer to Base64\n const privateKeyB64 = privateKey.toString('base64');\n const publicKeyB64 = publicKey.toString('base64');\n\n // Generate fingerprint (SHA-256 of public key)\n const fingerprint = crypto\n .createHash('sha256')\n .update(publicKey)\n .digest('hex');\n\n return {\n privateKey: privateKeyB64,\n publicKey: publicKeyB64,\n keyId,\n fingerprint,\n algorithm: 'ES256',\n };\n}\n\n/**\n * Generate RSA 2048 key pair (RS256)\n * Fallback option, larger size but wider compatibility\n */\nexport function generateKeyPairRS256(): KeyPair\n{\n const keyId = crypto.randomUUID();\n\n const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {\n modulusLength: 2048,\n publicKeyEncoding: {\n type: 'spki',\n format: 'der',\n },\n privateKeyEncoding: {\n type: 'pkcs8',\n format: 'der',\n },\n });\n\n const privateKeyB64 = privateKey.toString('base64');\n const publicKeyB64 = publicKey.toString('base64');\n\n const fingerprint = crypto\n .createHash('sha256')\n .update(publicKey)\n .digest('hex');\n\n return {\n privateKey: privateKeyB64,\n publicKey: publicKeyB64,\n keyId,\n fingerprint,\n algorithm: 'RS256',\n };\n}\n\n/**\n * Generate key pair (defaults to ES256)\n */\nexport function generateKeyPair(\n algorithm: KeyAlgorithmType = 'ES256',\n): KeyPair\n{\n return algorithm === 'ES256'\n ? generateKeyPairES256()\n : generateKeyPairRS256();\n}\n\n/**\n * Generate JWT signed with client private key (DER format)\n */\nexport function generateClientToken(\n payload: Record<string, any>,\n privateKeyB64: string,\n algorithm: Algorithm,\n options?: {\n expiresIn?: StringValue | number;\n issuer?: string;\n },\n): string\n{\n try\n {\n // Convert Base64 back to Buffer\n const privateKeyDER = Buffer.from(privateKeyB64, 'base64');\n\n // Create key object for signing\n const privateKeyObject = crypto.createPrivateKey({\n key: privateKeyDER,\n format: 'der',\n type: 'pkcs8',\n });\n\n // Export as PEM for jwt.sign\n const privateKeyPEM = privateKeyObject.export({\n type: 'pkcs8',\n format: 'pem',\n });\n\n const signOptions: SignOptions = {\n algorithm,\n issuer: options?.issuer || 'spfn-client',\n expiresIn: options?.expiresIn ?? '15m', // Default to 15 minutes\n };\n\n return jwt.sign(payload, privateKeyPEM, signOptions);\n }\n catch (error)\n {\n throw new Error(\n `Failed to generate client token: ${error instanceof Error ? error.message : 'Unknown error'}`,\n );\n }\n}\n\n/**\n * Get key size information\n */\nexport function getKeySize(publicKeyB64: string): {\n bytes: number;\n base64Length: number;\n}\n{\n const keyDER = Buffer.from(publicKeyB64, 'base64');\n\n return {\n bytes: keyDER.length,\n base64Length: publicKeyB64.length,\n };\n}\n\n/**\n * Check if key should be rotated based on creation date\n */\nexport function shouldRotateKey(\n createdAt: Date,\n rotationDays: number = 90,\n): {\n shouldRotate: boolean;\n daysRemaining: number;\n}\n{\n const now = new Date();\n const ageInDays = Math.floor(\n (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24),\n );\n const daysRemaining = Math.max(0, rotationDays - ageInDays);\n\n return {\n shouldRotate: daysRemaining <= 7, // Warn 7 days before expiry\n daysRemaining,\n };\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 - 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 * 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 - 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 * CSRF check for cookie-authenticated mutations\n *\n * Enforced here, in the Next.js proxy, because this is the only layer that knows\n * a request's credential was ambient: the browser sends an encrypted HttpOnly\n * session cookie, generalAuthInterceptor turns it into a short-lived bearer JWT,\n * and the backend then sees `scheme:'bearer'` for cookie callers and genuine\n * bearer callers alike. Requests the proxy does not authenticate from the session\n * cookie — no session, direct-to-backend bearer, clientProofV1, machine and ops\n * tokens — never reach this code and are unaffected.\n *\n * The threat model is in the README: the session cookie is SameSite=Lax, so this\n * covers what Lax does not (sibling-subdomain pivots, legacy browsers, domain\n * layout drift) and covers nothing an XSS on your own origin could not do anyway.\n */\n\nimport type { ProxyAbort, RequestInterceptorContext } from '@spfn/core/nextjs/server';\nimport type { SetCookie } from '@spfn/core/nextjs';\n\nimport { getCsrfMode, getCsrfExemptPaths, getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { CSRF_HEADER, deriveCsrfToken, matchesCsrfToken, timingSafeEqualString } from '../../server/lib/csrf';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\n\n/**\n * Methods that cannot mutate, so they need no token.\n *\n * Compared against the resolved *route* method, not the method the browser used\n * to reach the proxy: `GET /api/rpc/deleteAccount?input=…` is forwarded to the\n * backend as the route's DELETE, and SameSite=Lax does send the session cookie on\n * a cross-site top-level GET navigation. Gating on the wire method would leave\n * every mutation reachable that way.\n */\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);\n\n/**\n * The readable CSRF cookie, as every place that issues one builds it.\n *\n * Mirrors the session cookie's attributes minus HttpOnly — the client has to read\n * it — and carries only the HMAC, never the key id it derives from.\n */\nfunction csrfCookie(token: string, ttl: number): SetCookie\n{\n return {\n name: COOKIE_NAMES.CSRF,\n value: token,\n options: {\n httpOnly: false,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n };\n}\n\n/**\n * The 403 a failed check answers with.\n *\n * The body is the same whatever the reason: a caller learns that the request was\n * refused, never whether the header was absent, wrong, or impossible to check.\n *\n * It carries no cookie. The caller attaches one afterwards where there is a value\n * to attach — see refuseInvalidCsrf for why that order matters.\n */\nfunction refusal(): ProxyAbort\n{\n return {\n status: 403,\n body: {\n error: 'Forbidden',\n message: 'CSRF token missing or invalid',\n },\n setCookies: [],\n };\n}\n\n/**\n * Refuse a cookie-authenticated mutation that has no valid CSRF header.\n *\n * Call only once the session has been unsealed — a refusal must never be the\n * answer to a request that had no session, or the status alone would tell an\n * unauthenticated caller whether someone is signed in. Unauthenticated requests\n * keep taking the existing path (the backend answers 401).\n *\n * The refusal carries a fresh CSRF cookie. A refused request skips the backend\n * and every response interceptor, so this is the only chance to repair a browser\n * whose token cookie went missing or stale — without it the client would be told\n * \"wrong token\" while holding nothing better to try, and the documented recovery\n * would need an unrelated GET to happen first.\n *\n * @param ctx - Request interceptor context, mutated with `abort` on refusal\n * @param keyId - Key id of the session that authenticated this request\n * @returns True when the request was refused and the caller must stop\n */\nexport async function refuseInvalidCsrf(\n ctx: RequestInterceptorContext,\n keyId: string,\n): Promise<boolean>\n{\n const mode = getCsrfMode();\n\n if (mode === 'off' || SAFE_METHODS.has(ctx.method.toUpperCase()))\n {\n return false;\n }\n\n if (getCsrfExemptPaths().includes(ctx.path))\n {\n authLogger.interceptor.csrf.debug('Path is CSRF-exempt', { path: ctx.path });\n\n return false;\n }\n\n let expected: string;\n\n try\n {\n expected = await deriveCsrfToken(keyId);\n }\n catch (error)\n {\n // Fail closed, in every mode including the default `warn`. A deployment\n // that cannot derive the token is misconfigured, not unprotected, and\n // letting warn wave these through would leave a broken install silently\n // open while its logs looked like a healthy one's. No cookie either —\n // there is no value to issue.\n authLogger.interceptor.csrf.error(\n 'Cannot derive the CSRF token — refusing regardless of mode',\n error as Error,\n );\n\n ctx.abort = refusal();\n\n return true;\n }\n\n // Read the header off the ORIGINAL browser request. ctx.headers is the set\n // buildProxyHeaders() forwards to the backend, and that is a fixed allowlist\n // — the CSRF header is not on it, deliberately: it is proxy-terminated and\n // has no meaning past this point. Reading ctx.headers here would refuse\n // every request instead.\n const presented = ctx.request.headers.get(CSRF_HEADER);\n\n if (matchesCsrfToken(expected, presented))\n {\n return false;\n }\n\n // One line per request that fails the check, in either mode. Never the\n // expected token or the key id — the log would otherwise hand out what the\n // check exists to withhold.\n const detail = {\n method: ctx.method,\n path: ctx.path,\n headerPresent: !!presented,\n };\n\n if (mode === 'warn')\n {\n authLogger.interceptor.csrf.warn('CSRF check would refuse this request (mode=warn)', detail);\n\n return false;\n }\n\n authLogger.interceptor.csrf.warn('CSRF check refused this request', detail);\n\n ctx.abort = refusal();\n\n // The repair is attached after the refusal is already in place. Building the\n // cookie needs the session TTL, which parses configuration and throws on a\n // malformed value — and a throw on the way out of here is swallowed upstream,\n // which would hand the caller the backend's 401 instead of this 403. Refusing\n // without the repair costs one user one extra page load; refusing with the\n // wrong status changes what the check means.\n ctx.abort.setCookies = [csrfCookie(expected, getSessionTtl())];\n\n return true;\n}\n\n/**\n * Queue the readable CSRF cookie for a session.\n *\n * Set wherever a session is established or renewed.\n *\n * Queued in every mode, including `off`, so that turning enforcement on later\n * does not require everyone to sign in again.\n */\nexport async function pushCsrfCookie(\n setCookies: SetCookie[],\n keyId: string,\n ttl: number,\n): Promise<void>\n{\n setCookies.push(csrfCookie(await deriveCsrfToken(keyId), ttl));\n}\n\n/**\n * Queue the readable CSRF cookie when the request's copy is missing or stale.\n *\n * The other half of the recovery path: a session that predates this feature\n * carries no cookie, and one whose value stopped matching — a key rotated down a\n * path that did not reissue, a jar half-cleared by an extension — would\n * otherwise sit broken until the session came within a day of expiry. A cookie\n * that is present but no longer matches is reissued; presence alone is not\n * proof. Any authenticated response repairs it, which is what makes one page\n * load enough.\n *\n * A derivation failure is logged rather than thrown: the request-side check\n * already refuses those, and turning every authenticated response into a 500 on\n * top of that helps nobody diagnose it.\n */\nexport async function pushCsrfCookieIfStale(\n setCookies: SetCookie[],\n presented: string | undefined,\n keyId: string,\n): Promise<void>\n{\n try\n {\n const token = await deriveCsrfToken(keyId);\n\n if (presented && timingSafeEqualString(token, presented))\n {\n return;\n }\n\n setCookies.push(csrfCookie(token, getSessionTtl()));\n }\n catch (error)\n {\n authLogger.interceptor.csrf.error('Cannot reissue the CSRF cookie', error as Error);\n }\n}\n\n/**\n * Queue removal of the readable CSRF cookie (logout, expired session).\n */\nexport function pushCsrfCookieRemoval(setCookies: SetCookie[]): void\n{\n setCookies.push({\n name: COOKIE_NAMES.CSRF,\n value: '',\n options: { maxAge: 0, path: '/' },\n });\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 * The body a proxy-minted refusal carries.\n *\n * Exactly the shape a backend refusal has: `__type` and `message` at the top\n * level, plus the `{ code, message, requestId }` envelope `ErrorHandler` attaches\n * beside them. That is what makes an interceptor's 401 arrive at the app as the\n * error class it names — `handleErrorResponse` restores a class only when the\n * body has `__type` and `authErrorRegistry` knows it — so `err instanceof\n * SessionRenewalRequiredError` reads the same whether the refusal came from here\n * or from a route.\n *\n * `interceptors/csrf.ts` mints a refusal that is *not* this shape. It predates\n * this helper and its 403 carries a deliberately uninformative `{ error, message }`\n * body; it is not the precedent to follow, and it is named here so that the\n * difference reads as a decision rather than as drift.\n */\n\nimport type { ProxyAbort } from '@spfn/core/nextjs/server';\nimport type { HttpError } from '@spfn/core/errors';\n\n/**\n * Serialize a registered error as the refusal an interceptor aborts with.\n *\n * @param error - an error class listed in `authErrorRegistry`; anything else\n * reaches the client as a bare `ApiError`, which is the thing this avoids\n * @param setCookies - cookies to put on the refusal itself. A refusal skips the\n * backend and every response interceptor, so this is the only chance to touch\n * the browser's jar — and leaving it empty is how a refusal keeps the cookies\n * the caller already had.\n */\nexport function refusalEnvelope(error: HttpError, setCookies: ProxyAbort['setCookies'] = []): ProxyAbort\n{\n return {\n status: error.statusCode,\n body: refusalBody(error),\n setCookies,\n };\n}\n\n/**\n * The same body, for a refusal minted in a **response** phase.\n *\n * `ProxyAbort` belongs to the request phase — it is what stops the proxy before\n * the fetch — and a rule that has already seen the backend's answer replaces\n * `ctx.response` instead. The body has to be identical either way, or an app\n * would restore an error class from one seam and a bare `ApiError` from the\n * other for the same condition.\n *\n * @param error - an error class listed in `authErrorRegistry`\n */\nexport function refusalBody(error: HttpError): Record<string, unknown>\n{\n const body = error.toJSON() as { __type: string; message: string };\n\n return {\n ...body,\n error: {\n code: body.__type,\n message: body.message,\n requestId: mintRequestId(),\n },\n };\n}\n\n/**\n * A request id for a response no request logger ever saw.\n *\n * The backend's own envelope carries the id `RequestLogger` set, or mints one for\n * that response alone when there is none. A refusal minted here never reached the\n * backend, so there is nothing to correlate with and the same fallback applies —\n * 16 random bytes as hex, so a person reading one out to support is reading the\n * same shape of value either way.\n */\nfunction mintRequestId(): string\n{\n const bytes = crypto.getRandomValues(new Uint8Array(16));\n\n return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n","/**\n * Login/Register Interceptor\n *\n * Automatically handles key generation and session management\n * for login, register, and invitation-accept endpoints.\n * (Invitation acceptance creates the user account + key pair and\n * logs the new user in, so it follows the same key/session flow.)\n *\n * `session/renew/verify` is on the list too (#97). Renewing a bound session key\n * needs exactly what a sign-in needs — a fresh pair generated here, the public\n * half in the body, the private half sealed into the cookie — so it is served by\n * this interceptor rather than by a second copy of it. The body's `keyId` means\n * the new key on that path exactly as it does on every other; the key being\n * replaced is not in the body at all, it is the one `general-auth` signs the\n * request with — and because `general-auth` matches the same request, the\n * replacement credentials are kept under `newPrivateKey`/`newKeyId`/\n * `newAlgorithm` rather than under names that rule also writes (#99).\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { generateKeyPair } from '../../server/lib/crypto';\nimport { sealSession } from '../../server/lib/session';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\nimport { bindingSessionFields } from './session-binding';\n\n/**\n * The sign-in paths that replace a key the browser already holds.\n *\n * Register, invitation-accept and signup/password create the account, so there\n * is nothing to rotate; the two sign-ins can each arrive at a browser that is\n * already carrying a session key.\n */\nconst ROTATING_SIGN_IN_PATHS = new Set(['/_auth/login', '/_auth/passkeys/login/verify']);\n\n/**\n * Login, Register, and Invitation-Accept Interceptor\n *\n * Request: Generates key pair and adds publicKey to request body\n * Response: Saves privateKey to HttpOnly cookie\n */\nexport const loginRegisterInterceptor: InterceptorRule =\n {\n pathPattern: /^\\/_auth\\/(login|register|invitations\\/accept|signup\\/password|password\\/reset\\/complete|passkeys\\/login\\/verify|session\\/renew\\/verify)$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n // Get old session if exists (for key rotation on login)\n const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);\n\n // Extract remember option from request body (if provided)\n const remember = ctx.body?.remember;\n\n // Generate new key pair\n const keyPair = generateKeyPair('ES256');\n\n // Add publicKey data to request body\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.publicKey = keyPair.publicKey;\n ctx.body.keyId = keyPair.keyId;\n ctx.body.fingerprint = keyPair.fingerprint;\n ctx.body.algorithm = keyPair.algorithm;\n ctx.body.keySize = Buffer.from(keyPair.publicKey, 'base64').length;\n\n // Add oldKeyId for a sign-in (key rotation). Both sign-in paths:\n // a passkey assertion starts a session exactly as a password login\n // does, so the key the browser was already carrying has to be\n // retired by the same request that replaces it.\n if (ROTATING_SIGN_IN_PATHS.has(ctx.path) && oldKeyId)\n {\n ctx.body.oldKeyId = oldKeyId;\n }\n\n // Remove remember from body (not part of contract)\n delete ctx.body.remember;\n\n // Store the replacement credentials and remember in metadata for the\n // response interceptor. The `new` prefix is the whole point: the\n // metadata object is shared by every rule that matched this request,\n // and on `session/renew/verify` `generalAuthInterceptor` also matches\n // and writes `keyId` — the id of the *expiring* key it signs the\n // request with. Sharing that name sealed the new private key with the\n // retired id and answered 200 to a session the next request could not\n // use (#99). `keyRotationInterceptor` has always named them this way.\n ctx.metadata.newPrivateKey = keyPair.privateKey;\n ctx.metadata.newKeyId = keyPair.keyId;\n ctx.metadata.newAlgorithm = keyPair.algorithm;\n ctx.metadata.remember = remember;\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // Only process successful responses\n if (ctx.response.status !== 200)\n {\n await next();\n\n return;\n }\n\n // Handle both wrapped ({ data: { userId } }) and direct ({ userId }) responses\n const userData = ctx.response.body?.data || ctx.response.body;\n if (!userData?.userId)\n {\n authLogger.interceptor.login.error('No userId in response');\n await next();\n\n return;\n }\n\n try\n {\n // Get session TTL (priority: runtime > global > env > default)\n const ttl = getSessionTtl(ctx.metadata.remember);\n\n // Encrypt session data. The binding fields ride along when the\n // sign-in said the account asked for a bound session; without\n // them this is the same four-field literal it has always been.\n const sessionData =\n {\n userId: userData.userId,\n privateKey: ctx.metadata.newPrivateKey,\n keyId: ctx.metadata.newKeyId,\n algorithm: ctx.metadata.newAlgorithm,\n ...bindingSessionFields(userData, ctx.request.headers['user-agent']),\n };\n\n const sealed = await sealSession(sessionData, ttl);\n\n // Set HttpOnly session cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Set keyId cookie (for oldKeyId lookup)\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: ctx.metadata.newKeyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Set the readable CSRF cookie the client mirrors into a header\n await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.login.error('Failed to save session', err);\n }\n\n await next();\n },\n };\n","/**\n * Second-Factor Verify Interceptor (#95)\n *\n * The proxy half of new-device step-up. A sign-in on an enrolled account from a\n * device it has never seen answers 202 with a challenge and no session; this\n * rule holds the browser's half of that flow until `POST /_auth/mfa/verify`\n * succeeds, and then seals the session the 202 did not.\n *\n * **Response phase only, and a separate rule on purpose.**\n * `loginRegisterInterceptor`'s request phase mints a fresh ES256 pair and injects\n * it into every body it matches, which is exactly what `verify` must not get —\n * the key being activated already exists, and a second one would be a device\n * nobody asked for. Its response phase already returns early on any non-200, so\n * the 202 passes through it untouched and nothing in that file changes.\n *\n * The pending cookie is its own name and its own audience, separate from\n * `OAUTH_PENDING`. The two coexist: a person who starts a social login in one tab\n * while a step-up is outstanding in another has both 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 the verification activated.\n *\n * And the binding is checked before anything is sealed. The cookie names the\n * challenge and the key it was baked for, the verified response names both back,\n * and a session is sealed only when the two agree. Without that comparison the\n * proxy would seal whatever private key it happened to be holding around\n * whatever key the backend activated.\n */\n\nimport type { InterceptorRule, ResponseInterceptorContext } from '@spfn/core/nextjs/server';\nimport type { HttpError } from '@spfn/core/errors';\nimport { SessionPendingExpiredError, SessionPendingMismatchError } from '@spfn/auth/errors';\n\nimport { hashCredential } from '../../server/lib/link-credentials';\nimport { sealSession } from '../../server/lib/session';\nimport { COOKIE_NAMES, getSessionTtl } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport {\n sealPendingMfaSession,\n unsealPendingMfaSession,\n unsealPendingSession,\n type PendingSessionData,\n} from '../session-helpers';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\nimport { refusalBody } from './error-envelope';\nimport { bindingSessionFields } from './session-binding';\n\n/**\n * The four paths that can answer 202, plus the one that resolves it.\n *\n * `oauth/{provider}/native` is on the list for completeness rather than for the\n * cookie: a native client holds its own key and never reaches this proxy. When\n * it does come through one there is no pending key to seal, and the rule leaves\n * the 202 alone.\n */\nconst MFA_PATH_PATTERN =\n /^\\/_auth\\/(login|password\\/reset\\/complete|oauth\\/[\\w-]+\\/native|oauth\\/finalize|mfa\\/verify)$/;\n\n/** How long the pending cookie lives — the challenge's own ten minutes. */\nconst PENDING_TTL_SECONDS = 600;\n\n/**\n * The challenge secret out of a 202 body, whichever of the two shapes it is in.\n *\n * A sign-in answers `LoginResult`, whose `challenge` is `{ secret,\n * expiresAtMillis }`. `oauth/finalize` echoes back the string the callback query\n * carried, because the page is handing over a value it was given and the route\n * has no row to read an expiry from.\n */\nfunction challengeSecretOf(body: unknown): string | undefined\n{\n const challenge = (body as { challenge?: unknown } | null)?.challenge;\n\n if (typeof challenge === 'string')\n {\n return challenge;\n }\n\n const secret = (challenge as { secret?: unknown } | undefined)?.secret;\n\n return typeof secret === 'string' ? secret : undefined;\n}\n\n/**\n * The device key this browser is holding, whichever flow produced it.\n *\n * A password sign-in or a password reset ran through `loginRegisterInterceptor`\n * a moment ago, so the pair it minted is in shared metadata, under the `new`\n * names that rule reserves for the credentials it is installing (#99). The OAuth\n * page flow has no such request phase — its key was minted at\n * `oauth/{provider}/url` and sealed into `OAUTH_PENDING` — so that cookie is the\n * fallback.\n */\nasync function pendingKeyFor(ctx: ResponseInterceptorContext): Promise<PendingSessionData | null>\n{\n if (ctx.metadata.newPrivateKey && ctx.metadata.newKeyId)\n {\n return {\n privateKey: ctx.metadata.newPrivateKey,\n keyId: ctx.metadata.newKeyId,\n algorithm: ctx.metadata.newAlgorithm,\n };\n }\n\n const oauthPending = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);\n\n return oauthPending ? await unsealPendingSession(oauthPending) : null;\n}\n\n/**\n * Hold the browser's half of a 202 until the second factor is proved.\n *\n * Nothing is sealed and nothing is cleared: the person is mid-sign-in, and the\n * session they had before — if they had one — is still theirs. The OAuth pending\n * cookie is left in place too, since the page flow may still be reading it.\n */\nasync function bakePendingCookie(ctx: ResponseInterceptorContext): Promise<void>\n{\n const secret = challengeSecretOf(ctx.response.body);\n const pending = secret ? await pendingKeyFor(ctx) : null;\n\n if (!secret || !pending)\n {\n return;\n }\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.MFA_PENDING,\n value: await sealPendingMfaSession({ ...pending, challengeHash: hashCredential(secret) }, PENDING_TTL_SECONDS),\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: PENDING_TTL_SECONDS,\n path: '/',\n },\n });\n\n authLogger.interceptor.login.debug('Second-factor pending cookie set', { keyId: pending.keyId });\n}\n\n/**\n * Replace the backend's answer with a refusal this browser can act on.\n *\n * The verification really did succeed and the key really is active — what failed\n * is this browser's claim to be the one that started the sign-in. The refusal\n * carries the registered envelope, so an app reads it as the error class it\n * names rather than as an anonymous 401.\n *\n * The pending cookie is deliberately not cleared. Only one of them can exist at\n * a time, so a mismatch may well mean it belongs to a second flow that is still\n * live in another tab, and expiring it here would break that one too. It goes on\n * its own after ten minutes.\n */\nfunction refuse(ctx: ResponseInterceptorContext, error: HttpError): void\n{\n authLogger.interceptor.login.warn('Second-factor session not sealed', { reason: error.name });\n\n ctx.response.ok = false;\n ctx.response.status = error.statusCode;\n ctx.response.statusText = 'Unauthorized';\n ctx.response.body = refusalBody(error);\n}\n\n/**\n * Turn a verified challenge into the session the 202 withheld.\n *\n * Both halves of the binding are checked, and both matter. `challengeHash` says\n * this cookie was baked for this challenge; `keyId` says the key the backend\n * activated is the key whose private half this cookie holds. A session sealed\n * with a mismatched pair authenticates nothing — `authenticate` verifies the\n * signature against the stored public key — so the account would simply look\n * broken until the person signed in again.\n */\nasync function sealVerifiedSession(ctx: ResponseInterceptorContext): Promise<void>\n{\n const cookie = ctx.cookies.get(COOKIE_NAMES.MFA_PENDING);\n\n if (!cookie)\n {\n refuse(ctx, new SessionPendingExpiredError());\n\n return;\n }\n\n const pending = await unsealPendingMfaSession(cookie);\n const { userId, keyId, challengeHash } = ctx.response.body || {};\n\n if (pending.challengeHash !== challengeHash || pending.keyId !== keyId)\n {\n refuse(ctx, new SessionPendingMismatchError());\n\n return;\n }\n\n const ttl = getSessionTtl();\n const sealed = await sealSession({\n userId,\n privateKey: pending.privateKey,\n keyId: pending.keyId,\n algorithm: pending.algorithm,\n ...bindingSessionFields(ctx.response.body, ctx.request.headers['user-agent']),\n }, ttl);\n\n pushSessionCookies(ctx, sealed, pending.keyId, ttl);\n await pushCsrfCookie(ctx.setCookies, pending.keyId, ttl);\n}\n\n/** The session trio, plus the expiry of the pending cookie they replace. */\nfunction pushSessionCookies(ctx: ResponseInterceptorContext, sealed: string, keyId: string, ttl: number): void\n{\n const options = { httpOnly: true, secure: cookieSecure, sameSite: 'lax' as const, path: '/' };\n\n ctx.setCookies.push({ name: COOKIE_NAMES.SESSION, value: sealed, options: { ...options, maxAge: ttl } });\n ctx.setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: keyId, options: { ...options, maxAge: ttl } });\n ctx.setCookies.push({ name: COOKIE_NAMES.MFA_PENDING, value: '', options: { ...options, maxAge: 0 } });\n}\n\n/**\n * Second-Factor Verify Interceptor\n *\n * Response: bakes the pending cookie on a 202, and seals the session on a\n * verified challenge. Registered after `loginRegisterInterceptor`, whose 202\n * pass-through is what leaves the body for this rule to read.\n */\nexport const mfaVerifyInterceptor: InterceptorRule = {\n pathPattern: MFA_PATH_PATTERN,\n method: 'POST',\n\n response: async (ctx, next) =>\n {\n try\n {\n if (ctx.response.status === 202)\n {\n await bakePendingCookie(ctx);\n }\n else if (ctx.response.status === 200 && ctx.path === '/_auth/mfa/verify')\n {\n await sealVerifiedSession(ctx);\n }\n }\n catch (error)\n {\n // An unreadable or expired pending cookie lands here, and so does a\n // sealing failure. Both mean the same thing to the person in front of\n // the browser — the window closed — and the key is active either way,\n // so signing in again is the whole remedy.\n authLogger.interceptor.login.error('Second-factor session handling failed', error as Error);\n\n if (ctx.path === '/_auth/mfa/verify')\n {\n refuse(ctx, new SessionPendingExpiredError());\n }\n }\n\n await next();\n },\n};\n","/**\n * @spfn/auth - Link Flow Credentials\n *\n * The bearer credentials of the link flows — the emailed link token and the\n * password-setup secret — are minted and hashed the same way, and are minted in\n * more than one place each (the request, for a setup session; the\n * `auth.link-mail` worker, for a link; `createRevokeAllLink`, for the\n * sign-out-everywhere link the app mails itself). One definition rather than a\n * copy per flow, so \"never store the secret\" is one rule and not four.\n *\n * The URL every one of those links points at is built here too, for the same\n * reason: the rule that a link opens a page in the app and never an API route\n * is one rule.\n */\n\nimport crypto from 'crypto';\n\nimport { env } from '@spfn/auth/config';\n\n/**\n * Bytes of entropy in a link token or a setup secret.\n *\n * 32 bytes is why neither credential carries an attempt counter the way a\n * six-digit code does: there is nothing to brute force. Rate limits on these\n * flows bound request volume and mail sending, not guessing.\n */\nconst CREDENTIAL_BYTES = 32;\n\n/**\n * Mint a bearer credential and the value stored for it.\n *\n * The secret is returned once, to be emailed or set as a cookie, and is then\n * unrecoverable — only `hash` reaches the database.\n */\nexport function mintCredential(): { secret: string; hash: string }\n{\n const secret = crypto.randomBytes(CREDENTIAL_BYTES).toString('base64url');\n\n return { secret, hash: hashCredential(secret) };\n}\n\n/**\n * Hash a presented credential the same way it was stored.\n *\n * SHA-256 without a salt or a work factor, deliberately: the input is 32 random\n * bytes rather than a human-chosen secret, so there is no dictionary to slow\n * down, and lookup has to be a plain equality match on an indexed column.\n */\nexport function hashCredential(secret: string): string\n{\n return crypto.createHash('sha256').update(secret).digest('base64url');\n}\n\n/**\n * Absolute URL of an app page a link opens.\n *\n * The page is in the app, not in this package — the token travels in its query\n * string and the page posts it back to the confirm route. This package answers\n * JSON and serves no HTML, so there is nowhere else for the link to point.\n *\n * @param path - Page path within the app, from the flow's `*_CONFIRM_PATH`\n * @param token - The plaintext credential, encoded into the query string\n */\nexport function buildConfirmUrl(path: string, token: string): string\n{\n const appUrl = (env.NEXT_PUBLIC_SPFN_APP_URL || env.SPFN_APP_URL || '').replace(/\\/$/, '');\n\n return `${appUrl}${path}?token=${encodeURIComponent(token)}`;\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 * General Authentication Interceptor\n *\n * Handles authentication for all API requests except login/register\n * - Session validation and renewal\n * - JWT generation and signing\n * - Expired session cleanup\n */\n\nimport type { InterceptorRule, RequestInterceptorContext, ResponseInterceptorContext } from '@spfn/core/nextjs/server';\nimport { SessionContextChangedError, SessionRenewalRequiredError } from '@spfn/auth/errors';\nimport { unsealSession, sealSession, shouldRefreshSession, type SessionData } from '../../server/lib/session';\nimport { generateClientToken } from '../../server/lib/crypto';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { uaFamily } from '../../server/lib/ua-family';\nimport { refuseInvalidCsrf, pushCsrfCookie, pushCsrfCookieIfStale, pushCsrfCookieRemoval } from './csrf';\nimport { refusalEnvelope } from './error-envelope';\nimport { SESSION_RENEW_PATH_PATTERN } from './session-renew';\n\n/**\n * Check if path requires authentication\n */\nfunction requiresAuth(path: string): boolean\n{\n // Paths that don't require auth\n const publicPaths = [\n /^\\/_auth\\/login$/,\n /^\\/_auth\\/register$/,\n /^\\/_auth\\/codes$/, // Send verification code\n /^\\/_auth\\/codes\\/verify$/, // Verify code\n /^\\/_auth\\/exists$/, // Check account exists\n // The two halves of a second-factor step-up (#95). Public for the same\n // reason `login` is — the key they activate is inactive until they\n // succeed, so there is nothing to sign them with — and public *here* for\n // one more: a browser holding a stale session cookie would otherwise have\n // this rule refresh or clear that session on the way out, over the fresh\n // one `mfaVerifyInterceptor` just sealed.\n /^\\/_auth\\/mfa\\/verify$/,\n /^\\/_auth\\/mfa\\/verify\\/options$/,\n ];\n\n return !publicPaths.some((pattern) => pattern.test(path));\n}\n\n/**\n * Whether a bound session arrived from a different browser than it was sealed in.\n *\n * Three terms, and each one is a rule.\n *\n * Bound only. An unbound session is not checked and nothing is logged for it —\n * the check would be a warn line per request for every account that did not opt\n * in, which is the noisy half of a protection they did not ask for.\n *\n * A `user-agent` has to be present. Absent is no signal, not a different family:\n * a server component's `api.` call reaches this proxy as Node `fetch` and the\n * isomorphic client sets `Content-Type`, `Cookie` and the CSRF header and nothing\n * else, so fail-closed on absence would refuse every server-rendered page view.\n *\n * And the comparison is between families rather than strings, so a version bump,\n * a user-agent reduction, or \"Request desktop site\" on Android are all the same\n * browser. What is left is a session presented from a different cookie jar, which\n * is a thing that does not happen without a copy.\n */\nfunction contextChanged(session: SessionData, userAgent: string | null): boolean\n{\n return session.binding === 'passkey'\n && Boolean(session.uaFamily)\n && Boolean(userAgent)\n && uaFamily(userAgent) !== session.uaFamily;\n}\n\n/**\n * Refuse a bound session presented from another browser, and empty the jar.\n *\n * The opposite of the renewal refusal the response phase mints: this session is\n * not waiting for a prompt, it is one whose cookie is somewhere it was never\n * sealed. The three cookies go with the refusal, which is the only moment a\n * refused request can touch them — and it is the one check this layer makes\n * alone, because the backend never sees the browser's `user-agent`.\n */\nfunction refuseAsContextChanged(ctx: RequestInterceptorContext): void\n{\n authLogger.interceptor.general.warn('Bound session presented from a different browser family', {\n path: ctx.path,\n sealed: ctx.metadata.sealedUaFamily,\n presented: ctx.metadata.presentedUaFamily,\n });\n\n const cleared = [\n { name: COOKIE_NAMES.SESSION, value: '', options: { maxAge: 0, path: '/' } },\n { name: COOKIE_NAMES.SESSION_KEY_ID, value: '', options: { maxAge: 0, path: '/' } },\n { name: COOKIE_NAMES.CSRF, value: '', options: { maxAge: 0, path: '/' } },\n ];\n\n ctx.abort = refusalEnvelope(new SessionContextChangedError(), cleared);\n}\n\n/**\n * Whether a backend 401 is the one that says the device key has expired.\n *\n * Read off `__type`, which is what the error envelope classifies by; the string\n * is the class name, and it is compared rather than imported because the body\n * here is JSON off the wire rather than an error instance.\n */\nfunction isKeyExpiredRefusal(body: unknown): boolean\n{\n return (body as { __type?: unknown } | null)?.__type === 'KeyExpiredError';\n}\n\n/** Whether an earlier rule in this response chain already queued a session cookie. */\nfunction sessionQueued(setCookies: ResponseInterceptorContext['setCookies']): boolean\n{\n return setCookies.some(cookie => cookie.name === COOKIE_NAMES.SESSION);\n}\n\n/**\n * General Authentication Interceptor\n *\n * Applies to all paths except login/register/codes\n * - Validates session\n * - Generates JWT token\n * - Refreshes session if needed\n * - Clears expired sessions\n */\nexport const generalAuthInterceptor: InterceptorRule =\n {\n pathPattern: '*', // Match all paths, filter by requiresAuth()\n method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],\n\n request: async (ctx, next) =>\n {\n // Skip if path doesn't require auth\n if (!requiresAuth(ctx.path))\n {\n authLogger.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);\n await next();\n\n return;\n }\n\n // Log available cookies\n const cookieNames = Array.from(ctx.cookies.keys());\n authLogger.interceptor.general.debug('Available cookies:', {\n cookieNames,\n totalCount: cookieNames.length,\n lookingFor: COOKIE_NAMES.SESSION,\n });\n\n const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);\n\n authLogger.interceptor.general.debug('Request', {\n method: ctx.method,\n path: ctx.path,\n hasSession: !!sessionCookie,\n sessionLength: sessionCookie?.length ?? 0,\n sessionPrefix: sessionCookie?.slice(0, 20) ?? '',\n sessionSuffix: sessionCookie?.slice(-10) ?? '',\n });\n\n // No session cookie\n if (!sessionCookie)\n {\n authLogger.interceptor.general.debug('No session cookie, proceeding without auth');\n // Let request proceed - server will return 401\n await next();\n\n return;\n }\n\n try\n {\n // Decrypt and validate session\n const session = await unsealSession(sessionCookie);\n\n authLogger.interceptor.general.debug('Session valid', {\n userId: session.userId,\n keyId: session.keyId,\n });\n\n // The request is authenticated from the session cookie — the one\n // fact only this layer knows, and the whole reason the CSRF check\n // lives here. Refusals stop before the backend is called.\n if (await refuseInvalidCsrf(ctx, session.keyId))\n {\n return;\n }\n\n // Context before expiry. A session whose cookie has moved to\n // another browser is finished whether or not its key is still\n // live, and answering it \"renew with your passkey\" would keep\n // exactly the cookies that need to go.\n const presented = ctx.request.headers.get('user-agent');\n\n if (contextChanged(session, presented))\n {\n ctx.metadata.sealedUaFamily = session.uaFamily;\n ctx.metadata.presentedUaFamily = uaFamily(presented);\n refuseAsContextChanged(ctx);\n\n return;\n }\n\n // Check if session should be refreshed (within 24h of expiry)\n const needsRefresh = await shouldRefreshSession(sessionCookie, 24);\n\n if (needsRefresh)\n {\n authLogger.interceptor.general.debug('Session needs refresh (within 24h of expiry)');\n // Mark for session renewal in response interceptor\n ctx.metadata.refreshSession = true;\n ctx.metadata.sessionData = session;\n }\n\n // Generate JWT token\n const token = generateClientToken(\n {\n userId: session.userId,\n keyId: session.keyId,\n timestamp: Date.now(),\n },\n session.privateKey,\n session.algorithm,\n { expiresIn: '15m' },\n );\n\n authLogger.interceptor.general.debug('Generated JWT token (expires in 15m)');\n\n // Add authentication headers\n ctx.headers['Authorization'] = `Bearer ${token}`;\n ctx.headers['X-Key-Id'] = session.keyId;\n\n // Store session info in metadata\n ctx.metadata.userId = session.userId;\n ctx.metadata.keyId = session.keyId;\n ctx.metadata.sessionValid = true;\n ctx.metadata.sessionBound = session.binding === 'passkey';\n }\n catch (error)\n {\n const err = error as Error;\n const msg = err.message.toLowerCase();\n\n // Session expired or invalid\n if (msg.includes('expired') || msg.includes('invalid'))\n {\n authLogger.interceptor.general.warn('Session expired or invalid', {\n message: err.message,\n cookieLength: sessionCookie.length,\n cookiePrefix: sessionCookie.slice(0, 20),\n cookieSuffix: sessionCookie.slice(-10),\n });\n authLogger.interceptor.general.debug('Marking session for cleanup');\n\n // Mark for cleanup in response interceptor\n ctx.metadata.clearSession = true;\n ctx.metadata.sessionValid = false;\n }\n else\n {\n authLogger.interceptor.general.error('Failed to process session', err);\n }\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // A bound session the backend refused as expired. This is the only place\n // the renewal prompt is minted, and deliberately so: the cookie's copy of\n // the expiry is a hint, the key row is the fact, and a proxy that refused\n // on the hint alone would strand a session whose key was made long-lived\n // again on another device. The cookies stay — the session is renewable,\n // not finished.\n if (ctx.response.status === 401\n && ctx.metadata.sessionValid\n && ctx.metadata.sessionBound\n && isKeyExpiredRefusal(ctx.response.body))\n {\n ctx.response.body = refusalEnvelope(new SessionRenewalRequiredError()).body;\n\n await next();\n\n return;\n }\n\n // Backend returned 401 with a valid session — server rejected it.\n //\n // Never on the renewal paths. They are signed like any other path, so\n // `sessionValid` is set there and this branch would otherwise fire on\n // the refusal a renewal answers with — emptying the cookie jar, and\n // with it the session the person was in the middle of repairing.\n if (ctx.response.status === 401\n && ctx.metadata.sessionValid\n && !SESSION_RENEW_PATH_PATTERN.test(ctx.path))\n {\n authLogger.interceptor.general.warn('Backend returned 401, clearing session');\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: '',\n options: { maxAge: 0, path: '/' },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: '',\n options: { maxAge: 0, path: '/' },\n });\n\n pushCsrfCookieRemoval(ctx.setCookies);\n\n await next();\n\n return;\n }\n\n // Clear expired/invalid session\n if (ctx.metadata.clearSession)\n {\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: '',\n options: {\n maxAge: 0,\n path: '/',\n },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: '',\n options: {\n maxAge: 0,\n path: '/',\n },\n });\n\n pushCsrfCookieRemoval(ctx.setCookies);\n }\n // Refresh session if needed and request was successful.\n //\n // Never over a replacement. An earlier rule in this same chain — a\n // sign-in, a passkey renewal, a key rotation — may already have queued\n // the session it just installed, and response phases run in\n // registration order, so re-sealing the *inbound* session here would\n // be the last write of that cookie name and the one the browser keeps.\n // This branch exists to extend a session that is still the current\n // one; when it has just been replaced there is nothing to extend.\n else if (ctx.metadata.refreshSession && ctx.response.status === 200 && !sessionQueued(ctx.setCookies))\n {\n try\n {\n const sessionData = ctx.metadata.sessionData;\n const ttl = getSessionTtl();\n\n // Re-encrypt session with new TTL. The object is the one\n // unsealed on the way in, so a bound session's `binding`,\n // `keyExpiresAt` and `uaFamily` survive the refresh — this is\n // the one sealing site that carries them for free, and the\n // reason it must go on re-sealing the whole object rather\n // than rebuilding the four-field literal.\n const sealed = await sealSession(sessionData, ttl);\n\n // Update session cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Update keyId cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: sessionData.keyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Renewed session, renewed CSRF cookie — same lifetime, so the\n // readable value never outlives the session it belongs to.\n await pushCsrfCookie(ctx.setCookies, sessionData.keyId, ttl);\n\n authLogger.interceptor.general.info('Session refreshed', {\n userId: sessionData.userId,\n sealedLength: sealed.length,\n sealedPrefix: sealed.slice(0, 20),\n });\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.general.error('Failed to refresh session', err);\n }\n }\n // Handle logout (clear session)\n else if (ctx.path === '/_auth/logout' && ctx.response.ok)\n {\n const base = {\n httpOnly: true,\n secure: cookieSecure,\n maxAge: 0,\n path: '/',\n };\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: '',\n options: { ...base, sameSite: 'lax' },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: '',\n options: { ...base, sameSite: 'lax' },\n });\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: '',\n options: { ...base, sameSite: 'lax' },\n });\n\n pushCsrfCookieRemoval(ctx.setCookies);\n }\n\n // A session that predates CSRF protection carries no readable cookie,\n // and renewal only happens near expiry — an app switching to enforce\n // would otherwise refuse every mutation from everyone already signed\n // in, for days. Issue it on any authenticated response whose cookie is\n // missing or no longer matches; reads pass the check, so a page load\n // is enough to heal.\n const csrfQueued = ctx.setCookies.some(cookie => cookie.name === COOKIE_NAMES.CSRF);\n\n if (ctx.metadata.sessionValid && !csrfQueued)\n {\n await pushCsrfCookieIfStale(\n ctx.setCookies,\n ctx.cookies.get(COOKIE_NAMES.CSRF),\n ctx.metadata.keyId,\n );\n }\n\n await next();\n },\n };\n","/**\n * The two renewal paths, named once.\n *\n * There is no interceptor here any more, and the reason is the point of the\n * file. Renewal used to be told which key to renew by a body field the proxy\n * injected from the HttpOnly key-id cookie; now the backend reads it off the\n * `keyId` of the bearer JWT the request is signed with\n * (`authenticateForRenewal`), so there is nothing left to inject and nothing a\n * direct caller can name that they do not already hold the private key for.\n *\n * What the proxy still has to do for these two paths, `general-auth` does: they\n * are authenticated paths like any other, so the session cookie is unsealed, the\n * CSRF header checked, and a JWT signed with the private half of the expiring\n * key — `generateClientToken` signs with the key material in the cookie and never\n * consults the row's expiry, which is what makes an expired key still able to\n * speak for itself. The one thing that path must not do is clear the jar when\n * one of these answers 401, and the pattern below is how it knows.\n *\n * `renew/verify` is also on `loginRegisterInterceptor`'s path list, which is\n * where the *new* key pair is generated and the replacement session sealed.\n */\n\n/** The two public renewal paths, as one pattern the proxy layers agree on. */\nexport const SESSION_RENEW_PATH_PATTERN = /^\\/_auth\\/session\\/renew\\/(options|verify)$/;\n","/**\n * Key Rotation Interceptor\n *\n * Handles key rotation with new key generation and session update\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { generateKeyPair, generateClientToken } from '../../server/lib/crypto';\nimport { unsealSession, sealSession } from '../../server/lib/session';\nimport { getSessionTtl, COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\n\n/**\n * Key Rotation Interceptor\n *\n * Request: Generates new key pair and adds to body, authenticates with current key\n * Response: Updates session with new privateKey\n */\nexport const keyRotationInterceptor: InterceptorRule =\n {\n pathPattern: '/_auth/keys/rotate',\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);\n\n if (!sessionCookie)\n {\n await next();\n\n return;\n }\n\n try\n {\n // Get current session\n const currentSession = await unsealSession(sessionCookie);\n\n // Generate new key pair\n const newKeyPair = generateKeyPair('ES256');\n\n // Add new publicKey to request body\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.publicKey = newKeyPair.publicKey;\n ctx.body.keyId = newKeyPair.keyId;\n ctx.body.fingerprint = newKeyPair.fingerprint;\n ctx.body.algorithm = newKeyPair.algorithm;\n ctx.body.keySize = Buffer.from(newKeyPair.publicKey, 'base64').length;\n\n // Identity of the new key only — the pair carries `privateKey`,\n // the credential the session is sealed around.\n authLogger.interceptor.keyRotation.debug('Generated a new key pair', {\n keyId: newKeyPair.keyId,\n fingerprint: newKeyPair.fingerprint,\n algorithm: newKeyPair.algorithm,\n });\n\n // Authenticate with CURRENT key\n const token = generateClientToken(\n {\n userId: currentSession.userId,\n keyId: currentSession.keyId,\n action: 'rotate_key',\n timestamp: Date.now(),\n },\n currentSession.privateKey,\n currentSession.algorithm,\n {expiresIn: '15m'},\n );\n\n ctx.headers['Authorization'] = `Bearer ${token}`;\n ctx.headers['X-Key-Id'] = currentSession.keyId;\n\n // Store new key and userId in metadata\n ctx.metadata.newPrivateKey = newKeyPair.privateKey;\n ctx.metadata.newKeyId = newKeyPair.keyId;\n ctx.metadata.newAlgorithm = newKeyPair.algorithm;\n ctx.metadata.userId = currentSession.userId;\n ctx.metadata.bindingFields = currentSession.binding\n ? {\n binding: currentSession.binding,\n keyExpiresAt: currentSession.keyExpiresAt,\n ...(currentSession.uaFamily ? { uaFamily: currentSession.uaFamily } : {}),\n }\n : {};\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.keyRotation.error('Failed to prepare key rotation', err);\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // Only update session on successful rotation\n if (ctx.response.status !== 200)\n {\n await next();\n\n return;\n }\n\n if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId)\n {\n authLogger.interceptor.keyRotation.error('Missing key rotation metadata');\n await next();\n\n return;\n }\n\n try\n {\n // Get session TTL\n const ttl = getSessionTtl();\n\n // Create new session with rotated key.\n //\n // The binding fields come from the session being replaced, not\n // from the response: rotation registers a key that inherits the\n // replaced key's binding and its expiry verbatim, so the cookie\n // must inherit them too. Re-deriving would be wrong twice over —\n // the rotate response says nothing about binding, and a fresh\n // expiry is exactly what rotation must not hand a bound key.\n const newSessionData =\n {\n userId: ctx.metadata.userId,\n privateKey: ctx.metadata.newPrivateKey,\n keyId: ctx.metadata.newKeyId,\n algorithm: ctx.metadata.newAlgorithm,\n ...ctx.metadata.bindingFields,\n };\n\n const sealed = await sealSession(newSessionData, ttl);\n\n // Update session cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Update keyId cookie\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: ctx.metadata.newKeyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Rotation changes the key id the token is derived from, so the\n // old CSRF value stops verifying — reissue it in the same response.\n await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.keyRotation.error('Failed to update session after rotation', err);\n }\n\n await next();\n },\n };\n","/**\n * OAuth State Management\n *\n * CSRF 방지를 위한 state 파라미터 암호화/복호화\n * - returnUrl: OAuth 성공 후 리다이렉트할 URL\n * - nonce: CSRF 방지용 일회용 토큰\n * - provider: OAuth provider (google, github 등)\n * - publicKey, keyId, fingerprint, algorithm: 클라이언트 키 정보\n * - expiresAt: state 만료 시간\n */\n\nimport * as jose from 'jose';\nimport { env } from '@spfn/auth/config';\nimport { type KeyAlgorithmType } from '../../types';\n\nexport interface OAuthState\n{\n returnUrl: string;\n nonce: string;\n provider: string;\n publicKey: string;\n keyId: string;\n fingerprint: string;\n algorithm: KeyAlgorithmType;\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Get encryption key derived from session secret\n */\nasync function getStateKey(): Promise<Uint8Array>\n{\n const secret = env.SPFN_AUTH_SESSION_SECRET;\n const encoder = new TextEncoder();\n const data = encoder.encode(`oauth-state:${secret}`);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n return new Uint8Array(hashBuffer);\n}\n\n/**\n * Generate random nonce\n */\nfunction generateNonce(): string\n{\n const array = new Uint8Array(16);\n crypto.getRandomValues(array);\n\n return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Generate a CSRF nonce for the OAuth flow. The caller passes it to\n * createOAuthState AND sets it as the oauth_csrf cookie, so the callback can\n * double-submit-verify the flow was initiated by this same browser.\n */\nexport function generateOAuthNonce(): string\n{\n return generateNonce();\n}\n\nexport interface CreateOAuthStateParams\n{\n provider: string;\n returnUrl: string;\n publicKey: string;\n keyId: string;\n fingerprint: string;\n algorithm: KeyAlgorithmType;\n metadata?: Record<string, unknown>;\n /**\n * CSRF nonce bound into the state. Pass the same value as the oauth_csrf\n * cookie. Defaults to a fresh nonce (unbound — legacy/no-CSRF callers).\n */\n nonce?: string;\n}\n\n/**\n * OAuth state 생성 및 암호화\n *\n * @param params - state 생성에 필요한 파라미터\n * @returns 암호화된 state 문자열\n */\nexport async function createOAuthState(params: CreateOAuthStateParams): Promise<string>\n{\n const key = await getStateKey();\n\n const state: OAuthState = {\n returnUrl: params.returnUrl,\n nonce: params.nonce ?? generateNonce(),\n provider: params.provider,\n publicKey: params.publicKey,\n keyId: params.keyId,\n fingerprint: params.fingerprint,\n algorithm: params.algorithm,\n metadata: params.metadata,\n };\n\n const jwe = await new jose.EncryptJWT({ state })\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .setExpirationTime('10m')\n .encrypt(key);\n\n // URL-safe base64 encoding\n return encodeURIComponent(jwe);\n}\n\n/**\n * OAuth state 복호화 및 검증\n *\n * @param encryptedState - 암호화된 state 문자열\n * @returns 복호화된 state 객체\n * @throws Error if state is invalid or expired (JWE exp claim으로 자동 검증)\n */\nexport async function verifyOAuthState(encryptedState: string): Promise<OAuthState>\n{\n const key = await getStateKey();\n\n const jwe = decodeURIComponent(encryptedState);\n const { payload } = await jose.jwtDecrypt(jwe, key);\n\n return payload.state as OAuthState;\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 * OAuth Interceptors\n *\n * 1. oauthUrlInterceptor: OAuth URL 요청 시 키쌍 생성 및 state 주입\n * 2. oauthFinalizeInterceptor: OAuth 완료 시 pending session에서 세션 저장\n */\n\nimport type { InterceptorRule, ProxyAbort, ResponseInterceptorContext } from '@spfn/core/nextjs/server';\nimport { generateKeyPair } from '../../server/lib/crypto';\nimport { createOAuthState, generateOAuthNonce } from '../../server/lib/oauth/state';\nimport { sealSession } from '../../server/lib/session';\nimport { COOKIE_NAMES, getSessionTtl } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { isSafeReturnPath } from '../../lib/return-path';\nimport { sealPendingSession, unsealPendingSession } from '../session-helpers';\nimport { cookieSecure } from './cookie-options';\nimport { pushCsrfCookie } from './csrf';\nimport { bindingSessionFields } from './session-binding';\n\nconst UNSAFE_RETURN_URL_MESSAGE = 'returnUrl must be a relative path within the app';\n\n/**\n * Refuse an OAuth start whose `returnUrl` would leave the app.\n *\n * This is where the value has to be checked: the interceptor seals it into the\n * encrypted state, and every layer after this one — the backend `/url` routes,\n * the provider, the callback — sees only the sealed state and cannot recover\n * what the caller asked for. An unchecked value comes back as a redirect after\n * a real login, which is what turns a forgotten screen into an open redirect.\n *\n * Refused the same way the signup-link route refuses `returnPath`: 400 carrying\n * a ValidationError, so the typed client restores the same error class whether\n * the refusal came from here or from the backend.\n */\nfunction refuseUnsafeReturnUrl(): ProxyAbort\n{\n return {\n status: 400,\n body: {\n __type: 'ValidationError',\n message: UNSAFE_RETURN_URL_MESSAGE,\n error: {\n code: 'ValidationError',\n message: UNSAFE_RETURN_URL_MESSAGE,\n },\n },\n };\n}\n\n/**\n * OAuth URL Interceptor\n *\n * POST /_auth/oauth/:provider/url 요청을 가로채서\n * 키쌍 생성 및 state 주입 처리\n */\nexport const oauthUrlInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/oauth\\/\\w+\\/url$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n const provider = ctx.path.split('/')[3]; // google, github, etc.\n const returnUrl = ctx.body?.returnUrl || '/';\n const metadata = ctx.body?.metadata as Record<string, unknown> | undefined;\n\n // `ctx.body` is whatever the caller posted, so the value reaching the rule\n // is not a string just because the route's schema says it is — the schema\n // runs at the backend, one hop after this. A non-string is refused here\n // rather than left to throw out of `isSafeReturnPath` as a 500.\n if (typeof returnUrl !== 'string' || !isSafeReturnPath(returnUrl))\n {\n authLogger.interceptor.oauth?.warn?.('OAuth start refused: returnUrl is not a path within the app', {\n provider,\n });\n ctx.abort = refuseUnsafeReturnUrl();\n\n return;\n }\n\n // 키쌍 생성\n const keyPair = generateKeyPair('ES256');\n\n // CSRF nonce: bound into the state AND set as the oauth_csrf cookie below,\n // so the backend callback can confirm the flow started in THIS browser.\n const csrfNonce = generateOAuthNonce();\n\n // state 생성 (publicKey 포함)\n const state = await createOAuthState({\n provider,\n returnUrl,\n publicKey: keyPair.publicKey,\n keyId: keyPair.keyId,\n fingerprint: keyPair.fingerprint,\n algorithm: keyPair.algorithm,\n nonce: csrfNonce,\n metadata,\n });\n\n // body에 state 주입\n if (!ctx.body)\n {\n ctx.body = {};\n }\n ctx.body.state = state;\n\n // pending session 저장용 metadata\n ctx.metadata.pendingSession = {\n privateKey: keyPair.privateKey,\n keyId: keyPair.keyId,\n algorithm: keyPair.algorithm,\n };\n ctx.metadata.oauthCsrf = csrfNonce;\n\n authLogger.interceptor.oauth?.debug?.('OAuth state created', {\n provider,\n keyId: keyPair.keyId,\n });\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n // 성공 응답이고 pending session이 있으면 쿠키 설정\n if (ctx.response.ok && ctx.metadata.pendingSession)\n {\n try\n {\n const sealed = await sealPendingSession(ctx.metadata.pendingSession);\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: sealed,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax', // OAuth 리다이렉트 허용\n maxAge: 600, // 10분\n path: '/',\n },\n });\n\n // CSRF nonce cookie (double-submit against the state.nonce at callback)\n if (ctx.metadata.oauthCsrf)\n {\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_CSRF,\n value: ctx.metadata.oauthCsrf,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: 600,\n path: '/',\n },\n });\n }\n\n authLogger.interceptor.oauth?.debug?.('Pending session cookie set', {\n keyId: ctx.metadata.pendingSession.keyId,\n });\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.oauth?.error?.('Failed to set pending session', err);\n }\n }\n\n await next();\n },\n};\n\n/**\n * Finalize 실패 시 에러 응답 설정 + pending 쿠키 정리\n */\nfunction setFinalizeError(ctx: ResponseInterceptorContext, message: string): void\n{\n ctx.response.ok = false;\n ctx.response.status = 401;\n ctx.response.statusText = 'Unauthorized';\n ctx.response.body = { success: false, message };\n\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: '',\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: 0,\n path: '/',\n },\n });\n}\n\n/**\n * OAuth Finalize Interceptor\n *\n * POST /_auth/oauth/finalize 요청을 가로채서\n * pending session에서 세션 저장\n */\nexport const oauthFinalizeInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/oauth\\/finalize$/,\n method: 'POST',\n\n response: async (ctx, next) =>\n {\n // 성공 응답일 때만 처리.\n //\n // A 202 is `ok` and is deliberately not one of them (#95): the callback\n // carried a second-factor challenge rather than a userId/keyId pair, so\n // there is no session to finalize yet and nothing here to match against\n // the pending cookie. `mfaVerifyInterceptor` bakes its own cookie from\n // that body and the app page sends the person to the confirm screen;\n // sealing anything here would be sealing a session for a key whose\n // second factor has not been proved.\n if (!ctx.response.ok || ctx.response.status === 202)\n {\n await next();\n\n return;\n }\n\n const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);\n if (!pendingCookie)\n {\n authLogger.interceptor.oauth?.warn?.('No pending session cookie found');\n setFinalizeError(ctx, 'OAuth session expired. Please try again.');\n await next();\n\n return;\n }\n\n try\n {\n // pending session에서 privateKey 복원\n const pendingSession = await unsealPendingSession(pendingCookie);\n\n // body에서 userId, keyId 추출\n const { userId, keyId } = ctx.response.body || {};\n\n if (!userId || !keyId)\n {\n authLogger.interceptor.oauth?.error?.('Missing userId or keyId in response');\n setFinalizeError(ctx, 'OAuth finalize failed: missing credentials');\n await next();\n\n return;\n }\n\n // keyId 일치 확인\n if (pendingSession.keyId !== keyId)\n {\n authLogger.interceptor.oauth?.error?.('KeyId mismatch', {\n expected: pendingSession.keyId,\n received: keyId,\n });\n setFinalizeError(ctx, 'OAuth session mismatch. Please try again.');\n await next();\n\n return;\n }\n\n // 세션 생성.\n // `userId` here is the value reflected by /_auth/oauth/finalize (a UI\n // convenience, not a trust anchor). It's safe to seal: keyId was matched\n // against the pending cookie above, the session is sealed, and the\n // backend re-derives identity from keyId on every request — see the\n // SECURITY note on the oauthFinalize route handler.\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(ctx.response.body, ctx.request.headers['user-agent']),\n }, ttl);\n\n // 세션 쿠키 설정\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION,\n value: sessionToken,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // keyId 쿠키 설정\n ctx.setCookies.push({\n name: COOKIE_NAMES.SESSION_KEY_ID,\n value: keyId,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: ttl,\n path: '/',\n },\n });\n\n // Set the readable CSRF cookie the client mirrors into a header\n await pushCsrfCookie(ctx.setCookies, keyId, ttl);\n\n // pending session 쿠키 삭제 (maxAge: 0)\n ctx.setCookies.push({\n name: COOKIE_NAMES.OAUTH_PENDING,\n value: '',\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax',\n maxAge: 0,\n path: '/',\n },\n });\n\n authLogger.interceptor.oauth?.debug?.('OAuth session finalized', {\n userId,\n keyId,\n });\n }\n catch (error)\n {\n const err = error as Error;\n authLogger.interceptor.oauth?.error?.('Failed to finalize OAuth session', err);\n setFinalizeError(ctx, err.message);\n }\n\n await next();\n },\n};\n","/**\n * Verified-Email Signup Interceptor\n *\n * Carries the password-setup session between the two browser-facing steps of the\n * verified-email signup, so the secret that authorizes password setup lives in an\n * HttpOnly cookie and never in page script.\n *\n * On the confirm response it moves `setupSecret` out of the body and into the\n * cookie. On the password request it puts the cookie back into the body, the same\n * way `loginRegisterInterceptor` injects a device key. Both interceptors match\n * `/_auth/signup/password` and both run — matching rules execute as a chain in\n * registration order, they do not compete — so the password request arrives with\n * the setup secret and a freshly generated key.\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\n\n/**\n * Setup-session cookie lifetime, in seconds.\n *\n * Deliberately a little longer than the server-side setup session: the server\n * decides when the session dies, and a cookie that expired first would turn an\n * expired-session refusal into a missing-cookie one, which reads as a different\n * bug to whoever is looking.\n */\nconst SETUP_COOKIE_TTL_SECONDS = 60 * 60;\n\n/**\n * Cookie carrying the password-setup session.\n */\nfunction setupCookie(value: string, maxAge: number)\n{\n return {\n name: COOKIE_NAMES.SIGNUP_SETUP,\n value,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax' as const,\n maxAge,\n path: '/',\n },\n };\n}\n\nexport const signupLinkInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/signup\\/(email\\/confirm|password)$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n if (ctx.path === '/_auth/signup/password')\n {\n const cookie = ctx.cookies.get(COOKIE_NAMES.SIGNUP_SETUP);\n\n if (cookie)\n {\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.setupSecret = cookie;\n }\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n if (!ctx.response.ok)\n {\n // A refusal here is often the user's to fix — a password that fails\n // the strength policy, an app policy that rejected the signup. The\n // setup session survives those on the server, so the cookie has to\n // survive them too, or the retry has nothing to present.\n await next();\n\n return;\n }\n\n if (ctx.path === '/_auth/signup/email/confirm')\n {\n const secret = ctx.response.body?.setupSecret;\n\n if (!secret)\n {\n authLogger.interceptor.oauth?.error?.('Signup confirm response carried no setup secret');\n await next();\n\n return;\n }\n\n ctx.setCookies.push(setupCookie(secret, SETUP_COOKIE_TTL_SECONDS));\n\n // The browser must never see it — an HttpOnly cookie that page\n // script can also read out of the JSON body is not HttpOnly.\n delete ctx.response.body.setupSecret;\n }\n\n if (ctx.path === '/_auth/signup/password')\n {\n // Spent. Clearing it stops a stale cookie from being presented to a\n // session the server has already marked used.\n ctx.setCookies.push(setupCookie('', 0));\n }\n\n await next();\n },\n};\n","/**\n * Password Reset Interceptor\n *\n * Carries the password-setup session between the two browser-facing steps of a\n * password reset, so the secret that authorizes setting a new password lives in\n * an HttpOnly cookie and never in page script.\n *\n * On the confirm response it moves `setupSecret` out of the body and into the\n * cookie. On the complete request it puts the cookie back into the body, the\n * same way `loginRegisterInterceptor` injects a device key. Both interceptors\n * match `/_auth/password/reset/complete` and both run — matching rules execute\n * as a chain in registration order, they do not compete — so the request arrives\n * with the setup secret and a freshly generated key.\n *\n * A cookie of its own rather than the signup one: the two secrets address\n * different tables, and a browser that abandoned a signup mid-flow must not\n * present its leftover secret to a reset.\n */\n\nimport type { InterceptorRule } from '@spfn/core/nextjs/server';\nimport { COOKIE_NAMES } from '../../server/lib/config';\nimport { authLogger } from '../../server/logger';\nimport { cookieSecure } from './cookie-options';\n\n/**\n * Setup-session cookie lifetime, in seconds.\n *\n * Deliberately a little longer than the server-side setup session: the server\n * decides when the session dies, and a cookie that expired first would turn an\n * expired-session refusal into a missing-cookie one, which reads as a different\n * bug to whoever is looking.\n */\nconst SETUP_COOKIE_TTL_SECONDS = 60 * 60;\n\n/**\n * Cookie carrying the password-setup session.\n */\nfunction setupCookie(value: string, maxAge: number)\n{\n return {\n name: COOKIE_NAMES.PASSWORD_RESET_SETUP,\n value,\n options: {\n httpOnly: true,\n secure: cookieSecure,\n sameSite: 'lax' as const,\n maxAge,\n path: '/',\n },\n };\n}\n\nexport const passwordResetInterceptor: InterceptorRule = {\n pathPattern: /^\\/_auth\\/password\\/reset\\/(confirm|complete)$/,\n method: 'POST',\n\n request: async (ctx, next) =>\n {\n if (ctx.path === '/_auth/password/reset/complete')\n {\n const cookie = ctx.cookies.get(COOKIE_NAMES.PASSWORD_RESET_SETUP);\n\n if (cookie)\n {\n if (!ctx.body)\n {\n ctx.body = {};\n }\n\n ctx.body.setupSecret = cookie;\n }\n }\n\n await next();\n },\n\n response: async (ctx, next) =>\n {\n if (!ctx.response.ok)\n {\n // A refusal here is often the user's to fix — a password that fails\n // the strength policy. The setup session survives those on the\n // server, so the cookie has to survive them too, or the retry has\n // nothing to present.\n await next();\n\n return;\n }\n\n if (ctx.path === '/_auth/password/reset/confirm')\n {\n const secret = ctx.response.body?.setupSecret;\n\n if (!secret)\n {\n authLogger.interceptor.oauth?.error?.('Password reset confirm response carried no setup secret');\n await next();\n\n return;\n }\n\n ctx.setCookies.push(setupCookie(secret, SETUP_COOKIE_TTL_SECONDS));\n\n // The browser must never see it — an HttpOnly cookie that page\n // script can also read out of the JSON body is not HttpOnly.\n delete ctx.response.body.setupSecret;\n }\n\n if (ctx.path === '/_auth/password/reset/complete')\n {\n // Spent. Clearing it stops a stale cookie from being presented to a\n // session the server has already marked used.\n ctx.setCookies.push(setupCookie('', 0));\n }\n\n await next();\n },\n};\n","/**\n * Auth Interceptors for Next.js Proxy\n *\n * Automatically registers interceptors for authentication flow\n *\n * Every rule whose path and method match runs, as a chain in this order — they\n * do not compete for a single match. Two of them share /_auth/signup/password on\n * purpose: signupLinkInterceptor supplies the setup secret and\n * loginRegisterInterceptor supplies the device key.\n *\n * Order matters - more specific interceptors first:\n * 1. signupLinkInterceptor - Most specific (verified-email signup only)\n * 2. passwordResetInterceptor - Most specific (password reset only)\n * 3. loginRegisterInterceptor - Specific (login/register/signup password/reset complete)\n * 4. mfaVerifyInterceptor - Right after it, so the 202 it passed through is still\n * the response body this rule reads (#95)\n * 5. keyRotationInterceptor - Specific (key rotation only)\n * 6. oauthUrlInterceptor - OAuth URL generation (key generation + state injection)\n * 7. generalAuthInterceptor - General (all authenticated requests)\n * 8. sessionBindingInterceptor - Last, so its re-sealed cookie wins over the\n * general one: response phases run in this order and the later write of a\n * cookie name is the one the browser keeps.\n */\n\nimport { loginRegisterInterceptor } from './login-register';\nimport { mfaVerifyInterceptor } from './mfa-verify';\nimport { generalAuthInterceptor } from './general-auth';\nimport { keyRotationInterceptor } from './key-rotation';\nimport { oauthUrlInterceptor, oauthFinalizeInterceptor } from './oauth';\nimport { signupLinkInterceptor } from './signup-link';\nimport { passwordResetInterceptor } from './password-reset';\nimport { sessionBindingInterceptor } from './session-binding';\n\n/**\n * All auth interceptors\n *\n * Execution order:\n * 1. signupLinkInterceptor - Handles verified-email signup (setup secret ↔ HttpOnly cookie)\n * 2. passwordResetInterceptor - Handles password reset (setup secret ↔ HttpOnly cookie)\n * 3. loginRegisterInterceptor - Handles login/register/signup password/reset complete/session renew (key generation + session save)\n * 4. mfaVerifyInterceptor - Handles the second-factor step-up (202 → pending cookie, verify → session)\n * 5. keyRotationInterceptor - Handles key rotation (new key generation + session update)\n * 6. oauthUrlInterceptor - Handles OAuth URL requests (key generation + state injection + pending session)\n * 7. oauthFinalizeInterceptor - Handles OAuth finalize (pending session → full session)\n * 8. generalAuthInterceptor - Handles all authenticated requests (session validation + JWT injection + session renewal)\n * 9. sessionBindingInterceptor - Re-seals the session cookie when the binding setting changes\n */\nexport const authInterceptors = [\n signupLinkInterceptor,\n passwordResetInterceptor,\n loginRegisterInterceptor,\n mfaVerifyInterceptor,\n keyRotationInterceptor,\n oauthUrlInterceptor,\n oauthFinalizeInterceptor,\n generalAuthInterceptor,\n sessionBindingInterceptor,\n];\n\nexport { loginRegisterInterceptor } from './login-register';\nexport { mfaVerifyInterceptor } from './mfa-verify';\nexport { generalAuthInterceptor } from './general-auth';\nexport { keyRotationInterceptor } from './key-rotation';\nexport { oauthUrlInterceptor, oauthFinalizeInterceptor } from './oauth';\nexport { signupLinkInterceptor } from './signup-link';\nexport { passwordResetInterceptor } from './password-reset';\nexport { sessionBindingInterceptor, bindingSessionFields } from './session-binding';\nexport { SESSION_RENEW_PATH_PATTERN } from './session-renew';\n\n// Deprecated: use generalAuthInterceptor instead\nexport { generalAuthInterceptor as authenticationInterceptor };\n"],"mappings":"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","names":["crypto","jwt","env","env","env","refusal","crypto","env","crypto","jose","env","env","jwt","jose","env","SETUP_COOKIE_TTL_SECONDS","setupCookie"]}
|