diff --git a/backend/src/google/linkIdentity.ts b/backend/src/google/linkIdentity.ts new file mode 100644 index 0000000..a025bc1 --- /dev/null +++ b/backend/src/google/linkIdentity.ts @@ -0,0 +1,76 @@ +import { pool } from '../db'; +import type { GoogleIdentity } from './oauth'; + +/** + * Joining a Google identity to an account that already exists (#343). + * + * The smallest module 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 that belongs to somebody who did. + * + * ## The rule, and why it is defensible + * + * Link only when Google asserts `email_verified` and the address matches an + * existing customer exactly. Refuse otherwise. + * + * Google asserting the address means whoever completed that sign-in + * demonstrably controls the mailbox. 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 is enough to take 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 the password. + * + * **Never link on an unverified address.** That is not a degraded version of the + * same thing — it is an account takeover with extra steps, since the assertion + * would be one nobody has checked. It is why this is a written rule rather than + * a default that arrived with a library. + * + * ## Why the identity lookup happens before any of this + * + * The caller matches on `(provider, provider_sub)` first, and only reaches here + * when that finds nothing. An identity that has signed in before keeps working + * even if the address on either side has since changed, which is the whole + * reason the subject claim is what gets stored. + */ + +export type LinkOutcome = + | { kind: 'linked'; customerId: number } + /** Google did not vouch for the address, or nothing matched it. */ + | { kind: 'refused' }; + +interface CustomerRow { + id: number; + disabled_at: Date | null; +} + +export async function linkToExistingCustomer(identity: GoogleIdentity): Promise { + // The first thing checked, and it is the whole policy. Everything below is + // bookkeeping; this line is the security. + if (!identity.emailVerified) return { kind: 'refused' }; + + const { rows } = await pool.query( + // Compared exactly, against an address the caller has already lowercased + // and trimmed the way registration does. A stricter comparison here would + // silently fail to match and produce a second account for one person + // instead of an error anybody sees. + `SELECT id, disabled_at FROM customers WHERE email = $1`, + [identity.email] + ); + const customer = rows[0]; + if (!customer) return { kind: 'refused' }; + + // Refused here as well as at sign-in. Linking to a disabled account 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. + if (customer.disabled_at !== null) return { kind: 'refused' }; + + await pool.query( + `INSERT INTO customer_identities (customer_id, provider, provider_sub, last_used_at) + VALUES ($1, 'google', $2, now()) + ON CONFLICT (provider, provider_sub) DO NOTHING`, + [customer.id, identity.sub] + ); + + return { kind: 'linked', customerId: customer.id }; +} diff --git a/backend/src/google/newCustomer.ts b/backend/src/google/newCustomer.ts index c18708d..435cb44 100644 --- a/backend/src/google/newCustomer.ts +++ b/backend/src/google/newCustomer.ts @@ -5,13 +5,14 @@ import type { GoogleIdentity } from './oauth'; /** * Creating a customer from a Google identity (#342). * - * ## What this deliberately does not do + * ## What this deliberately does not decide * - * 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. + * It reports `email-taken` when the address already belongs to a customer, and + * stops there. Whether to join those two accounts is linking, which is the most + * security-sensitive decision in this project and lives in `linkIdentity.ts` + * (#343). Deciding it here would mean an account is handed over as a side + * effect of an INSERT failing, which is exactly the shape that decision must + * never take. * * ## Consent, which is the actual problem in this issue * diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts index cf832af..90f424d 100755 --- a/backend/src/routes/customers.ts +++ b/backend/src/routes/customers.ts @@ -626,6 +626,36 @@ router.post('/me/analytics-consent', requireCustomer, asyncRoute(async (req: Req res.status(204).end(); })); +/** + * Which identity providers this account is signed in with (#343). + * + * Linking happens automatically when Google vouches for an address that already + * has an account, which is defensible but not obvious. 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. + * + * So it is shown, beside the passkeys, for the reason the passkey list exists + * at all: a customer cannot manage credentials they cannot see. + * + * No unlinking yet. Removing the only way into an account is the question #344 + * settles, and offering the button before that check runs would be the fastest + * possible way to lock somebody out of their own orders. + */ +router.get('/me/identities', requireCustomer, asyncRoute(async (req: Request, res: Response) => { + const { rows } = await pool.query<{ provider: string; created_at: Date; last_used_at: Date | null }>( + // No provider_sub. The customer cannot act on it, and it is the one value + // that identifies them to the provider — the same reasoning that keeps + // credential ids out of the passkey list. + `SELECT provider, created_at, last_used_at + FROM customer_identities + WHERE customer_id = $1 + ORDER BY created_at`, + [req.customerId] + ); + res.json(rows); +})); + router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name diff --git a/backend/src/routes/googleAuth.ts b/backend/src/routes/googleAuth.ts index 8f91e11..0f3a14a 100644 --- a/backend/src/routes/googleAuth.ts +++ b/backend/src/routes/googleAuth.ts @@ -7,6 +7,7 @@ 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 { linkToExistingCustomer } from '../google/linkIdentity'; import { issueVerificationEmail } from '../customerVerification'; import type { AttemptSecrets } from '../google/oauth'; import { googleSignInLimiter } from '../rateLimit'; @@ -26,11 +27,10 @@ const router = Router(); * 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. + * It also joins a Google identity to an account that already holds the same + * address — but only when Google vouches for that address (#343). The whole of + * that policy lives in `google/linkIdentity.ts`, which is the smallest module + * in this feature and the one to read most carefully. * * ## The cookie, and why it is the whole security of the callback * @@ -78,6 +78,15 @@ const FAILURE_PATH = '/login?auth=google-failed'; */ const WELCOME_PATH = '/welcome'; +/** + * Where a customer goes when they have an account this sign-in cannot reach. + * + * Its own destination rather than the generic failure, because it is the one + * refusal a customer can act on: the login form reads this and says to sign in + * with the password they already have. + */ +const USE_PASSWORD_PATH = '/login?auth=google-use-password'; + function setAttemptCookie(res: Response, attempt: Attempt): void { res.cookie(ATTEMPT_COOKIE, Buffer.from(JSON.stringify(attempt)).toString('base64url'), { httpOnly: true, @@ -129,16 +138,24 @@ function secretsMatch(a: string, b: string): boolean { } /** - * Creates an account for a Google identity nobody here has seen, and signs in. + * What happens when the identity lookup found nothing: create, link, or refuse. * * 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. + * The order below is the policy from #343, and it is an order rather than a set + * of independent checks: + * + * 1. Nobody has this address — create the account, and land on the consent step + * 2. Somebody does, and Google vouches for it — link, and sign in + * 3. Somebody does, and Google does not vouch — refuse, and say to use the + * password + * + * The return path is deliberately dropped in case 1 only. That customer lands + * on the consent step, which 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 @@ -147,28 +164,42 @@ function secretsMatch(a: string, b: string): boolean { * 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 { +async function signUpOrLink(res: Response, identity: GoogleIdentity, returnTo: string): Promise { 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); + if (outcome.kind === 'created') { + // 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); 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); + // The address belongs to somebody. Whether that is the same person is the + // question #343 exists to answer, and `linkToExistingCustomer` holds the + // whole of the answer. + const link = await linkToExistingCustomer(identity); + if (link.kind === 'refused') { + // Deliberately its own destination rather than the generic failure. This is + // the one refusal a customer can act on: they have an account, they simply + // cannot reach it this way, and telling them to use the password they + // already have is more useful than "that did not work". + // + // 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 about themselves. + console.warn('[google] refused a link: the address is taken and Google did not verify it'); + res.redirect(USE_PASSWORD_PATH); + return; } - await signIn(res, outcome.customerId); - res.redirect(WELCOME_PATH); + await signIn(res, link.customerId); + res.redirect(returnTo); } router.get( @@ -241,7 +272,7 @@ router.get( // 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); + if (!linked) return signUpOrLink(res, identity, attempt.returnTo); // 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, @@ -267,6 +298,6 @@ router.get( ); /** Exported for the tests; nothing else needs the cookie's name. */ -export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH, WELCOME_PATH }; +export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH, WELCOME_PATH, USE_PASSWORD_PATH }; export default router; diff --git a/backend/tests/integration/googleSignIn.integration.test.ts b/backend/tests/integration/googleSignIn.integration.test.ts index 21855f1..de0bae2 100644 --- a/backend/tests/integration/googleSignIn.integration.test.ts +++ b/backend/tests/integration/googleSignIn.integration.test.ts @@ -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 { ); } +/** A session cookie for a customer, without going through any sign-in flow. */ +async function sessionFor(customerId: number): Promise { + 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 = {}, 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 { + 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); + }); +}); diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 57507c7..6aff4fb 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -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). */} + + {/* Order history is a page of its own now. The link stays here because diff --git a/frontend/src/customer/AuthForm.tsx b/frontend/src/customer/AuthForm.tsx index 74f31d1..b70a598 100644 --- a/frontend/src/customer/AuthForm.tsx +++ b/frontend/src/customer/AuthForm.tsx @@ -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(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 && ( + + )} {error && } = { 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([]); + 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 ; + if (identities.length === 0) return null; + + return ( + <> + Connected accounts + + You can sign in with these as well as with your password. + + {identities.map((identity) => ( +
+ {PROVIDER_NAMES[identity.provider] ?? identity.provider} + + {/* 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'} + +
+ ))} + + ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index 6431a92..83c392a 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -183,6 +183,17 @@ export function changeMyEmail(currentPassword: string, email: string): Promise handle(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 { + return fetch('/api/customers/me/identities').then(res => handle(res)); +} + /** A registered passkey, as the account page lists it (#40). */ export interface Passkey { id: number;