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 { 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 = {}) { 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 { 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 { 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('refuses when the address already belongs to an 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); // Joining those two accounts is linking, which is #343. Doing it here on // the strength of a matching address is the takeover path that decision // exists to reason about 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); }); }); /** * #342. A Google account nobody here has seen becomes a customer. * * The consent behaviour is most of what these assert, because it is the part * that is easy to get quietly wrong: an account created with a consent nobody * gave, or with wording that does not match what the customer was shown, is * still an account that works. */ describe('signing up with Google', () => { async function signUpWith(overrides: Record = {}) { const { cookie, state, nonce } = await startSignIn(); respondWithToken(idToken(claimsFor(nonce, overrides))); return request(app) .get('/api/auth/google/callback') .query({ code: 'an-auth-code', state }) .set('Cookie', cookie); } async function customerBy(email: string) { const { rows } = await pool.query<{ id: number; first_name: string | null; last_name: string | null; email_verified: boolean; password_hash: string | null; marketing_consent: boolean; marketing_consent_text: string | null; analytics_consent: boolean; analytics_consent_text: string | null; }>(`SELECT * FROM customers WHERE email = $1`, [email]); return requireRow(rows, `the customer for ${email}`); } async function countOf(table: 'customers' | 'customer_identities'): Promise { const { rows } = await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM ${table}`); return requireRow(rows, `a count of ${table}`).n; } async function verificationTokens(customerId: number): Promise { const { rows } = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`, [customerId] ); return requireRow(rows, 'a count of verification tokens').n; } it('creates a customer and an identity, and signs them in', async () => { const res = await signUpWith({ email: 'brandnew@example.com' }); expect(res.status).toBe(302); const customer = await customerBy('brandnew@example.com'); const { rows } = await pool.query<{ customer_id: number }>( `SELECT customer_id FROM customer_identities WHERE provider_sub = $1`, [SUB] ); expect(requireRow(rows, 'the new identity').customer_id).toBe(customer.id); expect(res.headers['set-cookie'] as unknown as string[]).toContainEqual( expect.stringContaining('rd_session=') ); }); it('lands the new customer on the consent step rather than the storefront', async () => { // The one moment the two consent sentences can honestly be shown: the // redirect to Google happened before anyone knew this person was new. const res = await signUpWith({ email: 'consentstep@example.com' }); expect(res.headers.location).toBe('/welcome'); }); it('creates the account with no password at all', async () => { await signUpWith({ email: 'nopassword@example.com' }); // The first accounts in this project's history without one. #344 is where // the routes that assumed otherwise learn to cope. expect((await customerBy('nopassword@example.com')).password_hash).toBeNull(); }); it('gives both consents as false, with no stored wording', async () => { await signUpWith({ email: 'noconsent@example.com' }); const customer = await customerBy('noconsent@example.com'); // Nobody agreed to anything, so nothing is recorded as though they had. A // stored wording against a false consent would be a record of a // conversation that never happened. expect(customer.marketing_consent).toBe(false); expect(customer.analytics_consent).toBe(false); expect(customer.marketing_consent_text).toBeNull(); expect(customer.analytics_consent_text).toBeNull(); }); it('takes the names from the Google profile', async () => { await signUpWith({ email: 'named@example.com' }); const customer = await customerBy('named@example.com'); expect(customer.first_name).toBe('Test'); expect(customer.last_name).toBe('Customer'); }); it('creates the account anyway when Google sends no names', async () => { // Registration demands both because every email greets by first name, but // Google may return neither and refusing over it would be absurd — the // greeting already has a fallback for exactly this. const { cookie, state, nonce } = await startSignIn(); const claims = claimsFor(nonce, { email: 'nameless@example.com' }) as Record; delete claims.given_name; delete claims.family_name; respondWithToken(idToken(claims)); await request(app) .get('/api/auth/google/callback') .query({ code: 'an-auth-code', state }) .set('Cookie', cookie); const customer = await customerBy('nameless@example.com'); expect(customer.first_name).toBeNull(); expect(customer.last_name).toBeNull(); }); describe('the verified address', () => { it('is marked verified, and sends no confirmation email, when Google vouches', async () => { await signUpWith({ email: 'vouched@example.com', email_verified: true }); const customer = await customerBy('vouched@example.com'); expect(customer.email_verified).toBe(true); // The confirmation email exists to prove the customer receives mail at // the address. Google has just proved exactly that, so sending one would // ask them to do a thing that is already done. expect(await verificationTokens(customer.id)).toBe(0); }); it('is unverified, and does send one, when Google does not', async () => { await signUpWith({ email: 'unvouched@example.com', email_verified: false }); const customer = await customerBy('unvouched@example.com'); // An unverified assertion is worth nothing, so this account goes through // the ordinary confirmation exactly as a password sign-up would. expect(customer.email_verified).toBe(false); expect(await verificationTokens(customer.id)).toBe(1); }); }); it('reaches the same customer on a second sign-in, not a second account', async () => { await signUpWith({ email: 'returning@example.com' }); const first = await customerBy('returning@example.com'); const second = await signUpWith({ email: 'returning@example.com' }); // Signed in, and back to the storefront rather than the consent step — // which is shown once, to somebody who has just been created. expect(second.headers.location).toBe('/'); expect(await countOf('customers')).toBe(1); expect((await customerBy('returning@example.com')).id).toBe(first.id); }); it('reaches the same customer even after they change their Google address', async () => { await signUpWith({ email: 'was@example.com' }); const original = await customerBy('was@example.com'); // Matched on the subject, which is the whole reason that column exists. An // email match would have created a second account here — and an address // that had since been reassigned would have handed this one to a stranger. const second = await signUpWith({ email: 'now@example.com' }); expect(second.headers.location).toBe('/'); expect(await countOf('customers')).toBe(1); expect((await customerBy('was@example.com')).id).toBe(original.id); }); it('still refuses a disabled account, which a sign-up must not route around', async () => { const customerId = await createCustomer('blocked@example.com'); await linkGoogle(customerId, SUB); await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]); const res = await signUpWith({ email: 'blocked@example.com' }); // The identity exists, so this takes the sign-in path and is refused // there. No second account is created as a way around it. expect(res.headers.location).toBe('/login?auth=google-failed'); expect(await countOf('customers')).toBe(1); }); });