import crypto from 'node:crypto'; import type { GoogleConfig } from './config'; /** * The OpenID Connect authorization code flow, as far as Google implements it (#341). * * ## Why the code flow, and not the one with a token in the browser * * The browser is sent to Google, comes back carrying a code, and *this server* * exchanges that code for tokens over its own TLS connection. The customer's * browser never holds a token, so nothing that can read the page can steal one. * * PKCE goes in as well, even though this is a confidential client that holds a * secret. It costs one hash and it closes code interception outright rather * than resting the whole flow on the secret staying secret. * * ## Why there is no JWKS fetch here * * The id token arrives on a direct TLS connection to Google's token endpoint, * in the response to a request this server made. OpenID Connect Core §3.1.3.7 * says signature verification MAY be skipped in exactly that case, because TLS * has already established who answered and that nothing altered the reply. * * That removes a key fetch, a cache and a rotation path from the auth code, * which is a real saving in the place least worth having moving parts. It * removes none of the claim checks: those are what stop a token minted for * another application, or for another attempt, being accepted here. See * `verifiedIdentity`, where every one of them is enforced and none is optional. * * The moment an id token reaches this code from anywhere other than that * response — a redirect fragment, a request body, a header — this reasoning * stops holding and signature verification becomes mandatory. Nothing does that * today, and nothing should. */ const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'; const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'; /** * The only scopes this asks for, and the reason publishing needs no review. * * `openid` produces the id token, `email` carries the address and the * `email_verified` flag the linking policy turns on, and `profile` carries the * names used when an account is created. All three are non-sensitive; adding a * sensitive one turns publishing into a verification review with a video * walkthrough and a wait measured in weeks. */ const SCOPES = 'openid email profile'; /** * Both spellings Google issues for the issuer claim. * * It really does use both, and accepting only one produces sign-ins that fail * for some customers and not others — which is about the least diagnosable * failure this flow can have. */ const ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']); /** A little slack for clock skew between this host and Google. */ const CLOCK_SKEW_SECONDS = 60; /** Who Google says signed in. Everything here has been checked. */ export interface GoogleIdentity { /** The subject claim: opaque, stable, and the only safe identifier. */ sub: string; email: string; /** Whether Google asserts the address. The linking policy turns on this. */ emailVerified: boolean; firstName: string | null; lastName: string | null; } /** The claims this cares about. Google sends more; none of it is wanted. */ interface IdTokenClaims { iss?: unknown; aud?: unknown; exp?: unknown; sub?: unknown; nonce?: unknown; email?: unknown; email_verified?: unknown; given_name?: unknown; family_name?: unknown; } /** One attempt's secrets, minted at the start and spent at the callback. */ export interface AttemptSecrets { state: string; nonce: string; codeVerifier: string; } function randomToken(): string { return crypto.randomBytes(32).toString('base64url'); } /** * Fresh secrets for one sign-in attempt. * * `state` proves the callback belongs to the request this browser started. * `nonce` is echoed inside the id token and proves the token was minted for * this attempt rather than replayed from another. `codeVerifier` is PKCE. * * Three separate values rather than one reused three times: they are checked by * different parties at different moments, and a single value would mean * anything that learned it from one check could satisfy the others. */ export function newAttempt(): AttemptSecrets { return { state: randomToken(), nonce: randomToken(), codeVerifier: randomToken() }; } /** The S256 challenge for a verifier. Google supports S256; plain is not offered. */ export function codeChallenge(verifier: string): string { return crypto.createHash('sha256').update(verifier).digest('base64url'); } /** Where to send the browser to begin. */ export function authorizationUrl(config: GoogleConfig, attempt: AttemptSecrets): string { const url = new URL(AUTH_ENDPOINT); url.searchParams.set('client_id', config.clientId); url.searchParams.set('redirect_uri', config.redirectUri); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', SCOPES); url.searchParams.set('state', attempt.state); url.searchParams.set('nonce', attempt.nonce); url.searchParams.set('code_challenge', codeChallenge(attempt.codeVerifier)); url.searchParams.set('code_challenge_method', 'S256'); // No `access_type=offline` and no `prompt=consent`, deliberately. Those ask // for a refresh token, and Google is being used to answer one question once — // a stored refresh token would be a long-lived credential with nothing to // spend it on and everything to lose if it leaked. return url.toString(); } /** * Trades the code for an id token. * * Returns the raw token rather than parsed claims, so the exchange and the * checking stay separable: the checking is pure and can be tested exhaustively * without a network, which is where the security actually lives. */ export async function exchangeCode(config: GoogleConfig, code: string, codeVerifier: string): Promise { const response = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: config.clientId, client_secret: config.clientSecret, redirect_uri: config.redirectUri, grant_type: 'authorization_code', code_verifier: codeVerifier }) }); if (!response.ok) { // Logged rather than returned. The body names the client id and can carry // the secret back in an error description, and none of it means anything to // the customer. const detail = await response.text().catch(() => ''); console.warn(`[google] token exchange failed: ${response.status} ${detail.slice(0, 300)}`); throw new Error('the Google token exchange was refused'); } const body = (await response.json()) as { id_token?: unknown }; if (typeof body.id_token !== 'string' || body.id_token === '') { throw new Error('Google returned no id token'); } return body.id_token; } /** * The claims inside an id token, without verifying its signature. * * Named for what it does. Anywhere the token has not come straight back from * the token endpoint over TLS, this function is the wrong one to call, and the * name is meant to make that obvious at the call site. */ function decodeClaims(idToken: string): IdTokenClaims { const [, payload] = idToken.split('.'); if (!payload) throw new Error('the id token is not a JWT'); try { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as IdTokenClaims; } catch { throw new Error('the id token payload is not JSON'); } } function asString(value: unknown): string | null { return typeof value === 'string' && value !== '' ? value : null; } /** * Who signed in, or a thrown error saying which check failed. * * Every check here is mandatory, and each one closes something specific: * * | Claim | What accepting it blindly would allow | * | --- | --- | * | `iss` | A token from an issuer we never chose to trust | * | `aud` | A token minted for a different application, replayed here | * | `exp` | A token captured once and reused indefinitely | * | `nonce` | A token from an earlier attempt, replayed into this one | * | `sub` | An identity row keyed on nothing | * * The messages name the failing check because they are logged, never shown. A * customer sees one refusal for every cause, exactly as the passkey path does. */ export function verifiedIdentity( idToken: string, expected: { clientId: string; nonce: string }, now: Date = new Date() ): GoogleIdentity { const claims = decodeClaims(idToken); if (typeof claims.iss !== 'string' || !ISSUERS.has(claims.iss)) { throw new Error(`unexpected issuer: ${String(claims.iss)}`); } if (claims.aud !== expected.clientId) { throw new Error('the id token was minted for a different client'); } const exp = typeof claims.exp === 'number' ? claims.exp : NaN; if (!Number.isFinite(exp)) throw new Error('the id token has no expiry'); if (exp + CLOCK_SKEW_SECONDS < Math.floor(now.getTime() / 1000)) { throw new Error('the id token has expired'); } // Compared in constant time. The nonce is a secret this server minted, and a // byte-by-byte comparison that stops early is a timing oracle for it. const nonce = asString(claims.nonce) ?? ''; const supplied = Buffer.from(nonce); const wanted = Buffer.from(expected.nonce); if (supplied.length !== wanted.length || !crypto.timingSafeEqual(supplied, wanted)) { throw new Error('the id token belongs to a different sign-in attempt'); } const sub = asString(claims.sub); if (sub === null) throw new Error('the id token carries no subject'); const email = asString(claims.email); if (email === null) throw new Error('the id token carries no email'); return { sub, email: email.toLowerCase().trim(), // Strictly true, never merely truthy. Google sends a boolean, and treating // the string "false" as a verified address is the exact mistake that turns // the linking policy into an account-takeover path. emailVerified: claims.email_verified === true, firstName: asString(claims.given_name), lastName: asString(claims.family_name) }; }