// ── SSO transaction state (CSRF + PKCE) ──────────────────────────────────── // // An OAuth redirect flow spans two requests (start → callback) with a hop to the // IdP in between, so we must carry state across it safely: // // - CSRF: an attacker must not be able to forge a callback. We bind the flow to // the user's browser with a short-lived, signed, httpOnly cookie (sso_tx) and // put only an opaque `nonce` in the URL `state` param. The callback requires // state === cookie.nonce, so a callback not initiated by this browser fails. // - PKCE: the code_verifier is generated at start, kept ONLY in the httpOnly // cookie (never in the URL/logs), and sent to the token endpoint at callback. // // The cookie is a signed JWT (reusing the app's JWT signing) with a tight TTL, so // it cannot be tampered with and expires quickly if a flow is abandoned. const crypto = require('crypto') const token = require('./token') const TX_COOKIE = 'sso_tx' const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes // Second leg of an SSO login for an account that has TOTP enabled. The callback // authenticated the user with the IdP but must NOT bypass their second factor // (see issue #31), so instead of minting a session it stages this signed, // httpOnly cookie and routes the browser through the TOTP form — mirroring the // local password→TOTP gate. TTL matches the local challenge window. const TOTP_COOKIE = 'sso_totp' const TOTP_TTL = '5m' // base64url of random bytes — used for the nonce and the PKCE verifier. function randomUrlSafe(bytes = 32) { return crypto.randomBytes(bytes).toString('base64url') } // PKCE S256 challenge for a given verifier. function codeChallengeFor(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url') } // Create a transaction: returns { nonce, verifier, codeChallenge, txToken }. // `data` = { provider, mode ('login'|'link'), linkUserId?, returnTo? }. function createTx(data) { const nonce = randomUrlSafe(16) const verifier = randomUrlSafe(32) const codeChallenge = codeChallengeFor(verifier) const txToken = token.signToken( { id: 'sso' }, // subject is irrelevant; this is a flow token, not a session { nonce, verifier, ...data, kind: 'sso_tx' }, { expiresIn: TX_TTL }, ) return { nonce, verifier, codeChallenge, txToken } } // Verify a tx cookie against the state param. Returns the tx payload // ({ nonce, verifier, provider, mode, ... }) or null if missing/expired/mismatched. function verifyTx(txToken, stateNonce) { if (!txToken || !stateNonce) return null const decoded = token.verifyToken(txToken) if (!decoded || decoded.kind !== 'sso_tx') return null // Constant-time compare so a mismatch can't be timed. const a = Buffer.from(String(decoded.nonce)) const b = Buffer.from(String(stateNonce)) if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null return decoded } // Stage the pending second factor for an SSO login. Carries the context the // callback already resolved (userId, provider, authMethod, returnTo) so that // presenting a valid code alone finishes the login. It is deliberately NOT a // session: `stage: 'totp'` makes session validation reject it (same marker the // local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and // scopes it to the SSO completion endpoint. // // `mobileSessionId` is present only for a mobile SSO bridge flow (mode 'mobile'): // it threads the bridge session through the TOTP form so that, on a correct code, // the completion mints an authorization code + deep-links back to the app instead // of setting a web session cookie. Absent for ordinary web SSO. function createTotpPending({ userId, provider, authMethod, returnTo, mobileSessionId }) { return token.signToken( { id: userId }, // subject only; identity is re-loaded fresh when the code is verified { stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo, mobileSessionId }, { expiresIn: TOTP_TTL }, ) } // Verify a pending-TOTP cookie. Returns the payload // ({ id, provider, authMethod, returnTo, ... }) or null if missing/expired/wrong-kind. function verifyTotpPending(pendingToken) { if (!pendingToken) return null const decoded = token.verifyToken(pendingToken) if (!decoded || decoded.stage !== 'totp' || decoded.kind !== 'sso_totp') return null return decoded } module.exports = { TX_COOKIE, TX_TTL, TOTP_COOKIE, TOTP_TTL, createTx, verifyTx, createTotpPending, verifyTotpPending, codeChallengeFor, randomUrlSafe, }