feat(auth): the Google sign-in round trip (#341)
Linting / lint (pull_request) Successful in 3m39s
SonarQube Analysis / sonarqube (pull_request) Failing after 28m35s

Two routes, and a customer whose Google identity is already linked can sign in. Creating accounts and linking them are deliberately held back to the next two issues, so this change is about the protocol alone and can be reviewed as such.

The authorization code flow, with PKCE. The browser is sent to Google, comes back carrying a code, and this server exchanges it over its own TLS connection, so nothing that can read the page ever holds a token. PKCE goes in even though this is a confidential client with a secret: it costs one hash and closes code interception outright rather than resting the whole flow on the secret staying secret.

No JWKS fetch, and the reasoning is written into the module rather than left to be rediscovered. The id token arrives in the response to a request this server made, over TLS, directly to Google's token endpoint, which is exactly the case OpenID Connect permits skipping signature verification for. That removes a key fetch, a cache and a rotation path from the part of the codebase least worth having moving parts in. It removes none of the claim checks, and the comment says plainly that the moment an id token reaches this code from anywhere else, the reasoning stops holding.

So the claim checks are load-bearing rather than belt and braces, and each has a test naming what accepting it blindly would allow. A wrong audience is a token minted for another application being replayed here. A wrong nonce is a token from an earlier attempt. A missing subject is an identity row keyed on nothing. Both spellings of the issuer are accepted because Google really does send both, and taking only one produces sign-ins that fail for some customers and not others.

email_verified is compared to the boolean and never merely tested for truthiness. The string "false" is truthy, and the linking policy turns entirely on this flag, so that one line is the difference between a policy and an account-takeover path.

The attempt cookie is the whole security of the callback, which is a plain GET anyone on the internet can invoke. It carries three secrets, minted separately because they are checked by different parties at different moments: state proves the callback belongs to the request this browser started, nonce proves the token was minted for this attempt, and the verifier proves the code is being spent by whoever asked for it. It is cleared on every path through the callback, so one attempt cannot be replayed even once.

SameSite is Lax and not Strict, and that line has the longest comment in the file because it is the most expensive thing here to get wrong. The callback arrives as a cross-site top-level navigation; Strict withholds the cookie, the state check then fails, and every sign-in is refused with an error that looks exactly like tampering.

Where the customer returns to survives the round trip in that cookie, and it is a value an attacker can propose. Unchecked, the start route is an open redirect wearing a sign-in flow as a disguise: a link on our own domain, with our own certificate, that lands somewhere else. Its own module, so it can be tested without a database and so the next path needing the same question has an obvious place to ask it. Its first draft used a regex that inverted its own character class and rejected every path, which passed every other test and would have broken every real sign-in — there is now a test for exactly that.

Declining at Google's consent screen is a cancellation rather than a failure. The customer goes back where they were with nothing said, the same distinction #41 drew for a dismissed passkey prompt.

A disabled account is refused here as well, because enforcing it on some sign-in routes and not others is how a disabled account keeps a way in.

Signing in calls the shared function, not a third implementation that agrees today. There is a test that the resulting session is accepted by an unrelated route, which is what makes that sharing worth something rather than merely tidy.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint back to the seven warnings that predate this branch. The integration suite covers the routes end to end with only the token exchange stubbed, and needs a database this machine has no Docker for.

Closes #341

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
synAdmin
2026-09-10 08:49:42 -05:00
co-authored by Claude Opus 5
parent 607881b711
commit 87f07baaff
8 changed files with 1125 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
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<string> {
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)
};
}