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
+5
View File
@@ -19,6 +19,7 @@ import filtersRouter from './routes/filters';
import customersRouter from './routes/customers';
import passkeysRouter from './routes/passkeys';
import passkeyLoginRouter from './routes/passkeyLogin';
import googleAuthRouter from './routes/googleAuth';
import publicRouter from './routes/public';
import cartRouter from './routes/cart';
import shippingAddressesRouter from './routes/shippingAddresses';
@@ -105,6 +106,10 @@ app.use('/api/customers/me/passkeys', passkeysRouter);
// Unauthenticated, unlike the router above: this is how a customer becomes
// signed in, so it cannot sit behind requireCustomer (#39).
app.use('/api/customers/passkeys', passkeyLoginRouter);
// Its own prefix rather than under /api/customers: this is the one route a
// third party redirects a browser into, and the callback path is registered
// verbatim in Google's console (#341).
app.use('/api/auth/google', googleAuthRouter);
app.use('/api/customers', customersRouter);
app.use('/api/client-errors', clientErrorsRouter);
app.use('/', publicRouter);
+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)
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Where a customer is sent back to after signing in with Google (#341).
*
* An OAuth flow leaves this application entirely and comes back, so where the
* customer was has to survive the round trip — and the value doing that is one
* an attacker can propose, by handing somebody a link to our own start route
* with their destination attached.
*
* Unchecked, that makes the start route an open redirect wearing a sign-in flow
* as a disguise: a link on our real domain, with our real certificate, that
* deposits the customer somewhere else entirely. It is precisely the shape a
* credible phishing page wants, and it is worth more to an attacker than most
* bugs in the flow it hides behind.
*
* Its own module rather than a helper inside the route, so it can be tested
* without a database connection and so the next path that needs the same
* question has somewhere obvious to ask it.
*/
/** Where anyone goes when the answer is "not that". */
export const DEFAULT_RETURN_TO = '/';
/**
* A path inside this site, or the home page.
*
* Everything that is not plainly a local path is replaced rather than rejected.
* A refusal would mean a customer who signed in successfully sees an error
* about a query parameter they never typed, which helps nobody — the storefront
* is a fine place to land.
*
* The cases worth naming, because each is a way of writing "somewhere else"
* that still starts with a slash or looks like it might:
*
* - `//evil.test` is protocol-relative, and browsers treat it as absolute
* - `/\evil.test` is treated as protocol-relative by several browsers
* - `https://evil.test` does not start with a slash at all
* - a backslash anywhere in the authority position is normalised to a slash
*/
export function safeReturnTo(value: unknown): string {
if (typeof value !== 'string' || value === '') return DEFAULT_RETURN_TO;
if (!value.startsWith('/')) return DEFAULT_RETURN_TO;
// Both slashes, because browsers disagree about which they normalise.
if (value.startsWith('//') || value.startsWith('/\\')) return DEFAULT_RETURN_TO;
// A control character can truncate or split the Location header a browser
// reads. Checked by code point rather than by a regex, because a regex that
// matches control characters trips a lint rule existing for good reasons of
// its own, and this is clearer than an exemption from it.
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return DEFAULT_RETURN_TO;
}
return value;
}
+23
View File
@@ -184,3 +184,26 @@ export const intakeSubmitLimiter = rateLimit({
legacyHeaders: false,
message: { error: 'too many submissions — please try again later' }
});
/**
* Starting a Google sign-in (#341).
*
* The route mints three secrets and issues a redirect, which is cheap but not
* free, and it is reachable without a session by anyone who knows the URL.
*
* Generous, because a customer who bounces off Google's consent screen and
* tries again is doing something entirely reasonable and must never be told to
* wait. The limit exists so a loop cannot spend the server's entropy and fill
* the log, not to police customers.
*
* Keyed on the caller alone: this endpoint carries no email, which is the
* distinction the comment on the client-error limiter above draws.
*/
export const googleSignInLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 60,
keyGenerator: keyByCaller,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many sign-in attempts — please try again shortly' }
});
+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;
@@ -0,0 +1,356 @@
import request from 'supertest';
import app from '../../src/app';
import { pool, requireRow } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
const CLIENT_ID = 'test-client.apps.googleusercontent.com';
const SUB = 'google-subject-1234567890';
let fetchSpy: jest.SpyInstance;
beforeEach(async () => {
await resetDb();
process.env.GOOGLE_CLIENT_ID = CLIENT_ID;
process.env.GOOGLE_CLIENT_SECRET = 'test-secret';
process.env.PUBLIC_URL = 'http://localhost:3000';
// Nothing here talks to Google. The exchange is the only network call in the
// flow, so stubbing it leaves every decision this suite cares about — the
// cookie, the state check, the claim checks, the lookup — running for real.
fetchSpy = jest.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
delete process.env.GOOGLE_CLIENT_ID;
delete process.env.GOOGLE_CLIENT_SECRET;
delete process.env.PUBLIC_URL;
});
afterAll(async () => {
await pool.end();
await closeDb();
});
/** An unsigned id token. Nothing in this flow reads a signature — see google/oauth.ts. */
function idToken(claims: Record<string, unknown>): string {
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`;
}
function claimsFor(nonce: string, overrides: Record<string, unknown> = {}) {
return {
iss: 'https://accounts.google.com',
aud: CLIENT_ID,
exp: Math.floor(Date.now() / 1000) + 3600,
sub: SUB,
nonce,
email: 'customer@example.com',
email_verified: true,
given_name: 'Test',
family_name: 'Customer',
...overrides
};
}
function respondWithToken(token: string): void {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ id_token: token }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
);
}
/** The attempt cookie the start route set, as a header for the callback. */
function cookieHeader(setCookie: string[]): string {
const attempt = setCookie.find((c) => c.startsWith('rd_oauth='));
if (!attempt) throw new Error('the start route set no attempt cookie');
// Non-null: the header was just matched, so it has at least one segment.
return attempt.split(';')[0] as string;
}
/** One Set-Cookie value, as a request header. Throws rather than typing around its absence. */
function requireCookie(setCookie: string[], prefix: string): string {
const found = setCookie.find((c) => c.startsWith(prefix));
if (!found) throw new Error(`no ${prefix} cookie was set`);
return found.split(';')[0] as string;
}
/** Reads the secrets back out of the cookie, which is the only place they exist. */
function attemptFrom(setCookie: string[]): { state: string; nonce: string; returnTo: string } {
const value = cookieHeader(setCookie).slice('rd_oauth='.length);
return JSON.parse(Buffer.from(decodeURIComponent(value), 'base64url').toString('utf8'));
}
/** Starts a sign-in and hands back what the callback needs to finish it. */
async function startSignIn(returnTo?: string) {
const res = await request(app)
.get('/api/auth/google/start')
.query(returnTo === undefined ? {} : { returnTo });
const setCookie = res.headers['set-cookie'] as unknown as string[];
return { res, cookie: cookieHeader(setCookie), ...attemptFrom(setCookie) };
}
async function createCustomer(email: string): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO customers (email, password_hash, first_name, last_name, unsubscribe_token)
VALUES ($1, 'not-a-real-hash', 'Test', 'Customer', $2) RETURNING id`,
[email, `unsub-${email}`]
);
return requireRow(rows, 'the customer this test just created').id;
}
async function linkGoogle(customerId: number, sub = SUB): Promise<void> {
await pool.query(
`INSERT INTO customer_identities (customer_id, provider, provider_sub)
VALUES ($1, 'google', $2)`,
[customerId, sub]
);
}
describe('GET /api/auth/google/start', () => {
it('sends the customer to Google with the code flow and PKCE', async () => {
const { res } = await startSignIn();
expect(res.status).toBe(302);
const target = new URL(res.headers.location as string);
expect(target.origin).toBe('https://accounts.google.com');
expect(target.searchParams.get('response_type')).toBe('code');
expect(target.searchParams.get('code_challenge_method')).toBe('S256');
});
it('sets an httpOnly attempt cookie, which is the whole security of the callback', async () => {
const res = await request(app).get('/api/auth/google/start');
const attempt = (res.headers['set-cookie'] as unknown as string[]).find((c) =>
c.startsWith('rd_oauth=')
);
expect(attempt).toMatch(/HttpOnly/i);
// Lax and not Strict. Strict withholds the cookie on the cross-site
// top-level navigation back from Google, and every sign-in then fails the
// state check in a way that looks exactly like tampering.
expect(attempt).toMatch(/SameSite=Lax/i);
});
it('never puts the client secret anywhere the browser can see', async () => {
const res = await request(app).get('/api/auth/google/start');
expect(res.headers.location).not.toContain('test-secret');
expect(JSON.stringify(res.headers['set-cookie'])).not.toContain('test-secret');
});
it('carries a local return path through the round trip', async () => {
const { returnTo } = await startSignIn('/?max_price=50000');
expect(returnTo).toBe('/?max_price=50000');
});
it('refuses an off-site return path rather than becoming an open redirect', async () => {
const { returnTo } = await startSignIn('//evil.test');
expect(returnTo).toBe('/');
});
it('sends the customer to the storefront when Google sign-in is switched off', async () => {
delete process.env.GOOGLE_CLIENT_ID;
delete process.env.GOOGLE_CLIENT_SECRET;
const res = await request(app).get('/api/auth/google/start');
// A stale bookmark or a hand-typed URL, and the storefront answers both.
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/');
expect(res.headers['set-cookie']).toBeUndefined();
});
});
describe('GET /api/auth/google/callback', () => {
it('signs in a customer whose Google identity is already linked', async () => {
const customerId = await createCustomer('linked@example.com');
await linkGoogle(customerId);
const { cookie, state, nonce } = await startSignIn('/cart');
respondWithToken(idToken(claimsFor(nonce)));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/cart');
const session = (res.headers['set-cookie'] as unknown as string[]).find((c) =>
c.startsWith('rd_session=')
);
expect(session).toBeDefined();
});
it('produces a session the rest of the application accepts', async () => {
const customerId = await createCustomer('session@example.com');
await linkGoogle(customerId);
const { cookie, state, nonce } = await startSignIn();
respondWithToken(idToken(claimsFor(nonce)));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
const setCookie = res.headers['set-cookie'] as unknown as string[];
const session = requireCookie(setCookie, 'rd_session=');
// The point of sharing signIn with the password and passkey paths: the
// session is not merely present, it is the same kind of session.
const me = await request(app).get('/api/customers/me').set('Cookie', session);
expect(me.status).toBe(200);
expect(me.body.email).toBe('session@example.com');
});
it('stamps last_used_at, which is what tells two identities apart', async () => {
const customerId = await createCustomer('stamped@example.com');
await linkGoogle(customerId);
const { cookie, state, nonce } = await startSignIn();
respondWithToken(idToken(claimsFor(nonce)));
await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
const { rows } = await pool.query<{ last_used_at: Date | null }>(
`SELECT last_used_at FROM customer_identities WHERE provider_sub = $1`,
[SUB]
);
expect(requireRow(rows, 'the identity just used').last_used_at).not.toBeNull();
});
it('clears the attempt cookie, so one attempt cannot be replayed', async () => {
const customerId = await createCustomer('once@example.com');
await linkGoogle(customerId);
const { cookie, state, nonce } = await startSignIn();
respondWithToken(idToken(claimsFor(nonce)));
const first = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
const cleared = (first.headers['set-cookie'] as unknown as string[]).find((c) =>
c.startsWith('rd_oauth=')
);
expect(cleared).toMatch(/rd_oauth=;/);
});
it('refuses a state that does not match the attempt cookie', async () => {
const { cookie } = await startSignIn();
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state: 'not-the-state-we-issued' })
.set('Cookie', cookie);
expect(res.headers.location).toBe('/login?auth=google-failed');
// Refused before any network call: a mismatched state is not worth a token
// exchange, and spending the code would be handing it to whoever forged it.
expect(fetchSpy).not.toHaveBeenCalled();
});
it('refuses a callback carrying no attempt cookie at all', async () => {
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state: 'anything' });
expect(res.headers.location).toBe('/login?auth=google-failed');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('treats a declined consent screen as a cancellation, not a failure', async () => {
const { cookie } = await startSignIn('/cart');
const res = await request(app)
.get('/api/auth/google/callback')
.query({ error: 'access_denied' })
.set('Cookie', cookie);
// Back where they were, with nothing said. A customer who changed their
// mind has not encountered an error, which is the distinction #41 draws
// for a dismissed passkey prompt.
expect(res.headers.location).toBe('/cart');
});
it('refuses an id token minted for a different application', async () => {
const customerId = await createCustomer('wrongaud@example.com');
await linkGoogle(customerId);
const { cookie, state, nonce } = await startSignIn();
respondWithToken(idToken(claimsFor(nonce, { aud: 'someone-else.apps.googleusercontent.com' })));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
expect(res.headers.location).toBe('/login?auth=google-failed');
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
});
it('refuses an id token from a different sign-in attempt', async () => {
const customerId = await createCustomer('wrongnonce@example.com');
await linkGoogle(customerId);
const { cookie, state } = await startSignIn();
respondWithToken(idToken(claimsFor('a-nonce-from-somewhere-else')));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
expect(res.headers.location).toBe('/login?auth=google-failed');
});
it('refuses when Google declines the token exchange', async () => {
const { cookie, state } = await startSignIn();
fetchSpy.mockResolvedValue(new Response('{"error":"invalid_grant"}', { status: 400 }));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'a-spent-code', state })
.set('Cookie', cookie);
expect(res.headers.location).toBe('/login?auth=google-failed');
});
it('refuses a disabled account, as the password and passkey paths do', async () => {
const customerId = await createCustomer('disabled@example.com');
await linkGoogle(customerId);
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
const { cookie, state, nonce } = await startSignIn();
respondWithToken(idToken(claimsFor(nonce)));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
// Enforcing it on some sign-in routes and not others is how a disabled
// account keeps a way in.
expect(res.headers.location).toBe('/login?auth=google-failed');
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
});
it('does not sign in an unlinked Google account, and creates nothing', async () => {
await createCustomer('unlinked@example.com');
const { cookie, state, nonce } = await startSignIn();
respondWithToken(idToken(claimsFor(nonce, { email: 'unlinked@example.com' })));
const res = await request(app)
.get('/api/auth/google/callback')
.query({ code: 'an-auth-code', state })
.set('Cookie', cookie);
// Account creation is #342 and linking is #343. Matching on the address
// here would be the takeover path both of those exist to decide carefully.
expect(res.headers.location).toBe('/login?auth=google-failed');
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM customer_identities`
);
expect(requireRow(rows, 'a count of identities').n).toBe(0);
});
});
+221
View File
@@ -0,0 +1,221 @@
import crypto from 'node:crypto';
import { authorizationUrl, codeChallenge, newAttempt, verifiedIdentity } from '../../src/google/oauth';
import type { GoogleConfig } from '../../src/google/config';
const CONFIG: GoogleConfig = {
clientId: 'id.apps.googleusercontent.com',
clientSecret: 'shh',
redirectUri: 'https://redefined-designs.com/api/auth/google/callback',
enabled: true
};
const NONCE = 'the-nonce-for-this-attempt';
/** An id token with the given claims. Unsigned, because nothing here reads a signature. */
function idToken(claims: Record<string, unknown>): string {
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`;
}
const VALID = {
iss: 'https://accounts.google.com',
aud: CONFIG.clientId,
exp: Math.floor(Date.now() / 1000) + 3600,
sub: '1234567890',
nonce: NONCE,
email: 'Customer@Example.com',
email_verified: true,
given_name: 'Test',
family_name: 'Customer'
};
/** VALID minus one claim, for the tests about a claim being absent. */
function without(claim: keyof typeof VALID): Record<string, unknown> {
const copy: Record<string, unknown> = { ...VALID };
delete copy[claim];
return copy;
}
describe('authorizationUrl', () => {
const attempt = { state: 'st', nonce: 'no', codeVerifier: 'ver' };
it('sends the customer to Google with the code flow and PKCE', () => {
const url = new URL(authorizationUrl(CONFIG, attempt));
expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe(CONFIG.clientId);
expect(url.searchParams.get('redirect_uri')).toBe(CONFIG.redirectUri);
expect(url.searchParams.get('state')).toBe('st');
expect(url.searchParams.get('nonce')).toBe('no');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toBe(codeChallenge('ver'));
});
it('asks for exactly the three non-sensitive scopes', () => {
// Anything beyond these turns publishing into a verification review with a
// video walkthrough and a wait measured in weeks.
const url = new URL(authorizationUrl(CONFIG, attempt));
expect(url.searchParams.get('scope')?.split(' ').sort()).toEqual(['email', 'openid', 'profile']);
});
it('never asks for offline access', () => {
// A refresh token would be a long-lived credential with nothing to spend it
// on. Google issues one only when asked, so the check is that we do not ask.
const url = new URL(authorizationUrl(CONFIG, attempt));
expect(url.searchParams.get('access_type')).toBeNull();
expect(url.searchParams.get('prompt')).toBeNull();
});
it('never puts the client secret in a URL the browser follows', () => {
expect(authorizationUrl(CONFIG, attempt)).not.toContain(CONFIG.clientSecret);
});
});
describe('newAttempt', () => {
it('mints three different secrets', () => {
// Three rather than one reused: they are checked by different parties at
// different moments, and a single value would mean anything learning it
// from one check satisfies the others.
const { state, nonce, codeVerifier } = newAttempt();
expect(new Set([state, nonce, codeVerifier]).size).toBe(3);
});
it('does not repeat itself', () => {
expect(newAttempt().state).not.toBe(newAttempt().state);
});
});
describe('codeChallenge', () => {
it('is the base64url SHA-256 of the verifier, which is what S256 means', () => {
const verifier = 'a-verifier';
expect(codeChallenge(verifier)).toBe(
crypto.createHash('sha256').update(verifier).digest('base64url')
);
});
});
/**
* The security of the whole flow lives here.
*
* No signature is verified, because the token arrives on a direct TLS
* connection to Google's token endpoint — the case OpenID Connect explicitly
* permits skipping it. That makes every one of these claim checks load-bearing
* rather than belt-and-braces, so each has a test naming what accepting it
* blindly would allow.
*/
describe('verifiedIdentity', () => {
const expected = { clientId: CONFIG.clientId, nonce: NONCE };
it('accepts a well-formed token and reports who signed in', () => {
const identity = verifiedIdentity(idToken(VALID), expected);
expect(identity.sub).toBe('1234567890');
expect(identity.emailVerified).toBe(true);
expect(identity.firstName).toBe('Test');
expect(identity.lastName).toBe('Customer');
});
it('normalises the email the way registration does', () => {
// A stricter comparison than registration's would silently fail to match an
// existing customer and produce a duplicate account instead (#343).
expect(verifiedIdentity(idToken(VALID), expected).email).toBe('customer@example.com');
});
it('accepts both spellings of the issuer, because Google sends both', () => {
// 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.
for (const iss of ['https://accounts.google.com', 'accounts.google.com']) {
expect(verifiedIdentity(idToken({ ...VALID, iss }), expected).sub).toBe('1234567890');
}
});
it('refuses an issuer it was not told to trust', () => {
expect(() => verifiedIdentity(idToken({ ...VALID, iss: 'https://evil.test' }), expected)).toThrow(
/unexpected issuer/
);
});
it('refuses a token minted for a different application', () => {
// Without this, a token obtained by any other Google app could be replayed
// here and would sign somebody in.
expect(() =>
verifiedIdentity(idToken({ ...VALID, aud: 'someone-else.apps.googleusercontent.com' }), expected)
).toThrow(/different client/);
});
it('refuses an expired token', () => {
const expired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 3600 };
expect(() => verifiedIdentity(idToken(expired), expected)).toThrow(/expired/);
});
it('allows a minute of clock skew, so a healthy host does not refuse valid tokens', () => {
const justExpired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 5 };
expect(verifiedIdentity(idToken(justExpired), expected).sub).toBe('1234567890');
});
it('refuses a token with no expiry at all', () => {
expect(() => verifiedIdentity(idToken(without('exp')), expected)).toThrow(/no expiry/);
});
it('refuses a token from a different sign-in attempt', () => {
// This is what stops a token captured from one attempt being replayed into
// another, which is the whole reason the nonce exists.
expect(() => verifiedIdentity(idToken({ ...VALID, nonce: 'someone-elses' }), expected)).toThrow(
/different sign-in attempt/
);
});
it('refuses a token carrying no nonce', () => {
expect(() => verifiedIdentity(idToken(without('nonce')), expected)).toThrow(
/different sign-in attempt/
);
});
it('refuses a token with no subject, which is the identity itself', () => {
expect(() => verifiedIdentity(idToken(without('sub')), expected)).toThrow(/no subject/);
});
it('refuses a token with no email', () => {
expect(() => verifiedIdentity(idToken(without('email')), expected)).toThrow(/no email/);
});
describe('email_verified', () => {
it('is true only for the boolean, never a truthy string', () => {
// The linking policy turns entirely on this flag (#343). Treating the
// string "false" as verified is exactly the mistake that would make
// auto-linking an account-takeover path.
expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'false' }), expected).emailVerified)
.toBe(false);
expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'true' }), expected).emailVerified)
.toBe(false);
});
it('is false when the claim is missing', () => {
expect(verifiedIdentity(idToken(without('email_verified')), expected).emailVerified).toBe(false);
});
});
it('tolerates a profile with no names, because Google may send none', () => {
const anonymous = { ...without('given_name') };
delete anonymous.family_name;
const identity = verifiedIdentity(idToken(anonymous), expected);
expect(identity.firstName).toBeNull();
expect(identity.lastName).toBeNull();
});
it.each([
['not a JWT', 'nonsense'],
['a JWT whose payload is not JSON', 'aGVhZGVy.bm90LWpzb24.sig']
])('refuses %s', (_label, token) => {
expect(() => verifiedIdentity(token, expected)).toThrow();
});
});
Binary file not shown.