Merge pull request 'feat(auth): create an account from a Google identity, then ask about consent (#342)' (#349) from feature/342-google-new-accounts into main
Linting / lint (push) Successful in 3m4s
SonarQube Analysis / sonarqube (push) Failing after 39m7s

Reviewed-on: #349
This commit was merged in pull request #349.
This commit is contained in:
2026-09-10 10:11:16 -05:00
5 changed files with 481 additions and 15 deletions
+105
View File
@@ -0,0 +1,105 @@
import crypto from 'node:crypto';
import { pool } from '../db';
import type { GoogleIdentity } from './oauth';
/**
* Creating a customer from a Google identity (#342).
*
* ## What this deliberately does not do
*
* It refuses when the address already belongs to a customer. Joining those two
* accounts is linking, it is the most security-sensitive decision in this
* project, and it belongs to #343 rather than falling out of an INSERT here.
* Refusing is the safe half of that decision and the only one available until
* the policy is written down.
*
* ## Consent, which is the actual problem in this issue
*
* Registration captures two consents and stores their wording verbatim, and
* marketing consent must start unticked (#56). A customer arriving through
* Google has never seen those checkboxes and **cannot have**: the redirect to
* Google happens before anyone knows whether they are new.
*
* So the account is created with both false and no stored wording, which is
* legally correct — nobody has agreed to anything, and nothing is recorded as
* though they had. What makes it honest rather than merely lawful is that the
* customer is then asked, on a step that shows the same two sentences, through
* the same endpoints registration uses. That is what keeps the stored text
* byte-identical, which is the whole point of storing it.
*
* Skipping that step is allowed and leaves both false. A consent nobody gave is
* the correct default and a perfectly fine resting state.
*/
export type SignUpOutcome =
| { kind: 'created'; customerId: number }
/** The address is already an account's. #343 decides whether to link. */
| { kind: 'email-taken' };
interface IdRow {
id: number;
}
export async function createCustomerFromGoogle(identity: GoogleIdentity): Promise<SignUpOutcome> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: existing } = await client.query<IdRow>(
`SELECT id FROM customers WHERE email = $1`,
[identity.email]
);
if (existing.length) {
await client.query('ROLLBACK');
return { kind: 'email-taken' };
}
const { rows } = await client.query<IdRow>(
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
VALUES ($1, NULL, $2, $3, $4, $5)
RETURNING id`,
[
identity.email,
// Hints rather than requirements. Registration demands both names
// because every email greets by first name, but Google may return
// neither and refusing the sign-in over it would be absurd — the
// greeting already has a fallback for exactly this.
identity.firstName,
identity.lastName,
// Only on Google's word, never assumed. An unverified assertion is
// worth nothing, and the caller sends the usual confirmation email when
// this is false.
identity.emailVerified,
crypto.randomBytes(16).toString('hex')
]
);
// The INSERT above has a RETURNING clause, so no row means the statement
// did not do what it says.
const customer = rows[0];
if (!customer) throw new Error('the customer INSERT returned no row');
// In the same transaction, deliberately. A customer row with no identity is
// an account nobody can sign in to and nobody can recover, because it has
// no password either — the worst possible thing to leave behind.
await client.query(
`INSERT INTO customer_identities (customer_id, provider, provider_sub, last_used_at)
VALUES ($1, 'google', $2, now())`,
[customer.id, identity.sub]
);
await client.query('COMMIT');
return { kind: 'created', customerId: customer.id };
} catch (err) {
await client.query('ROLLBACK');
// Two sign-ins racing for the same brand-new address. The SELECT above
// cannot see the other transaction's uncommitted row, so the unique index
// is what actually holds — and losing that race means the account now
// exists, which is 'email-taken' rather than an error.
if ((err as { code?: string }).code === '23505') {
return { kind: 'email-taken' };
}
throw err;
} finally {
client.release();
}
}
+70 -11
View File
@@ -5,6 +5,9 @@ import { asyncRoute } from '../asyncRoute';
import { signIn } from '../customerSession';
import { googleConfig } from '../google/config';
import { newAttempt, authorizationUrl, exchangeCode, verifiedIdentity } from '../google/oauth';
import type { GoogleIdentity } from '../google/oauth';
import { createCustomerFromGoogle } from '../google/newCustomer';
import { issueVerificationEmail } from '../customerVerification';
import type { AttemptSecrets } from '../google/oauth';
import { googleSignInLimiter } from '../rateLimit';
import { safeReturnTo } from '../google/returnTo';
@@ -18,12 +21,16 @@ const router = Router();
* 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
* ## What this 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.
* It signs in a customer whose Google identity is already linked, and creates
* an account for one nobody here has seen (#342).
*
* It refuses when the address already belongs to a customer. Joining those two
* accounts is linking, it is the most security-sensitive decision in this
* project, and it belongs to #343 rather than falling out of an INSERT.
* Refusing is the safe half of that decision and the only half available until
* the policy is written down.
*
* ## The cookie, and why it is the whole security of the callback
*
@@ -62,6 +69,15 @@ interface IdentityRow {
*/
const FAILURE_PATH = '/login?auth=google-failed';
/**
* Where a customer who has just been created lands.
*
* A route rather than a flag on the storefront, so it is a page with an address
* — reachable again, linkable from the account page later, and rendered by the
* same modal-route machinery every other auth screen uses.
*/
const WELCOME_PATH = '/welcome';
function setAttemptCookie(res: Response, attempt: Attempt): void {
res.cookie(ATTEMPT_COOKIE, Buffer.from(JSON.stringify(attempt)).toString('base64url'), {
httpOnly: true,
@@ -112,6 +128,49 @@ function secretsMatch(a: string, b: string): boolean {
return crypto.timingSafeEqual(digest(a), digest(b));
}
/**
* Creates an account for a Google identity nobody here has seen, and signs in.
*
* A named function rather than an inline block for the reason
* `routesAreWrapped.test.ts` cares about, and because the callback is already
* the longest handler in this file.
*
* The return path is deliberately dropped for a brand-new customer, who lands
* on the consent step instead. That step is worth interrupting for: it is the
* only moment the two consent sentences can honestly be shown, because the
* redirect to Google happened before anyone knew this person was new.
*
* Carrying the path through as a query parameter was the alternative, and it
* was rejected. The consent page would then have to redirect somewhere a URL
* told it to, which is the open-redirect question `safeReturnTo` already
* answers on the server — asked a second time, in a second language, on a page
* an attacker can link to directly. One new customer occasionally landing on
* the storefront rather than back at their cart is the cheaper of the two.
*/
async function signUp(res: Response, identity: GoogleIdentity): Promise<void> {
const outcome = await createCustomerFromGoogle(identity);
if (outcome.kind === 'email-taken') {
// An account already uses this address, and joining them is #343. Refusing
// is the safe half of that decision: linking on an address is exactly the
// takeover path the policy exists to reason about carefully.
console.warn('[google] refused a sign-up: that address already has an account');
res.redirect(FAILURE_PATH);
return;
}
// Only when Google did not vouch for the address. When it did, the customer
// has already demonstrated they receive mail there — which is precisely what
// the confirmation email exists to establish — so sending one would ask them
// to do a thing that is done.
if (!identity.emailVerified) {
await issueVerificationEmail(outcome.customerId, identity.email, identity.firstName, identity.lastName);
}
await signIn(res, outcome.customerId);
res.redirect(WELCOME_PATH);
}
router.get(
'/start',
googleSignInLimiter,
@@ -178,11 +237,11 @@ router.get(
);
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);
// Nobody this shop has seen through Google before. Either they are new, or
// they already have an account under this address — and joining those two
// is linking, which is #343 and is refused here until its policy is
// written down rather than falling out of an INSERT.
if (!linked) return signUp(res, identity);
// 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,
@@ -208,6 +267,6 @@ router.get(
);
/** Exported for the tests; nothing else needs the cookie's name. */
export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH };
export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH, WELCOME_PATH };
export default router;
@@ -335,7 +335,7 @@ describe('GET /api/auth/google/callback', () => {
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
});
it('does not sign in an unlinked Google account, and creates nothing', async () => {
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' })));
@@ -345,8 +345,9 @@ describe('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.
// 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`
@@ -354,3 +355,185 @@ describe('GET /api/auth/google/callback', () => {
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);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react';
import Modal from 'antd/es/modal';
import Checkbox from 'antd/es/checkbox';
import Button from 'antd/es/button';
import Space from 'antd/es/space';
import Alert from 'antd/es/alert';
import Typography from 'antd/es/typography';
import { updateConsent, updateAnalyticsConsent } from './customerApi';
import { MARKETING_CONSENT_TEXT, ANALYTICS_CONSENT_TEXT } from './AuthForm';
const { Paragraph, Title } = Typography;
type Props = Readonly<{ onClose: () => void }>;
/**
* The consent step a customer sees once, right after signing up with Google (#342).
*
* ## Why this screen has to exist
*
* Registration asks for two consents and stores their wording verbatim, and
* marketing consent must start unticked (#56). Somebody who arrived through
* Google has never seen those checkboxes and could not have: the redirect
* happened before anyone knew whether they were new.
*
* Their account is created with both false, which is legally correct — nobody
* agreed to anything and nothing is recorded as though they had. But leaving it
* there would mean a Google sign-up is never asked at all, and a silent no is
* still a decision made on someone else's behalf.
*
* ## Why the wording is imported rather than written here
*
* These two constants are the same strings the server stores against the
* consent. The record is meant to say what the customer actually saw, so a
* second copy of the sentence that drifted by a word would quietly defeat that.
* Three wordings were already in circulation once before this was shared.
*
* ## Why skipping is a real option, not a soft refusal
*
* Consent has to be as easy to withhold as to give. "Not now" leaves both false
* and closes, and nothing is sent. Both can be changed later from the account
* page, which is where a customer who changes their mind will look.
*/
export default function Welcome({ onClose }: Props) {
const [marketing, setMarketing] = useState(false);
const [analytics, setAnalytics] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
// Two calls to two endpoints, which is the point rather than an
// inefficiency: they are separate consents with separate purposes, and
// the server stores the wording for each independently.
await updateConsent(marketing);
await updateAnalyticsConsent(analytics);
onClose();
} catch (err) {
// The account exists and they are signed in either way, so this is not a
// failure to recover from — only a preference that did not save.
setError(`Those preferences didn't save — ${(err as Error).message}. You can set them on your account page.`);
} finally {
setSaving(false);
}
}
return (
<Modal
title="Welcome to Redefined Designs"
open
onCancel={onClose}
footer={null}
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnHidden
>
<Paragraph type="secondary">
Your account is ready and you are signed in. Two optional things, and you can change
either of them later on your account page.
</Paragraph>
{error && <Alert type="warning" showIcon message={error} style={{ marginBottom: 16 }} />}
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
<Checkbox checked={marketing} onChange={(e) => setMarketing(e.target.checked)}>
{MARKETING_CONSENT_TEXT}
</Checkbox>
{/* Its own checkbox and independently refusable. Someone has to be able
to take the emails and refuse the tracking, or the consent is not
granular and is not valid. Unticked, and never pre-ticked: Quebec's
Law 25 requires profiling to be off until the person switches it on. */}
<Checkbox checked={analytics} onChange={(e) => setAnalytics(e.target.checked)}>
{ANALYTICS_CONSENT_TEXT}
</Checkbox>
</Space>
<Space style={{ marginTop: 24 }}>
<Button type="primary" loading={saving} onClick={save}>
Save preferences
</Button>
{/* As prominent as it needs to be. Withholding consent has to be as
easy as giving it, and a "Not now" hidden in small print is the
pattern that makes a consent invalid. */}
<Button onClick={onClose} disabled={saving}>
Not now
</Button>
</Space>
<Title level={5} style={{ marginTop: 24, fontSize: 13, opacity: 0.65 }}>
Leaving both unticked is fine we will not email you or share what you browse.
</Title>
</Modal>
);
}
+6 -1
View File
@@ -13,6 +13,7 @@ import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
import Admin from './admin/Admin';
import AuthRouteModal from './customer/AuthRouteModal';
import Welcome from './customer/Welcome';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import Submit from './intake/Submit';
@@ -45,7 +46,7 @@ const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash
// than as a page of their own. Each stays a real, linkable URL — bookmarkable,
// refreshable, and closed by the browser's Back button — while never being
// somewhere with no way out.
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password'];
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password', '/welcome'];
// Respects the OS-level "reduce motion" accessibility setting by turning off
// antd's transitions. Beyond the accessibility win, animated popups are a
@@ -200,6 +201,10 @@ function AppRoutes() {
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
)}
{/* One-time, right after a Google sign-up (#342). A route rather than
a flag so it has an address and uses the same modal machinery as
every other auth screen. */}
{modalPath === '/welcome' && <Welcome onClose={closeModal} />}
{modalPath === '/reset-password' && (
<ResetPassword
onClose={closeModal}