Compare commits
14
Commits
8a5f6eb08c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0361ef35d0 | ||
|
|
f5a29127fb | ||
|
|
c9dccfe4a2 | ||
|
|
903a1d8b76 | ||
|
|
838749df64 | ||
|
|
3958bda489 | ||
|
|
b82b989cc8 | ||
|
|
c2ee0b0c5d | ||
|
|
843c51dd91 | ||
|
|
2c6ac4d2be | ||
|
|
0014a8db8a | ||
|
|
dcb3c7c91b | ||
|
|
c5014d5342 | ||
|
|
44df0bd2d9 |
+11
-1
@@ -20,6 +20,7 @@ import customersRouter from './routes/customers';
|
||||
import passkeysRouter from './routes/passkeys';
|
||||
import passkeyLoginRouter from './routes/passkeyLogin';
|
||||
import googleAuthRouter from './routes/googleAuth';
|
||||
import { googleConfig } from './google/config';
|
||||
import publicRouter from './routes/public';
|
||||
import cartRouter from './routes/cart';
|
||||
import shippingAddressesRouter from './routes/shippingAddresses';
|
||||
@@ -72,7 +73,16 @@ app.get('/api/config', (_req, res) => {
|
||||
// keeps QA out of production's Brevo account: QA sets no key, so no QA
|
||||
// browsing is ever reported, and there is no flag anyone can forget to
|
||||
// turn off. Same shape as paypalClientId above.
|
||||
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null
|
||||
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null,
|
||||
// Whether to offer the Google button at all (#345). A boolean, never the
|
||||
// client id: the browser does not need it, because the whole flow is a
|
||||
// redirect this server builds.
|
||||
//
|
||||
// Absent rather than disabled is the point. A developer with no credentials
|
||||
// gets a storefront that works and simply does not offer the option, the
|
||||
// same choice #41 made for a browser without WebAuthn — and QA, which
|
||||
// cannot have credentials until #313, gets the same.
|
||||
googleSignIn: googleConfig().enabled
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,27 +14,32 @@
|
||||
* source, and it is the one that is already correct in any environment where
|
||||
* mail works.
|
||||
*
|
||||
* ## The consequence worth stating plainly
|
||||
* ## Every environment needs its own console entry
|
||||
*
|
||||
* Google refuses a redirect URI whose host is not under an **authorized
|
||||
* domain**, and a domain can only be authorized after ownership has been proved
|
||||
* by DNS in Search Console. `localhost` is the sole exemption.
|
||||
* Whatever this resolves to has to exist, verbatim, under Authorized redirect
|
||||
* URIs for the client this app uses. Google compares the two as strings, and a
|
||||
* mismatch is answered with `redirect_uri_mismatch` — accurate, and silent
|
||||
* about which half is wrong.
|
||||
*
|
||||
* `qa-redefined-designs.bermudalamb.synology.me` therefore **cannot ever be
|
||||
* used**: Synology owns the registrable domain above it, so there is no record
|
||||
* to add and nothing to prove. This is the same wall #285 hit with Cloudflare.
|
||||
* | Environment | Redirect URI |
|
||||
* | --- | --- |
|
||||
* | Local, Vite | `http://localhost:5173/api/auth/google/callback` |
|
||||
* | Local, built | `http://localhost:3000/api/auth/google/callback` |
|
||||
* | QA | `https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback` |
|
||||
* | Production | `https://redefined-designs.com/api/auth/google/callback` |
|
||||
*
|
||||
* | Environment | Redirect URI | Works |
|
||||
* | --- | --- | --- |
|
||||
* | Local | `http://localhost:3000/...` | Yes, by exemption |
|
||||
* | QA on the Synology host | — | **No, and cannot** |
|
||||
* | QA on `qa.redefined-designs.com` | `https://qa.redefined-designs.com/...` | After #313 |
|
||||
* | Production | `https://redefined-designs.com/...` | After #313 |
|
||||
* Local development needs the 5173 one, because that is where the dev server
|
||||
* serves the app; the 3000 one only applies when the backend serves a built
|
||||
* frontend.
|
||||
*
|
||||
* So this feature is built and exercised locally, and QA cannot see it until QA
|
||||
* moves onto a subdomain of the real domain. That is a `PUBLIC_URL` change and
|
||||
* one console entry, not a code change — this module follows `PUBLIC_URL`
|
||||
* wherever it points. See #345.
|
||||
* An earlier version of this comment claimed the QA hostname could never be
|
||||
* registered, because it sits under a domain Synology owns. **That was wrong**,
|
||||
* and it is recorded here rather than quietly deleted: it was asserted from the
|
||||
* shape of #285, which is a related but different problem, and it sent QA
|
||||
* testing of this feature behind #313 for no reason. Adding the URI works.
|
||||
*
|
||||
* This module needs no change in any environment. It follows `PUBLIC_URL`
|
||||
* wherever it points.
|
||||
*/
|
||||
|
||||
/** The callback path. One constant, because it appears in two sentences. */
|
||||
|
||||
@@ -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<LinkOutcome> {
|
||||
// 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<CustomerRow>(
|
||||
// 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 };
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -181,6 +181,13 @@ function publicCustomer(c: CustomerRecord) {
|
||||
// neither, and the UI has to be able to show that honestly.
|
||||
analytics_consent: analyticsConsent(c),
|
||||
favorite_alerts: c.favorite_alerts,
|
||||
// Whether, not what (#344). A customer who signed up with Google has none,
|
||||
// and the account page has to be able to say so — offering "change your
|
||||
// password" to somebody who has never had one is a dead end, and saying
|
||||
// nothing leaves them unable to see a credential they are entitled to
|
||||
// manage. A boolean is the whole of what the UI needs, and the hash itself
|
||||
// must never leave this function.
|
||||
has_password: c.password_hash !== null,
|
||||
created_at: c.created_at
|
||||
};
|
||||
}
|
||||
@@ -519,9 +526,26 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
|
||||
}
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
if (!(await passwordMatches(currentPassword, customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
|
||||
// Setting the first password and changing an existing one, in one route
|
||||
// rather than two (#344).
|
||||
//
|
||||
// A customer who signed up with Google has no password, so there is nothing
|
||||
// to compare against and asking for one would be a dead end — they cannot
|
||||
// supply a value that was never set. What authorises the change is the
|
||||
// session they are already holding, which is the same thing that authorises
|
||||
// every other setting on the account page.
|
||||
//
|
||||
// One route because two would be two places to get the guard wrong, and the
|
||||
// one that would be forgotten is whichever is not on the path exercised by
|
||||
// hand. The branch is on the stored hash rather than on anything the caller
|
||||
// sends, so a request cannot talk its way into the first-password case.
|
||||
if (customer.password_hash !== null) {
|
||||
if (!(await passwordMatches(currentPassword, customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, PASSWORD_HASH_ROUNDS);
|
||||
await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]);
|
||||
|
||||
@@ -552,6 +576,24 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
|
||||
// A customer with no password is refused here rather than waved through, and
|
||||
// the asymmetry with change-password above is deliberate (#344).
|
||||
//
|
||||
// Setting a first password is a change to a credential the customer already
|
||||
// controls. Changing the email address is a change to *where recovery goes* —
|
||||
// whoever holds the new address can reset the password and own the account
|
||||
// outright. That is why this route has always demanded more than a live
|
||||
// session, and dropping the demand for the accounts that cannot meet it would
|
||||
// remove the protection from exactly the ones that need it.
|
||||
//
|
||||
// So the message says the real thing and gives them the route out, rather
|
||||
// than claiming a password was wrong when there is no password at all.
|
||||
if (customer.password_hash === null) {
|
||||
return res.status(409).json({
|
||||
error: 'this account has no password — set one first, then you can change your email address'
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await passwordMatches(currentPassword, customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
@@ -626,6 +668,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<CustomerOrderRow>(
|
||||
`SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
||||
|
||||
@@ -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<void> {
|
||||
async function signUpOrLink(res: Response, identity: GoogleIdentity, returnTo: string): 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);
|
||||
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;
|
||||
|
||||
@@ -82,7 +82,10 @@ describe('POST /api/customers/register', () => {
|
||||
|
||||
expect(Object.keys(res.body).sort()).toEqual([
|
||||
'analytics_consent', 'created_at', 'email', 'email_verified', 'favorite_alerts',
|
||||
'first_name', 'id', 'last_name', 'marketing_consent'
|
||||
// Whether, never what. Added in #344 so the account page can offer to set
|
||||
// a first password rather than to change one that does not exist; the
|
||||
// hash itself must never appear in this list.
|
||||
'first_name', 'has_password', 'id', 'last_name', 'marketing_consent'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,241 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import app from '../../src/app';
|
||||
import { pool, requireRow } from '../../src/db';
|
||||
import { createSession } from '../../src/customerSession';
|
||||
import { PASSWORD_HASH_ROUNDS } from '../../src/passwordHashing';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
|
||||
/**
|
||||
* A customer who signed up with Google: no password at all (#344).
|
||||
*
|
||||
* Inserted rather than driven through the OAuth flow, because what these tests
|
||||
* are about is the state, not how it was reached. The flow that produces it has
|
||||
* its own suite.
|
||||
*/
|
||||
async function passwordlessCustomer(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||
VALUES ($1, NULL, 'Test', 'Customer', true, $2) RETURNING id`,
|
||||
[email, `unsub-${email}`]
|
||||
);
|
||||
const id = requireRow(rows, 'the passwordless customer').id;
|
||||
await pool.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub) VALUES ($1, 'google', $2)`,
|
||||
[id, `sub-${email}`]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function customerWithPassword(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||
VALUES ($1, $2, 'Test', 'Customer', true, $3) RETURNING id`,
|
||||
[email, await bcrypt.hash(PASSWORD, PASSWORD_HASH_ROUNDS), `unsub-${email}`]
|
||||
);
|
||||
return requireRow(rows, 'the customer with a password').id;
|
||||
}
|
||||
|
||||
async function sessionFor(customerId: number): Promise<string> {
|
||||
return `rd_session=${await createSession(customerId)}`;
|
||||
}
|
||||
|
||||
async function storedHash(customerId: number): Promise<string | null> {
|
||||
const { rows } = await pool.query<{ password_hash: string | null }>(
|
||||
`SELECT password_hash FROM customers WHERE id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
return requireRow(rows, 'the customer').password_hash;
|
||||
}
|
||||
|
||||
describe('an account with no password', () => {
|
||||
describe('setting a first one', () => {
|
||||
it('takes no current password, because there is none to give', async () => {
|
||||
const id = await passwordlessCustomer('first@example.com');
|
||||
const session = await sessionFor(id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
// Asking for a value that was never set is a dead end. The session they
|
||||
// are already holding is what authorises this, exactly as it authorises
|
||||
// every other setting on the account page.
|
||||
expect(res.status).toBe(204);
|
||||
expect(await storedHash(id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lets them sign in with it afterwards', async () => {
|
||||
const id = await passwordlessCustomer('cansignin@example.com');
|
||||
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const login = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'cansignin@example.com', password: 'a-brand-new-password' });
|
||||
expect(login.status).toBe(200);
|
||||
});
|
||||
|
||||
it('enforces the same minimum length as registration', async () => {
|
||||
const id = await passwordlessCustomer('short@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ newPassword: 'short' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(await storedHash(id)).toBeNull();
|
||||
});
|
||||
|
||||
it('still demands the current one from an account that has a password', async () => {
|
||||
// The branch is on the stored hash, never on what the caller sends, so a
|
||||
// request cannot talk its way into the first-password case by omitting a
|
||||
// field.
|
||||
const id = await customerWithPassword('haspassword@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signing in with a password', () => {
|
||||
it('is refused exactly as a wrong password is', async () => {
|
||||
await passwordlessCustomer('oracle@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'oracle@example.com', password: 'anything-at-all' });
|
||||
|
||||
// Answering "this account has no password" would turn the login form into
|
||||
// an oracle for which customers use Google. One refusal for every cause,
|
||||
// and the account page is where a signed-in customer learns what they
|
||||
// have.
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid email or password');
|
||||
});
|
||||
|
||||
it('is refused for a blank password too, rather than matching an absent hash', async () => {
|
||||
await passwordlessCustomer('blank@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'blank@example.com', password: '' });
|
||||
|
||||
// Both sides missing is the combination most tempting to call a match,
|
||||
// and calling it one would let anyone sign in as any Google-only customer.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing the email address', () => {
|
||||
it('is refused, and says why rather than claiming a password was wrong', async () => {
|
||||
const id = await passwordlessCustomer('moving@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/customers/me/email')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ email: 'somewhere-else@example.com' });
|
||||
|
||||
// Changing the address is a change to where recovery goes: whoever holds
|
||||
// the new one can reset the password and own the account outright. That is
|
||||
// why this route has always demanded more than a live session, and
|
||||
// dropping the demand for accounts that cannot meet it would remove the
|
||||
// protection from exactly the ones that need it.
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/no password/);
|
||||
});
|
||||
|
||||
it('works once they have set one', async () => {
|
||||
const id = await passwordlessCustomer('thenmoving@example.com');
|
||||
const session = await sessionFor(id);
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/customers/me/email')
|
||||
.set('Cookie', session)
|
||||
.send({ email: 'moved@example.com', currentPassword: 'a-brand-new-password' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting the account', () => {
|
||||
it('works, because deletion never asked for a password', async () => {
|
||||
const id = await passwordlessCustomer('deleting@example.com');
|
||||
|
||||
const res = await request(app).delete('/api/customers/me').set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(rows, 'a count of customers').n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the passkey lockout guard, which becomes reachable here', () => {
|
||||
async function givePasskey(customerId: number, credentialId: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, $2, 'not-a-real-key', 'Phone') RETURNING id`,
|
||||
[customerId, credentialId]
|
||||
);
|
||||
return requireRow(rows, 'the credential just created').id;
|
||||
}
|
||||
|
||||
it('refuses to remove the last way into an account with no password', async () => {
|
||||
// Written in #40 against the condition rather than the schema, and
|
||||
// unreachable until now because password_hash was NOT NULL. This is the
|
||||
// first test that actually exercises it.
|
||||
const id = await passwordlessCustomer('lastway@example.com');
|
||||
const credentialId = await givePasskey(id, 'only-credential');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/customers/me/passkeys/${credentialId}`)
|
||||
.set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/only way you can sign in/);
|
||||
});
|
||||
|
||||
it('allows it when a second passkey remains', async () => {
|
||||
const id = await passwordlessCustomer('twokeys@example.com');
|
||||
const first = await givePasskey(id, 'credential-one');
|
||||
await givePasskey(id, 'credential-two');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/customers/me/passkeys/${first}`)
|
||||
.set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('allows it once a password has been set', async () => {
|
||||
const id = await passwordlessCustomer('nowhaspassword@example.com');
|
||||
const credentialId = await givePasskey(id, 'credential-with-password');
|
||||
const session = await sessionFor(id);
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/customers/me/passkeys/${credentialId}`)
|
||||
.set('Cookie', session);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetting a password that was never set', () => {
|
||||
it('gives them one, which is a reasonable answer rather than an error', async () => {
|
||||
await passwordlessCustomer('resetting@example.com');
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'resetting@example.com' });
|
||||
const { rows } = await pool.query<{ token: string }>(
|
||||
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'password_reset'`,
|
||||
['resetting@example.com']
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
|
||||
|
||||
// The reset path sets a hash and does not care whether one was there
|
||||
// before. A customer who reaches for "forgot password" without ever
|
||||
// having had one gets a working password, which is what they were asking
|
||||
// for.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('removes their passkeys, which is worth knowing rather than assuming', async () => {
|
||||
// #42 made a reset remove every passkey, on the reasoning that recovery
|
||||
// has to be complete. That still holds here: nothing about this path
|
||||
// identifies who asked, and a Google-only customer resetting a password
|
||||
// they never had is not obviously in a better position than one who did.
|
||||
const id = await passwordlessCustomer('resetkeys@example.com');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, 'reset-credential', 'not-a-real-key', 'Phone')`,
|
||||
[id]
|
||||
);
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'resetkeys@example.com' });
|
||||
const { rows } = await pool.query<{ token: string }>(
|
||||
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'password_reset'`,
|
||||
['resetkeys@example.com']
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
|
||||
|
||||
expect(res.body.passkeysRemoved).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the Google identity attached, so they keep both ways in', async () => {
|
||||
const id = await passwordlessCustomer('keepsgoogle@example.com');
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'keepsgoogle@example.com' });
|
||||
const { rows } = await pool.query<{ token: string }>(
|
||||
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'password_reset'`,
|
||||
['keepsgoogle@example.com']
|
||||
);
|
||||
|
||||
await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
|
||||
|
||||
// Deliberately not removed alongside the passkeys. A passkey is a
|
||||
// credential this shop issued and can revoke; a Google identity is one
|
||||
// Google holds, and severing it would leave the customer unable to use
|
||||
// the button they signed up with for no gain — whoever completed the
|
||||
// reset controls the mailbox either way.
|
||||
const { rows: identities } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_identities WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(identities, 'a count of identities').n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the account page is told', () => {
|
||||
it('reports has_password false for a Google-only customer', async () => {
|
||||
const id = await passwordlessCustomer('told@example.com');
|
||||
|
||||
const res = await request(app).get('/api/customers/me').set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.body.has_password).toBe(false);
|
||||
});
|
||||
|
||||
it('reports it true once one is set', async () => {
|
||||
const id = await passwordlessCustomer('nowtrue@example.com');
|
||||
const session = await sessionFor(id);
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const res = await request(app).get('/api/customers/me').set('Cookie', session);
|
||||
|
||||
expect(res.body.has_password).toBe(true);
|
||||
});
|
||||
|
||||
it('never returns the hash itself', async () => {
|
||||
const id = await customerWithPassword('nohash@example.com');
|
||||
|
||||
const res = await request(app).get('/api/customers/me').set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.body.password_hash).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain('$2');
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-12
@@ -63,6 +63,12 @@
|
||||
# in the notification email (#224). Absent, the email still
|
||||
# sends and simply carries no shortcuts. Its own value, not
|
||||
# production's: a link signed with it acts without a login.
|
||||
# QA_GOOGLE_CLIENT_ID — optional, and all-or-nothing with the secret below:
|
||||
# QA_GOOGLE_CLIENT_SECRET setting one without the other refuses to boot
|
||||
# (#340). Both unset means the Google button is not offered
|
||||
# at all, which is the right answer until QA's callback URL
|
||||
# is registered in the Google Auth Platform. See the note
|
||||
# beside the values themselves for the exact URL (#345).
|
||||
# QA_REMBG_URL — optional. The background-removal sidecar, e.g.
|
||||
# http://rembg-syn:7000. Unset turns the feature off rather
|
||||
# than breaking anything. The sidecar must be on the same
|
||||
@@ -195,20 +201,28 @@ services:
|
||||
# rotating it revokes every outstanding link, which is the intended way to
|
||||
# deal with a leak.
|
||||
- INTAKE_ACTION_SECRET=${QA_INTAKE_ACTION_SECRET:-}
|
||||
# Deliberately left empty, and it is not an oversight (#340, #345).
|
||||
# Read from the stack like every other QA secret, rather than hardcoded
|
||||
# empty as they were in #340. Leaving them unreadable made this file the
|
||||
# odd one out and cost a QA deploy: the variables were set on the stack,
|
||||
# nothing read them, and the button stayed missing with no explanation.
|
||||
#
|
||||
# Google refuses a redirect URI whose host is not under a domain whose
|
||||
# ownership has been proved by DNS, and nobody can prove ownership of
|
||||
# *.bermudalamb.synology.me because Synology owns the registrable domain
|
||||
# above it. Same wall as #285. So QA cannot run Google sign-in at all
|
||||
# while it lives on this hostname, and setting these would only produce a
|
||||
# button that fails at Google.
|
||||
# Setting these needs one thing done first: the QA callback registered
|
||||
# under Authorized redirect URIs for this client in the Google Auth
|
||||
# Platform, exactly as it appears below. Google compares the two as
|
||||
# strings and answers a mismatch with redirect_uri_mismatch.
|
||||
#
|
||||
# It becomes possible when #313 moves QA to qa.redefined-designs.com:
|
||||
# set both here, set PUBLIC_URL to the new host, and add the matching
|
||||
# callback in the Google Auth Platform. No code change either way.
|
||||
- GOOGLE_CLIENT_ID=
|
||||
- GOOGLE_CLIENT_SECRET=
|
||||
# https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback
|
||||
#
|
||||
# An earlier version of this comment said that URI could never be
|
||||
# registered, because Synology owns the domain above it. That was wrong,
|
||||
# and the correction is left here rather than removed: it was inferred
|
||||
# from #285, which is a related but different problem, and it put QA
|
||||
# testing of this feature behind #313 for no reason.
|
||||
#
|
||||
# When #313 moves QA to qa.redefined-designs.com, point PUBLIC_URL at the
|
||||
# new host and register that callback too. No code change either way.
|
||||
- GOOGLE_CLIENT_ID=${QA_GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${QA_GOOGLE_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
# Separate uploads directory. Sharing production's would let a QA run
|
||||
# write into, and a QA teardown delete, real product images.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Google sign-in
|
||||
|
||||
What has to be true outside the repository for the Google button to work, and
|
||||
what to do at the domain cutover. The code side is #332 and the six issues under
|
||||
it; this is only the parts that live in a browser tab at Google.
|
||||
|
||||
## Where it is configured
|
||||
|
||||
The **Google Auth Platform** in the Google Cloud Console, in one project. There
|
||||
is one consent screen per project and every OAuth client in it shares that
|
||||
screen, so what appears there is the production identity even while testing.
|
||||
|
||||
| Section | What it holds |
|
||||
| --- | --- |
|
||||
| Branding | App name, support email, authorized domains, the three app links |
|
||||
| Audience | External, publishing status, test users |
|
||||
| Clients | The OAuth client, its redirect URIs, the id and secret |
|
||||
| Data Access | Exactly `openid`, `email`, `profile` |
|
||||
| Verification Center | Nothing to submit, and it should stay that way |
|
||||
|
||||
## Redirect URIs, one per environment
|
||||
|
||||
Every environment sends a redirect URI derived from its own `PUBLIC_URL`, and
|
||||
each one has to exist verbatim under **Authorized redirect URIs** on the client
|
||||
this app uses. Google compares them as strings and answers a mismatch with
|
||||
`redirect_uri_mismatch`, which is accurate and says nothing about which half is
|
||||
wrong.
|
||||
|
||||
| Environment | Redirect URI |
|
||||
| --- | --- |
|
||||
| Local, Vite dev server | `http://localhost:5173/api/auth/google/callback` |
|
||||
| Local, backend serving a build | `http://localhost:3000/api/auth/google/callback` |
|
||||
| QA | `https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback` |
|
||||
| Production | `https://redefined-designs.com/api/auth/google/callback` |
|
||||
|
||||
Local development needs the 5173 entry, because that is where the dev server
|
||||
serves the app. The 3000 one applies only when the backend serves a built
|
||||
frontend, which local development does not produce.
|
||||
|
||||
### A correction
|
||||
|
||||
An earlier version of this document said the QA hostname **could never be
|
||||
registered**, because it sits under a domain Synology owns rather than one we
|
||||
do. That was wrong. Adding the URI works.
|
||||
|
||||
The claim is recorded here rather than quietly removed, because of what it
|
||||
cost. It was inferred from #285, where Cloudflare genuinely cannot be applied to
|
||||
that hostname, and asserted with far more confidence than the inference
|
||||
supported. On the strength of it, QA testing of Google sign-in was documented as
|
||||
blocked behind #313, the QA compose file hardcoded its credentials to empty, and
|
||||
two issues recorded it as fact.
|
||||
|
||||
What is true, and is all that was ever established: `localhost` is exempt from
|
||||
the authorized-domain rules, and a domain listed as an authorized domain has to
|
||||
be verified in Search Console. Whether either of those actually applied to this
|
||||
hostname, and how, was never checked.
|
||||
|
||||
## Scopes, and why publishing needs no review
|
||||
|
||||
`openid` produces the id token carrying the subject claim, which is the identity
|
||||
stored. `email` carries the address and the `email_verified` flag the linking
|
||||
policy turns on. `profile` carries the names used when an account is created.
|
||||
|
||||
All three are non-sensitive. Requesting only them is what lets the app publish
|
||||
without verification and without customers seeing an unverified-app warning.
|
||||
**Add one sensitive scope and publishing becomes a review with a video
|
||||
walkthrough and a wait measured in weeks.** Nothing in this feature needs one.
|
||||
|
||||
Uploading an app logo also triggers a brand review, which is why Branding has
|
||||
none.
|
||||
|
||||
## Turning it on in QA
|
||||
|
||||
Already done, and recorded here because the order matters.
|
||||
|
||||
1. Register the QA callback under **Clients**, Authorized redirect URIs:
|
||||
`https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback`
|
||||
2. Set `QA_GOOGLE_CLIENT_ID` and `QA_GOOGLE_CLIENT_SECRET` on the QA stack. Both
|
||||
or neither — the backend refuses to start on one without the other, because
|
||||
the failure would otherwise arrive the moment a customer presses the button.
|
||||
3. Redeploy.
|
||||
|
||||
Registering first is the point. Setting the variables makes the button appear,
|
||||
and a button that appears before its callback exists fails at Google rather than
|
||||
in the storefront, where nothing in the logs explains it.
|
||||
|
||||
## The cutover checklist, for #313
|
||||
|
||||
1. Point QA at `qa.redefined-designs.com` and set its `PUBLIC_URL` to match.
|
||||
2. In **Clients**, add the new QA callback:
|
||||
`https://qa.redefined-designs.com/api/auth/google/callback`
|
||||
3. Confirm the production callback is registered:
|
||||
`https://redefined-designs.com/api/auth/google/callback`
|
||||
4. In **Audience**, move the publishing status from Testing to **In production**.
|
||||
Do it once the domain resolves, so the home page and privacy links Google
|
||||
shows actually answer.
|
||||
|
||||
The old QA callback can be left registered until the hostname is retired. An
|
||||
extra entry costs nothing and removing it early breaks QA for no gain.
|
||||
|
||||
No code changes at any step. The redirect URI is derived from `PUBLIC_URL`, so
|
||||
the environment variable and the console entry are the whole of it.
|
||||
|
||||
**Leaving it in Testing is the failure to watch for.** Only listed test users can
|
||||
sign in, and the refusal happens on Google's own page, so nothing reaches the
|
||||
storefront and nothing appears in its logs. A customer reports a broken button
|
||||
and the logs are silent.
|
||||
|
||||
## The production smoke test
|
||||
|
||||
The consent screen, the redirect and the domain are all environment-specific, so
|
||||
QA proves the flow and not the configuration. After the cutover:
|
||||
|
||||
1. Sign in with a Google account that has never been used on the site. A new
|
||||
customer is created and lands on the consent step.
|
||||
2. Sign in again with the same account. It reaches the same customer rather than
|
||||
a second one.
|
||||
3. Check the account page lists Google under connected accounts.
|
||||
|
||||
## What is not offered, and why
|
||||
|
||||
**Unlinking.** A customer cannot detach their Google account. Removing the only
|
||||
way into an account is guarded for passkeys and the same guard would be needed
|
||||
here first. Worth its own issue when somebody actually asks.
|
||||
|
||||
**Apple.** A separate decision with a materially different cost, set out on
|
||||
#332: a paid developer programme, a client secret that expires every six months,
|
||||
no `localhost` redirect URIs at all, and a name and email returned exactly once.
|
||||
Apple is required for iOS apps offering third-party sign-in, and this is a
|
||||
website, so that rule does not apply here.
|
||||
@@ -61,6 +61,14 @@ export interface SiteConfig {
|
||||
* A key alone does not start tracking — see brevo.ts.
|
||||
*/
|
||||
brevoTrackerKey: string | null;
|
||||
/**
|
||||
* Whether Google sign-in is configured in this environment (#345).
|
||||
*
|
||||
* False locally without credentials, and false in QA until #313 moves it off
|
||||
* the Synology hostname — Google refuses a redirect URI whose domain nobody
|
||||
* can prove they own. The button is then absent rather than disabled.
|
||||
*/
|
||||
googleSignIn: boolean;
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<SiteConfig> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,10 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [emailForm] = Form.useForm();
|
||||
|
||||
// A customer who signed up with Google has none, which changes the wording,
|
||||
// the button, and whether a current-password field exists at all (#344).
|
||||
const hasPassword = customer.has_password;
|
||||
|
||||
async function saveName(values: { firstName: string; lastName: string }) {
|
||||
setBusy('name');
|
||||
setNameError(null);
|
||||
@@ -59,15 +63,21 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(values: { currentPassword: string; newPassword: string }) {
|
||||
async function savePassword(values: { currentPassword?: string; newPassword: string }) {
|
||||
setBusy('password');
|
||||
setPasswordError(null);
|
||||
try {
|
||||
await changeMyPassword(values.currentPassword, values.newPassword);
|
||||
// Nothing to refresh: this session is deliberately the one kept alive.
|
||||
// Clearing the fields matters more, since they hold both passwords.
|
||||
await changeMyPassword(values.currentPassword ?? '', values.newPassword);
|
||||
// Clearing the fields matters more than anything else here, since they
|
||||
// hold both passwords. Setting a first one does refresh, because
|
||||
// has_password has just changed and this panel renders from it.
|
||||
passwordForm.resetFields();
|
||||
message.success('Password changed. Other devices have been signed out.');
|
||||
if (hasPassword) {
|
||||
message.success('Password changed. Other devices have been signed out.');
|
||||
} else {
|
||||
onChanged();
|
||||
message.success('Password set. You can now sign in with it as well as with Google.');
|
||||
}
|
||||
} catch (err) {
|
||||
setPasswordError((err as Error).message);
|
||||
} finally {
|
||||
@@ -151,23 +161,33 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Change your password',
|
||||
// Named for what it is for this customer. Offering to change a
|
||||
// password to somebody who signed up with Google and has never had
|
||||
// one is a dead end (#344).
|
||||
label: hasPassword ? 'Change your password' : 'Set a password',
|
||||
children: (
|
||||
<>
|
||||
<Paragraph type="secondary">
|
||||
Signing in elsewhere will end. You will stay signed in on this device.
|
||||
{hasPassword
|
||||
? 'Signing in elsewhere will end. You will stay signed in on this device.'
|
||||
: 'You signed up without a password. Setting one gives you a second way in, alongside the accounts listed below.'}
|
||||
</Paragraph>
|
||||
{passwordError && (
|
||||
<Alert type="error" showIcon message={passwordError} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Form layout="vertical" form={passwordForm} onFinish={savePassword}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
rules={[{ required: true, message: 'Your current password is required' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
{/* Absent, not disabled, for an account that has none. The
|
||||
server branches on the stored hash rather than on anything
|
||||
sent, so there is nothing for this field to carry. */}
|
||||
{hasPassword && (
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
rules={[{ required: true, message: 'Your current password is required' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
@@ -193,7 +213,7 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={busy === 'password'}>
|
||||
Change password
|
||||
{hasPassword ? 'Change password' : 'Set password'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, 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';
|
||||
@@ -9,6 +10,8 @@ import Typography from 'antd/es/typography';
|
||||
import Divider from 'antd/es/divider';
|
||||
import { registerCustomer, loginCustomer, signInWithPasskey, passkeysSupported } from './customerApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import GoogleSignInButton from './GoogleSignInButton';
|
||||
import { fetchConfig } from '../api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -41,13 +44,49 @@ type Props = Readonly<{
|
||||
// route closes back to the page behind it, while the cart and favorite
|
||||
// prompts resume the action the customer was interrupted doing.
|
||||
onSuccess: () => void;
|
||||
/**
|
||||
* Where a Google sign-in should return the customer (#345).
|
||||
*
|
||||
* Supplied by the caller because only the caller knows: the route modal has a
|
||||
* page behind it, and the cart prompt has the page it interrupted. An OAuth
|
||||
* redirect leaves the application entirely, so this cannot be recovered
|
||||
* afterwards the way onSuccess recovers it for every other path.
|
||||
*/
|
||||
returnTo?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess, returnTo = '/' }: 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
|
||||
@@ -61,6 +100,20 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
// rather than whether pressing it works.
|
||||
const canUsePasskeys = passkeysSupported();
|
||||
|
||||
// Whether this environment has Google credentials at all. Fetched rather than
|
||||
// built in, because one image serves every environment — and false is the
|
||||
// right starting value: a button that appears a moment late is better than
|
||||
// one that appears and then vanishes.
|
||||
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
.then((config) => setGoogleEnabled(config.googleSignIn))
|
||||
// Silent, and the button simply never appears. The password form behind
|
||||
// it works regardless, which is the whole reason it is below rather than
|
||||
// above.
|
||||
.catch(() => setGoogleEnabled(false));
|
||||
}, []);
|
||||
|
||||
async function submit(action: () => Promise<unknown>) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -107,6 +160,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}
|
||||
@@ -174,6 +233,27 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
By creating an account you agree to our{' '}
|
||||
<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
|
||||
</Text>
|
||||
|
||||
{/* On this tab too, and its absence here was a bug (#345).
|
||||
A passkey belongs only on Log In, because you cannot
|
||||
register an account with one — but creating an account is
|
||||
exactly what a new customer reaches for Google to do, so
|
||||
leaving it off the sign-up tab hid the feature from the
|
||||
people it helps most.
|
||||
|
||||
The two consent boxes above are not carried across. Google
|
||||
takes the customer off this site entirely, and a tick that
|
||||
survived that round trip would be a consent recorded from a
|
||||
form nobody submitted. They are asked again, with the same
|
||||
wording, on the step they land on (#342). */}
|
||||
{googleEnabled && (
|
||||
<>
|
||||
<Divider plain style={{ marginBlock: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
|
||||
</Divider>
|
||||
<GoogleSignInButton returnTo={returnTo} intent="sign-up" />
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
},
|
||||
@@ -204,11 +284,13 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
works for everyone. Absent entirely where WebAuthn is not
|
||||
available, rather than shown disabled: a greyed button
|
||||
invites a customer to wonder what they are missing (#41). */}
|
||||
{(canUsePasskeys || googleEnabled) && (
|
||||
<Divider plain style={{ marginBlock: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{canUsePasskeys && (
|
||||
<>
|
||||
<Divider plain style={{ marginBlock: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
|
||||
</Divider>
|
||||
<Button
|
||||
block
|
||||
loading={passkeyLoading}
|
||||
@@ -224,6 +306,17 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{/* 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. Absent
|
||||
rather than disabled where it is not configured, for the
|
||||
same reason as the one above (#345). */}
|
||||
{googleEnabled && (
|
||||
<div style={{ marginTop: canUsePasskeys ? 16 : 0 }}>
|
||||
<GoogleSignInButton returnTo={returnTo} />
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
|
||||
onClose();
|
||||
navigate('/forgot-password', { state: { background: location } });
|
||||
}}
|
||||
// The page the customer was on when this interrupted them, which is
|
||||
// where a Google round trip should put them back (#345). Unlike
|
||||
// onSuccess it cannot resume the interrupted action — the redirect
|
||||
// leaves the application — so it returns them to the page and they
|
||||
// press the button again.
|
||||
returnTo={`${location.pathname}${location.search}`}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -7,6 +7,15 @@ type Props = Readonly<{
|
||||
// Moving between the auth routes, supplied by the router so the rule about
|
||||
// keeping the whole detour to one history entry lives in one place.
|
||||
onNavigate: (path: string) => void;
|
||||
/**
|
||||
* The page behind this modal, for a Google sign-in to return to (#345).
|
||||
*
|
||||
* Supplied by the router, which is the only thing that knows it: this modal
|
||||
* renders over a backdrop location, and its own path is /login, so reading
|
||||
* the current URL here would send the customer back to the form they just
|
||||
* left.
|
||||
*/
|
||||
returnTo: string;
|
||||
}>;
|
||||
|
||||
const TITLES: Record<AuthMode, string> = {
|
||||
@@ -18,7 +27,7 @@ const TITLES: Record<AuthMode, string> = {
|
||||
// clicks Log in while browsing and changes their mind is not stranded. Both
|
||||
// stay real routes: /reset-password links to /login, and customers may have
|
||||
// bookmarks.
|
||||
export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) {
|
||||
export default function AuthRouteModal({ mode, onClose, onNavigate, returnTo }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
title={TITLES[mode]}
|
||||
@@ -36,6 +45,7 @@ export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) {
|
||||
// in while browsing wants to carry on browsing rather than be moved to
|
||||
// their account page.
|
||||
onSuccess={onClose}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Button from 'antd/es/button';
|
||||
|
||||
/**
|
||||
* Google's own mark, inlined as SVG (#345).
|
||||
*
|
||||
* Their identity guidelines specify the four colours and the geometry, and a
|
||||
* hand-drawn approximation of somebody else's trademark is a compliance problem
|
||||
* rather than a style choice. These are the published values.
|
||||
*
|
||||
* Inlined rather than fetched, for the reason every other asset in this app is:
|
||||
* a second origin is a second thing that can be down, blocked, or slow, and
|
||||
* this one sits on the sign-in path.
|
||||
*/
|
||||
function GoogleMark() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.34A9 9 0 0 0 9 18z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.94H.96a9 9 0 0 0 0 8.12l3.01-2.34z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.94l3.01 2.34C4.68 5.16 6.66 3.58 9 3.58z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = Readonly<{
|
||||
/** Where to send the customer back to. Validated again on the server. */
|
||||
returnTo: string;
|
||||
/**
|
||||
* Which tab this sits on, which changes only the wording.
|
||||
*
|
||||
* One endpoint serves both: it signs in a known identity, links a verified
|
||||
* address, or creates an account. The customer does not know or care which
|
||||
* of those will happen, so the label matches what they came to the tab to
|
||||
* do rather than what the server ends up doing.
|
||||
*
|
||||
* Both spellings are in Google identity guidelines alongside the mark.
|
||||
*/
|
||||
intent?: 'sign-in' | 'sign-up';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Signing in with Google (#345).
|
||||
*
|
||||
* A navigation rather than a fetch, which is what makes this different from
|
||||
* every other control on the auth form. The flow leaves this application
|
||||
* entirely, so there is no promise to await and no error to catch here — the
|
||||
* server's callback decides what happens and redirects accordingly.
|
||||
*
|
||||
* `returnTo` is sent as a query parameter and **validated on the server**, not
|
||||
* here. It has to be, since anyone can type the URL, and doing it in one place
|
||||
* beats doing it in two languages. See `google/returnTo.ts`.
|
||||
*/
|
||||
export default function GoogleSignInButton({ returnTo, intent = 'sign-in' }: Props) {
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
icon={<GoogleMark />}
|
||||
onClick={() => {
|
||||
// assign rather than the router: this is a full page departure to
|
||||
// another origin, and react-router would try to match it as a route.
|
||||
window.location.assign(`/api/auth/google/start?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}}
|
||||
>
|
||||
{intent === 'sign-up' ? 'Sign up with Google' : 'Sign in with Google'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,14 @@ export interface Customer {
|
||||
*/
|
||||
analytics_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
/**
|
||||
* Whether this account has a password at all (#344).
|
||||
*
|
||||
* False for anyone who signed up with Google. The account page reads it to
|
||||
* decide between offering to change a password and offering to set a first
|
||||
* one, which are different things to somebody who has never had one.
|
||||
*/
|
||||
has_password: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -183,6 +191,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;
|
||||
|
||||
@@ -148,6 +148,10 @@ function AppRoutes() {
|
||||
// the storefront, so closing always lands somewhere real.
|
||||
const background = state?.background;
|
||||
const backdrop = modalPath ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
|
||||
// Where a Google sign-in should land the customer: the page behind the modal,
|
||||
// not the modal's own path. Built here because the backdrop is only known
|
||||
// here, and validated again on the server (#345).
|
||||
const returnTo = `${backdrop.pathname}${backdrop.search ?? ''}`;
|
||||
|
||||
function closeModal() {
|
||||
// Back, when there is somewhere to go back to, so closing the modal and
|
||||
@@ -193,10 +197,10 @@ function AppRoutes() {
|
||||
{import.meta.env.DEV && <DevThrow scope="modal" />}
|
||||
{modalPath === '/account' && <Account onClose={closeModal} />}
|
||||
{modalPath === '/login' && (
|
||||
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} />
|
||||
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
|
||||
)}
|
||||
{modalPath === '/register' && (
|
||||
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} />
|
||||
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
|
||||
)}
|
||||
{modalPath === '/forgot-password' && (
|
||||
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
|
||||
|
||||
@@ -119,6 +119,47 @@ test.describe('Customer accounts', () => {
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
|
||||
// #345. Local development has no Google credentials, and neither does QA
|
||||
// until #313 moves it off a hostname whose domain nobody can prove they own.
|
||||
// So the button being ABSENT is the behaviour under test here, and it is the
|
||||
// one that matters: a control that appears and then fails at Google is worse
|
||||
// than one that was never offered.
|
||||
// The sign-up tab, which #345 left it off entirely. A passkey belongs only on
|
||||
// Log In, because you cannot register an account with one — but creating an
|
||||
// account is exactly what a new customer reaches for Google to do, so its
|
||||
// absence there hid the feature from the people it helps most.
|
||||
//
|
||||
// Asserted as absent for the same reason as the login one: local and QA have
|
||||
// no credentials, so absence is the behaviour that actually runs here.
|
||||
test('offers no Google button on the sign-up tab either, when unconfigured', async ({
|
||||
authModal
|
||||
}) => {
|
||||
await authModal.gotoRegister();
|
||||
|
||||
await expect(
|
||||
authModal.registerDialog.getByRole('button', { name: /with Google/i })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('offers no Google button when the environment is not configured for it', async ({
|
||||
authModal,
|
||||
accountModal,
|
||||
customer,
|
||||
header
|
||||
}) => {
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
await authModal.gotoLogIn();
|
||||
|
||||
const google = authModal.logInDialog.getByRole('button', { name: /Sign in with Google/i });
|
||||
await expect(google).toHaveCount(0);
|
||||
|
||||
// And the password form is untouched by its absence, which is the whole
|
||||
// reason the alternatives sit below it rather than above.
|
||||
await authModal.logIn(customer.email, customer.password);
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
|
||||
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
|
||||
@@ -25,14 +25,18 @@ test.describe('Editing the customer emails', () => {
|
||||
'Favorited item sold',
|
||||
'Favorited item withdrawn',
|
||||
'Cart reminder',
|
||||
'Email address changed'
|
||||
'Email address changed',
|
||||
// Added in #337, and the reason these are matched on the whole
|
||||
// accessible name rather than as substrings: it extends the label above
|
||||
// it, so an unanchored match resolved to both tabs.
|
||||
'Email address changed by the shop'
|
||||
]) {
|
||||
await expect(adminEmails.railTab(new RegExp(label))).toBeVisible();
|
||||
await expect(adminEmails.railTab(label)).toBeVisible();
|
||||
}
|
||||
|
||||
// Only a customised template is marked, so which ones have been changed is
|
||||
// visible without opening each one. An untouched template carries nothing.
|
||||
await expect(adminEmails.railTab(/Password reset/)).toBeVisible();
|
||||
await expect(adminEmails.railTab('Password reset')).toBeVisible();
|
||||
await expect(adminEmails.customisedTab('Password reset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { FrameLocator, Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* A matcher for one template label, against a tab's *whole* accessible name.
|
||||
*
|
||||
* A tab is named for its template, plus the word "Customised" once it has been
|
||||
* edited — the dot beside it carries that as an aria-label, so the state is not
|
||||
* colour-only.
|
||||
*
|
||||
* Anchored at both ends, which is the entire point of this function. The
|
||||
* locators here used to build an unanchored regex from the label, so a template
|
||||
* whose name merely *began* with another's matched both. Adding "Email address
|
||||
* changed by the shop" alongside "Email address changed" broke a passing test
|
||||
* with a strict-mode violation naming the assertion rather than the new
|
||||
* template — the same shape as the switch locator that silently retargeted in
|
||||
* #317, and the same cost to diagnose.
|
||||
*
|
||||
* The escape matters for the same reason: these labels are copy, and copy
|
||||
* acquires brackets and full stops eventually.
|
||||
*/
|
||||
function nameMatching(label: string, options: { customised?: boolean } = {}): RegExp {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const suffix = options.customised ? '\\s+Customised' : '(?:\\s+Customised)?';
|
||||
return new RegExp(`^${escaped}${suffix}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Emails tab: a vertical rail of template types and one editor at a time.
|
||||
*
|
||||
@@ -30,13 +54,30 @@ export class AdminEmails {
|
||||
return this.page.getByRole('button', { name: `Insert {{${name}}}` });
|
||||
}
|
||||
|
||||
/** One template's entry in the rail. */
|
||||
railTab(label: string | RegExp): Locator {
|
||||
return this.page.getByRole('tab', { name: label });
|
||||
/**
|
||||
* One template's entry in the rail, matched on its whole accessible name.
|
||||
*
|
||||
* A tab's accessible name is the template's label, plus the word "Customised"
|
||||
* when it has been edited — the dot beside it carries that as an aria-label so
|
||||
* the state is not colour-only.
|
||||
*
|
||||
* Anchored at both ends, which is the point of this helper rather than a bare
|
||||
* substring match. These locators used to build an unanchored regex from the
|
||||
* label, so a template whose name merely *began* with another's matched both.
|
||||
* Adding "Email address changed by the shop" beside "Email address changed"
|
||||
* broke a passing test with a strict-mode violation, and the failure named the
|
||||
* assertion rather than the new template — the same shape as the switch
|
||||
* locator that silently retargeted in #317.
|
||||
*
|
||||
* The escape matters for the same reason: a label is copy, and copy acquires
|
||||
* brackets and full stops eventually.
|
||||
*/
|
||||
railTab(label: string): Locator {
|
||||
return this.page.getByRole('tab', { name: nameMatching(label) });
|
||||
}
|
||||
|
||||
customisedTab(label: string): Locator {
|
||||
return this.page.getByRole('tab', { name: new RegExp(`${label}.*Customised`) });
|
||||
return this.page.getByRole('tab', { name: nameMatching(label, { customised: true }) });
|
||||
}
|
||||
|
||||
subject(label: string): Locator {
|
||||
@@ -65,7 +106,7 @@ export class AdminEmails {
|
||||
* resolved mid-swap finds the outgoing one.
|
||||
*/
|
||||
async openTemplate(label: string): Promise<void> {
|
||||
await this.railTab(new RegExp(label)).click();
|
||||
await this.railTab(label).click();
|
||||
await expect(this.subject(label)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user