feat(auth): link a Google identity to an account that already exists (#343)
Linting / lint (pull_request) Successful in 3m52s
SonarQube Analysis / sonarqube (pull_request) Failing after 29m7s

The smallest change in this feature and the one to read most carefully. It is the point where somebody who has proved nothing to this shop is handed an account belonging to somebody who did.

The rule is one line at the top of linkIdentity.ts: link only when Google asserts the address is verified, and refuse otherwise. Everything below it is bookkeeping.

That is defensible for Google specifically, and the reasoning is worth stating rather than assuming. Google asserting the address means whoever completed the sign-in demonstrably controls the mailbox, and that mailbox is already the root of trust for every other route into the account — it is where a password reset goes, and following a reset link takes the account over completely. So linking on it grants nothing that was not already reachable, and it spares the customer who came to Google precisely because they forgot their password.

Never on an unverified address. That is not a weaker version of the same thing; it is an account takeover with extra steps, because the assertion would be one nobody checked. There is a test for the specific trap: the string "false" is truthy, and if that check ever becomes a truthiness test then every unverified Google account links to whatever account holds its address.

The order matters and is an order rather than a set of independent checks. The identity lookup runs first and nothing else is consulted when it matches, which is why an identity that has signed in before keeps working after the address changes on either side. There is a test where a second customer has since taken the address the Google account reports, and the sign-in correctly reaches the first.

Linking to a disabled account is refused, and the reason is not obvious. 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.

The refusal gets its own destination rather than the generic failure. It is the one refusal in this flow a customer can act on: they have an account and simply cannot reach it this way, so the login form now says to use the password they already have. That reveals nothing, because they arrived holding a Google account for that address — being told the address has an account here tells them only about themselves.

Deciding this in newCustomer.ts, where the unique constraint already fires, was the shape to avoid. An account must never be handed over as a side effect of an INSERT failing, so that module reports the address is taken and stops, and the policy lives somewhere it can be read on its own.

Automatic linking is defensible but it is not obvious, so the account page now shows it. A customer who signed up with a password and later used Google has had two credentials joined without being asked, and a silent link is indistinguishable from a bug when they later wonder why the password is no longer needed. It sits beside the passkeys for the reason that list exists at all: a customer cannot manage credentials they cannot see. The endpoint never returns the provider subject, which is the same reasoning that keeps credential ids out of the passkey list.

No unlinking. Removing the only way into an account is the question #344 settles, and offering that button before the check runs would be the fastest possible way to lock somebody out of their own orders.

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 suite needs a database this machine has no Docker for.

Closes #343

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
synAdmin
2026-09-10 10:18:05 -05:00
co-authored by Claude Opus 5
parent 8a5f6eb08c
commit 44df0bd2d9
9 changed files with 482 additions and 33 deletions
@@ -1,6 +1,7 @@
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';
@@ -108,6 +109,11 @@ async function linkGoogle(customerId: number, sub = SUB): Promise<void> {
);
}
/** 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();
@@ -537,3 +543,190 @@ describe('signing up with Google', () => {
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);
});
});