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
+3
View File
@@ -12,6 +12,7 @@ import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
import Passkeys from './Passkeys';
import ConnectedAccounts from './ConnectedAccounts';
const { Text } = Typography;
@@ -178,6 +179,8 @@ export default function Account({ onClose }: Props) {
cannot do this is not offered a button that fails (#40). */}
<Passkeys />
<ConnectedAccounts />
<Divider />
<Space wrap>
{/* Order history is a page of its own now. The link stays here because
+34
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
@@ -43,11 +44,38 @@ type Props = Readonly<{
onSuccess: () => void;
}>;
/**
* What a Google sign-in that ended badly wants the login form to say (#343).
*
* Read from the query string because the callback is a redirect: it cannot
* return a body, and the customer's browser arrives here having been sent by
* Google. A parameter is the only channel there is.
*
* `google-use-password` is the interesting one. It means the customer has an
* account and simply cannot reach it this way, which is the single refusal in
* this flow they can act on — so it says what to do rather than what failed.
*
* It reveals nothing they did not already supply. They arrived holding a Google
* account for this address, so being told the address has an account here tells
* them only about themselves.
*/
function googleNotice(reason: string | null): string | null {
if (reason === 'google-use-password') {
return 'You already have an account with this email address. Log in with your password below.';
}
if (reason === 'google-failed') {
return 'That Google sign-in did not work. You can log in with your password instead.';
}
return null;
}
// The one implementation of signing in and registering. It was previously
// written twice — once as the /login and /register pages, once inside the
// prompt shown when a signed-out visitor adds to the cart — which had already
// drifted in consent wording and in which links each offered.
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
const [searchParams] = useSearchParams();
const notice = googleNotice(searchParams.get('auth'));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Separate from `loading`, so the password button does not sit disabled and
@@ -107,6 +135,12 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
return (
<>
{/* The notice sits above the tabs and below any live error, because it
describes how the customer arrived rather than what they just did. An
error from this form supersedes it. */}
{!error && notice && (
<Alert type="info" showIcon message={notice} style={{ marginBottom: 16 }} />
)}
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
<Tabs
activeKey={mode}
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import Typography from 'antd/es/typography';
import Tag from 'antd/es/tag';
import Spin from 'antd/es/spin';
import { fetchIdentities, Identity } from './customerApi';
const { Title, Text, Paragraph } = Typography;
const PROVIDER_NAMES: Record<string, string> = { google: 'Google' };
/**
* Which identity providers this account can be signed in with (#343).
*
* Linking happens automatically when Google vouches for an address that already
* has an account here. That is defensible — whoever completed the sign-in
* demonstrably controls the mailbox, which is already the root of trust for a
* password reset — but it is not obvious, and a customer who signed up with a
* password has had two credentials joined without being asked.
*
* A silent link is indistinguishable from a bug when somebody later wonders why
* the password is no longer needed. So it is shown, beside the passkeys, for the
* reason the passkey list exists at all: a customer cannot manage credentials
* they cannot see.
*
* Read-only for now. Removing the only way into an account is the question #344
* settles, and offering an unlink button before that check runs would be the
* fastest way to lock somebody out of their own orders.
*/
export default function ConnectedAccounts() {
const [identities, setIdentities] = useState<Identity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchIdentities()
.then(setIdentities)
// Silent. This is a supplementary panel on a page whose real content is
// elsewhere, and a red error over the account settings because one extra
// read failed would be worse than the panel simply not appearing.
.catch(() => setIdentities([]))
.finally(() => setLoading(false));
}, []);
// Nothing at all for the overwhelming majority, who have never used a
// provider. An empty state here would appear on every account page to say
// that nothing had happened.
if (loading) return <Spin />;
if (identities.length === 0) return null;
return (
<>
<Title level={5}>Connected accounts</Title>
<Paragraph type="secondary" style={{ fontSize: 13 }}>
You can sign in with these as well as with your password.
</Paragraph>
{identities.map((identity) => (
<div key={identity.provider} style={{ marginBottom: 8 }}>
<Tag color="blue">{PROVIDER_NAMES[identity.provider] ?? identity.provider}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{/* Last used rather than connected, for the reason the passkey list
shows it: it is what tells a customer whether something is still
theirs, where a connection date says only that it happened. */}
{identity.last_used_at
? `last used ${new Date(identity.last_used_at).toLocaleDateString()}`
: 'never used to sign in'}
</Text>
</div>
))}
</>
);
}
+11
View File
@@ -183,6 +183,17 @@ export function changeMyEmail(currentPassword: string, email: string): Promise<C
}).then(res => handle<Customer>(res));
}
/** One identity provider this account can sign in with (#343). */
export interface Identity {
provider: string;
created_at: string;
last_used_at: string | null;
}
export function fetchIdentities(): Promise<Identity[]> {
return fetch('/api/customers/me/identities').then(res => handle<Identity[]>(res));
}
/** A registered passkey, as the account page lists it (#40). */
export interface Passkey {
id: number;