import { Router, Request, Response } from 'express'; import crypto from 'node:crypto'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; import { signIn } from '../customerSession'; import { googleConfig } from '../google/config'; import { newAttempt, authorizationUrl, exchangeCode, verifiedIdentity } from '../google/oauth'; import type { AttemptSecrets } from '../google/oauth'; import { googleSignInLimiter } from '../rateLimit'; import { safeReturnTo } from '../google/returnTo'; const router = Router(); /** * Signing in with Google (#341). * * Unauthenticated by design — this is how a customer becomes authenticated — * and mounted at `/api/auth/google`, away from `/api/customers`, because it is * the first route in this application that a third party redirects into. * * ## What this phase does and does not do * * It signs in a customer whose Google identity is **already linked**. A * successful sign-in by somebody with no identity row does nothing yet: account * creation is #342 and the linking policy is #343, and holding them back keeps * this change about the protocol alone. * * ## The cookie, and why it is the whole security of the callback * * The callback is a plain GET that anyone on the internet can invoke. What * makes it safe is that it can only complete for a browser holding a cookie * this server set moments earlier, carrying three secrets: * * - **state** proves the callback belongs to the request this browser started * - **nonce** proves the id token was minted for this attempt * - **code verifier** proves the code is being spent by whoever asked for it * * The cookie is cleared on every path through the callback, success or failure, * so one attempt cannot be replayed even once. */ /** Ten minutes. Long enough to sign in, short enough that a stolen one is stale. */ const ATTEMPT_TTL_MS = 10 * 60 * 1000; const ATTEMPT_COOKIE = 'rd_oauth'; interface Attempt extends AttemptSecrets { returnTo: string; } interface IdentityRow { customer_id: number; disabled_at: Date | null; } /** * Where the customer is sent when this ends. * * Always a redirect, never JSON. The browser arrives here by following Google's * redirect, so whatever this responds with is rendered as a page — and a bare * JSON error is a dead end with no way back to the storefront. */ const FAILURE_PATH = '/login?auth=google-failed'; function setAttemptCookie(res: Response, attempt: Attempt): void { res.cookie(ATTEMPT_COOKIE, Buffer.from(JSON.stringify(attempt)).toString('base64url'), { httpOnly: true, secure: process.env.NODE_ENV === 'production', // Lax, and NOT Strict. The callback arrives as a top-level navigation from // Google, which is cross-site. Strict withholds the cookie, the state check // then fails, and every sign-in is refused with an error that looks exactly // like tampering. This one line is the single most expensive thing to get // wrong in the whole flow. sameSite: 'lax', maxAge: ATTEMPT_TTL_MS, path: '/' }); } function readAttemptCookie(req: Request): Attempt | null { const raw = req.cookies?.[ATTEMPT_COOKIE]; if (typeof raw !== 'string' || raw === '') return null; try { const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Partial; if ( typeof parsed.state !== 'string' || typeof parsed.nonce !== 'string' || typeof parsed.codeVerifier !== 'string' ) { return null; } return { state: parsed.state, nonce: parsed.nonce, codeVerifier: parsed.codeVerifier, returnTo: typeof parsed.returnTo === 'string' ? parsed.returnTo : '/' }; } catch { return null; } } /** * Compares two secrets without leaking where they first differ. * * `timingSafeEqual` throws on buffers of unequal length, and a length check * before it would leak the length, so both are hashed to a fixed 32 bytes * first — the same trick `adminGate` uses, for the same reason. */ function secretsMatch(a: string, b: string): boolean { const digest = (value: string) => crypto.createHash('sha256').update(value, 'utf8').digest(); return crypto.timingSafeEqual(digest(a), digest(b)); } router.get( '/start', googleSignInLimiter, asyncRoute(async (req: Request, res: Response) => { const config = googleConfig(); if (!config.enabled) { // Not a 404 and not an error page. Nothing offers this link when Google // sign-in is switched off, so reaching it means a stale bookmark or a // hand-typed URL, and the storefront is the right answer to both. return res.redirect('/'); } const attempt: Attempt = { ...newAttempt(), returnTo: safeReturnTo(req.query.returnTo) }; setAttemptCookie(res, attempt); res.redirect(authorizationUrl(config, attempt)); }) ); router.get( '/callback', asyncRoute(async (req: Request, res: Response) => { const config = googleConfig(); const attempt = readAttemptCookie(req); // Cleared before anything is decided, on every path. A cookie that survives // a failed attempt is a second try at the same state and nonce. res.clearCookie(ATTEMPT_COOKIE, { path: '/' }); if (!config.enabled || attempt === null) return res.redirect(FAILURE_PATH); // Google sends `error=access_denied` when the customer declines at the // consent screen. That is a cancellation rather than a failure, and it goes // back to the storefront with nothing said — the same distinction #41 draws // for a dismissed passkey prompt. if (typeof req.query.error === 'string') { return res.redirect(attempt.returnTo); } const state = typeof req.query.state === 'string' ? req.query.state : ''; const code = typeof req.query.code === 'string' ? req.query.code : ''; if (state === '' || code === '' || !secretsMatch(state, attempt.state)) { console.warn('[google] callback refused: state did not match the attempt cookie'); return res.redirect(FAILURE_PATH); } let identity; try { const idToken = await exchangeCode(config, code, attempt.codeVerifier); identity = verifiedIdentity(idToken, { clientId: config.clientId, nonce: attempt.nonce }); } catch (err) { // Logged, never returned. These messages name which check failed, which // is exactly what the person reading the logs needs and exactly what an // attacker would like to be told. console.warn(`[google] callback refused: ${(err as Error).message}`); return res.redirect(FAILURE_PATH); } const { rows } = await pool.query( `SELECT i.customer_id, c.disabled_at FROM customer_identities i JOIN customers c ON c.id = i.customer_id WHERE i.provider = 'google' AND i.provider_sub = $1`, [identity.sub] ); const linked = rows[0]; // No identity row means a customer this shop has never seen through Google. // Creating one is #342 and linking to an existing account is #343; until // those land there is nothing to do, and doing nothing must not look like a // protocol failure. if (!linked) return res.redirect(FAILURE_PATH); // Refused here as well as on the password and passkey paths. Enforcing it // on some routes and not others is how a disabled account keeps a way in, // which is the reason #39 called this out for passkeys. if (linked.disabled_at !== null) { console.warn(`[google] refused a disabled account: customer ${linked.customer_id}`); return res.redirect(FAILURE_PATH); } await pool.query( `UPDATE customer_identities SET last_used_at = now() WHERE provider = 'google' AND provider_sub = $1`, [identity.sub] ); // The same call password login and passkey login make. Not a third // implementation that agrees today — the same one, so cookie flags, expiry // and logout behave identically however a customer got here. await signIn(res, linked.customer_id); res.redirect(attempt.returnTo); }) ); /** Exported for the tests; nothing else needs the cookie's name. */ export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH }; export default router;