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
+213
View File
@@ -0,0 +1,213 @@
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<Attempt>;
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<IdentityRow>(
`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;