The last of the six, and the first a customer can see. The auth form is the single sign-in implementation rendered by both the route modal and the cart prompt, so the button goes in one place and appears in both. Below the passkey button, which is below the password form. The order is deliberate and it is not about preference: a passkey is already on the device in front of the customer, while Google is a round trip to somebody else's site, and passwords are how every existing customer signs in. Each step down that list asks more of the person using it. Absent rather than disabled where it is not configured, which is the same call #41 made for a browser without WebAuthn. It matters more here, because being unconfigured is the normal state rather than the exception: local development has no credentials, and QA cannot have any until #313. The storefront advertises a boolean through the existing public config, never the client id — the browser has no use for one, since the whole flow is a redirect the server builds. Google's mark is inlined as SVG with their published colours and geometry. A hand-drawn approximation of somebody else's trademark is a compliance problem rather than a style choice, and a second origin on the sign-in path is a second thing that can be down. The button is a navigation rather than a fetch, which makes it unlike every other control on that form. The flow leaves the application entirely, so there is no promise to await and no error to catch — the callback decides and redirects. Where to return to is supplied by the caller, because only the caller knows. The route modal renders over a backdrop location and its own path is /login, so reading the current URL there would send the customer back to the form they just left; the router builds it from the backdrop instead. The cart prompt uses the page it interrupted. It cannot resume the interrupted action the way onSuccess does — the redirect leaves the app — so the customer lands back on the page and presses the button again. That value is validated on the server and not in the browser. It has to be, since anyone can type the URL, and doing it in one place beats doing it twice in two languages. The end-to-end test asserts the button is ABSENT, which is the behaviour local and QA actually have, and then signs in with the password form to show that its absence changes nothing. That is the point of putting the alternatives below rather than above. docs/ops/google-sign-in.md records what has to be true outside the repository: the seven sections of the Google Auth Platform, the three scopes that keep publishing out of a verification review, the cutover checklist for #313, and the production smoke test. It states plainly that QA on the Synology hostname is impossible rather than merely unconfigured, because Google will not accept a redirect URI whose domain nobody can prove they own — the same wall #285 hit with Cloudflare. The failure that document warns about hardest is leaving the consent screen in Testing. Only listed test users can then sign in, the refusal happens on Google's own page, and nothing reaches the storefront at all — so a customer reports a broken button and the logs are silent. Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for. Closes #345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
784 lines
32 KiB
TypeScript
784 lines
32 KiB
TypeScript
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
import { pool, requireRow } from '../../src/db';
|
|
import { createSession } from '../../src/customerSession';
|
|
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]
|
|
);
|
|
}
|
|
|
|
/** A session cookie for a customer, without going through any sign-in flow. */
|
|
async function sessionFor(customerId: number): Promise<string> {
|
|
return `rd_session=${await createSession(customerId)}`;
|
|
}
|
|
|
|
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<string, unknown> = {}) {
|
|
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<number> {
|
|
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<number> {
|
|
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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* #343. The most security-sensitive phase of this feature.
|
|
*
|
|
* Every test here is about the same question asked from a different angle: when
|
|
* is it right to hand somebody an account they have not proved they own?
|
|
*/
|
|
describe('linking a Google identity to an existing account', () => {
|
|
async function attempt(overrides: Record<string, unknown> = {}, returnTo?: string) {
|
|
const { cookie, state, nonce } = await startSignIn(returnTo);
|
|
respondWithToken(idToken(claimsFor(nonce, overrides)));
|
|
return request(app)
|
|
.get('/api/auth/google/callback')
|
|
.query({ code: 'an-auth-code', state })
|
|
.set('Cookie', cookie);
|
|
}
|
|
|
|
async function identityCount(customerId: number): Promise<number> {
|
|
const { rows } = await pool.query<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM customer_identities WHERE customer_id = $1`,
|
|
[customerId]
|
|
);
|
|
return requireRow(rows, 'a count of identities').n;
|
|
}
|
|
|
|
it('links when Google vouches for an address an account already holds', async () => {
|
|
const customerId = await createCustomer('haspassword@example.com');
|
|
|
|
const res = await attempt({ email: 'haspassword@example.com', email_verified: true }, '/cart');
|
|
|
|
// Whoever completed that sign-in demonstrably controls the mailbox, which
|
|
// is already the root of trust for a password reset on this account. So
|
|
// linking grants nothing that was not already reachable.
|
|
expect(res.headers.location).toBe('/cart');
|
|
expect(await identityCount(customerId)).toBe(1);
|
|
expect(res.headers['set-cookie'] as unknown as string[]).toContainEqual(
|
|
expect.stringContaining('rd_session=')
|
|
);
|
|
});
|
|
|
|
it('signs the linked customer into their existing account, not a new one', async () => {
|
|
const customerId = await createCustomer('same@example.com');
|
|
|
|
const res = await attempt({ email: 'same@example.com', email_verified: true });
|
|
const session = requireCookie(res.headers['set-cookie'] as unknown as string[], 'rd_session=');
|
|
const me = await request(app).get('/api/customers/me').set('Cookie', session);
|
|
|
|
expect(me.body.id).toBe(customerId);
|
|
const { rows } = await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM customers`);
|
|
expect(requireRow(rows, 'a count of customers').n).toBe(1);
|
|
});
|
|
|
|
it('refuses when Google does not vouch for the address', async () => {
|
|
const customerId = await createCustomer('unverified@example.com');
|
|
|
|
const res = await attempt({ email: 'unverified@example.com', email_verified: false });
|
|
|
|
// The whole policy in one assertion. Linking on an unverified assertion is
|
|
// not a degraded version of the same thing — it is an account takeover with
|
|
// extra steps, because nobody has checked the claim.
|
|
expect(res.headers.location).toBe('/login?auth=google-use-password');
|
|
expect(await identityCount(customerId)).toBe(0);
|
|
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(
|
|
expect.stringContaining('rd_session=')
|
|
);
|
|
});
|
|
|
|
it('refuses on a merely truthy email_verified, which is the trap', async () => {
|
|
// The string "false" is truthy. If this check ever becomes a truthiness
|
|
// test, every unverified Google account links to whatever account holds
|
|
// its address.
|
|
const customerId = await createCustomer('trap@example.com');
|
|
|
|
const res = await attempt({ email: 'trap@example.com', email_verified: 'false' });
|
|
|
|
expect(res.headers.location).toBe('/login?auth=google-use-password');
|
|
expect(await identityCount(customerId)).toBe(0);
|
|
});
|
|
|
|
it('sends the refused customer somewhere they can act on', async () => {
|
|
// They have an account and simply cannot reach it this way. Telling them to
|
|
// use the password they already have beats "that did not work", and reveals
|
|
// nothing: they arrived holding a Google account for this address.
|
|
await createCustomer('actionable@example.com');
|
|
|
|
const res = await attempt({ email: 'actionable@example.com', email_verified: false });
|
|
|
|
expect(res.headers.location).toBe('/login?auth=google-use-password');
|
|
});
|
|
|
|
it('refuses to link to a disabled account', async () => {
|
|
const customerId = await createCustomer('disabledlink@example.com');
|
|
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
|
|
|
const res = await attempt({ email: 'disabledlink@example.com', email_verified: true });
|
|
|
|
// Linking and then refusing the session would leave the identity attached,
|
|
// so the next attempt would take the sign-in path instead — turning a
|
|
// disabled account into one that is merely inconvenient to reach.
|
|
expect(await identityCount(customerId)).toBe(0);
|
|
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(
|
|
expect.stringContaining('rd_session=')
|
|
);
|
|
});
|
|
|
|
it('matches the address case-insensitively, as registration stores it', async () => {
|
|
const customerId = await createCustomer('mixedcase@example.com');
|
|
|
|
const res = await attempt({ email: 'MixedCase@Example.COM', email_verified: true });
|
|
|
|
// A stricter comparison than registration's would silently fail to match
|
|
// and produce a second account for one person, rather than an error anyone
|
|
// sees.
|
|
expect(await identityCount(customerId)).toBe(1);
|
|
expect(res.headers.location).toBe('/');
|
|
});
|
|
|
|
it('prefers the identity over the address once linked', async () => {
|
|
const withIdentity = await createCustomer('theirs@example.com');
|
|
await linkGoogle(withIdentity, SUB);
|
|
// A second customer now holds the address this Google account reports.
|
|
const withAddress = await createCustomer('moved@example.com');
|
|
|
|
const res = await attempt({ email: 'moved@example.com', email_verified: true });
|
|
const session = requireCookie(res.headers['set-cookie'] as unknown as string[], 'rd_session=');
|
|
const me = await request(app).get('/api/customers/me').set('Cookie', session);
|
|
|
|
// The identity lookup runs first and nothing else is consulted. An identity
|
|
// that has signed in before keeps working even when the address on either
|
|
// side has since changed — and the account matching the address is somebody
|
|
// else's, which is exactly why the order matters.
|
|
expect(me.body.id).toBe(withIdentity);
|
|
expect(await identityCount(withAddress)).toBe(0);
|
|
});
|
|
|
|
it('does not link twice when the same customer signs in again', async () => {
|
|
const customerId = await createCustomer('twice@example.com');
|
|
|
|
await attempt({ email: 'twice@example.com', email_verified: true });
|
|
await attempt({ email: 'twice@example.com', email_verified: true });
|
|
|
|
expect(await identityCount(customerId)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/customers/me/identities', () => {
|
|
it('shows the customer what they are linked to', async () => {
|
|
const customerId = await createCustomer('shown@example.com');
|
|
await linkGoogle(customerId);
|
|
const session = await sessionFor(customerId);
|
|
|
|
const res = await request(app).get('/api/customers/me/identities').set('Cookie', session);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(res.body[0].provider).toBe('google');
|
|
});
|
|
|
|
it('never returns the provider subject', async () => {
|
|
const customerId = await createCustomer('opaque@example.com');
|
|
await linkGoogle(customerId);
|
|
const session = await sessionFor(customerId);
|
|
|
|
const res = await request(app).get('/api/customers/me/identities').set('Cookie', session);
|
|
|
|
// The customer cannot act on it, and it is the one value that identifies
|
|
// them to Google — the same reasoning that keeps credential ids out of the
|
|
// passkey list.
|
|
expect(JSON.stringify(res.body)).not.toContain(SUB);
|
|
expect(res.body[0].provider_sub).toBeUndefined();
|
|
});
|
|
|
|
it('is empty for a customer who has never used a provider', async () => {
|
|
const customerId = await createCustomer('none@example.com');
|
|
const session = await sessionFor(customerId);
|
|
|
|
const res = await request(app).get('/api/customers/me/identities').set('Cookie', session);
|
|
|
|
expect(res.body).toEqual([]);
|
|
});
|
|
|
|
it('refuses without a session', async () => {
|
|
const res = await request(app).get('/api/customers/me/identities');
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Whether the storefront offers a Google button at all (#345).
|
|
*
|
|
* A boolean and never the client id: the browser does not need one, because
|
|
* the whole flow is a redirect the server builds.
|
|
*/
|
|
describe('GET /api/config, google sign-in', () => {
|
|
// The suite-wide beforeEach configures Google so the flow above can run.
|
|
// These tests are about the unconfigured case too, so they start from clean.
|
|
beforeEach(() => {
|
|
delete process.env.GOOGLE_CLIENT_ID;
|
|
delete process.env.GOOGLE_CLIENT_SECRET;
|
|
});
|
|
|
|
it('is false when the environment has no credentials', async () => {
|
|
const res = await request(app).get('/api/config');
|
|
|
|
// Which is the state of local development, and of QA until #313 moves it
|
|
// off a hostname whose domain nobody can prove they own.
|
|
expect(res.body.googleSignIn).toBe(false);
|
|
});
|
|
|
|
it('is true when both credentials are set', async () => {
|
|
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
|
|
process.env.GOOGLE_CLIENT_SECRET = 'shh';
|
|
|
|
const res = await request(app).get('/api/config');
|
|
|
|
expect(res.body.googleSignIn).toBe(true);
|
|
});
|
|
|
|
it('is false with only one of the pair, matching what the backend refuses to boot on', async () => {
|
|
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
|
|
|
|
const res = await request(app).get('/api/config');
|
|
|
|
expect(res.body.googleSignIn).toBe(false);
|
|
});
|
|
|
|
it('never sends the client id or secret to the browser', async () => {
|
|
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
|
|
process.env.GOOGLE_CLIENT_SECRET = 'a-real-looking-secret';
|
|
|
|
const res = await request(app).get('/api/config');
|
|
|
|
const body = JSON.stringify(res.body);
|
|
expect(body).not.toContain('a-real-looking-secret');
|
|
expect(body).not.toContain('googleusercontent');
|
|
});
|
|
});
|