Compare commits
53
Commits
218be6d298
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0361ef35d0 | ||
|
|
f5a29127fb | ||
|
|
c9dccfe4a2 | ||
|
|
903a1d8b76 | ||
|
|
838749df64 | ||
|
|
3958bda489 | ||
|
|
b82b989cc8 | ||
|
|
c2ee0b0c5d | ||
|
|
843c51dd91 | ||
|
|
2c6ac4d2be | ||
|
|
0014a8db8a | ||
|
|
dcb3c7c91b | ||
|
|
c5014d5342 | ||
|
|
44df0bd2d9 | ||
|
|
8a5f6eb08c | ||
|
|
25078417e5 | ||
|
|
79a2b606ea | ||
|
|
022ab8edcf | ||
|
|
9177b54dea | ||
|
|
87f07baaff | ||
|
|
607881b711 | ||
|
|
9cc82002b8 | ||
|
|
d69091d5ad | ||
|
|
f9c40146d4 | ||
|
|
401cacaac0 | ||
|
|
90e372d6bd | ||
|
|
20554a4f1d | ||
|
|
36dbf18916 | ||
|
|
a0238ea4ed | ||
|
|
72e8090fd1 | ||
|
|
1da4dc7acc | ||
|
|
1c8763f30f | ||
|
|
36bbaf99e4 | ||
|
|
f95013850b | ||
|
|
fc674561e9 | ||
|
|
88f76926d5 | ||
|
|
c6f6ddc6dd | ||
|
|
6d320fd867 | ||
|
|
6a2143696a | ||
|
|
0c297f8465 | ||
|
|
f1600774db | ||
|
|
4b1ffffc25 | ||
|
|
83b5b5e287 | ||
|
|
2bc9440b38 | ||
|
|
de979f4bb6 | ||
|
|
d7bfd47797 | ||
|
|
40c5623cdc | ||
|
|
5bba28bfda | ||
|
|
8c9f28f731 | ||
|
|
8956b9f122 | ||
|
|
530a2e14db | ||
|
|
5b40ac25db | ||
|
|
0ec6064391 |
@@ -0,0 +1,53 @@
|
||||
name: Clean up old workflow runs
|
||||
|
||||
# Manual only, and dry run by default (#324).
|
||||
#
|
||||
# Gitea 1.27.3 expires a run's logs and artifacts but never the run record
|
||||
# itself, so the Actions list grows without limit and fills with entries whose
|
||||
# logs are already gone. This removes those entries.
|
||||
#
|
||||
# Deliberately not on a schedule. Deleting a run cannot be undone, the list is
|
||||
# an annoyance rather than a problem, and a cron that quietly removes history
|
||||
# should be a decision taken on its own rather than the default that arrives
|
||||
# with the tool.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
keep_days:
|
||||
description: 'Keep runs newer than this many days'
|
||||
required: false
|
||||
default: '7'
|
||||
apply:
|
||||
description: 'Type true to actually delete. Anything else reports only.'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# No npm install: the script uses only node's own https module, so there
|
||||
# is nothing to fetch and nothing that can break when a dependency moves.
|
||||
- name: Delete old runs
|
||||
env:
|
||||
# A dedicated secret rather than the automatic per-job token, because
|
||||
# deleting a run may be beyond what that token is allowed to do. If it
|
||||
# turns out to be sufficient, this and the secret can both go.
|
||||
GITEA_ACCESS_TOKEN: ${{ secrets.ACTIONS_CLEANUP_TOKEN }}
|
||||
# Taken from the run's own context so this file carries no hostname
|
||||
# and works unchanged if the instance ever moves — which #313 may yet
|
||||
# make happen.
|
||||
GITEA_HOST: ${{ github.server_url }}
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
KEEP_DAYS: ${{ github.event.inputs.keep_days }}
|
||||
APPLY: ${{ github.event.inputs.apply }}
|
||||
run: node scripts/cleanup-workflow-runs.js
|
||||
@@ -0,0 +1,97 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- A registered passkey (#37). Groundwork only: nothing reads these yet.
|
||||
--
|
||||
-- Bound to the customer and deleted with them. Account deletion already
|
||||
-- removes the personal data this sits beside, and a credential that
|
||||
-- outlived its owner could authenticate as a customer who no longer exists.
|
||||
-- Disabling an account (#33) is a different question and is deliberately not
|
||||
-- a schema concern: a disabled customer keeps their credentials and is
|
||||
-- refused at the authentication ceremony instead, so re-enabling them does
|
||||
-- not mean re-registering every device.
|
||||
CREATE TABLE IF NOT EXISTS customer_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- The credential ID as base64url text rather than bytea. It arrives from
|
||||
-- the browser in that form, is compared as an opaque string, and is never
|
||||
-- interpreted here — storing bytes would mean encoding on write and
|
||||
-- decoding on every read for no gain.
|
||||
--
|
||||
-- Unique across the table, not merely per customer: a credential ID
|
||||
-- identifies an authenticator, and the same one appearing under two
|
||||
-- accounts means something has gone wrong rather than that two people
|
||||
-- share a key.
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- The COSE public key, base64url. Verified against, never parsed here.
|
||||
public_key TEXT NOT NULL,
|
||||
|
||||
-- BIGINT because the spec allows a 32-bit unsigned value, which overflows
|
||||
-- a signed INTEGER at half its range.
|
||||
--
|
||||
-- What to do when this fails to increase is NOT decided here. Many synced
|
||||
-- passkeys report 0 forever, so "a regression means cloning" is wrong for
|
||||
-- them and right for hardware keys. That policy belongs with the
|
||||
-- authentication ceremony that enforces it (#39); this column only has to
|
||||
-- be able to hold the value.
|
||||
signature_counter BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- How the authenticator can be reached: usb, nfc, ble, internal, hybrid.
|
||||
-- A JSON array as text, because it is passed back to the browser verbatim
|
||||
-- and never queried on.
|
||||
transports TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Null until first used. Shown on the account page (#40) so a customer can
|
||||
-- recognise which device a credential belongs to, which is the only way
|
||||
-- they can tell two entries apart.
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Listing a customer's credentials is the common read, and revocation (#40)
|
||||
-- has to scope by owner.
|
||||
CREATE INDEX IF NOT EXISTS customer_credentials_customer_id_idx
|
||||
ON customer_credentials (customer_id);
|
||||
|
||||
-- An in-flight WebAuthn challenge (#37).
|
||||
--
|
||||
-- A separate table rather than customer_tokens with a new kind, and the
|
||||
-- reason is structural rather than tidiness: customer_tokens.customer_id is
|
||||
-- NOT NULL, and an *authentication* challenge is issued before anyone is
|
||||
-- identified. A discoverable-credential sign-in has no customer to attach
|
||||
-- to at the moment the challenge is created, so it could not be stored
|
||||
-- there without making that column nullable for every other kind of token.
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
-- The challenge itself, base64url, as issued. Primary key because it is
|
||||
-- the thing looked up, and unique by construction.
|
||||
challenge TEXT PRIMARY KEY,
|
||||
|
||||
-- Null for authentication, set for registration. Registration requires an
|
||||
-- authenticated session — it is not a sign-up path — so that half always
|
||||
-- knows whose it is.
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'registration' or 'authentication'. Not a CHECK constraint: the values
|
||||
-- come from this codebase rather than from a request, and the project has
|
||||
-- no enum types elsewhere.
|
||||
kind TEXT NOT NULL,
|
||||
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- Expiry is swept by time, so the sweep reads this rather than the whole
|
||||
-- table. Single use is enforced by deleting the row on consumption, which
|
||||
-- needs no index beyond the primary key.
|
||||
CREATE INDEX IF NOT EXISTS webauthn_challenges_expires_at_idx
|
||||
ON webauthn_challenges (expires_at);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS webauthn_challenges;
|
||||
DROP TABLE IF EXISTS customer_credentials;
|
||||
`);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- What the customer calls this passkey (#38).
|
||||
--
|
||||
-- Not in the #37 groundwork because that issue listed the columns the
|
||||
-- ceremony needs and this one is for the person: the management screen (#40)
|
||||
-- shows a list, and "phone" against "laptop" is the only thing that makes
|
||||
-- two entries tellable apart. Without it a customer revoking a credential is
|
||||
-- choosing between identical rows.
|
||||
--
|
||||
-- NOT NULL with a default rather than nullable. Every row must be
|
||||
-- displayable, and a null would push the "or a sensible default" half of the
|
||||
-- requirement out into every read site. The route derives a better default
|
||||
-- from the authenticator's transports; this is the floor under that, and the
|
||||
-- value existing rows take.
|
||||
ALTER TABLE customer_credentials
|
||||
ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT 'Passkey';
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE customer_credentials DROP COLUMN IF EXISTS name;`);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- An email address changed by the shop rather than by the customer (#337).
|
||||
--
|
||||
-- This exists because of what the action is. A customer who has lost access
|
||||
-- to their mailbox has no self-service route back in, and there should not
|
||||
-- be one — this shop holds no second proof of identity, and anything
|
||||
-- invented to fill that gap would be a weaker credential than the one it
|
||||
-- replaced. So the route is manual: the owner verifies the customer against
|
||||
-- order history and moves the account to an address they can reach.
|
||||
--
|
||||
-- That is also, exactly, what an account takeover looks like. The two are
|
||||
-- the same operation and differ only in whether the verification was sound.
|
||||
-- A hand-written database edit leaves nothing to tell them apart afterwards.
|
||||
-- This table is what does.
|
||||
CREATE TABLE IF NOT EXISTS customer_email_changes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Cascades with the customer, deliberately. Both addresses here are
|
||||
-- personal data, so a record that outlived an erasure request would keep
|
||||
-- exactly what the erasure was for. A deleted account also has no
|
||||
-- takeover left to investigate.
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- Copied rather than referenced, because the whole point is what the
|
||||
-- address *was*. The customers row holds the new one and cannot answer
|
||||
-- this question a moment after the change.
|
||||
previous_email TEXT NOT NULL,
|
||||
new_email TEXT NOT NULL,
|
||||
|
||||
-- What the operator typed, and NOT NULL because a change with no stated
|
||||
-- reason is the one this table exists to make impossible. Never shown to
|
||||
-- the customer: it is a note about how they were verified, and it can
|
||||
-- name things the customer should not be handed back.
|
||||
reason TEXT NOT NULL,
|
||||
|
||||
-- No "who". Admin access is one shared gate secret in front of a single
|
||||
-- operator (see middleware/adminGate.ts), so a column for it could only
|
||||
-- ever hold a constant, and a constant dressed up as an identity is worse
|
||||
-- than an honest absence. If per-admin identity ever arrives, that is
|
||||
-- when this gains a column and not before.
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The read is always "what has happened to this account", so it is scoped
|
||||
-- by owner and ordered by time.
|
||||
CREATE INDEX IF NOT EXISTS customer_email_changes_customer_id_idx
|
||||
ON customer_email_changes (customer_id, changed_at DESC);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS customer_email_changes;`);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- A sign-in that belongs to somebody else's identity provider (#340).
|
||||
--
|
||||
-- A table rather than columns on customers, because one customer may hold
|
||||
-- more than one: Google today, and Apple if #332 ever decides in its
|
||||
-- favour. Columns would mean a second provider is a migration and a third
|
||||
-- is an embarrassment.
|
||||
--
|
||||
-- Same shape as customer_credentials, and for the same reason: a row that
|
||||
-- links this account to something a third party can vouch for, deleted with
|
||||
-- the customer because an identity that outlived its owner could
|
||||
-- authenticate as a customer who no longer exists.
|
||||
CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'google' today. Not a CHECK constraint: the values come from this
|
||||
-- codebase rather than from a request, and the project has no enum types
|
||||
-- elsewhere.
|
||||
provider TEXT NOT NULL,
|
||||
|
||||
-- The provider's subject claim, and **never the email**.
|
||||
--
|
||||
-- This is the whole security posture of the table in one column. An email
|
||||
-- is a display value that its owner can change and that a provider may
|
||||
-- reassign; a subject is opaque, stable for the life of the account, and
|
||||
-- means nothing outside the provider that issued it. Matching on the
|
||||
-- email would strand a customer who changed theirs and, far worse, hand
|
||||
-- their account to whoever inherited the old address.
|
||||
provider_sub TEXT NOT NULL,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Null until first used, exactly as on a passkey. It is what tells two
|
||||
-- entries apart on an account page where the names are similar.
|
||||
last_used_at TIMESTAMPTZ,
|
||||
|
||||
-- Unique across the pair, not on the subject alone. Two providers could
|
||||
-- in principle issue the same opaque string and it would mean nothing —
|
||||
-- but the same provider issuing one subject to two accounts here means
|
||||
-- something has gone wrong rather than that two people share an identity.
|
||||
UNIQUE (provider, provider_sub)
|
||||
);
|
||||
|
||||
-- Listing what an account is linked to is the common read, and it is always
|
||||
-- scoped by owner.
|
||||
CREATE INDEX IF NOT EXISTS customer_identities_customer_id_idx
|
||||
ON customer_identities (customer_id);
|
||||
|
||||
-- The change with the widest blast radius in the whole project (#332).
|
||||
--
|
||||
-- A customer who signed up through Google has no password and never will
|
||||
-- unless they ask for one, so the column has to admit that. Making it
|
||||
-- nullable is one line; what it costs is that every read of it is now a
|
||||
-- question rather than a fact, and the three that compare against it with
|
||||
-- bcrypt have been made to ask first.
|
||||
--
|
||||
-- Nothing writes a NULL yet. The first accounts without a password arrive
|
||||
-- with the sign-up path, and this is deliberately landed before them so the
|
||||
-- schema change can be reviewed on its own.
|
||||
ALTER TABLE customers ALTER COLUMN password_hash DROP NOT NULL;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS customer_identities;
|
||||
|
||||
-- Deliberately not restored. Re-adding NOT NULL fails outright if any
|
||||
-- passwordless customer exists by then, and a down migration that destroys
|
||||
-- accounts to satisfy a constraint would be far worse than a column that is
|
||||
-- merely more permissive than it needs to be. Reversing this properly means
|
||||
-- deciding what happens to those customers, which is not a schema decision.
|
||||
SELECT 1;
|
||||
`);
|
||||
};
|
||||
Generated
+338
-2
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.122.0",
|
||||
"@simplewebauthn/server": "^14.0.1",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
@@ -102,6 +103,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1304,6 +1306,12 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -2257,6 +2265,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -2280,6 +2294,260 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
|
||||
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-asym-key": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz",
|
||||
"integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
|
||||
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
|
||||
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
|
||||
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
|
||||
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
|
||||
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
|
||||
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pfx": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
|
||||
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
|
||||
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
|
||||
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
|
||||
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-post-quantum": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz",
|
||||
"integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-asym-key": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz",
|
||||
"integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-csr": "^2.9.4",
|
||||
"@peculiar/asn1-ecc": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs9": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.1.tgz",
|
||||
"integrity": "sha512-kjWgcm9NdSv+Tobo4Swxd1WnEawsBogpYFMjg9pKsVLPN3FdCmR6BipkL+DcPDgmQ7UKbbBZqRqn3E3YaA7NBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.1",
|
||||
"@peculiar/asn1-rsa": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
|
||||
"@peculiar/x509": "^2.1.0",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.12",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
|
||||
@@ -2415,6 +2683,7 @@
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -2575,6 +2844,7 @@
|
||||
"integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -2721,6 +2991,7 @@
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
@@ -3090,6 +3361,7 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3207,6 +3479,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -3424,6 +3710,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -4245,6 +4532,7 @@
|
||||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4635,6 +4923,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -5556,6 +5845,7 @@
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -6268,6 +6558,7 @@
|
||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
|
||||
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
@@ -7102,6 +7393,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
@@ -7385,6 +7677,24 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
|
||||
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -7466,6 +7776,12 @@
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/regexp-ast-analysis": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz",
|
||||
@@ -8154,6 +8470,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8291,8 +8608,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
@@ -8313,6 +8629,24 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -8374,6 +8708,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8696,6 +9031,7 @@
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
|
||||
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck:tests": "tsc -p tsconfig.test.json --noEmit",
|
||||
"lint": "eslint src scripts tests",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.122.0",
|
||||
"@simplewebauthn/server": "^14.0.1",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
|
||||
+24
-1
@@ -17,6 +17,10 @@ import adminVersionRouter from './routes/adminVersion';
|
||||
import adminConfigRouter from './routes/adminConfig';
|
||||
import filtersRouter from './routes/filters';
|
||||
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';
|
||||
@@ -69,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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +110,16 @@ app.use('/api/admin/version', requireAdminGate, adminVersionRouter);
|
||||
app.use('/api/admin/config', requireAdminGate, adminConfigRouter);
|
||||
app.use('/api/admin', requireAdminGate, adminRouter);
|
||||
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
||||
// Before /api/customers, like the addresses router above: Express matches
|
||||
// mounts in order, so the broader prefix would swallow these otherwise (#38).
|
||||
app.use('/api/customers/me/passkeys', passkeysRouter);
|
||||
// Unauthenticated, unlike the router above: this is how a customer becomes
|
||||
// signed in, so it cannot sit behind requireCustomer (#39).
|
||||
app.use('/api/customers/passkeys', passkeyLoginRouter);
|
||||
// Its own prefix rather than under /api/customers: this is the one route a
|
||||
// third party redirects a browser into, and the callback path is registered
|
||||
// verbatim in Google's console (#341).
|
||||
app.use('/api/auth/google', googleAuthRouter);
|
||||
app.use('/api/customers', customersRouter);
|
||||
app.use('/api/client-errors', clientErrorsRouter);
|
||||
app.use('/', publicRouter);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import crypto from 'crypto';
|
||||
import { Response } from 'express';
|
||||
import { pool } from './db';
|
||||
|
||||
/**
|
||||
* Establishing a signed-in session, for every way of signing in.
|
||||
*
|
||||
* Lifted out of routes/customers.ts when passkey authentication arrived (#39),
|
||||
* which requires that a passkey sign-in "go through the same session creation as
|
||||
* password login, so cookie flags, expiry, and logout behave identically. A
|
||||
* second, subtly different session path is how auth bugs get in."
|
||||
*
|
||||
* Shared rather than copied is what makes that true rather than merely intended.
|
||||
* Two implementations that agree today are two implementations that can be
|
||||
* changed one at a time — and the one that would be forgotten is whichever is
|
||||
* not the password path, because that is the one every manual test exercises.
|
||||
*
|
||||
* Anything that establishes a session belongs here: password login,
|
||||
* registration, password reset, passkeys, and social sign-in when #332 lands.
|
||||
*/
|
||||
|
||||
export const SESSION_DAYS = 30;
|
||||
|
||||
const SESSION_MS = SESSION_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function setSessionCookie(res: Response, token: string): void {
|
||||
res.cookie('rd_session', token, {
|
||||
httpOnly: true,
|
||||
// Gated on NODE_ENV rather than hardcoded true, or the integration tests —
|
||||
// plain HTTP, no TLS — would silently fail to persist a session and every
|
||||
// signed-in assertion would fail for a reason that looks unrelated.
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MS
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(customerId: number): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_MS);
|
||||
await pool.query(
|
||||
`INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
[token, customerId, expiresAt]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Mints a session and sets its cookie — the whole of "sign this customer in". */
|
||||
export async function signIn(res: Response, customerId: number): Promise<void> {
|
||||
setSessionCookie(res, await createSession(customerId));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from './db';
|
||||
import { sendMail } from './mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from './emailTemplates';
|
||||
import { getSettings } from './adminSettings';
|
||||
import { loadStoredTemplate } from './routes/adminEmailTemplates';
|
||||
|
||||
/**
|
||||
* Issuing a "confirm this address" link, for every route that changes an address.
|
||||
*
|
||||
* Lifted out of routes/customers.ts when the admin gained the ability to move an
|
||||
* account to a new address (#337), for the same reason session creation was
|
||||
* lifted out for passkeys: two implementations that agree today are two
|
||||
* implementations that can be changed one at a time, and the one that would be
|
||||
* forgotten is whichever the manual testing does not exercise. The admin path
|
||||
* runs perhaps once a year, so it is exactly the one that would rot.
|
||||
*
|
||||
* Anything that puts a new address on an account belongs here: registration, a
|
||||
* resend, the customer changing their own, and the shop changing it for them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supersedes any outstanding link as part of issuing the new one, so a message
|
||||
* already sitting in an old inbox cannot verify a newer address. Deleting first
|
||||
* is the part that matters — an un-superseded link means an older message still
|
||||
* verifies.
|
||||
*
|
||||
* Sending is fire-and-forget by the rule the rest of this codebase follows: the
|
||||
* token row is written first, so a send that fails cannot leave a customer
|
||||
* believing a link exists that does not, only waiting for one that never came.
|
||||
*/
|
||||
export async function issueVerificationEmail(
|
||||
customerId: number,
|
||||
email: string,
|
||||
firstName: string | null,
|
||||
lastName: string | null = null
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[customerId]
|
||||
);
|
||||
const { verifyTokenHours, greetingFormat, greetingFallback } = await getSettings();
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
||||
[token, customerId, new Date(Date.now() + verifyTokenHours * 60 * 60 * 1000)]
|
||||
);
|
||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`;
|
||||
const template = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
||||
greeting: greeting(firstName, greetingFormat, greetingFallback, lastName),
|
||||
firstName: firstName ?? '',
|
||||
lastName: lastName ?? '',
|
||||
verifyUrl,
|
||||
expiresIn: formatDuration(verifyTokenHours)
|
||||
});
|
||||
sendMail(email, template.subject, template.html)
|
||||
.catch(err => console.error('verify email send failed', err));
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||
|
||||
export type Json = JsonValue;
|
||||
|
||||
export type JsonArray = JsonValue[];
|
||||
@@ -71,7 +73,22 @@ export interface Checkouts {
|
||||
status: Generated<string>;
|
||||
}
|
||||
|
||||
export interface CustomerCredentials {
|
||||
created_at: Generated<Timestamp>;
|
||||
credential_id: string;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
last_used_at: Timestamp | null;
|
||||
name: Generated<string>;
|
||||
public_key: string;
|
||||
signature_counter: Generated<Int8>;
|
||||
transports: string | null;
|
||||
}
|
||||
|
||||
export interface Customers {
|
||||
analytics_consent: Generated<boolean>;
|
||||
analytics_consent_at: Timestamp | null;
|
||||
analytics_consent_text: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
disabled_at: Timestamp | null;
|
||||
email: string;
|
||||
@@ -85,10 +102,28 @@ export interface Customers {
|
||||
marketing_consent: Generated<boolean>;
|
||||
marketing_consent_at: Timestamp | null;
|
||||
marketing_consent_text: string | null;
|
||||
password_hash: string;
|
||||
password_hash: string | null;
|
||||
unsubscribe_token: string;
|
||||
}
|
||||
|
||||
export interface CustomerEmailChanges {
|
||||
changed_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
new_email: string;
|
||||
previous_email: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CustomerIdentities {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
last_used_at: Timestamp | null;
|
||||
provider: string;
|
||||
provider_sub: string;
|
||||
}
|
||||
|
||||
export interface CustomerSessions {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
@@ -209,6 +244,13 @@ export interface UploadLinks {
|
||||
token_hash: string;
|
||||
}
|
||||
|
||||
export interface WebauthnChallenges {
|
||||
challenge: string;
|
||||
customer_id: number | null;
|
||||
expires_at: Timestamp;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
admin_settings: AdminSettings;
|
||||
cart_items: CartItems;
|
||||
@@ -216,6 +258,9 @@ export interface DB {
|
||||
categories: Categories;
|
||||
checkout_items: CheckoutItems;
|
||||
checkouts: Checkouts;
|
||||
customer_credentials: CustomerCredentials;
|
||||
customer_email_changes: CustomerEmailChanges;
|
||||
customer_identities: CustomerIdentities;
|
||||
customer_sessions: CustomerSessions;
|
||||
customer_tokens: CustomerTokens;
|
||||
customers: Customers;
|
||||
@@ -228,4 +273,5 @@ export interface DB {
|
||||
shipping_addresses: ShippingAddresses;
|
||||
tags: Tags;
|
||||
upload_links: UploadLinks;
|
||||
webauthn_challenges: WebauthnChallenges;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type TemplateKey =
|
||||
| 'favoriteWithdrawn'
|
||||
| 'cartReminder'
|
||||
| 'emailChanged'
|
||||
| 'emailChangedByAdmin'
|
||||
| 'intakeDraft'
|
||||
| 'uploadLink';
|
||||
|
||||
@@ -68,6 +69,12 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
|
||||
defaultBody:
|
||||
'Someone asked to reset the password for this account.\n\n' +
|
||||
'[Choose a new password]({{resetUrl}}). This link expires in {{expiresIn}}.\n\n' +
|
||||
// Said before the customer follows the link rather than after they have
|
||||
// used it, because it is the one consequence of a reset they cannot undo
|
||||
// and might have chosen differently about (#42). Worded so it reads the
|
||||
// same to someone who has never registered one.
|
||||
'Resetting your password also removes any passkeys saved on this account, ' +
|
||||
'and signs you out everywhere. You can add your passkeys again afterwards.\n\n' +
|
||||
"If this wasn't you, you can ignore this email — your password has not changed."
|
||||
},
|
||||
|
||||
@@ -113,6 +120,40 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
|
||||
'If you did not, contact us straight away: whoever made the change can now\n' +
|
||||
'receive password reset links for your account.'
|
||||
},
|
||||
emailChangedByAdmin: {
|
||||
label: 'Email address changed by the shop',
|
||||
// Its own template rather than reusing emailChanged, because the two are
|
||||
// addressed to different readers (#337).
|
||||
//
|
||||
// The self-service notice says "if you did not make this change, contact
|
||||
// us". Here somebody already did contact us — that is how the change came
|
||||
// about — so that sentence would be addressed to a customer who has just
|
||||
// done the thing it asks for, while the person who actually needs to act on
|
||||
// it is the one who did nothing.
|
||||
//
|
||||
// This is the mail that catches a takeover *by* the recovery route, which
|
||||
// is the risk the route carries: a stranger who talks their way past the
|
||||
// verification gets the account, and the only person who can say otherwise
|
||||
// is whoever still reads the old address. So it goes there, it says plainly
|
||||
// that the account has moved, and it makes contradicting it the easy reply.
|
||||
//
|
||||
// The operator's stated reason is deliberately not a placeholder. It is a
|
||||
// private note about how somebody was verified, and it can name things the
|
||||
// customer should not be handed back.
|
||||
required: ['newEmail'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'newEmail'],
|
||||
defaultSubject: 'Your Redefined Designs account has moved to a new email address',
|
||||
defaultBody:
|
||||
'{{greeting}}\n\n' +
|
||||
'Someone contacted us saying they could no longer get into this account, and\n' +
|
||||
'we moved it to **{{newEmail}}** after checking their answers against the\n' +
|
||||
'order history on it.\n\n' +
|
||||
'If that was you, nothing more is needed — sign in at the new address and\n' +
|
||||
'confirm it when you get the message we sent there.\n\n' +
|
||||
'**If it was not you, reply to this email straight away.** Whoever asked for\n' +
|
||||
'the change can now sign in to this account, and we will undo it.'
|
||||
},
|
||||
|
||||
cartReminder: {
|
||||
label: 'Cart reminder',
|
||||
required: ['itemList', 'cartUrl'],
|
||||
|
||||
@@ -152,6 +152,57 @@ function checkMail(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Google sign-in is all or nothing (#340).
|
||||
*
|
||||
* Half-configured is the case worth naming. With neither value set the feature
|
||||
* reports itself disabled and the button never appears, which is a legitimate
|
||||
* environment. With one set, the token exchange fails at the moment a customer
|
||||
* presses the button — the worst possible time to discover a typo in a stack
|
||||
* variable.
|
||||
*
|
||||
* An error rather than a warning, matching the SMTP pair above: both refuse to
|
||||
* start rather than serving something that is visibly offered and cannot work.
|
||||
*/
|
||||
function checkGoogleSignIn(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
const hasId = isPresent(env, 'GOOGLE_CLIENT_ID');
|
||||
const hasSecret = isPresent(env, 'GOOGLE_CLIENT_SECRET');
|
||||
|
||||
if (hasId && !hasSecret) {
|
||||
return {
|
||||
errors: ['GOOGLE_CLIENT_SECRET is required when GOOGLE_CLIENT_ID is set — set both or neither.'],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
if (hasSecret && !hasId) {
|
||||
return {
|
||||
errors: ['GOOGLE_CLIENT_ID is required when GOOGLE_CLIENT_SECRET is set — set both or neither.'],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
if (!hasId) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: ['Google sign-in is not configured — the button will not be offered.']
|
||||
};
|
||||
}
|
||||
|
||||
// Only meaningful once the credentials exist, and only a warning: a
|
||||
// deployment with no PUBLIC_URL falls back to localhost, which is right for
|
||||
// local development and wrong everywhere else in a way worth saying out loud.
|
||||
if (!isPresent(env, 'PUBLIC_URL')) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: [
|
||||
'Google sign-in is configured but PUBLIC_URL is not, so the redirect URI falls back to ' +
|
||||
'localhost. Correct locally; anywhere else, Google will refuse the callback.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
function checkAdminGate(env: NodeJS.ProcessEnv): string[] {
|
||||
if (isPresent(env, 'ADMIN_GATE_SECRET')) {
|
||||
return [];
|
||||
@@ -227,6 +278,7 @@ function checkUploadsOrigin(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
const mail = checkMail(env);
|
||||
const uploads = checkUploadsOrigin(env);
|
||||
const google = checkGoogleSignIn(env);
|
||||
|
||||
return {
|
||||
errors: [
|
||||
@@ -234,8 +286,16 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
...checkDemoMode(env),
|
||||
...checkPayPal(env),
|
||||
...mail.errors,
|
||||
...uploads.errors
|
||||
...uploads.errors,
|
||||
...google.errors
|
||||
],
|
||||
warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env), ...checkIntakeActionSecret(env)]
|
||||
warnings: [
|
||||
...mail.warnings,
|
||||
...checkAdminGate(env),
|
||||
...uploads.warnings,
|
||||
...checkDraftingKey(env),
|
||||
...checkIntakeActionSecret(env),
|
||||
...google.warnings
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Who this application is, as far as Google is concerned (#340).
|
||||
*
|
||||
* ## Why the redirect URI is derived rather than written down
|
||||
*
|
||||
* It is registered in two places that must agree exactly: Google's console, and
|
||||
* every authorization request this server sends. Google compares them as
|
||||
* strings — scheme, host, port, path, trailing slash and case all count — and
|
||||
* answers a mismatch with `redirect_uri_mismatch`, which is accurate and tells
|
||||
* you nothing about which half is wrong.
|
||||
*
|
||||
* So it comes from `PUBLIC_URL`, the same value every customer-facing link is
|
||||
* already built from, exactly as the WebAuthn Relying Party ID does (#37). One
|
||||
* source, and it is the one that is already correct in any environment where
|
||||
* mail works.
|
||||
*
|
||||
* ## Every environment needs its own console entry
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* | 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` |
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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. */
|
||||
export const GOOGLE_CALLBACK_PATH = '/api/auth/google/callback';
|
||||
|
||||
/**
|
||||
* Local development, where `PUBLIC_URL` is legitimately unset.
|
||||
*
|
||||
* `envValidation` requires `PUBLIC_URL` only when SMTP is configured, so a local
|
||||
* setup that cannot send mail does not have it. Falling back to the backend's
|
||||
* own port rather than refusing keeps that setup working, and `localhost` is
|
||||
* the one host Google will accept without an authorized domain — so the fallback
|
||||
* is also the only value that could possibly work here.
|
||||
*/
|
||||
const LOCAL_ORIGIN = 'http://localhost:3000';
|
||||
|
||||
export interface GoogleConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/** Absolute, and byte-identical to what is registered in Google's console. */
|
||||
redirectUri: string;
|
||||
/**
|
||||
* Whether to offer Google sign-in at all.
|
||||
*
|
||||
* False when either credential is missing, and the button is then **absent
|
||||
* rather than disabled** — the same choice #41 made for a browser without
|
||||
* WebAuthn. A developer without credentials gets a storefront that works and
|
||||
* simply does not offer the option, rather than one that offers it and fails.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google configuration for this environment.
|
||||
*
|
||||
* Takes the environment as an argument so it can be tested without touching
|
||||
* `process.env`, and reads it on each call rather than at import time: the
|
||||
* module would otherwise capture whatever was set when it was first required,
|
||||
* which in tests is whatever the previous suite happened to leave behind.
|
||||
*
|
||||
* Throws on a `PUBLIC_URL` that is set but unparseable, for the reason
|
||||
* `relyingParty` does: that is a deployment already producing broken links in
|
||||
* every email, so failing here is not the first thing to go wrong — it is the
|
||||
* first thing to say so.
|
||||
*/
|
||||
export function googleConfig(env: NodeJS.ProcessEnv = process.env): GoogleConfig {
|
||||
const clientId = (env.GOOGLE_CLIENT_ID ?? '').trim();
|
||||
const clientSecret = (env.GOOGLE_CLIENT_SECRET ?? '').trim();
|
||||
const publicUrl = (env.PUBLIC_URL ?? '').trim();
|
||||
|
||||
let base = LOCAL_ORIGIN;
|
||||
if (publicUrl !== '') {
|
||||
try {
|
||||
// `origin` normalises away any path, trailing slash or default port,
|
||||
// which is what makes the result stable regardless of how PUBLIC_URL was
|
||||
// written. A trailing slash there would otherwise produce a double slash
|
||||
// here and a mismatch at Google.
|
||||
base = new URL(publicUrl).origin;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`PUBLIC_URL is not a URL (${publicUrl}), so the Google redirect URI cannot be derived ` +
|
||||
'from it. Google compares that value as an exact string, so this is refused rather ' +
|
||||
'than guessed at.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: `${base}${GOOGLE_CALLBACK_PATH}`,
|
||||
// Both, or neither. One without the other cannot complete a token exchange,
|
||||
// and offering a button that always fails is worse than offering none.
|
||||
enabled: clientId !== '' && clientSecret !== ''
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import type { GoogleIdentity } from './oauth';
|
||||
|
||||
/**
|
||||
* Creating a customer from a Google identity (#342).
|
||||
*
|
||||
* ## What this deliberately does not decide
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* Registration captures two consents and stores their wording verbatim, and
|
||||
* marketing consent must start unticked (#56). A customer arriving through
|
||||
* Google has never seen those checkboxes and **cannot have**: the redirect to
|
||||
* Google happens before anyone knows whether they are new.
|
||||
*
|
||||
* So the account is created with both false and no stored wording, which is
|
||||
* legally correct — nobody has agreed to anything, and nothing is recorded as
|
||||
* though they had. What makes it honest rather than merely lawful is that the
|
||||
* customer is then asked, on a step that shows the same two sentences, through
|
||||
* the same endpoints registration uses. That is what keeps the stored text
|
||||
* byte-identical, which is the whole point of storing it.
|
||||
*
|
||||
* Skipping that step is allowed and leaves both false. A consent nobody gave is
|
||||
* the correct default and a perfectly fine resting state.
|
||||
*/
|
||||
|
||||
export type SignUpOutcome =
|
||||
| { kind: 'created'; customerId: number }
|
||||
/** The address is already an account's. #343 decides whether to link. */
|
||||
| { kind: 'email-taken' };
|
||||
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export async function createCustomerFromGoogle(identity: GoogleIdentity): Promise<SignUpOutcome> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { rows: existing } = await client.query<IdRow>(
|
||||
`SELECT id FROM customers WHERE email = $1`,
|
||||
[identity.email]
|
||||
);
|
||||
if (existing.length) {
|
||||
await client.query('ROLLBACK');
|
||||
return { kind: 'email-taken' };
|
||||
}
|
||||
|
||||
const { rows } = await client.query<IdRow>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
[
|
||||
identity.email,
|
||||
// Hints rather than requirements. Registration demands both names
|
||||
// because every email greets by first name, but Google may return
|
||||
// neither and refusing the sign-in over it would be absurd — the
|
||||
// greeting already has a fallback for exactly this.
|
||||
identity.firstName,
|
||||
identity.lastName,
|
||||
// Only on Google's word, never assumed. An unverified assertion is
|
||||
// worth nothing, and the caller sends the usual confirmation email when
|
||||
// this is false.
|
||||
identity.emailVerified,
|
||||
crypto.randomBytes(16).toString('hex')
|
||||
]
|
||||
);
|
||||
// The INSERT above has a RETURNING clause, so no row means the statement
|
||||
// did not do what it says.
|
||||
const customer = rows[0];
|
||||
if (!customer) throw new Error('the customer INSERT returned no row');
|
||||
|
||||
// In the same transaction, deliberately. A customer row with no identity is
|
||||
// an account nobody can sign in to and nobody can recover, because it has
|
||||
// no password either — the worst possible thing to leave behind.
|
||||
await client.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub, last_used_at)
|
||||
VALUES ($1, 'google', $2, now())`,
|
||||
[customer.id, identity.sub]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return { kind: 'created', customerId: customer.id };
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
// Two sign-ins racing for the same brand-new address. The SELECT above
|
||||
// cannot see the other transaction's uncommitted row, so the unique index
|
||||
// is what actually holds — and losing that race means the account now
|
||||
// exists, which is 'email-taken' rather than an error.
|
||||
if ((err as { code?: string }).code === '23505') {
|
||||
return { kind: 'email-taken' };
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { GoogleConfig } from './config';
|
||||
|
||||
/**
|
||||
* The OpenID Connect authorization code flow, as far as Google implements it (#341).
|
||||
*
|
||||
* ## Why the code flow, and not the one with a token in the browser
|
||||
*
|
||||
* The browser is sent to Google, comes back carrying a code, and *this server*
|
||||
* exchanges that code for tokens over its own TLS connection. The customer's
|
||||
* browser never holds a token, so nothing that can read the page can steal one.
|
||||
*
|
||||
* PKCE goes in as well, even though this is a confidential client that holds a
|
||||
* secret. It costs one hash and it closes code interception outright rather
|
||||
* than resting the whole flow on the secret staying secret.
|
||||
*
|
||||
* ## Why there is no JWKS fetch here
|
||||
*
|
||||
* The id token arrives on a direct TLS connection to Google's token endpoint,
|
||||
* in the response to a request this server made. OpenID Connect Core §3.1.3.7
|
||||
* says signature verification MAY be skipped in exactly that case, because TLS
|
||||
* has already established who answered and that nothing altered the reply.
|
||||
*
|
||||
* That removes a key fetch, a cache and a rotation path from the auth code,
|
||||
* which is a real saving in the place least worth having moving parts. It
|
||||
* removes none of the claim checks: those are what stop a token minted for
|
||||
* another application, or for another attempt, being accepted here. See
|
||||
* `verifiedIdentity`, where every one of them is enforced and none is optional.
|
||||
*
|
||||
* The moment an id token reaches this code from anywhere other than that
|
||||
* response — a redirect fragment, a request body, a header — this reasoning
|
||||
* stops holding and signature verification becomes mandatory. Nothing does that
|
||||
* today, and nothing should.
|
||||
*/
|
||||
|
||||
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
/**
|
||||
* The only scopes this asks for, and the reason publishing needs no review.
|
||||
*
|
||||
* `openid` produces the id token, `email` carries the address and the
|
||||
* `email_verified` flag the linking policy turns on, and `profile` carries the
|
||||
* names used when an account is created. All three are non-sensitive; adding a
|
||||
* sensitive one turns publishing into a verification review with a video
|
||||
* walkthrough and a wait measured in weeks.
|
||||
*/
|
||||
const SCOPES = 'openid email profile';
|
||||
|
||||
/**
|
||||
* Both spellings Google issues for the issuer claim.
|
||||
*
|
||||
* It really does use both, and accepting only one produces sign-ins that fail
|
||||
* for some customers and not others — which is about the least diagnosable
|
||||
* failure this flow can have.
|
||||
*/
|
||||
const ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']);
|
||||
|
||||
/** A little slack for clock skew between this host and Google. */
|
||||
const CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
/** Who Google says signed in. Everything here has been checked. */
|
||||
export interface GoogleIdentity {
|
||||
/** The subject claim: opaque, stable, and the only safe identifier. */
|
||||
sub: string;
|
||||
email: string;
|
||||
/** Whether Google asserts the address. The linking policy turns on this. */
|
||||
emailVerified: boolean;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
}
|
||||
|
||||
/** The claims this cares about. Google sends more; none of it is wanted. */
|
||||
interface IdTokenClaims {
|
||||
iss?: unknown;
|
||||
aud?: unknown;
|
||||
exp?: unknown;
|
||||
sub?: unknown;
|
||||
nonce?: unknown;
|
||||
email?: unknown;
|
||||
email_verified?: unknown;
|
||||
given_name?: unknown;
|
||||
family_name?: unknown;
|
||||
}
|
||||
|
||||
/** One attempt's secrets, minted at the start and spent at the callback. */
|
||||
export interface AttemptSecrets {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh secrets for one sign-in attempt.
|
||||
*
|
||||
* `state` proves the callback belongs to the request this browser started.
|
||||
* `nonce` is echoed inside the id token and proves the token was minted for
|
||||
* this attempt rather than replayed from another. `codeVerifier` is PKCE.
|
||||
*
|
||||
* Three separate values rather than one reused three times: they are checked by
|
||||
* different parties at different moments, and a single value would mean
|
||||
* anything that learned it from one check could satisfy the others.
|
||||
*/
|
||||
export function newAttempt(): AttemptSecrets {
|
||||
return { state: randomToken(), nonce: randomToken(), codeVerifier: randomToken() };
|
||||
}
|
||||
|
||||
/** The S256 challenge for a verifier. Google supports S256; plain is not offered. */
|
||||
export function codeChallenge(verifier: string): string {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
/** Where to send the browser to begin. */
|
||||
export function authorizationUrl(config: GoogleConfig, attempt: AttemptSecrets): string {
|
||||
const url = new URL(AUTH_ENDPOINT);
|
||||
url.searchParams.set('client_id', config.clientId);
|
||||
url.searchParams.set('redirect_uri', config.redirectUri);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', SCOPES);
|
||||
url.searchParams.set('state', attempt.state);
|
||||
url.searchParams.set('nonce', attempt.nonce);
|
||||
url.searchParams.set('code_challenge', codeChallenge(attempt.codeVerifier));
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
// No `access_type=offline` and no `prompt=consent`, deliberately. Those ask
|
||||
// for a refresh token, and Google is being used to answer one question once —
|
||||
// a stored refresh token would be a long-lived credential with nothing to
|
||||
// spend it on and everything to lose if it leaked.
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades the code for an id token.
|
||||
*
|
||||
* Returns the raw token rather than parsed claims, so the exchange and the
|
||||
* checking stay separable: the checking is pure and can be tested exhaustively
|
||||
* without a network, which is where the security actually lives.
|
||||
*/
|
||||
export async function exchangeCode(config: GoogleConfig, code: string, codeVerifier: string): Promise<string> {
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
redirect_uri: config.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Logged rather than returned. The body names the client id and can carry
|
||||
// the secret back in an error description, and none of it means anything to
|
||||
// the customer.
|
||||
const detail = await response.text().catch(() => '');
|
||||
console.warn(`[google] token exchange failed: ${response.status} ${detail.slice(0, 300)}`);
|
||||
throw new Error('the Google token exchange was refused');
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { id_token?: unknown };
|
||||
if (typeof body.id_token !== 'string' || body.id_token === '') {
|
||||
throw new Error('Google returned no id token');
|
||||
}
|
||||
return body.id_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The claims inside an id token, without verifying its signature.
|
||||
*
|
||||
* Named for what it does. Anywhere the token has not come straight back from
|
||||
* the token endpoint over TLS, this function is the wrong one to call, and the
|
||||
* name is meant to make that obvious at the call site.
|
||||
*/
|
||||
function decodeClaims(idToken: string): IdTokenClaims {
|
||||
const [, payload] = idToken.split('.');
|
||||
if (!payload) throw new Error('the id token is not a JWT');
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as IdTokenClaims;
|
||||
} catch {
|
||||
throw new Error('the id token payload is not JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who signed in, or a thrown error saying which check failed.
|
||||
*
|
||||
* Every check here is mandatory, and each one closes something specific:
|
||||
*
|
||||
* | Claim | What accepting it blindly would allow |
|
||||
* | --- | --- |
|
||||
* | `iss` | A token from an issuer we never chose to trust |
|
||||
* | `aud` | A token minted for a different application, replayed here |
|
||||
* | `exp` | A token captured once and reused indefinitely |
|
||||
* | `nonce` | A token from an earlier attempt, replayed into this one |
|
||||
* | `sub` | An identity row keyed on nothing |
|
||||
*
|
||||
* The messages name the failing check because they are logged, never shown. A
|
||||
* customer sees one refusal for every cause, exactly as the passkey path does.
|
||||
*/
|
||||
export function verifiedIdentity(
|
||||
idToken: string,
|
||||
expected: { clientId: string; nonce: string },
|
||||
now: Date = new Date()
|
||||
): GoogleIdentity {
|
||||
const claims = decodeClaims(idToken);
|
||||
|
||||
if (typeof claims.iss !== 'string' || !ISSUERS.has(claims.iss)) {
|
||||
throw new Error(`unexpected issuer: ${String(claims.iss)}`);
|
||||
}
|
||||
if (claims.aud !== expected.clientId) {
|
||||
throw new Error('the id token was minted for a different client');
|
||||
}
|
||||
|
||||
const exp = typeof claims.exp === 'number' ? claims.exp : NaN;
|
||||
if (!Number.isFinite(exp)) throw new Error('the id token has no expiry');
|
||||
if (exp + CLOCK_SKEW_SECONDS < Math.floor(now.getTime() / 1000)) {
|
||||
throw new Error('the id token has expired');
|
||||
}
|
||||
|
||||
// Compared in constant time. The nonce is a secret this server minted, and a
|
||||
// byte-by-byte comparison that stops early is a timing oracle for it.
|
||||
const nonce = asString(claims.nonce) ?? '';
|
||||
const supplied = Buffer.from(nonce);
|
||||
const wanted = Buffer.from(expected.nonce);
|
||||
if (supplied.length !== wanted.length || !crypto.timingSafeEqual(supplied, wanted)) {
|
||||
throw new Error('the id token belongs to a different sign-in attempt');
|
||||
}
|
||||
|
||||
const sub = asString(claims.sub);
|
||||
if (sub === null) throw new Error('the id token carries no subject');
|
||||
|
||||
const email = asString(claims.email);
|
||||
if (email === null) throw new Error('the id token carries no email');
|
||||
|
||||
return {
|
||||
sub,
|
||||
email: email.toLowerCase().trim(),
|
||||
// Strictly true, never merely truthy. Google sends a boolean, and treating
|
||||
// the string "false" as a verified address is the exact mistake that turns
|
||||
// the linking policy into an account-takeover path.
|
||||
emailVerified: claims.email_verified === true,
|
||||
firstName: asString(claims.given_name),
|
||||
lastName: asString(claims.family_name)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Where a customer is sent back to after signing in with Google (#341).
|
||||
*
|
||||
* An OAuth flow leaves this application entirely and comes back, so where the
|
||||
* customer was has to survive the round trip — and the value doing that is one
|
||||
* an attacker can propose, by handing somebody a link to our own start route
|
||||
* with their destination attached.
|
||||
*
|
||||
* Unchecked, that makes the start route an open redirect wearing a sign-in flow
|
||||
* as a disguise: a link on our real domain, with our real certificate, that
|
||||
* deposits the customer somewhere else entirely. It is precisely the shape a
|
||||
* credible phishing page wants, and it is worth more to an attacker than most
|
||||
* bugs in the flow it hides behind.
|
||||
*
|
||||
* Its own module rather than a helper inside the route, so it can be tested
|
||||
* without a database connection and so the next path that needs the same
|
||||
* question has somewhere obvious to ask it.
|
||||
*/
|
||||
|
||||
/** Where anyone goes when the answer is "not that". */
|
||||
export const DEFAULT_RETURN_TO = '/';
|
||||
|
||||
/**
|
||||
* A path inside this site, or the home page.
|
||||
*
|
||||
* Everything that is not plainly a local path is replaced rather than rejected.
|
||||
* A refusal would mean a customer who signed in successfully sees an error
|
||||
* about a query parameter they never typed, which helps nobody — the storefront
|
||||
* is a fine place to land.
|
||||
*
|
||||
* The cases worth naming, because each is a way of writing "somewhere else"
|
||||
* that still starts with a slash or looks like it might:
|
||||
*
|
||||
* - `//evil.test` is protocol-relative, and browsers treat it as absolute
|
||||
* - `/\evil.test` is treated as protocol-relative by several browsers
|
||||
* - `https://evil.test` does not start with a slash at all
|
||||
* - a backslash anywhere in the authority position is normalised to a slash
|
||||
*/
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
if (typeof value !== 'string' || value === '') return DEFAULT_RETURN_TO;
|
||||
if (!value.startsWith('/')) return DEFAULT_RETURN_TO;
|
||||
// Both slashes, because browsers disagree about which they normalise.
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return DEFAULT_RETURN_TO;
|
||||
// A control character can truncate or split the Location header a browser
|
||||
// reads. Checked by code point rather than by a regex, because a regex that
|
||||
// matches control characters trips a lint rule existing for good reasons of
|
||||
// its own, and this is clearer than an exemption from it.
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || code === 0x7f) return DEFAULT_RETURN_TO;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* A name for a passkey the customer did not name themselves (#38).
|
||||
*
|
||||
* The management screen (#40) lists credentials and offers to revoke them, so
|
||||
* two entries that read identically are a screen where the customer cannot tell
|
||||
* which device they are removing. A default that says something is the
|
||||
* difference between "Passkey, Passkey, Passkey" and a list worth showing.
|
||||
*
|
||||
* Derived from the authenticator's transports, which is the only thing the
|
||||
* ceremony learns about the device. It is a hint rather than a fact — the
|
||||
* browser reports what the authenticator claims — so these are deliberately
|
||||
* vague. "This device" is honest about a platform authenticator in a way that
|
||||
* guessing "MacBook" would not be.
|
||||
*/
|
||||
|
||||
/** The fallback when the authenticator reports nothing usable. */
|
||||
export const GENERIC_CREDENTIAL_NAME = 'Passkey';
|
||||
|
||||
export function defaultCredentialName(transports: readonly string[] | null | undefined): string {
|
||||
if (!transports || transports.length === 0) return GENERIC_CREDENTIAL_NAME;
|
||||
|
||||
// Checked in this order because an authenticator can report several. A phone
|
||||
// used as a cross-device passkey reports `hybrid` and often `internal` too,
|
||||
// and "Phone or tablet" is the more useful of the two readings — `internal`
|
||||
// alone means the authenticator built into the machine being used.
|
||||
if (transports.includes('hybrid')) return 'Phone or tablet';
|
||||
if (transports.includes('internal')) return 'This device';
|
||||
if (transports.some((t) => t === 'usb' || t === 'nfc' || t === 'ble')) return 'Security key';
|
||||
|
||||
return GENERIC_CREDENTIAL_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's own name for a passkey, or null when they gave none.
|
||||
*
|
||||
* Trimmed, because a name of spaces is a name nobody can read in a list, and
|
||||
* bounded because this is rendered — a customer is naming their laptop, not
|
||||
* writing prose, and an unbounded string in a table cell is a layout problem
|
||||
* rather than an expressive one.
|
||||
*/
|
||||
export const MAX_CREDENTIAL_NAME_LENGTH = 64;
|
||||
|
||||
export function readCredentialName(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return null;
|
||||
return trimmed.slice(0, MAX_CREDENTIAL_NAME_LENGTH);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Who this application is, as far as WebAuthn is concerned (#37).
|
||||
*
|
||||
* ## Why this is derived rather than written down
|
||||
*
|
||||
* The Relying Party ID is a domain, and **a credential is bound to it
|
||||
* permanently**. A passkey registered against one RP ID cannot be used against
|
||||
* another — there is no migration, no re-signing, and no way to carry one over.
|
||||
* So the RP ID is the one piece of configuration that must never be wrong, and
|
||||
* must never be a value someone remembered to change.
|
||||
*
|
||||
* It comes from `PUBLIC_URL`, which is the same value every customer-facing
|
||||
* link is already built from. That makes the RP ID correct by construction in
|
||||
* any environment where mail works, and wrong only in environments where the
|
||||
* links were already wrong.
|
||||
*
|
||||
* ## The consequence worth stating plainly
|
||||
*
|
||||
* Each environment is a different Relying Party:
|
||||
*
|
||||
* | Environment | RP ID | Effect |
|
||||
* | --- | --- | --- |
|
||||
* | Local | `localhost` | A secure context by exception, so passkeys work |
|
||||
* | QA | the QA hostname | Registered here, usable only here |
|
||||
* | Production | the production hostname | Different credentials again |
|
||||
*
|
||||
* **QA can prove the flow and can never prove the credentials.** A passkey
|
||||
* registered in QA will not sign in to production, and that is correct rather
|
||||
* than a bug to work around.
|
||||
*
|
||||
* It also means **#313 destroys every passkey registered before it**. Moving to
|
||||
* `redefined-designs.com` changes the RP ID, so credentials bound to
|
||||
* `*.bermudalamb.synology.me` stop working at the cutover with no way back.
|
||||
* This code needs no change when that happens — it follows `PUBLIC_URL` — but
|
||||
* anyone who registered a passkey beforehand has to register it again. That is
|
||||
* free today, because production is not live and no real customer holds one,
|
||||
* and it stops being free the moment the shop opens.
|
||||
*/
|
||||
|
||||
/** Everything the ceremonies need to identify this Relying Party. */
|
||||
export interface RelyingParty {
|
||||
/** The RP ID: a bare domain, no scheme and no port. */
|
||||
id: string;
|
||||
/** Shown by the authenticator when it asks the customer to confirm. */
|
||||
name: string;
|
||||
/**
|
||||
* Origins a ceremony may legitimately come from.
|
||||
*
|
||||
* A list rather than one string because local development serves the app from
|
||||
* two: Vite on 5173 during `npm run dev`, and the backend on 3000 when the
|
||||
* built frontend is served by Express. Both are `localhost`, so both are the
|
||||
* same Relying Party — only the port differs, and the port is not part of the
|
||||
* RP ID. Deployed environments have exactly one.
|
||||
*/
|
||||
origins: string[];
|
||||
}
|
||||
|
||||
export const RELYING_PARTY_NAME = 'Redefined Designs';
|
||||
|
||||
/**
|
||||
* Local development, where `PUBLIC_URL` is legitimately unset.
|
||||
*
|
||||
* `envValidation` requires `PUBLIC_URL` only when SMTP is configured, so a local
|
||||
* setup that cannot send mail does not have it — and refusing to start there
|
||||
* would break every such setup to prevent nothing. `localhost` is a secure
|
||||
* context by exception in every browser that implements WebAuthn, so this works
|
||||
* without TLS.
|
||||
*/
|
||||
const LOCAL_ORIGINS = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
|
||||
/**
|
||||
* The Relying Party for this environment.
|
||||
*
|
||||
* Takes the environment as an argument so it can be tested without touching
|
||||
* `process.env`, and reads it on each call rather than at import time: the
|
||||
* module would otherwise capture whatever was set when it was first required,
|
||||
* which in tests is whatever the previous suite happened to leave behind.
|
||||
*
|
||||
* Throws on a `PUBLIC_URL` that is set but unparseable. That is a deployment
|
||||
* that will also produce broken links in every email, so failing here is not
|
||||
* the first thing to go wrong — it is the first thing to *say so*.
|
||||
*/
|
||||
export function relyingParty(env: NodeJS.ProcessEnv = process.env): RelyingParty {
|
||||
const publicUrl = (env.PUBLIC_URL ?? '').trim();
|
||||
|
||||
if (publicUrl === '') {
|
||||
return { id: 'localhost', name: RELYING_PARTY_NAME, origins: LOCAL_ORIGINS };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(publicUrl);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`PUBLIC_URL is not a URL (${publicUrl}), so the WebAuthn Relying Party ID cannot be ` +
|
||||
'derived from it. Every passkey is bound permanently to that ID, so this is refused ' +
|
||||
'rather than guessed at.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
// `hostname` rather than `host`: the RP ID is a domain and must not carry a
|
||||
// port. `host` includes one when the URL has it, and an RP ID of
|
||||
// "example.com:8443" matches nothing.
|
||||
id: parsed.hostname,
|
||||
name: RELYING_PARTY_NAME,
|
||||
// `origin` normalises away any path, trailing slash or default port, which
|
||||
// is exactly the string the browser will report.
|
||||
origins: [parsed.origin]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Whether an authenticator's signature counter is acceptable (#39).
|
||||
*
|
||||
* #37 deliberately left this open, because the schema only had to hold the
|
||||
* value and the policy belongs with the ceremony that enforces it. This is that
|
||||
* policy.
|
||||
*
|
||||
* ## The counter, and why a naive rule is wrong
|
||||
*
|
||||
* A hardware authenticator increments a counter on every assertion. If a
|
||||
* credential is cloned, the two copies drift, and a counter that fails to
|
||||
* advance is the signal that has happened. Requiring it to increase is the
|
||||
* whole point of storing it.
|
||||
*
|
||||
* **But most passkeys never increment it.** A synced credential — iCloud
|
||||
* Keychain, Google Password Manager — exists on several devices by design, so a
|
||||
* per-device counter would be meaningless and the specification allows
|
||||
* reporting zero forever. Requiring an increase from those would refuse every
|
||||
* sign-in from the authenticators most customers actually use.
|
||||
*
|
||||
* So the rule is conditional on what the authenticator claims about itself:
|
||||
*
|
||||
* - **Both zero** — it does not implement counters. Accept, and keep accepting.
|
||||
* There is no signal here to read, and inventing one refuses real customers.
|
||||
* - **Anything else** — it does implement them, so require a strict increase.
|
||||
* A counter that stalls or goes backwards is the clone signal, and refusing
|
||||
* is the entire reason the column exists.
|
||||
*
|
||||
* The asymmetry is deliberate: an authenticator that has ever reported a
|
||||
* non-zero counter is held to the strict rule from then on, so one cannot
|
||||
* downgrade itself to zero to escape the check.
|
||||
*/
|
||||
|
||||
export interface CounterVerdict {
|
||||
ok: boolean;
|
||||
/** Why it was refused, for the log. Never shown to the caller. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function checkSignatureCounter(stored: number, received: number): CounterVerdict {
|
||||
if (stored === 0 && received === 0) return { ok: true };
|
||||
|
||||
if (received > stored) return { ok: true };
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`signature counter did not advance (stored ${stored}, received ${received}) — ` +
|
||||
'the credential may have been cloned'
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* How expensive a password hash is, and why that differs under test.
|
||||
*
|
||||
@@ -47,3 +49,30 @@ export function hashRoundsFor(nodeEnv: string | undefined): number {
|
||||
|
||||
/** Resolved once at import: NODE_ENV does not change while the process runs. */
|
||||
export const PASSWORD_HASH_ROUNDS = hashRoundsFor(process.env.NODE_ENV);
|
||||
|
||||
/**
|
||||
* Whether a supplied password matches a stored hash that may not exist (#340).
|
||||
*
|
||||
* `customers.password_hash` became nullable when social sign-in arrived, and a
|
||||
* null one is not an edge case to tidy away — it is a customer who signed up
|
||||
* through Google and has never set a password. There is nothing to compare
|
||||
* against, so the answer is no.
|
||||
*
|
||||
* This exists because the alternative is worse than a wrong answer.
|
||||
* `bcrypt.compare` throws `Illegal arguments` on a null hash rather than
|
||||
* returning false, so every call site that forgot the check would answer a
|
||||
* sign-in attempt with a 500 instead of a refusal — and a 500 on the login route
|
||||
* is also an oracle, since it happens for exactly the accounts that have no
|
||||
* password.
|
||||
*
|
||||
* One function rather than a null check repeated at each call site, so the
|
||||
* question is asked the same way in all three places and a fourth cannot forget
|
||||
* to ask it.
|
||||
*/
|
||||
export async function passwordMatches(
|
||||
supplied: unknown,
|
||||
storedHash: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
if (!storedHash) return false;
|
||||
return bcrypt.compare(String(supplied ?? ''), storedHash);
|
||||
}
|
||||
|
||||
@@ -184,3 +184,26 @@ export const intakeSubmitLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many submissions — please try again later' }
|
||||
});
|
||||
|
||||
/**
|
||||
* Starting a Google sign-in (#341).
|
||||
*
|
||||
* The route mints three secrets and issues a redirect, which is cheap but not
|
||||
* free, and it is reachable without a session by anyone who knows the URL.
|
||||
*
|
||||
* Generous, because a customer who bounces off Google's consent screen and
|
||||
* tries again is doing something entirely reasonable and must never be told to
|
||||
* wait. The limit exists so a loop cannot spend the server's entropy and fill
|
||||
* the log, not to police customers.
|
||||
*
|
||||
* Keyed on the caller alone: this endpoint carries no email, which is the
|
||||
* distinction the comment on the client-error limiter above draws.
|
||||
*/
|
||||
export const googleSignInLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 60,
|
||||
keyGenerator: keyByCaller,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many sign-in attempts — please try again shortly' }
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { isValidEmail, readId } from '../utils';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
|
||||
/**
|
||||
* Row shapes for the reads here, kept in step with their SQL by hand.
|
||||
@@ -67,6 +73,15 @@ interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** One recorded admin-initiated address change (#337). */
|
||||
interface EmailChangeRow {
|
||||
id: number;
|
||||
previous_email: string;
|
||||
new_email: string;
|
||||
reason: string;
|
||||
changed_at: Date;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
@@ -205,6 +220,173 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
|
||||
}
|
||||
}));
|
||||
|
||||
/** The reason the operator typed, or null if it is not usable as one. */
|
||||
function readReason(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
// A length floor rather than merely non-empty. The record exists to
|
||||
// distinguish a verified recovery from a takeover afterwards, and "ok" cannot
|
||||
// do that — but no floor high enough to be gamed is worth having either, so
|
||||
// this asks for a sentence and trusts the person writing it.
|
||||
if (trimmed.length < 10) return null;
|
||||
// Bounded because it is free text going into a TEXT column from a form.
|
||||
return trimmed.slice(0, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving an account to an address its owner can actually reach (#337).
|
||||
*
|
||||
* This is the third step of the only recovery route a customer who has lost
|
||||
* their mailbox has, and there is deliberately no self-service equivalent: the
|
||||
* email address is the root of trust for every other route, this shop holds no
|
||||
* second proof of identity, and anything invented to fill that gap would be a
|
||||
* weaker credential than the one it replaced. So the route is manual, and
|
||||
* `docs/ops/account-recovery.md` describes the verification that has to happen
|
||||
* before this endpoint is called.
|
||||
*
|
||||
* The uncomfortable part, stated plainly: this operation and an account takeover
|
||||
* are the same operation. They differ only in whether the verification was
|
||||
* sound, and nothing here can check that. What this can do is make the change
|
||||
* recorded, announced, and reversible in its effects — which is what everything
|
||||
* below is for.
|
||||
*
|
||||
* No current-password check, unlike the customer's own change. There is no
|
||||
* password to ask for; the whole premise is that the person asking cannot prove
|
||||
* anything the system can verify. The admin gate is the only authorisation, and
|
||||
* the operator's judgement is the only verification.
|
||||
*/
|
||||
router.put('/:id/email', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { email, reason } = req.body ?? {};
|
||||
|
||||
const normalized = String(email ?? '').toLowerCase().trim();
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const stated = readReason(reason);
|
||||
if (stated === null) {
|
||||
return res.status(400).json({
|
||||
error: 'say why this account is being moved — a sentence naming how the customer was verified'
|
||||
});
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<{ id: number; email: string; first_name: string | null; last_name: string | null }>(
|
||||
`SELECT id, email, first_name, last_name FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
const customer = rows[0];
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
if (normalized === customer.email) {
|
||||
return res.status(400).json({ error: 'that is already this customer’s email address' });
|
||||
}
|
||||
|
||||
const { rows: taken } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalized]);
|
||||
if (taken.length) {
|
||||
return res.status(409).json({ error: 'another account already uses this email address' });
|
||||
}
|
||||
|
||||
// Captured before the update, because it is where the notice has to go and
|
||||
// the row will not be able to answer for it a moment from now.
|
||||
const previousEmail = customer.email;
|
||||
|
||||
let passkeysRemoved = 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query(
|
||||
// Unverified, exactly as the self-service change leaves it. Nobody has
|
||||
// demonstrated receiving mail at this address yet — a customer describing
|
||||
// it over the phone is not that, and it is the commonest way this goes
|
||||
// wrong harmlessly.
|
||||
`UPDATE customers SET email = $1, email_verified = false WHERE id = $2`,
|
||||
[normalized, id]
|
||||
);
|
||||
|
||||
// Everything the previous holder of this account had, on the reasoning #42
|
||||
// settled for password reset. An account being moved to a recovered address
|
||||
// is in the same position as one being recovered by reset, and the same
|
||||
// argument applies with more force: here somebody the system cannot
|
||||
// identify has asked for the change, so a session or a credential surviving
|
||||
// it would be one the new owner cannot see and cannot revoke.
|
||||
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [id]);
|
||||
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [id]);
|
||||
passkeysRemoved = removed.rowCount ?? 0;
|
||||
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [id]);
|
||||
|
||||
// Reset links already sent are addressed to the old mailbox, which is the
|
||||
// one this change is taking away. Leaving them live would let whoever still
|
||||
// reads it take the account straight back.
|
||||
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [id]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO customer_email_changes (customer_id, previous_email, new_email, reason)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, previousEmail, normalized, stated]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Both sends happen after the row is written, never before, so a change that
|
||||
// failed cannot produce mail saying it succeeded.
|
||||
await issueVerificationEmail(id, normalized, customer.first_name, customer.last_name);
|
||||
|
||||
// To the address being replaced, which is the whole point. If the recovery
|
||||
// was sound this reaches nobody, and that costs nothing. If it was not, it
|
||||
// reaches the real owner — who is the only person who can say so, and the
|
||||
// only reason this endpoint is safe to have at all.
|
||||
const { greetingFormat, greetingFallback } = await getSettings();
|
||||
const notice = renderTemplate('emailChangedByAdmin', await loadStoredTemplate('emailChangedByAdmin'), {
|
||||
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
||||
firstName: customer.first_name ?? '',
|
||||
lastName: customer.last_name ?? '',
|
||||
newEmail: normalized
|
||||
});
|
||||
sendMail(previousEmail, notice.subject, notice.html)
|
||||
.catch(err => console.error('admin email change notice send failed', err));
|
||||
|
||||
const { rows: updated } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
customer: requireRow(updated, 'the customer after the admin email change'),
|
||||
previousEmail,
|
||||
// Reported so the operator can tell the customer what they will have to set
|
||||
// up again, and so a surprising number is visible at the moment it happens
|
||||
// rather than never.
|
||||
passkeysRemoved
|
||||
});
|
||||
}));
|
||||
|
||||
/** What has been done to this account's address, and why (#337). */
|
||||
router.get('/:id/email-changes', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<EmailChangeRow>(
|
||||
`SELECT id, previous_email, new_email, reason, changed_at
|
||||
FROM customer_email_changes
|
||||
WHERE customer_id = $1
|
||||
ORDER BY changed_at DESC`,
|
||||
[id]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
|
||||
+133
-64
@@ -1,6 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { PASSWORD_HASH_ROUNDS } from '../passwordHashing';
|
||||
import { PASSWORD_HASH_ROUNDS, passwordMatches } from '../passwordHashing';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
@@ -13,66 +13,19 @@ import { ItemStatus } from '../types';
|
||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit';
|
||||
// Shared with passkey sign-in, so both paths establish a session identically
|
||||
// rather than in two places that merely agree today (#39).
|
||||
import { setSessionCookie, createSession } from '../customerSession';
|
||||
// Registration, a resend, the customer changing their own address and the shop
|
||||
// changing it for them all need the same three steps, and they now live in one
|
||||
// place for the same reason session creation does (#337).
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
|
||||
// Registration, changing an address, and resending all need the same three
|
||||
// steps: supersede any outstanding link, mint a new one, send it. Written out
|
||||
// three times they would drift, and the step most likely to be forgotten is the
|
||||
// first — which is the one that matters, since an un-superseded link means an
|
||||
// older message in the inbox still verifies.
|
||||
//
|
||||
// Sending is fire-and-forget by the same rule the rest of this file follows:
|
||||
// the token row is written first, so a send that fails cannot leave a customer
|
||||
// believing a link exists that does not, only waiting for one that never came.
|
||||
async function issueVerificationEmail(
|
||||
customerId: number,
|
||||
email: string,
|
||||
firstName: string | null,
|
||||
lastName: string | null = null
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[customerId]
|
||||
);
|
||||
const { verifyTokenHours, greetingFormat, greetingFallback } = await getSettings();
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
||||
[token, customerId, new Date(Date.now() + verifyTokenHours * 60 * 60 * 1000)]
|
||||
);
|
||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`;
|
||||
const template = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
||||
greeting: greeting(firstName, greetingFormat, greetingFallback, lastName),
|
||||
firstName: firstName ?? '',
|
||||
lastName: lastName ?? '',
|
||||
verifyUrl,
|
||||
expiresIn: formatDuration(verifyTokenHours)
|
||||
});
|
||||
sendMail(email, template.subject, template.html)
|
||||
.catch(err => console.error('verify email send failed', err));
|
||||
}
|
||||
|
||||
function setSessionCookie(res: Response, token: string) {
|
||||
res.cookie('rd_session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
async function createSession(customerId: number): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
await pool.query(
|
||||
`INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
[token, customerId, expiresAt]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
// setSessionCookie and createSession now live in ../customerSession, shared with
|
||||
// passkey sign-in. #39 requires that path to establish a session identically to
|
||||
// this one, and sharing the code is what makes that true rather than intended.
|
||||
|
||||
// The subset of a customers row that is safe to return to the customer it
|
||||
// belongs to. Typed as its own shape rather than `any` so that adding a column
|
||||
@@ -104,7 +57,7 @@ interface CustomerRow {
|
||||
* Kept in step with the schema by hand; nothing checks this against Postgres.
|
||||
*/
|
||||
interface CustomerRecord extends CustomerRow {
|
||||
password_hash: string;
|
||||
password_hash: string | null;
|
||||
disabled_at: Date | null;
|
||||
unsubscribe_token: string;
|
||||
marketing_consent_at: Date | null;
|
||||
@@ -228,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
|
||||
};
|
||||
}
|
||||
@@ -388,6 +348,11 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
|
||||
const passwordHash = await bcrypt.hash(String(password), PASSWORD_HASH_ROUNDS);
|
||||
|
||||
// Reported back so the customer is told, rather than finding an empty list
|
||||
// the next time they look. Declared out here because it is decided inside the
|
||||
// transaction and read after it.
|
||||
let passkeysRemoved = 0;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -403,6 +368,37 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
// up to 30 days.
|
||||
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]);
|
||||
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]);
|
||||
|
||||
// Passkeys go with the sessions, for the same reason and more of it (#42).
|
||||
//
|
||||
// A reset is the recovery path, and recovery has to be complete. The line
|
||||
// above already takes the position that a reset must evict anyone else
|
||||
// holding the account — a session an intruder holds lasts up to 30 days, and
|
||||
// a passkey an intruder registered lasts forever. Leaving those behind would
|
||||
// mean a customer can recover their password and still not have their
|
||||
// account back.
|
||||
//
|
||||
// The obvious objection is that this lets whoever controls the mailbox strip
|
||||
// a customer's passkeys. It does, and it costs nothing: anyone who can
|
||||
// complete a reset already controls the email address, and therefore already
|
||||
// controls the account. The passkeys were not protecting anything at that
|
||||
// point.
|
||||
//
|
||||
// Deliberately NOT the same rule as change-password, which leaves passkeys
|
||||
// alone. That one requires the current password from someone already signed
|
||||
// in — no part of it suggests a lockout or a compromise, and a customer who
|
||||
// suspects one device can revoke that device by name on the account page
|
||||
// (#40). This path has no idea which credential is the problem, so it takes
|
||||
// all of them.
|
||||
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [customerId]);
|
||||
passkeysRemoved = removed.rowCount ?? 0;
|
||||
|
||||
// Including anything in flight. A registration challenge issued to an
|
||||
// intruder moments before the reset would otherwise still be completable
|
||||
// afterwards, which would put a passkey back on the account the reset just
|
||||
// cleared.
|
||||
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [customerId]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -414,14 +410,22 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
const { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
|
||||
const sessionToken = await createSession(customerId);
|
||||
setSessionCookie(res, sessionToken);
|
||||
res.json(publicCustomer(requireRow(fresh, 'the customer whose password was just reset')));
|
||||
// The count rides along with the customer rather than being left for the
|
||||
// account page to imply. A customer who never registered a passkey sees zero
|
||||
// and is told nothing; one who is told two were removed and only remembers
|
||||
// registering one has just learned something they could not otherwise find
|
||||
// out — the row is already gone by the time they could go looking.
|
||||
res.json({
|
||||
...publicCustomer(requireRow(fresh, 'the customer whose password was just reset')),
|
||||
passkeysRemoved
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password } = req.body;
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
||||
const customer = rows[0];
|
||||
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
|
||||
if (!customer || !(await passwordMatches(password, customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'invalid email or password' });
|
||||
}
|
||||
// Only after the password checks out, so a wrong password still looks like a
|
||||
@@ -522,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 bcrypt.compare(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]);
|
||||
|
||||
@@ -555,7 +576,25 @@ 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');
|
||||
|
||||
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
|
||||
// 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' });
|
||||
}
|
||||
|
||||
@@ -629,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
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { signIn } from '../customerSession';
|
||||
import { googleConfig } from '../google/config';
|
||||
import { newAttempt, authorizationUrl, exchangeCode, verifiedIdentity } from '../google/oauth';
|
||||
import type { GoogleIdentity } from '../google/oauth';
|
||||
import { createCustomerFromGoogle } from '../google/newCustomer';
|
||||
import { linkToExistingCustomer } from '../google/linkIdentity';
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
import type { AttemptSecrets } from '../google/oauth';
|
||||
import { googleSignInLimiter } from '../rateLimit';
|
||||
import { safeReturnTo } from '../google/returnTo';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Signing in with Google (#341).
|
||||
*
|
||||
* Unauthenticated by design — this is how a customer becomes authenticated —
|
||||
* and mounted at `/api/auth/google`, away from `/api/customers`, because it is
|
||||
* the first route in this application that a third party redirects into.
|
||||
*
|
||||
* ## What this does and does not do
|
||||
*
|
||||
* It signs in a customer whose Google identity is already linked, and creates
|
||||
* an account for one nobody here has seen (#342).
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* The callback is a plain GET that anyone on the internet can invoke. What
|
||||
* makes it safe is that it can only complete for a browser holding a cookie
|
||||
* this server set moments earlier, carrying three secrets:
|
||||
*
|
||||
* - **state** proves the callback belongs to the request this browser started
|
||||
* - **nonce** proves the id token was minted for this attempt
|
||||
* - **code verifier** proves the code is being spent by whoever asked for it
|
||||
*
|
||||
* The cookie is cleared on every path through the callback, success or failure,
|
||||
* so one attempt cannot be replayed even once.
|
||||
*/
|
||||
|
||||
/** Ten minutes. Long enough to sign in, short enough that a stolen one is stale. */
|
||||
const ATTEMPT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const ATTEMPT_COOKIE = 'rd_oauth';
|
||||
|
||||
interface Attempt extends AttemptSecrets {
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
interface IdentityRow {
|
||||
customer_id: number;
|
||||
disabled_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the customer is sent when this ends.
|
||||
*
|
||||
* Always a redirect, never JSON. The browser arrives here by following Google's
|
||||
* redirect, so whatever this responds with is rendered as a page — and a bare
|
||||
* JSON error is a dead end with no way back to the storefront.
|
||||
*/
|
||||
const FAILURE_PATH = '/login?auth=google-failed';
|
||||
|
||||
/**
|
||||
* Where a customer who has just been created lands.
|
||||
*
|
||||
* A route rather than a flag on the storefront, so it is a page with an address
|
||||
* — reachable again, linkable from the account page later, and rendered by the
|
||||
* same modal-route machinery every other auth screen uses.
|
||||
*/
|
||||
const WELCOME_PATH = '/welcome';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// Lax, and NOT Strict. The callback arrives as a top-level navigation from
|
||||
// Google, which is cross-site. Strict withholds the cookie, the state check
|
||||
// then fails, and every sign-in is refused with an error that looks exactly
|
||||
// like tampering. This one line is the single most expensive thing to get
|
||||
// wrong in the whole flow.
|
||||
sameSite: 'lax',
|
||||
maxAge: ATTEMPT_TTL_MS,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
function readAttemptCookie(req: Request): Attempt | null {
|
||||
const raw = req.cookies?.[ATTEMPT_COOKIE];
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Partial<Attempt>;
|
||||
if (
|
||||
typeof parsed.state !== 'string' ||
|
||||
typeof parsed.nonce !== 'string' ||
|
||||
typeof parsed.codeVerifier !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
state: parsed.state,
|
||||
nonce: parsed.nonce,
|
||||
codeVerifier: parsed.codeVerifier,
|
||||
returnTo: typeof parsed.returnTo === 'string' ? parsed.returnTo : '/'
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two secrets without leaking where they first differ.
|
||||
*
|
||||
* `timingSafeEqual` throws on buffers of unequal length, and a length check
|
||||
* before it would leak the length, so both are hashed to a fixed 32 bytes
|
||||
* first — the same trick `adminGate` uses, for the same reason.
|
||||
*/
|
||||
function secretsMatch(a: string, b: string): boolean {
|
||||
const digest = (value: string) => crypto.createHash('sha256').update(value, 'utf8').digest();
|
||||
return crypto.timingSafeEqual(digest(a), digest(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* told it to, which is the open-redirect question `safeReturnTo` already
|
||||
* answers on the server — asked a second time, in a second language, on a page
|
||||
* an attacker can link to directly. One new customer occasionally landing on
|
||||
* the storefront rather than back at their cart is the cheaper of the two.
|
||||
*/
|
||||
async function signUpOrLink(res: Response, identity: GoogleIdentity, returnTo: string): Promise<void> {
|
||||
const outcome = await createCustomerFromGoogle(identity);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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, link.customerId);
|
||||
res.redirect(returnTo);
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/start',
|
||||
googleSignInLimiter,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const config = googleConfig();
|
||||
if (!config.enabled) {
|
||||
// Not a 404 and not an error page. Nothing offers this link when Google
|
||||
// sign-in is switched off, so reaching it means a stale bookmark or a
|
||||
// hand-typed URL, and the storefront is the right answer to both.
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const attempt: Attempt = { ...newAttempt(), returnTo: safeReturnTo(req.query.returnTo) };
|
||||
setAttemptCookie(res, attempt);
|
||||
res.redirect(authorizationUrl(config, attempt));
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/callback',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const config = googleConfig();
|
||||
const attempt = readAttemptCookie(req);
|
||||
|
||||
// Cleared before anything is decided, on every path. A cookie that survives
|
||||
// a failed attempt is a second try at the same state and nonce.
|
||||
res.clearCookie(ATTEMPT_COOKIE, { path: '/' });
|
||||
|
||||
if (!config.enabled || attempt === null) return res.redirect(FAILURE_PATH);
|
||||
|
||||
// Google sends `error=access_denied` when the customer declines at the
|
||||
// consent screen. That is a cancellation rather than a failure, and it goes
|
||||
// back to the storefront with nothing said — the same distinction #41 draws
|
||||
// for a dismissed passkey prompt.
|
||||
if (typeof req.query.error === 'string') {
|
||||
return res.redirect(attempt.returnTo);
|
||||
}
|
||||
|
||||
const state = typeof req.query.state === 'string' ? req.query.state : '';
|
||||
const code = typeof req.query.code === 'string' ? req.query.code : '';
|
||||
if (state === '' || code === '' || !secretsMatch(state, attempt.state)) {
|
||||
console.warn('[google] callback refused: state did not match the attempt cookie');
|
||||
return res.redirect(FAILURE_PATH);
|
||||
}
|
||||
|
||||
let identity;
|
||||
try {
|
||||
const idToken = await exchangeCode(config, code, attempt.codeVerifier);
|
||||
identity = verifiedIdentity(idToken, { clientId: config.clientId, nonce: attempt.nonce });
|
||||
} catch (err) {
|
||||
// Logged, never returned. These messages name which check failed, which
|
||||
// is exactly what the person reading the logs needs and exactly what an
|
||||
// attacker would like to be told.
|
||||
console.warn(`[google] callback refused: ${(err as Error).message}`);
|
||||
return res.redirect(FAILURE_PATH);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<IdentityRow>(
|
||||
`SELECT i.customer_id, c.disabled_at
|
||||
FROM customer_identities i
|
||||
JOIN customers c ON c.id = i.customer_id
|
||||
WHERE i.provider = 'google' AND i.provider_sub = $1`,
|
||||
[identity.sub]
|
||||
);
|
||||
const linked = rows[0];
|
||||
|
||||
// Nobody this shop has seen through Google before. Either they are new, or
|
||||
// they already have an account under this address — and joining those two
|
||||
// is linking, which is #343 and is refused here until its policy is
|
||||
// written down rather than falling out of an INSERT.
|
||||
if (!linked) return 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,
|
||||
// which is the reason #39 called this out for passkeys.
|
||||
if (linked.disabled_at !== null) {
|
||||
console.warn(`[google] refused a disabled account: customer ${linked.customer_id}`);
|
||||
return res.redirect(FAILURE_PATH);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE customer_identities SET last_used_at = now()
|
||||
WHERE provider = 'google' AND provider_sub = $1`,
|
||||
[identity.sub]
|
||||
);
|
||||
|
||||
// The same call password login and passkey login make. Not a third
|
||||
// implementation that agrees today — the same one, so cookie flags, expiry
|
||||
// and logout behave identically however a customer got here.
|
||||
await signIn(res, linked.customer_id);
|
||||
|
||||
res.redirect(attempt.returnTo);
|
||||
})
|
||||
);
|
||||
|
||||
/** Exported for the tests; nothing else needs the cookie's name. */
|
||||
export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH, WELCOME_PATH, USE_PASSWORD_PATH };
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { publicItemQuery, PublicItemRow, ItemContext } from '../itemSelect';
|
||||
import { readId } from '../utils';
|
||||
import {
|
||||
parseItemFilters,
|
||||
itemFilterExpressions,
|
||||
@@ -93,16 +94,20 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
// Number() rather than readId(), and that is deliberate rather than an
|
||||
// oversight. readId would be stricter and would match every other id-taking
|
||||
// route (#207) — but errorHandling.integration.test.ts drives this exact
|
||||
// route with a non-numeric id to prove that asyncRoute plus the error
|
||||
// middleware answer 500 rather than leaving the request hanging, and a
|
||||
// stricter parse here would leave that test green while removing the thing
|
||||
// it tests. Switching this over means giving that test another trigger in
|
||||
// the same change. See #307.
|
||||
// readId, like every other id-taking route since #207. This was the last one
|
||||
// reading its id with a bare Number(), which meant an unreadable id reached
|
||||
// Postgres and came back to the caller as a 500 for an item that cannot
|
||||
// exist. 404 is what "/items/abc" actually means.
|
||||
//
|
||||
// It was left on Number() because errorHandling.integration.test.ts used this
|
||||
// route's looseness as its way of making a handler reject. That test now
|
||||
// fails a database call directly instead, so it no longer depends on a route
|
||||
// declining to validate — which is what allowed this to be fixed (#307).
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const rows = await publicItemQuery()
|
||||
.where('i.id', '=', Number(req.params.id))
|
||||
.where('i.id', '=', id)
|
||||
.where((eb) => notPending(eb))
|
||||
.execute();
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/server';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { relyingParty } from '../passkeys/relyingParty';
|
||||
import { checkSignatureCounter } from '../passkeys/signatureCounter';
|
||||
import { signIn } from '../customerSession';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Signing in with a passkey (#39).
|
||||
*
|
||||
* Unauthenticated by design — this is how a customer becomes authenticated —
|
||||
* which is why it is a separate router from the registration one at
|
||||
* `/api/customers/me/passkeys`, where every route requires a session.
|
||||
*
|
||||
* ## Usernameless, and what that buys
|
||||
*
|
||||
* The customer is never asked who they are. `begin` takes no email and returns
|
||||
* no `allowCredentials`, so the browser offers whichever accounts it holds for
|
||||
* this Relying Party and the assertion says which credential answered. #38 asked
|
||||
* for discoverable credentials precisely so this would work.
|
||||
*
|
||||
* That is the better experience, and it also makes one of this issue's
|
||||
* requirements structural rather than something to be careful about: "failures
|
||||
* must not reveal whether an email has an account or has passkeys registered."
|
||||
* **No email is ever sent to this endpoint**, so there is nothing to reveal.
|
||||
* An email-first flow would have had to be careful to answer identically for a
|
||||
* known and an unknown address, forever, in every branch.
|
||||
*/
|
||||
|
||||
/** Matches the registration ceremony, so neither can be the odd one out. */
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface CredentialRow {
|
||||
customer_id: number;
|
||||
credential_id: string;
|
||||
public_key: string;
|
||||
signature_counter: string;
|
||||
transports: string | null;
|
||||
disabled_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer given whenever a sign-in does not succeed.
|
||||
*
|
||||
* One message for every reason: no such credential, a disabled account, a bad
|
||||
* assertion, a stalled counter. They are all "that did not work" to the caller,
|
||||
* and saying which would turn this endpoint into an oracle for whether a
|
||||
* credential exists and whether its account is in good standing.
|
||||
*/
|
||||
const REFUSED = 'that passkey could not be used to sign in';
|
||||
|
||||
/**
|
||||
* Spends an authentication challenge, reporting whether it was spendable.
|
||||
*
|
||||
* Passed to `verifyAuthenticationResponse` as its `expectedChallenge`, which
|
||||
* accepts a predicate precisely for this flow: in a usernameless sign-in the
|
||||
* challenge is not known until the assertion names it, so it cannot be looked
|
||||
* up in advance.
|
||||
*
|
||||
* Deleting it is the check. A replay finds nothing to delete and fails, and the
|
||||
* expiry sits in the same statement so a stale challenge fails the same way and
|
||||
* for the same reason.
|
||||
*
|
||||
* A named function rather than an inline callback because
|
||||
* `routesAreWrapped.test.ts` reads the text of each `router.post(...)` looking
|
||||
* for an `async` that no `asyncRoute` covers — and an async callback nested
|
||||
* inside a wrapped handler looks exactly like an unwrapped one to it. Hoisting
|
||||
* it out keeps that guard sharp instead of teaching it another exception.
|
||||
*/
|
||||
async function spendAuthenticationChallenge(challenge: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM webauthn_challenges
|
||||
WHERE challenge = $1 AND kind = 'authentication' AND expires_at > now()`,
|
||||
[challenge]
|
||||
);
|
||||
return rowCount === 1;
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/login/begin',
|
||||
asyncRoute(async (_req: Request, res: Response) => {
|
||||
const rp = relyingParty();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.id,
|
||||
// Empty by design: the browser offers what it holds. Naming credentials
|
||||
// here would require knowing who is signing in, which is the thing this
|
||||
// flow exists to avoid asking.
|
||||
allowCredentials: [],
|
||||
userVerification: 'preferred',
|
||||
timeout: CHALLENGE_TTL_MS
|
||||
});
|
||||
|
||||
// customer_id is null — nobody is identified yet, which is exactly why #37
|
||||
// made that column nullable rather than reusing customer_tokens.
|
||||
await pool.query(
|
||||
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
|
||||
VALUES ($1, NULL, 'authentication', now() + ($2 || ' milliseconds')::interval)`,
|
||||
[options.challenge, String(CHALLENGE_TTL_MS)]
|
||||
);
|
||||
|
||||
res.json(options);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/login/finish',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const rp = relyingParty();
|
||||
const body = req.body as AuthenticationResponseJSON;
|
||||
|
||||
if (typeof body?.id !== 'string' || body.id === '') {
|
||||
return res.status(400).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
// The assertion says which credential answered, and that is what identifies
|
||||
// the customer. Joined so the disabled check reads the same row rather than
|
||||
// a second one that could have changed in between.
|
||||
const { rows } = await pool.query<CredentialRow>(
|
||||
`SELECT c.customer_id, c.credential_id, c.public_key, c.signature_counter,
|
||||
c.transports, cu.disabled_at
|
||||
FROM customer_credentials c
|
||||
JOIN customers cu ON cu.id = c.customer_id
|
||||
WHERE c.credential_id = $1`,
|
||||
[body.id]
|
||||
);
|
||||
const stored = rows[0];
|
||||
|
||||
// A disabled account is refused here as well as on the password path.
|
||||
// Enforcing it on one and not the other would leave passkeys as a way
|
||||
// around it, which is the whole reason #39 calls this out (#33).
|
||||
if (!stored || stored.disabled_at !== null) {
|
||||
// The challenge is still consumed below by verification never running, so
|
||||
// sweep it here: a refused attempt must not leave one usable.
|
||||
await pool.query(`DELETE FROM webauthn_challenges WHERE kind = 'authentication' AND expires_at <= now()`);
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
// A predicate rather than a value, which is what lets a usernameless
|
||||
// flow work at all: the challenge is not known until the assertion
|
||||
// names it. See the function for why single use falls out of this.
|
||||
expectedChallenge: spendAuthenticationChallenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.id,
|
||||
credential: {
|
||||
id: stored.credential_id,
|
||||
publicKey: new Uint8Array(Buffer.from(stored.public_key, 'base64url')),
|
||||
// Stored as BIGINT, which pg returns as a string.
|
||||
counter: Number(stored.signature_counter),
|
||||
transports: stored.transports ? (JSON.parse(stored.transports) as string[]) : undefined
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
const verdict = checkSignatureCounter(
|
||||
Number(stored.signature_counter),
|
||||
verification.authenticationInfo.newCounter
|
||||
);
|
||||
if (!verdict.ok) {
|
||||
// Logged rather than returned. The customer cannot act on it, and the
|
||||
// person who can is reading the logs.
|
||||
console.warn(`[passkeys] refused credential ${stored.credential_id}: ${verdict.reason}`);
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE customer_credentials
|
||||
SET signature_counter = $1, last_used_at = now()
|
||||
WHERE credential_id = $2`,
|
||||
[verification.authenticationInfo.newCounter, stored.credential_id]
|
||||
);
|
||||
|
||||
// The same call password login makes. Not a second implementation that
|
||||
// agrees today — the same one.
|
||||
await signIn(res, stored.customer_id);
|
||||
|
||||
const { rows: customers } = await pool.query<{ id: number; email: string }>(
|
||||
`SELECT id, email FROM customers WHERE id = $1`,
|
||||
[stored.customer_id]
|
||||
);
|
||||
res.json(customers[0]);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { relyingParty } from '../passkeys/relyingParty';
|
||||
import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName';
|
||||
import { readId } from '../utils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Registering a passkey (#38).
|
||||
*
|
||||
* Every route here is behind `requireCustomer`. Registration is not a sign-up
|
||||
* path — it adds a credential to an account that already exists and is already
|
||||
* signed in — so an unauthenticated caller has nothing to register against.
|
||||
*
|
||||
* Signing in with a passkey is #39, and the management screen is #40. Neither
|
||||
* exists yet, so nothing reads these credentials.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How long a customer has to complete the ceremony.
|
||||
*
|
||||
* Long enough to find a phone and use it; short enough that an intercepted
|
||||
* challenge is not useful for long. The browser's own timeout is set to match,
|
||||
* so the two cannot disagree about when the attempt has expired.
|
||||
*/
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface CredentialIdRow {
|
||||
credential_id: string;
|
||||
transports: string | null;
|
||||
}
|
||||
|
||||
interface ChallengeRow {
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a challenge and reports whether it was there.
|
||||
*
|
||||
* Single use is the whole point, and deleting it *is* the check: a replayed
|
||||
* response finds nothing to delete and is refused. Doing it as one statement
|
||||
* rather than a read followed by a delete means two requests racing cannot both
|
||||
* see the row and both proceed.
|
||||
*
|
||||
* Expiry is part of the same condition, so an expired challenge is refused for
|
||||
* the same reason and by the same statement.
|
||||
*/
|
||||
async function consumeChallenge(customerId: number, kind: string): Promise<string | null> {
|
||||
const { rows } = await pool.query<ChallengeRow>(
|
||||
`DELETE FROM webauthn_challenges
|
||||
WHERE customer_id = $1 AND kind = $2 AND expires_at > now()
|
||||
RETURNING challenge`,
|
||||
[customerId, kind]
|
||||
);
|
||||
return rows[0]?.challenge ?? null;
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/register/begin',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const customerId = req.customerId as number;
|
||||
const rp = relyingParty();
|
||||
|
||||
const { rows: existing } = await pool.query<CredentialIdRow>(
|
||||
`SELECT credential_id, transports FROM customer_credentials WHERE customer_id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
|
||||
const { rows: customers } = await pool.query<{ email: string; first_name: string | null }>(
|
||||
`SELECT email, first_name FROM customers WHERE id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
const customer = customers[0];
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.name,
|
||||
rpID: rp.id,
|
||||
userName: customer.email,
|
||||
userDisplayName: customer.first_name ?? customer.email,
|
||||
// The customer id, not the email. A userID is meant to be stable and
|
||||
// opaque; the email is neither, and a customer changing theirs would
|
||||
// otherwise look like a different person to their own authenticator.
|
||||
userID: new TextEncoder().encode(String(customerId)),
|
||||
// Stops the same authenticator being enrolled twice. Without it a
|
||||
// customer pressing register again on a device they already registered
|
||||
// gets a second row that behaves identically to the first, and a
|
||||
// management screen showing two entries they cannot tell apart.
|
||||
excludeCredentials: existing.map((row) => ({ id: row.credential_id })),
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
// Discoverable, because #39 wants sign-in without the customer first
|
||||
// saying who they are. 'preferred' rather than 'required' so an
|
||||
// authenticator that cannot store one is still usable here.
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
timeout: CHALLENGE_TTL_MS
|
||||
});
|
||||
|
||||
// One in-flight registration per customer. Pressing the button twice must
|
||||
// not leave the first challenge usable — the second replaces it, and the
|
||||
// first response is then refused by consumeChallenge finding nothing.
|
||||
await pool.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1 AND kind = 'registration'`, [
|
||||
customerId
|
||||
]);
|
||||
await pool.query(
|
||||
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
|
||||
VALUES ($1, $2, 'registration', now() + ($3 || ' milliseconds')::interval)`,
|
||||
[options.challenge, customerId, String(CHALLENGE_TTL_MS)]
|
||||
);
|
||||
|
||||
res.json(options);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/register/finish',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const customerId = req.customerId as number;
|
||||
const rp = relyingParty();
|
||||
|
||||
const expectedChallenge = await consumeChallenge(customerId, 'registration');
|
||||
if (expectedChallenge === null) {
|
||||
// Deliberately the same answer for "never started", "already used" and
|
||||
// "expired". They are the same thing from here — no challenge this
|
||||
// customer may still complete — and distinguishing them would tell an
|
||||
// attacker which of their guesses was closest.
|
||||
return res.status(400).json({ error: 'start again — that registration is no longer valid' });
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: req.body as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.id
|
||||
});
|
||||
} catch {
|
||||
// The library throws on a malformed or unverifiable response. The
|
||||
// challenge is already consumed by this point, deliberately: a failed
|
||||
// attempt must not leave one usable for a second try.
|
||||
return res.status(400).json({ error: 'that passkey could not be registered' });
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(400).json({ error: 'that passkey could not be registered' });
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
const transports = credential.transports ?? [];
|
||||
const name = readCredentialName((req.body as { name?: unknown }).name)
|
||||
?? defaultCredentialName(transports);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials
|
||||
(customer_id, credential_id, public_key, signature_counter, transports, name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
customerId,
|
||||
credential.id,
|
||||
Buffer.from(credential.publicKey).toString('base64url'),
|
||||
credential.counter,
|
||||
JSON.stringify(transports),
|
||||
name
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// credential_id is unique across the table. excludeCredentials should
|
||||
// have stopped the browser offering an already-registered authenticator,
|
||||
// but that is a hint the browser may ignore, so the constraint is what
|
||||
// actually holds — and hitting it means the credential is already
|
||||
// registered rather than that anything is broken.
|
||||
if ((err as { code?: string }).code === '23505') {
|
||||
return res.status(409).json({ error: 'that passkey is already registered' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.status(201).json({ name });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* The customer's registered passkeys (#40).
|
||||
*
|
||||
* Registering one with no way to see or remove it is worse than not offering
|
||||
* passkeys at all, which is what makes this the smallest issue in the project
|
||||
* and the one that makes the rest usable.
|
||||
*
|
||||
* `last_used_at` is here because it is the only thing that tells two entries
|
||||
* apart when the names are similar — a customer about to revoke one needs to
|
||||
* know which device they are cutting off, and "used an hour ago" answers that
|
||||
* where a creation date does not.
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
}>(
|
||||
`SELECT id, name, created_at, last_used_at
|
||||
FROM customer_credentials
|
||||
WHERE customer_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[req.customerId]
|
||||
);
|
||||
|
||||
// No public key, no credential id, no counter. The customer cannot act on
|
||||
// any of them, and a credential id is the one value that identifies this
|
||||
// authenticator to anyone who has it.
|
||||
res.json(rows);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// Removing the last way in must not lock the customer out.
|
||||
//
|
||||
// This cannot fire today: password_hash is NOT NULL, so every customer has
|
||||
// a password and removing every passkey still leaves them a way to sign in.
|
||||
// The issue asks for the check anyway, and that is the right call — it is
|
||||
// written against the condition rather than against today's schema, so it
|
||||
// starts holding on its own the moment the condition changes.
|
||||
//
|
||||
// #332 is what changes it. Social sign-in makes password_hash nullable and
|
||||
// creates the first customers with no password, at which point a customer
|
||||
// whose only credential is a passkey can genuinely lock themselves out with
|
||||
// this button. When that lands, `has_password` stops being always true and
|
||||
// this branch starts running.
|
||||
const { rows: waysIn } = await pool.query<{ has_password: boolean; credentials: string }>(
|
||||
`SELECT (c.password_hash IS NOT NULL) AS has_password,
|
||||
(SELECT count(*) FROM customer_credentials WHERE customer_id = c.id) AS credentials
|
||||
FROM customers c
|
||||
WHERE c.id = $1`,
|
||||
[req.customerId]
|
||||
);
|
||||
const waysInRow = waysIn[0];
|
||||
if (waysInRow && !waysInRow.has_password && Number(waysInRow.credentials) <= 1) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
'that is the only way you can sign in — set a password first, or add another passkey'
|
||||
});
|
||||
}
|
||||
|
||||
// Scoped to the signed-in customer in the same statement that deletes.
|
||||
// Reading first and deleting after would leave a window, and a credential
|
||||
// id is not a secret — the only thing making this safe is that the WHERE
|
||||
// names whose it must be.
|
||||
//
|
||||
// Revocation is the row going away: #39 looks the credential up by id on
|
||||
// every sign-in, so a deleted one is refused immediately and by
|
||||
// construction rather than by a flag something has to remember to check.
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM customer_credentials WHERE id = $1 AND customer_id = $2`,
|
||||
[id, req.customerId]
|
||||
);
|
||||
|
||||
// 404 for both "no such credential" and "not yours", deliberately. The
|
||||
// second is the interesting case and saying so would confirm that some
|
||||
// other customer holds that id.
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
/** Exported for the tests; nothing else constructs a challenge. */
|
||||
export { CHALLENGE_TTL_MS };
|
||||
|
||||
export default router;
|
||||
+20
-3
@@ -133,9 +133,26 @@ export function trimTrailingSlashes(value: string): string {
|
||||
*
|
||||
* Rejects 0 and negatives as well as fractions: every id in this schema is a
|
||||
* positive serial, so anything else identifies nothing.
|
||||
*
|
||||
* Matched against decimal digits before parsing, because `Number` on its own is
|
||||
* far more permissive than "is this an id" wants. It reads `5.0`, `1e2`, `0x10`
|
||||
* and `+5` as 5, 100, 16 and 5 — each a positive integer, each passing the
|
||||
* checks below, and each therefore fetching a real row for a URL nobody wrote.
|
||||
* That is not a crash and so it never announced itself; #307 noticed it only
|
||||
* because #308 converted the comparison to a real integer. An id is a string of
|
||||
* digits, and anything else is a different request.
|
||||
*
|
||||
* Bounded at the top for the reason the whole function exists: the column is a
|
||||
* 32-bit serial, so an id above that limit reaches Postgres as an out-of-range
|
||||
* integer and raises 22003 — the same shape of failure as the 22P02 above, and
|
||||
* the same wrong answer to the caller. Below the limit it is a 404.
|
||||
*/
|
||||
const MAX_SERIAL_ID = 2147483647;
|
||||
|
||||
export function readId(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
if (value === undefined) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_SERIAL_ID ? parsed : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
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';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
const REASON = 'Named the last two items bought and the shipping address on file.';
|
||||
|
||||
async function register(email: string): Promise<number> {
|
||||
const res = await request(app)
|
||||
.post('/api/customers/register')
|
||||
.send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const { rows } = await pool.query<{ id: number }>(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||
return requireRow(rows, 'the customer this test just registered').id;
|
||||
}
|
||||
|
||||
function moveTo(id: number, email: string, reason: string = REASON) {
|
||||
return request(app).put(`/api/admin/customers/${id}/email`).send({ email, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* #337. The third step of the only recovery route available to a customer who
|
||||
* has lost their mailbox — and, structurally, the same operation as an account
|
||||
* takeover. These tests are mostly about the second half of that sentence.
|
||||
*/
|
||||
describe('PUT /api/admin/customers/:id/email', () => {
|
||||
it('moves the account, and leaves the new address unverified', async () => {
|
||||
const id = await register('lost@example.com');
|
||||
|
||||
const res = await moveTo(id, 'Recovered@Example.com ');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.customer.email).toBe('recovered@example.com');
|
||||
expect(res.body.previousEmail).toBe('lost@example.com');
|
||||
// Nobody has demonstrated receiving mail at the new address. A customer
|
||||
// reading it out over the phone is not that, and it is the commonest way
|
||||
// this goes wrong harmlessly.
|
||||
expect(res.body.customer.email_verified).toBe(false);
|
||||
});
|
||||
|
||||
it('records the change with the reason, which is the point of the endpoint', async () => {
|
||||
const id = await register('recorded@example.com');
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
const { rows } = await pool.query<{ previous_email: string; new_email: string; reason: string }>(
|
||||
`SELECT previous_email, new_email, reason FROM customer_email_changes WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
// A hand edit to the database leaves nothing behind. This row is the only
|
||||
// thing that distinguishes a verified recovery from a takeover afterwards.
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toEqual({
|
||||
previous_email: 'recorded@example.com',
|
||||
new_email: 'new@example.com',
|
||||
reason: REASON
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses without a stated reason, rather than recording an empty one', async () => {
|
||||
const id = await register('noreason@example.com');
|
||||
|
||||
const res = await moveTo(id, 'new@example.com', '');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const { rows } = await pool.query<{ email: string }>(`SELECT email FROM customers WHERE id = $1`, [id]);
|
||||
// Refused entirely, not performed and left unexplained.
|
||||
expect(requireRow(rows, 'the unchanged customer').email).toBe('noreason@example.com');
|
||||
});
|
||||
|
||||
it('refuses a reason too short to be one', async () => {
|
||||
const id = await register('terse@example.com');
|
||||
|
||||
// "ok" cannot tell a recovery from a takeover, which is the only thing the
|
||||
// field is for.
|
||||
const res = await moveTo(id, 'new@example.com', 'ok');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('signs the customer out everywhere', async () => {
|
||||
const id = await register('sessions@example.com');
|
||||
const token = await createSession(id);
|
||||
const asCustomer = () => request(app).get('/api/customers/me').set('Cookie', `rd_session=${token}`);
|
||||
expect((await asCustomer()).status).toBe(200);
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
// Somebody the system cannot identify asked for this change. A session
|
||||
// surviving it is one the new owner cannot see and cannot revoke.
|
||||
expect((await asCustomer()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('removes every passkey, and says how many', async () => {
|
||||
const id = await register('keys@example.com');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, 'credential-a', 'not-a-real-key', 'Phone'),
|
||||
($1, 'credential-b', 'not-a-real-key', 'Laptop')`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const res = await moveTo(id, 'new@example.com');
|
||||
|
||||
expect(res.body.passkeysRemoved).toBe(2);
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(rows, 'a count of credentials').n).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels reset links already sent to the old address', async () => {
|
||||
const id = await register('resetlink@example.com');
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'resetlink@example.com' });
|
||||
const before = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(before.rows, 'a count of reset tokens').n).toBe(1);
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
// That link is addressed to the mailbox this change is taking away. Leaving
|
||||
// it live would let whoever still reads it take the account straight back.
|
||||
const after = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(after.rows, 'a count of reset tokens').n).toBe(0);
|
||||
});
|
||||
|
||||
it('issues a verification link to the new address', async () => {
|
||||
const id = await register('verify@example.com');
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[id]
|
||||
);
|
||||
// Exactly one: registration issued a link to the old address, and issuing
|
||||
// this one has to supersede it, or a message in the mailbox being taken
|
||||
// away could still verify.
|
||||
expect(requireRow(rows, 'a count of verification tokens').n).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses an address another account already uses', async () => {
|
||||
const id = await register('mover@example.com');
|
||||
await register('occupied@example.com');
|
||||
|
||||
const res = await moveTo(id, 'occupied@example.com');
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('refuses the address the account already has', async () => {
|
||||
const id = await register('same@example.com');
|
||||
|
||||
const res = await moveTo(id, 'same@example.com');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('refuses a malformed address', async () => {
|
||||
const id = await register('malformed@example.com');
|
||||
|
||||
const res = await moveTo(id, 'not-an-email');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('is a 404 for a customer who does not exist', async () => {
|
||||
const res = await moveTo(999999, 'new@example.com');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('leaves the customer able to sign in with their existing password', async () => {
|
||||
const id = await register('stillworks@example.com');
|
||||
|
||||
await moveTo(id, 'moved@example.com');
|
||||
|
||||
// The move is not a password reset. The customer knows their password —
|
||||
// what they lost was the mailbox — so demanding a new one would add a step
|
||||
// for no gain.
|
||||
const login = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'moved@example.com', password: PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/customers/:id/email-changes', () => {
|
||||
it('lists what has been done to this account, newest first', async () => {
|
||||
const id = await register('history@example.com');
|
||||
await moveTo(id, 'second@example.com', 'First recovery, verified against order history.');
|
||||
await moveTo(id, 'third@example.com', 'Second recovery, verified against the shipping address.');
|
||||
|
||||
const res = await request(app).get(`/api/admin/customers/${id}/email-changes`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
// Newest first, because an account that has been moved twice is the one
|
||||
// worth looking at and the most recent move is the one in question.
|
||||
expect(res.body[0].new_email).toBe('third@example.com');
|
||||
expect(res.body[1].new_email).toBe('second@example.com');
|
||||
});
|
||||
|
||||
it('is empty for a customer whose address has never been moved', async () => {
|
||||
const id = await register('untouched@example.com');
|
||||
|
||||
const res = await request(app).get(`/api/admin/customers/${id}/email-changes`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('GET /api/admin/email-templates', () => {
|
||||
expect(res.body.map((t: { key: string }) => t.key).sort()).toEqual([
|
||||
'cartReminder',
|
||||
'emailChanged',
|
||||
'emailChangedByAdmin',
|
||||
'favoriteSold',
|
||||
'favoriteWithdrawn',
|
||||
'intakeDraft',
|
||||
|
||||
@@ -12,23 +12,86 @@ afterAll(async () => {
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
describe('unexpected route failures', () => {
|
||||
it('answers with 500 instead of leaving the request hanging', async () => {
|
||||
// A non-numeric id reaches Postgres as `WHERE i.id = 'not-a-number'`, which
|
||||
// raises invalid-input-syntax. Express 4 does not forward a rejected async
|
||||
// handler on its own, so without the asyncRoute wrapper plus the error
|
||||
// middleware this request never gets a response at all — and a hung request
|
||||
// renders as an empty storefront rather than a visible failure.
|
||||
const res = await request(app).get('/api/items/not-a-number');
|
||||
/**
|
||||
* The message the failing query rejects with. Deliberately shaped like a real
|
||||
* Postgres error and deliberately containing both words the leak test looks
|
||||
* for, so that assertion is checking something rather than passing because the
|
||||
* words happened not to appear.
|
||||
*/
|
||||
const DB_FAILURE = 'invalid input syntax for type integer while reading items';
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe('internal error');
|
||||
/**
|
||||
* Fails the next database call, whichever route makes it.
|
||||
*
|
||||
* This used to be done by asking `/api/items/not-a-number` and relying on that
|
||||
* route sending an unparseable id to Postgres. That worked, but it tied this
|
||||
* test to one route declining to validate its input: fixing that route — which
|
||||
* #307 wanted, and #207 had already done everywhere else — would have left this
|
||||
* test green while removing the thing it tests.
|
||||
*
|
||||
* A rejected query is the failure the error middleware actually exists for, and
|
||||
* it does not depend on any route being wrong. `pool` is a real object, so this
|
||||
* spy is not sensitive to how the module is transpiled.
|
||||
*/
|
||||
function failNextQuery() {
|
||||
// `as never` is the type system, not a shortcut. `pg` declares `query` with
|
||||
// several overloads, and `jest.spyOn` resolves the mock's argument against
|
||||
// the last of them, whose parameter list is empty — so the inferred type for
|
||||
// a rejection value is `never` and nothing can be assigned to it. Rejecting
|
||||
// is what all of the overloads do on failure; the cast only says which one to
|
||||
// check against.
|
||||
return jest.spyOn(pool, 'query').mockRejectedValueOnce(new Error(DB_FAILURE) as never);
|
||||
}
|
||||
|
||||
describe('unexpected route failures', () => {
|
||||
// Express 4 does not forward a rejected async handler on its own. Without the
|
||||
// asyncRoute wrapper plus the error middleware this request never gets a
|
||||
// response at all — and a hung request renders as an empty storefront rather
|
||||
// than a visible failure, which is exactly how the 2026-08-17 incident
|
||||
// presented.
|
||||
it('answers with 500 instead of leaving the request hanging', async () => {
|
||||
const spy = failNextQuery();
|
||||
try {
|
||||
const res = await request(app).get('/api/filters');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe('internal error');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not leak the underlying database error to the client', async () => {
|
||||
const spy = failNextQuery();
|
||||
try {
|
||||
const res = await request(app).get('/api/filters');
|
||||
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain('syntax');
|
||||
expect(body).not.toContain('items');
|
||||
// The whole message, not only the words above, so a future error format
|
||||
// cannot slip through by wording it differently.
|
||||
expect(body).not.toContain(DB_FAILURE);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// The route that used to be this file's trigger. It answers 404 now rather
|
||||
// than 500, because an id that cannot be read identifies nothing — asserted
|
||||
// here so the behaviour that replaced the trigger is itself covered.
|
||||
it('answers 404, not 500, for an id that cannot be read', async () => {
|
||||
const res = await request(app).get('/api/items/not-a-number');
|
||||
|
||||
expect(JSON.stringify(res.body)).not.toContain('syntax');
|
||||
expect(JSON.stringify(res.body)).not.toContain('items');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('not found');
|
||||
});
|
||||
|
||||
// These parse to positive integers and used to fetch real rows — /items/5.0
|
||||
// answered with item 5 (#307). Never a crash, which is why it went unnoticed.
|
||||
it.each(['5.0', '1e2', '0x10'])('answers 404 for %p rather than fetching a row', async (id) => {
|
||||
const res = await request(app).get(`/api/items/${id}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
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';
|
||||
const SUB = 'google-subject-1234567890';
|
||||
|
||||
let fetchSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
process.env.GOOGLE_CLIENT_ID = CLIENT_ID;
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'test-secret';
|
||||
process.env.PUBLIC_URL = 'http://localhost:3000';
|
||||
// Nothing here talks to Google. The exchange is the only network call in the
|
||||
// flow, so stubbing it leaves every decision this suite cares about — the
|
||||
// cookie, the state check, the claim checks, the lookup — running for real.
|
||||
fetchSpy = jest.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
delete process.env.GOOGLE_CLIENT_SECRET;
|
||||
delete process.env.PUBLIC_URL;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
/** An unsigned id token. Nothing in this flow reads a signature — see google/oauth.ts. */
|
||||
function idToken(claims: Record<string, unknown>): string {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`;
|
||||
}
|
||||
|
||||
function claimsFor(nonce: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
iss: 'https://accounts.google.com',
|
||||
aud: CLIENT_ID,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: SUB,
|
||||
nonce,
|
||||
email: 'customer@example.com',
|
||||
email_verified: true,
|
||||
given_name: 'Test',
|
||||
family_name: 'Customer',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function respondWithToken(token: string): void {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id_token: token }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** The attempt cookie the start route set, as a header for the callback. */
|
||||
function cookieHeader(setCookie: string[]): string {
|
||||
const attempt = setCookie.find((c) => c.startsWith('rd_oauth='));
|
||||
if (!attempt) throw new Error('the start route set no attempt cookie');
|
||||
// Non-null: the header was just matched, so it has at least one segment.
|
||||
return attempt.split(';')[0] as string;
|
||||
}
|
||||
|
||||
/** One Set-Cookie value, as a request header. Throws rather than typing around its absence. */
|
||||
function requireCookie(setCookie: string[], prefix: string): string {
|
||||
const found = setCookie.find((c) => c.startsWith(prefix));
|
||||
if (!found) throw new Error(`no ${prefix} cookie was set`);
|
||||
return found.split(';')[0] as string;
|
||||
}
|
||||
|
||||
/** Reads the secrets back out of the cookie, which is the only place they exist. */
|
||||
function attemptFrom(setCookie: string[]): { state: string; nonce: string; returnTo: string } {
|
||||
const value = cookieHeader(setCookie).slice('rd_oauth='.length);
|
||||
return JSON.parse(Buffer.from(decodeURIComponent(value), 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
/** Starts a sign-in and hands back what the callback needs to finish it. */
|
||||
async function startSignIn(returnTo?: string) {
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/start')
|
||||
.query(returnTo === undefined ? {} : { returnTo });
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
return { res, cookie: cookieHeader(setCookie), ...attemptFrom(setCookie) };
|
||||
}
|
||||
|
||||
async function createCustomer(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, unsubscribe_token)
|
||||
VALUES ($1, 'not-a-real-hash', 'Test', 'Customer', $2) RETURNING id`,
|
||||
[email, `unsub-${email}`]
|
||||
);
|
||||
return requireRow(rows, 'the customer this test just created').id;
|
||||
}
|
||||
|
||||
async function linkGoogle(customerId: number, sub = SUB): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub)
|
||||
VALUES ($1, 'google', $2)`,
|
||||
[customerId, sub]
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const target = new URL(res.headers.location as string);
|
||||
expect(target.origin).toBe('https://accounts.google.com');
|
||||
expect(target.searchParams.get('response_type')).toBe('code');
|
||||
expect(target.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
});
|
||||
|
||||
it('sets an httpOnly attempt cookie, which is the whole security of the callback', async () => {
|
||||
const res = await request(app).get('/api/auth/google/start');
|
||||
const attempt = (res.headers['set-cookie'] as unknown as string[]).find((c) =>
|
||||
c.startsWith('rd_oauth=')
|
||||
);
|
||||
|
||||
expect(attempt).toMatch(/HttpOnly/i);
|
||||
// Lax and not Strict. Strict withholds the cookie on the cross-site
|
||||
// top-level navigation back from Google, and every sign-in then fails the
|
||||
// state check in a way that looks exactly like tampering.
|
||||
expect(attempt).toMatch(/SameSite=Lax/i);
|
||||
});
|
||||
|
||||
it('never puts the client secret anywhere the browser can see', async () => {
|
||||
const res = await request(app).get('/api/auth/google/start');
|
||||
|
||||
expect(res.headers.location).not.toContain('test-secret');
|
||||
expect(JSON.stringify(res.headers['set-cookie'])).not.toContain('test-secret');
|
||||
});
|
||||
|
||||
it('carries a local return path through the round trip', async () => {
|
||||
const { returnTo } = await startSignIn('/?max_price=50000');
|
||||
|
||||
expect(returnTo).toBe('/?max_price=50000');
|
||||
});
|
||||
|
||||
it('refuses an off-site return path rather than becoming an open redirect', async () => {
|
||||
const { returnTo } = await startSignIn('//evil.test');
|
||||
|
||||
expect(returnTo).toBe('/');
|
||||
});
|
||||
|
||||
it('sends the customer to the storefront when Google sign-in is switched off', async () => {
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
delete process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
const res = await request(app).get('/api/auth/google/start');
|
||||
|
||||
// A stale bookmark or a hand-typed URL, and the storefront answers both.
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/');
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/google/callback', () => {
|
||||
it('signs in a customer whose Google identity is already linked', async () => {
|
||||
const customerId = await createCustomer('linked@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn('/cart');
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/cart');
|
||||
const session = (res.headers['set-cookie'] as unknown as string[]).find((c) =>
|
||||
c.startsWith('rd_session=')
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
});
|
||||
|
||||
it('produces a session the rest of the application accepts', async () => {
|
||||
const customerId = await createCustomer('session@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const session = requireCookie(setCookie, 'rd_session=');
|
||||
|
||||
// The point of sharing signIn with the password and passkey paths: the
|
||||
// session is not merely present, it is the same kind of session.
|
||||
const me = await request(app).get('/api/customers/me').set('Cookie', session);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.email).toBe('session@example.com');
|
||||
});
|
||||
|
||||
it('stamps last_used_at, which is what tells two identities apart', async () => {
|
||||
const customerId = await createCustomer('stamped@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
const { rows } = await pool.query<{ last_used_at: Date | null }>(
|
||||
`SELECT last_used_at FROM customer_identities WHERE provider_sub = $1`,
|
||||
[SUB]
|
||||
);
|
||||
expect(requireRow(rows, 'the identity just used').last_used_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the attempt cookie, so one attempt cannot be replayed', async () => {
|
||||
const customerId = await createCustomer('once@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const first = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
const cleared = (first.headers['set-cookie'] as unknown as string[]).find((c) =>
|
||||
c.startsWith('rd_oauth=')
|
||||
);
|
||||
expect(cleared).toMatch(/rd_oauth=;/);
|
||||
});
|
||||
|
||||
it('refuses a state that does not match the attempt cookie', async () => {
|
||||
const { cookie } = await startSignIn();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state: 'not-the-state-we-issued' })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
// Refused before any network call: a mismatched state is not worth a token
|
||||
// exchange, and spending the code would be handing it to whoever forged it.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a callback carrying no attempt cookie at all', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state: 'anything' });
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a declined consent screen as a cancellation, not a failure', async () => {
|
||||
const { cookie } = await startSignIn('/cart');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ error: 'access_denied' })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
// Back where they were, with nothing said. A customer who changed their
|
||||
// mind has not encountered an error, which is the distinction #41 draws
|
||||
// for a dismissed passkey prompt.
|
||||
expect(res.headers.location).toBe('/cart');
|
||||
});
|
||||
|
||||
it('refuses an id token minted for a different application', async () => {
|
||||
const customerId = await createCustomer('wrongaud@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce, { aud: 'someone-else.apps.googleusercontent.com' })));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
|
||||
});
|
||||
|
||||
it('refuses an id token from a different sign-in attempt', async () => {
|
||||
const customerId = await createCustomer('wrongnonce@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor('a-nonce-from-somewhere-else')));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
});
|
||||
|
||||
it('refuses when Google declines the token exchange', async () => {
|
||||
const { cookie, state } = await startSignIn();
|
||||
fetchSpy.mockResolvedValue(new Response('{"error":"invalid_grant"}', { status: 400 }));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'a-spent-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
});
|
||||
|
||||
it('refuses a disabled account, as the password and passkey paths do', async () => {
|
||||
const customerId = await createCustomer('disabled@example.com');
|
||||
await linkGoogle(customerId);
|
||||
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
// Enforcing it on some sign-in routes and not others is how a disabled
|
||||
// account keeps a way in.
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
|
||||
});
|
||||
|
||||
it('refuses when the address already belongs to an account, and creates nothing', async () => {
|
||||
await createCustomer('unlinked@example.com');
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce, { email: 'unlinked@example.com' })));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
// Joining those two accounts is linking, which is #343. Doing it here on
|
||||
// the strength of a matching address is the takeover path that decision
|
||||
// exists to reason about carefully.
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_identities`
|
||||
);
|
||||
expect(requireRow(rows, 'a count of identities').n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #342. A Google account nobody here has seen becomes a customer.
|
||||
*
|
||||
* The consent behaviour is most of what these assert, because it is the part
|
||||
* that is easy to get quietly wrong: an account created with a consent nobody
|
||||
* gave, or with wording that does not match what the customer was shown, is
|
||||
* still an account that works.
|
||||
*/
|
||||
describe('signing up with Google', () => {
|
||||
async function signUpWith(overrides: Record<string, unknown> = {}) {
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce, overrides)));
|
||||
return request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
}
|
||||
|
||||
async function customerBy(email: string) {
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email_verified: boolean;
|
||||
password_hash: string | null;
|
||||
marketing_consent: boolean;
|
||||
marketing_consent_text: string | null;
|
||||
analytics_consent: boolean;
|
||||
analytics_consent_text: string | null;
|
||||
}>(`SELECT * FROM customers WHERE email = $1`, [email]);
|
||||
return requireRow(rows, `the customer for ${email}`);
|
||||
}
|
||||
|
||||
async function countOf(table: 'customers' | 'customer_identities'): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM ${table}`);
|
||||
return requireRow(rows, `a count of ${table}`).n;
|
||||
}
|
||||
|
||||
async function verificationTokens(customerId: number): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[customerId]
|
||||
);
|
||||
return requireRow(rows, 'a count of verification tokens').n;
|
||||
}
|
||||
|
||||
it('creates a customer and an identity, and signs them in', async () => {
|
||||
const res = await signUpWith({ email: 'brandnew@example.com' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const customer = await customerBy('brandnew@example.com');
|
||||
const { rows } = await pool.query<{ customer_id: number }>(
|
||||
`SELECT customer_id FROM customer_identities WHERE provider_sub = $1`,
|
||||
[SUB]
|
||||
);
|
||||
expect(requireRow(rows, 'the new identity').customer_id).toBe(customer.id);
|
||||
expect(res.headers['set-cookie'] as unknown as string[]).toContainEqual(
|
||||
expect.stringContaining('rd_session=')
|
||||
);
|
||||
});
|
||||
|
||||
it('lands the new customer on the consent step rather than the storefront', async () => {
|
||||
// The one moment the two consent sentences can honestly be shown: the
|
||||
// redirect to Google happened before anyone knew this person was new.
|
||||
const res = await signUpWith({ email: 'consentstep@example.com' });
|
||||
|
||||
expect(res.headers.location).toBe('/welcome');
|
||||
});
|
||||
|
||||
it('creates the account with no password at all', async () => {
|
||||
await signUpWith({ email: 'nopassword@example.com' });
|
||||
|
||||
// The first accounts in this project's history without one. #344 is where
|
||||
// the routes that assumed otherwise learn to cope.
|
||||
expect((await customerBy('nopassword@example.com')).password_hash).toBeNull();
|
||||
});
|
||||
|
||||
it('gives both consents as false, with no stored wording', async () => {
|
||||
await signUpWith({ email: 'noconsent@example.com' });
|
||||
const customer = await customerBy('noconsent@example.com');
|
||||
|
||||
// Nobody agreed to anything, so nothing is recorded as though they had. A
|
||||
// stored wording against a false consent would be a record of a
|
||||
// conversation that never happened.
|
||||
expect(customer.marketing_consent).toBe(false);
|
||||
expect(customer.analytics_consent).toBe(false);
|
||||
expect(customer.marketing_consent_text).toBeNull();
|
||||
expect(customer.analytics_consent_text).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the names from the Google profile', async () => {
|
||||
await signUpWith({ email: 'named@example.com' });
|
||||
const customer = await customerBy('named@example.com');
|
||||
|
||||
expect(customer.first_name).toBe('Test');
|
||||
expect(customer.last_name).toBe('Customer');
|
||||
});
|
||||
|
||||
it('creates the account anyway when Google sends no names', async () => {
|
||||
// Registration demands both because every email greets by first name, but
|
||||
// Google may return neither and refusing over it would be absurd — the
|
||||
// greeting already has a fallback for exactly this.
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
const claims = claimsFor(nonce, { email: 'nameless@example.com' }) as Record<string, unknown>;
|
||||
delete claims.given_name;
|
||||
delete claims.family_name;
|
||||
respondWithToken(idToken(claims));
|
||||
|
||||
await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
const customer = await customerBy('nameless@example.com');
|
||||
expect(customer.first_name).toBeNull();
|
||||
expect(customer.last_name).toBeNull();
|
||||
});
|
||||
|
||||
describe('the verified address', () => {
|
||||
it('is marked verified, and sends no confirmation email, when Google vouches', async () => {
|
||||
await signUpWith({ email: 'vouched@example.com', email_verified: true });
|
||||
const customer = await customerBy('vouched@example.com');
|
||||
|
||||
expect(customer.email_verified).toBe(true);
|
||||
// The confirmation email exists to prove the customer receives mail at
|
||||
// the address. Google has just proved exactly that, so sending one would
|
||||
// ask them to do a thing that is already done.
|
||||
expect(await verificationTokens(customer.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('is unverified, and does send one, when Google does not', async () => {
|
||||
await signUpWith({ email: 'unvouched@example.com', email_verified: false });
|
||||
const customer = await customerBy('unvouched@example.com');
|
||||
|
||||
// An unverified assertion is worth nothing, so this account goes through
|
||||
// the ordinary confirmation exactly as a password sign-up would.
|
||||
expect(customer.email_verified).toBe(false);
|
||||
expect(await verificationTokens(customer.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('reaches the same customer on a second sign-in, not a second account', async () => {
|
||||
await signUpWith({ email: 'returning@example.com' });
|
||||
const first = await customerBy('returning@example.com');
|
||||
|
||||
const second = await signUpWith({ email: 'returning@example.com' });
|
||||
|
||||
// Signed in, and back to the storefront rather than the consent step —
|
||||
// which is shown once, to somebody who has just been created.
|
||||
expect(second.headers.location).toBe('/');
|
||||
expect(await countOf('customers')).toBe(1);
|
||||
expect((await customerBy('returning@example.com')).id).toBe(first.id);
|
||||
});
|
||||
|
||||
it('reaches the same customer even after they change their Google address', async () => {
|
||||
await signUpWith({ email: 'was@example.com' });
|
||||
const original = await customerBy('was@example.com');
|
||||
|
||||
// Matched on the subject, which is the whole reason that column exists. An
|
||||
// email match would have created a second account here — and an address
|
||||
// that had since been reassigned would have handed this one to a stranger.
|
||||
const second = await signUpWith({ email: 'now@example.com' });
|
||||
|
||||
expect(second.headers.location).toBe('/');
|
||||
expect(await countOf('customers')).toBe(1);
|
||||
expect((await customerBy('was@example.com')).id).toBe(original.id);
|
||||
});
|
||||
|
||||
it('still refuses a disabled account, which a sign-up must not route around', async () => {
|
||||
const customerId = await createCustomer('blocked@example.com');
|
||||
await linkGoogle(customerId, SUB);
|
||||
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
||||
|
||||
const res = await signUpWith({ email: 'blocked@example.com' });
|
||||
|
||||
// The identity exists, so this takes the sign-in path and is refused
|
||||
// there. No second account is created as a way around it.
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(await countOf('customers')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool } from '../../src/db';
|
||||
import { pool, requireRow } from '../../src/db';
|
||||
import { createSession } from '../../src/customerSession';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -204,4 +205,131 @@ describe('POST /api/customers/reset-password', () => {
|
||||
const retry = await request(app).post('/api/customers/reset-password').send({ token, password: 'long-enough-password' });
|
||||
expect(retry.status).toBe(200);
|
||||
});
|
||||
|
||||
// #42. A reset is the recovery path, so it has to leave the account with no
|
||||
// way in that the customer did not just establish. Sessions were already
|
||||
// covered above; a passkey outlives a session without bound.
|
||||
describe('and the passkeys on the account', () => {
|
||||
async function customerId(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||
return requireRow(rows, 'the customer this test just registered').id;
|
||||
}
|
||||
|
||||
// Registering one for real needs an authenticator, which no test has. The
|
||||
// row is what the reset acts on, so the row is what these insert.
|
||||
async function giveAPasskey(id: number, credentialId: string): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, $2, 'not-a-real-key', 'Test key')`,
|
||||
[id, credentialId]
|
||||
);
|
||||
}
|
||||
|
||||
async function passkeyCount(id: number): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
return requireRow(rows, 'a count of credentials').n;
|
||||
}
|
||||
|
||||
it('removes every passkey, so one an intruder registered does not survive it', async () => {
|
||||
await register('haskeys@example.com');
|
||||
const id = await customerId('haskeys@example.com');
|
||||
await giveAPasskey(id, 'credential-one');
|
||||
await giveAPasskey(id, 'credential-two');
|
||||
|
||||
const token = await requestReset('haskeys@example.com');
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await passkeyCount(id)).toBe(0);
|
||||
});
|
||||
|
||||
it('says how many it removed, because nothing else can report it afterwards', async () => {
|
||||
await register('counted@example.com');
|
||||
const id = await customerId('counted@example.com');
|
||||
await giveAPasskey(id, 'credential-counted');
|
||||
|
||||
const token = await requestReset('counted@example.com');
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
// The rows are gone by the time the customer could go and look, so a
|
||||
// reset that removed something and said nothing would hide exactly the
|
||||
// case worth knowing about.
|
||||
expect(res.body.passkeysRemoved).toBe(1);
|
||||
});
|
||||
|
||||
it('reports zero for a customer who never registered one', async () => {
|
||||
await register('nokeys@example.com');
|
||||
|
||||
const token = await requestReset('nokeys@example.com');
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
// The notice on the reset form is shown on this number, so zero has to
|
||||
// mean zero rather than undefined.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.passkeysRemoved).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves another customer’s passkeys alone', async () => {
|
||||
await register('mine@example.com');
|
||||
await register('theirs@example.com');
|
||||
const mine = await customerId('mine@example.com');
|
||||
const theirs = await customerId('theirs@example.com');
|
||||
await giveAPasskey(mine, 'credential-mine');
|
||||
await giveAPasskey(theirs, 'credential-theirs');
|
||||
|
||||
const token = await requestReset('mine@example.com');
|
||||
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
expect(await passkeyCount(theirs)).toBe(1);
|
||||
});
|
||||
|
||||
it('clears a challenge in flight, so a registration cannot land after the reset', async () => {
|
||||
await register('inflight@example.com');
|
||||
const id = await customerId('inflight@example.com');
|
||||
await pool.query(
|
||||
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
|
||||
VALUES ('challenge-in-flight', $1, 'registration', now() + interval '5 minutes')`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const token = await requestReset('inflight@example.com');
|
||||
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
// Otherwise an intruder who pressed "add a passkey" moments earlier could
|
||||
// finish the ceremony afterwards and put a credential back on the account
|
||||
// the reset had just cleared.
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM webauthn_challenges WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(rows, 'a count of challenges').n).toBe(0);
|
||||
});
|
||||
|
||||
it('terminates a session established by a passkey, not only one from a password', async () => {
|
||||
await register('passkeysession@example.com');
|
||||
const id = await customerId('passkeysession@example.com');
|
||||
|
||||
// The call the passkey login route makes. Not an imitation of it — the
|
||||
// same function, so this asserts the shared session path rather than
|
||||
// asserting that two paths happen to agree today.
|
||||
const passkeySession = await createSession(id);
|
||||
const asPasskeyHolder = () =>
|
||||
request(app).get('/api/customers/me').set('Cookie', `rd_session=${passkeySession}`);
|
||||
expect((await asPasskeyHolder()).status).toBe(200);
|
||||
|
||||
const token = await requestReset('passkeysession@example.com');
|
||||
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
expect((await asPasskeyHolder()).status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe('when the database loses its schema', () => {
|
||||
|
||||
// The count and a few names, not the whole list: the point is that a reader
|
||||
// can tell at a glance this is a missing schema rather than a logic bug.
|
||||
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/Missing 18 of 18 tables/);
|
||||
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/Missing 22 of 22 tables/);
|
||||
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/items/);
|
||||
|
||||
await migrate();
|
||||
|
||||
@@ -23,9 +23,52 @@ async function waitForDb(retries = 20): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every address the database host resolves to, and which server answered (#154).
|
||||
*
|
||||
* This is the measurement that issue has needed twice and never had. The
|
||||
* schema in a failing run comes back — a suite fails on a missing `orders`,
|
||||
* and a later suite truncating that same table passes — which a database
|
||||
* losing its schema cannot do. More than one server answering to one name can,
|
||||
* and Docker's embedded DNS round-robins every container sharing an alias, so
|
||||
* a leftover service container from an earlier run fits every observation
|
||||
* including the empty `dmesg`.
|
||||
*
|
||||
* **More than one address printed here is the answer outright.** One address,
|
||||
* and this reading is wrong too — the next suspect is a single container being
|
||||
* restarted with a fresh data directory, which the postmaster start time
|
||||
* recorded alongside will show.
|
||||
*
|
||||
* Logged unconditionally rather than only on failure: a passing run's addresses
|
||||
* are the control, and without them a failing run's have nothing to be compared
|
||||
* against. Never throws — a diagnostic that can fail the run it was added to
|
||||
* explain is worse than none.
|
||||
*/
|
||||
async function reportDatabaseIdentity(): Promise<void> {
|
||||
const host = process.env.TEST_PGHOST || 'localhost';
|
||||
try {
|
||||
const { lookup } = await import('dns/promises');
|
||||
const addresses = await lookup(host, { all: true });
|
||||
const rendered = addresses.map((a) => a.address).join(', ');
|
||||
console.info(
|
||||
`[#154] "${host}" resolves to ${addresses.length} address(es): ${rendered}` +
|
||||
(addresses.length > 1 ? ' <-- more than one server can answer; this is the bug' : '')
|
||||
);
|
||||
} catch (err) {
|
||||
console.info(`[#154] could not resolve "${host}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
await waitForDb();
|
||||
const { migrate, closeDb, assertSchemaPresent } = await import('./testDb');
|
||||
const { migrate, closeDb, assertSchemaPresent, describeBackend } = await import('./testDb');
|
||||
|
||||
await reportDatabaseIdentity();
|
||||
// Recorded before migrating so the run's baseline is in the log even if the
|
||||
// migration is the thing that fails. A suite that later reports a different
|
||||
// postmaster start time is talking to a different instance.
|
||||
console.info(`[#154] ${await describeBackend('globalSetup reached')}`);
|
||||
|
||||
await migrate();
|
||||
|
||||
// Cheap, once, and it establishes the fact the rest of the run depends on:
|
||||
|
||||
@@ -37,6 +37,9 @@ const REQUIRED_TABLES = [
|
||||
'categories',
|
||||
'checkout_items',
|
||||
'checkouts',
|
||||
'customer_credentials',
|
||||
'customer_email_changes',
|
||||
'customer_identities',
|
||||
'customer_sessions',
|
||||
'customer_tokens',
|
||||
'customers',
|
||||
@@ -48,7 +51,8 @@ const REQUIRED_TABLES = [
|
||||
'orders',
|
||||
'shipping_addresses',
|
||||
'tags',
|
||||
'upload_links'
|
||||
'upload_links',
|
||||
'webauthn_challenges'
|
||||
] as const;
|
||||
|
||||
/** Which of them the database does not currently have. */
|
||||
@@ -75,21 +79,68 @@ async function missingTables(): Promise<string[]> {
|
||||
* can be fixed from here: whatever the cause, the next occurrence should read as
|
||||
* "the database lost its schema" on the first line.
|
||||
*/
|
||||
/**
|
||||
* Which Postgres actually answered, rather than which one we asked for (#154).
|
||||
*
|
||||
* The reason this exists: the schema in a failing run **comes back**. A suite
|
||||
* fails because `orders` does not exist, and a later suite truncating the same
|
||||
* table passes. A dropped database does not un-drop itself, so more than one
|
||||
* server must be answering to the same name — Docker's embedded DNS round-robins
|
||||
* every container sharing an alias, so a leftover service container from an
|
||||
* earlier run would produce exactly this.
|
||||
*
|
||||
* `pg_postmaster_start_time()` is what settles it and needs no special rights:
|
||||
* two Postgres instances cannot share one. Differing values across a single run
|
||||
* are proof outright, where a differing `inet_server_addr` alone could be argued
|
||||
* to be one container that moved.
|
||||
*
|
||||
* Never throws. This is a diagnostic, and a diagnostic that can fail a run it
|
||||
* was added to explain is worse than no diagnostic.
|
||||
*/
|
||||
export async function describeBackend(label: string): Promise<string> {
|
||||
try {
|
||||
const { rows } = await testPool.query<{
|
||||
addr: string | null;
|
||||
started: string;
|
||||
pid: number;
|
||||
db: string;
|
||||
}>(
|
||||
`SELECT inet_server_addr()::text AS addr,
|
||||
pg_postmaster_start_time()::text AS started,
|
||||
pg_backend_pid() AS pid,
|
||||
current_database() AS db`
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return `${label}: no row returned`;
|
||||
return `${label}: db=${row.db} addr=${row.addr ?? 'local'} postmaster_start=${row.started} pid=${row.pid}`;
|
||||
} catch (err) {
|
||||
return `${label}: could not be identified (${err instanceof Error ? err.message : String(err)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSchemaPresent(context: string): Promise<void> {
|
||||
const missing = await missingTables();
|
||||
if (missing.length === 0) return;
|
||||
|
||||
// Gathered only on the failure path, which is the one worth paying for, and
|
||||
// is where #154 has repeatedly lacked the one fact that would identify it.
|
||||
const backend = await describeBackend('Answering server');
|
||||
|
||||
throw new Error(
|
||||
`The test database has no schema (${context}).
|
||||
|
||||
` +
|
||||
`Missing ${missing.length} of ${REQUIRED_TABLES.length} tables: ${missing.join(', ')}.
|
||||
|
||||
` +
|
||||
`${backend}
|
||||
|
||||
` +
|
||||
`Migrations ran at the start of this run, so the schema existed and has since gone. ` +
|
||||
`Nothing in this suite drops tables — TRUNCATE does not — so the database itself was ` +
|
||||
`replaced or restarted underneath the run. On CI the likeliest cause is the Postgres ` +
|
||||
`service container being recreated, which comes back with an empty data directory. ` +
|
||||
`Nothing in this suite drops tables — TRUNCATE does not. Compare the postmaster start ` +
|
||||
`time above against the one globalSetup logged: if they differ, this is a different ` +
|
||||
`Postgres instance answering to the same name rather than one database losing its ` +
|
||||
`schema, and the schema was never lost at all. ` +
|
||||
`See #154.`
|
||||
);
|
||||
}
|
||||
@@ -104,6 +155,7 @@ export async function resetDb(): Promise<void> {
|
||||
await testPool.query(`
|
||||
TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts,
|
||||
shipping_addresses, cart_items, carts, customer_tokens, customer_sessions, favorites,
|
||||
webauthn_challenges, customer_credentials, customer_email_changes, customer_identities,
|
||||
customers, item_tags, item_images, items, tags, categories
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
defaultCredentialName,
|
||||
readCredentialName,
|
||||
GENERIC_CREDENTIAL_NAME,
|
||||
MAX_CREDENTIAL_NAME_LENGTH
|
||||
} from '../../src/passkeys/credentialName';
|
||||
|
||||
/**
|
||||
* The management screen (#40) lists credentials and offers to revoke them, so a
|
||||
* default that says nothing produces a list of identical rows and a customer
|
||||
* removing a device at random. These are about that list being readable.
|
||||
*/
|
||||
describe('defaultCredentialName', () => {
|
||||
it('calls a cross-device authenticator a phone or tablet', () => {
|
||||
expect(defaultCredentialName(['hybrid'])).toBe('Phone or tablet');
|
||||
});
|
||||
|
||||
it('prefers hybrid over internal when both are reported', () => {
|
||||
// A phone used as a cross-device passkey commonly reports both, and "Phone
|
||||
// or tablet" is the more useful reading — `internal` on its own means the
|
||||
// authenticator built into the machine in front of the customer.
|
||||
expect(defaultCredentialName(['internal', 'hybrid'])).toBe('Phone or tablet');
|
||||
});
|
||||
|
||||
it('calls a platform authenticator this device', () => {
|
||||
expect(defaultCredentialName(['internal'])).toBe('This device');
|
||||
});
|
||||
|
||||
it.each([['usb'], ['nfc'], ['ble']])('calls %s a security key', (transport) => {
|
||||
expect(defaultCredentialName([transport])).toBe('Security key');
|
||||
});
|
||||
|
||||
it.each([[[]], [null], [undefined], [['something-new']]])(
|
||||
'falls back to the generic name for %p',
|
||||
(transports) => {
|
||||
// Transports are a hint the authenticator supplies, so an unrecognised
|
||||
// one is expected rather than exceptional — the list grows.
|
||||
expect(defaultCredentialName(transports)).toBe(GENERIC_CREDENTIAL_NAME);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('readCredentialName', () => {
|
||||
it('takes a name the customer gave', () => {
|
||||
expect(readCredentialName('Work laptop')).toBe('Work laptop');
|
||||
});
|
||||
|
||||
it('trims, because a padded name is not a different name', () => {
|
||||
expect(readCredentialName(' Work laptop ')).toBe('Work laptop');
|
||||
});
|
||||
|
||||
it.each([[' '], [''], [null], [undefined], [42], [{}]])(
|
||||
'returns null for %p so the caller falls back to a default',
|
||||
(value) => {
|
||||
// A name of spaces is a row the customer cannot read, and a non-string is
|
||||
// a client sending something unexpected. Both mean "no name given".
|
||||
expect(readCredentialName(value)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('bounds the length, because this is rendered in a table cell', () => {
|
||||
const long = 'a'.repeat(MAX_CREDENTIAL_NAME_LENGTH + 50);
|
||||
|
||||
expect(readCredentialName(long)).toHaveLength(MAX_CREDENTIAL_NAME_LENGTH);
|
||||
});
|
||||
});
|
||||
@@ -265,7 +265,7 @@ describe('greeting, built from the configured format', () => {
|
||||
});
|
||||
|
||||
describe('every template can address the customer', () => {
|
||||
// Not KEYS: this is an invariant of the six customer-facing templates only.
|
||||
// Not KEYS: this is an invariant of the customer-facing templates only.
|
||||
// intakeDraft and uploadLink notify the shop and a contributor respectively,
|
||||
// not a customer with a name on file, so they are deliberately not held to
|
||||
// it — a hardcoded list is correct here rather than a staleness risk,
|
||||
@@ -277,7 +277,8 @@ describe('every template can address the customer', () => {
|
||||
'favoriteSold',
|
||||
'favoriteWithdrawn',
|
||||
'cartReminder',
|
||||
'emailChanged'
|
||||
'emailChanged',
|
||||
'emailChangedByAdmin'
|
||||
];
|
||||
|
||||
it.each(CUSTOMER_FACING_KEYS)('%s offers greeting, firstName and lastName', (key) => {
|
||||
|
||||
@@ -251,4 +251,44 @@ describe('UPLOADS_BASE_URL', () => {
|
||||
expect(warnings.join(' ')).not.toMatch(/INTAKE_ACTION_SECRET/);
|
||||
});
|
||||
});
|
||||
|
||||
// #340. All or nothing, and half-configured is the case worth catching: the
|
||||
// failure would otherwise arrive when a customer presses the button.
|
||||
describe('Google sign-in credentials', () => {
|
||||
const BOTH = { GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'shh', PUBLIC_URL: 'https://x.test' };
|
||||
|
||||
it('are not required', () => {
|
||||
expect(validateEnv(MINIMAL).errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('warn when absent, so a missing button has a stated reason', () => {
|
||||
expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/Google sign-in is not configured/);
|
||||
});
|
||||
|
||||
it('refuse to start with only the client id', () => {
|
||||
const { errors } = validateEnv(withEnv({ GOOGLE_CLIENT_ID: 'id' }));
|
||||
expect(errors.join(' ')).toMatch(/GOOGLE_CLIENT_SECRET is required/);
|
||||
});
|
||||
|
||||
it('refuse to start with only the client secret', () => {
|
||||
const { errors } = validateEnv(withEnv({ GOOGLE_CLIENT_SECRET: 'shh' }));
|
||||
expect(errors.join(' ')).toMatch(/GOOGLE_CLIENT_ID is required/);
|
||||
});
|
||||
|
||||
it('are accepted when both are set alongside PUBLIC_URL', () => {
|
||||
const { errors, warnings } = validateEnv(withEnv(BOTH));
|
||||
expect(errors).toEqual([]);
|
||||
expect(warnings.join(' ')).not.toMatch(/Google sign-in/);
|
||||
});
|
||||
|
||||
it('warn when configured without PUBLIC_URL, because the callback falls back to localhost', () => {
|
||||
// Correct locally and wrong everywhere else, which is precisely the shape
|
||||
// that needs saying out loud rather than failing at Google later.
|
||||
const { errors, warnings } = validateEnv(
|
||||
withEnv({ GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'shh' })
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
expect(warnings.join(' ')).toMatch(/redirect URI falls back to localhost/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { googleConfig, GOOGLE_CALLBACK_PATH } from '../../src/google/config';
|
||||
|
||||
const CREDENTIALS = { GOOGLE_CLIENT_ID: 'id.apps.googleusercontent.com', GOOGLE_CLIENT_SECRET: 'shh' };
|
||||
|
||||
/**
|
||||
* The redirect URI is compared by Google as an exact string, and a mismatch
|
||||
* answers `redirect_uri_mismatch` — accurate, and silent about which half is
|
||||
* wrong. So these assert the derivation itself rather than that the function
|
||||
* returns something, and most of them are about what must *not* end up in it.
|
||||
*/
|
||||
describe('googleConfig', () => {
|
||||
it('builds the redirect URI from PUBLIC_URL', () => {
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'https://redefined-designs.com' });
|
||||
|
||||
expect(config.redirectUri).toBe(`https://redefined-designs.com${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('normalises a trailing slash away, rather than producing a double slash', () => {
|
||||
// The failure this prevents is worth naming: "https://x.com//api/..." is a
|
||||
// different string from the one registered in the console, and it is an
|
||||
// easy way to write PUBLIC_URL.
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'https://redefined-designs.com/' });
|
||||
|
||||
expect(config.redirectUri).toBe(`https://redefined-designs.com${GOOGLE_CALLBACK_PATH}`);
|
||||
expect(config.redirectUri).not.toContain('//api');
|
||||
});
|
||||
|
||||
it('discards a path on PUBLIC_URL', () => {
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'https://redefined-designs.com/shop' });
|
||||
|
||||
expect(config.redirectUri).toBe(`https://redefined-designs.com${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('keeps a non-default port, because the console entry carries one too', () => {
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'http://localhost:5173' });
|
||||
|
||||
expect(config.redirectUri).toBe(`http://localhost:5173${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('falls back to localhost when PUBLIC_URL is unset', () => {
|
||||
// Local development legitimately has no PUBLIC_URL: envValidation only
|
||||
// demands it alongside SMTP. localhost is also the one host Google accepts
|
||||
// without an authorized domain, so the fallback is the only value that
|
||||
// could work here anyway.
|
||||
const config = googleConfig({ ...CREDENTIALS });
|
||||
|
||||
expect(config.redirectUri).toBe(`http://localhost:3000${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('refuses a PUBLIC_URL that is not a URL', () => {
|
||||
expect(() => googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'redefined-designs.com' })).toThrow(
|
||||
/not a URL/
|
||||
);
|
||||
});
|
||||
|
||||
describe('enabled', () => {
|
||||
it('is true only when both credentials are present', () => {
|
||||
expect(googleConfig(CREDENTIALS).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when neither is set, which is a legitimate environment', () => {
|
||||
// A developer without credentials gets a storefront that works and does
|
||||
// not offer the button, rather than one that offers it and fails.
|
||||
expect(googleConfig({}).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['only the id', { GOOGLE_CLIENT_ID: 'id' }],
|
||||
['only the secret', { GOOGLE_CLIENT_SECRET: 'shh' }]
|
||||
])('is false with %s', (_label, env) => {
|
||||
expect(googleConfig(env).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('treats whitespace as absent', () => {
|
||||
// A stack variable set to an empty string is the same as one never set,
|
||||
// and it is the shape a copied-and-blanked entry takes.
|
||||
expect(googleConfig({ GOOGLE_CLIENT_ID: ' ', GOOGLE_CLIENT_SECRET: 'shh' }).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { authorizationUrl, codeChallenge, newAttempt, verifiedIdentity } from '../../src/google/oauth';
|
||||
import type { GoogleConfig } from '../../src/google/config';
|
||||
|
||||
const CONFIG: GoogleConfig = {
|
||||
clientId: 'id.apps.googleusercontent.com',
|
||||
clientSecret: 'shh',
|
||||
redirectUri: 'https://redefined-designs.com/api/auth/google/callback',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const NONCE = 'the-nonce-for-this-attempt';
|
||||
|
||||
/** An id token with the given claims. Unsigned, because nothing here reads a signature. */
|
||||
function idToken(claims: Record<string, unknown>): string {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`;
|
||||
}
|
||||
|
||||
const VALID = {
|
||||
iss: 'https://accounts.google.com',
|
||||
aud: CONFIG.clientId,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: '1234567890',
|
||||
nonce: NONCE,
|
||||
email: 'Customer@Example.com',
|
||||
email_verified: true,
|
||||
given_name: 'Test',
|
||||
family_name: 'Customer'
|
||||
};
|
||||
|
||||
/** VALID minus one claim, for the tests about a claim being absent. */
|
||||
function without(claim: keyof typeof VALID): Record<string, unknown> {
|
||||
const copy: Record<string, unknown> = { ...VALID };
|
||||
delete copy[claim];
|
||||
return copy;
|
||||
}
|
||||
|
||||
|
||||
describe('authorizationUrl', () => {
|
||||
const attempt = { state: 'st', nonce: 'no', codeVerifier: 'ver' };
|
||||
|
||||
it('sends the customer to Google with the code flow and PKCE', () => {
|
||||
const url = new URL(authorizationUrl(CONFIG, attempt));
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('client_id')).toBe(CONFIG.clientId);
|
||||
expect(url.searchParams.get('redirect_uri')).toBe(CONFIG.redirectUri);
|
||||
expect(url.searchParams.get('state')).toBe('st');
|
||||
expect(url.searchParams.get('nonce')).toBe('no');
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(url.searchParams.get('code_challenge')).toBe(codeChallenge('ver'));
|
||||
});
|
||||
|
||||
it('asks for exactly the three non-sensitive scopes', () => {
|
||||
// Anything beyond these turns publishing into a verification review with a
|
||||
// video walkthrough and a wait measured in weeks.
|
||||
const url = new URL(authorizationUrl(CONFIG, attempt));
|
||||
|
||||
expect(url.searchParams.get('scope')?.split(' ').sort()).toEqual(['email', 'openid', 'profile']);
|
||||
});
|
||||
|
||||
it('never asks for offline access', () => {
|
||||
// A refresh token would be a long-lived credential with nothing to spend it
|
||||
// on. Google issues one only when asked, so the check is that we do not ask.
|
||||
const url = new URL(authorizationUrl(CONFIG, attempt));
|
||||
|
||||
expect(url.searchParams.get('access_type')).toBeNull();
|
||||
expect(url.searchParams.get('prompt')).toBeNull();
|
||||
});
|
||||
|
||||
it('never puts the client secret in a URL the browser follows', () => {
|
||||
expect(authorizationUrl(CONFIG, attempt)).not.toContain(CONFIG.clientSecret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newAttempt', () => {
|
||||
it('mints three different secrets', () => {
|
||||
// Three rather than one reused: they are checked by different parties at
|
||||
// different moments, and a single value would mean anything learning it
|
||||
// from one check satisfies the others.
|
||||
const { state, nonce, codeVerifier } = newAttempt();
|
||||
|
||||
expect(new Set([state, nonce, codeVerifier]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('does not repeat itself', () => {
|
||||
expect(newAttempt().state).not.toBe(newAttempt().state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeChallenge', () => {
|
||||
it('is the base64url SHA-256 of the verifier, which is what S256 means', () => {
|
||||
const verifier = 'a-verifier';
|
||||
|
||||
expect(codeChallenge(verifier)).toBe(
|
||||
crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The security of the whole flow lives here.
|
||||
*
|
||||
* No signature is verified, because the token arrives on a direct TLS
|
||||
* connection to Google's token endpoint — the case OpenID Connect explicitly
|
||||
* permits skipping it. That makes every one of these claim checks load-bearing
|
||||
* rather than belt-and-braces, so each has a test naming what accepting it
|
||||
* blindly would allow.
|
||||
*/
|
||||
describe('verifiedIdentity', () => {
|
||||
const expected = { clientId: CONFIG.clientId, nonce: NONCE };
|
||||
|
||||
it('accepts a well-formed token and reports who signed in', () => {
|
||||
const identity = verifiedIdentity(idToken(VALID), expected);
|
||||
|
||||
expect(identity.sub).toBe('1234567890');
|
||||
expect(identity.emailVerified).toBe(true);
|
||||
expect(identity.firstName).toBe('Test');
|
||||
expect(identity.lastName).toBe('Customer');
|
||||
});
|
||||
|
||||
it('normalises the email the way registration does', () => {
|
||||
// A stricter comparison than registration's would silently fail to match an
|
||||
// existing customer and produce a duplicate account instead (#343).
|
||||
expect(verifiedIdentity(idToken(VALID), expected).email).toBe('customer@example.com');
|
||||
});
|
||||
|
||||
it('accepts both spellings of the issuer, because Google sends both', () => {
|
||||
// Accepting only one produces sign-ins that fail for some customers and not
|
||||
// others, which is about the least diagnosable failure this flow can have.
|
||||
for (const iss of ['https://accounts.google.com', 'accounts.google.com']) {
|
||||
expect(verifiedIdentity(idToken({ ...VALID, iss }), expected).sub).toBe('1234567890');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an issuer it was not told to trust', () => {
|
||||
expect(() => verifiedIdentity(idToken({ ...VALID, iss: 'https://evil.test' }), expected)).toThrow(
|
||||
/unexpected issuer/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a token minted for a different application', () => {
|
||||
// Without this, a token obtained by any other Google app could be replayed
|
||||
// here and would sign somebody in.
|
||||
expect(() =>
|
||||
verifiedIdentity(idToken({ ...VALID, aud: 'someone-else.apps.googleusercontent.com' }), expected)
|
||||
).toThrow(/different client/);
|
||||
});
|
||||
|
||||
it('refuses an expired token', () => {
|
||||
const expired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 3600 };
|
||||
|
||||
expect(() => verifiedIdentity(idToken(expired), expected)).toThrow(/expired/);
|
||||
});
|
||||
|
||||
it('allows a minute of clock skew, so a healthy host does not refuse valid tokens', () => {
|
||||
const justExpired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 5 };
|
||||
|
||||
expect(verifiedIdentity(idToken(justExpired), expected).sub).toBe('1234567890');
|
||||
});
|
||||
|
||||
it('refuses a token with no expiry at all', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('exp')), expected)).toThrow(/no expiry/);
|
||||
});
|
||||
|
||||
it('refuses a token from a different sign-in attempt', () => {
|
||||
// This is what stops a token captured from one attempt being replayed into
|
||||
// another, which is the whole reason the nonce exists.
|
||||
expect(() => verifiedIdentity(idToken({ ...VALID, nonce: 'someone-elses' }), expected)).toThrow(
|
||||
/different sign-in attempt/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a token carrying no nonce', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('nonce')), expected)).toThrow(
|
||||
/different sign-in attempt/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a token with no subject, which is the identity itself', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('sub')), expected)).toThrow(/no subject/);
|
||||
});
|
||||
|
||||
it('refuses a token with no email', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('email')), expected)).toThrow(/no email/);
|
||||
});
|
||||
|
||||
describe('email_verified', () => {
|
||||
it('is true only for the boolean, never a truthy string', () => {
|
||||
// The linking policy turns entirely on this flag (#343). Treating the
|
||||
// string "false" as verified is exactly the mistake that would make
|
||||
// auto-linking an account-takeover path.
|
||||
expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'false' }), expected).emailVerified)
|
||||
.toBe(false);
|
||||
expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'true' }), expected).emailVerified)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the claim is missing', () => {
|
||||
expect(verifiedIdentity(idToken(without('email_verified')), expected).emailVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a profile with no names, because Google may send none', () => {
|
||||
const anonymous = { ...without('given_name') };
|
||||
delete anonymous.family_name;
|
||||
const identity = verifiedIdentity(idToken(anonymous), expected);
|
||||
|
||||
expect(identity.firstName).toBeNull();
|
||||
expect(identity.lastName).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not a JWT', 'nonsense'],
|
||||
['a JWT whose payload is not JSON', 'aGVhZGVy.bm90LWpzb24.sig']
|
||||
])('refuses %s', (_label, token) => {
|
||||
expect(() => verifiedIdentity(token, expected)).toThrow();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { passwordMatches, TEST_ROUNDS } from '../../src/passwordHashing';
|
||||
|
||||
/**
|
||||
* `customers.password_hash` became nullable in #340, and a null one is not an
|
||||
* edge case to tidy away — it is a customer who signed up through Google and
|
||||
* has never set a password.
|
||||
*
|
||||
* The reason this is a function rather than a null check at each call site is
|
||||
* what the second test asserts: `bcrypt.compare` throws on a null hash instead
|
||||
* of returning false, so a forgotten check answers a sign-in attempt with a 500.
|
||||
* On the login route that is also an oracle, because it happens for exactly the
|
||||
* accounts that have no password.
|
||||
*/
|
||||
describe('passwordMatches', () => {
|
||||
it('matches a correct password against a real hash', async () => {
|
||||
const hash = await bcrypt.hash('supersecret123', TEST_ROUNDS);
|
||||
|
||||
await expect(passwordMatches('supersecret123', hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a wrong password against a real hash', async () => {
|
||||
const hash = await bcrypt.hash('supersecret123', TEST_ROUNDS);
|
||||
|
||||
await expect(passwordMatches('nope', hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', '']
|
||||
])('answers false rather than throwing when the stored hash is %s', async (_label, stored) => {
|
||||
// The whole point. bcrypt.compare throws "Illegal arguments" here.
|
||||
await expect(passwordMatches('anything', stored)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('answers false for a missing password rather than throwing', async () => {
|
||||
const hash = await bcrypt.hash('supersecret123', TEST_ROUNDS);
|
||||
|
||||
await expect(passwordMatches(undefined, hash)).resolves.toBe(false);
|
||||
await expect(passwordMatches(null, hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat an empty password as matching an absent hash', async () => {
|
||||
// Both sides missing is the combination that would be most tempting to call
|
||||
// a match, and it would let anyone sign in as any social-only customer by
|
||||
// submitting a blank password.
|
||||
await expect(passwordMatches('', null)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,31 @@ describe('readId', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// These are the dangerous ones, because they were never a 500 and so never
|
||||
// announced themselves (#307). Number reads each as a positive integer, so
|
||||
// every check this function used to make passed and the route fetched a real
|
||||
// row for a URL nobody wrote: /items/5.0 answered with item 5.
|
||||
it.each(['5.0', '1e2', '0x10', '+5', '1_0'])(
|
||||
'refuses %p, which parses to a positive integer but is not an id',
|
||||
(value) => {
|
||||
expect(readId(value)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
// The column is a 32-bit serial. Above that Postgres raises 22003 rather than
|
||||
// returning nothing, which is the same wrong answer to the caller as the
|
||||
// 22P02 this function was written to prevent.
|
||||
it('refuses an id past the top of a 32-bit serial', () => {
|
||||
expect(readId('2147483647')).toBe(2147483647);
|
||||
expect(readId('2147483648')).toBeNull();
|
||||
expect(readId('99999999999999999999')).toBeNull();
|
||||
});
|
||||
|
||||
// Surrounding whitespace is a URL artefact rather than a different id.
|
||||
it('reads an id with surrounding whitespace', () => {
|
||||
expect(readId(' 7 ')).toBe(7);
|
||||
});
|
||||
|
||||
it('refuses a missing param', () => {
|
||||
expect(readId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { relyingParty, RELYING_PARTY_NAME } from '../../src/passkeys/relyingParty';
|
||||
|
||||
/**
|
||||
* The RP ID is the one value in this feature that cannot be corrected later: a
|
||||
* passkey is bound to it permanently, and a wrong one is only discovered when a
|
||||
* customer cannot sign in with a credential that no longer matches anything.
|
||||
*
|
||||
* So these assert the derivation itself rather than that the function returns
|
||||
* something. Most of them are about what must *not* end up in the ID.
|
||||
*/
|
||||
describe('relyingParty', () => {
|
||||
it('takes the RP ID from PUBLIC_URL', () => {
|
||||
const rp = relyingParty({ PUBLIC_URL: 'https://redefined-designs.com' });
|
||||
|
||||
expect(rp.id).toBe('redefined-designs.com');
|
||||
expect(rp.origins).toEqual(['https://redefined-designs.com']);
|
||||
expect(rp.name).toBe(RELYING_PARTY_NAME);
|
||||
});
|
||||
|
||||
it('keeps a subdomain, which is a different Relying Party', () => {
|
||||
// QA and production are separate RPs on purpose. A credential registered
|
||||
// against one does not work against the other, and collapsing them to the
|
||||
// registrable domain would silently make QA able to sign into production.
|
||||
const qa = relyingParty({ PUBLIC_URL: 'https://qa-redefined-designs.bermudalamb.synology.me' });
|
||||
const prod = relyingParty({ PUBLIC_URL: 'https://redefined-designs.bermudalamb.synology.me' });
|
||||
|
||||
expect(qa.id).toBe('qa-redefined-designs.bermudalamb.synology.me');
|
||||
expect(prod.id).toBe('redefined-designs.bermudalamb.synology.me');
|
||||
expect(qa.id).not.toBe(prod.id);
|
||||
});
|
||||
|
||||
it('strips the port, which is not part of an RP ID', () => {
|
||||
// An RP ID of "example.com:8443" matches nothing, and the failure is a
|
||||
// credential that cannot be used rather than an error at registration.
|
||||
const rp = relyingParty({ PUBLIC_URL: 'https://example.com:8443' });
|
||||
|
||||
expect(rp.id).toBe('example.com');
|
||||
expect(rp.origins).toEqual(['https://example.com:8443']);
|
||||
});
|
||||
|
||||
it('strips a path and a trailing slash from the origin', () => {
|
||||
const rp = relyingParty({ PUBLIC_URL: 'https://example.com/shop/' });
|
||||
|
||||
expect(rp.id).toBe('example.com');
|
||||
// The browser reports an origin, never a path, so a stored path would never
|
||||
// match what arrives.
|
||||
expect(rp.origins).toEqual(['https://example.com']);
|
||||
});
|
||||
|
||||
it.each(['', ' ', undefined])('falls back to localhost when PUBLIC_URL is %p', (value) => {
|
||||
// envValidation requires PUBLIC_URL only when SMTP is configured, so a local
|
||||
// setup that cannot send mail legitimately has none.
|
||||
const rp = relyingParty(value === undefined ? {} : { PUBLIC_URL: value });
|
||||
|
||||
expect(rp.id).toBe('localhost');
|
||||
// Both local origins, because the app is served from Vite during dev and
|
||||
// from Express once built, and they differ only by port.
|
||||
expect(rp.origins).toEqual(['http://localhost:5173', 'http://localhost:3000']);
|
||||
});
|
||||
|
||||
it('refuses a PUBLIC_URL that is not a URL rather than guessing', () => {
|
||||
// Guessing here would bind every passkey to something nobody chose, and the
|
||||
// binding is permanent.
|
||||
expect(() => relyingParty({ PUBLIC_URL: 'redefined-designs.com' })).toThrow(
|
||||
/Relying Party ID cannot be derived/
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the environment on each call rather than at import', () => {
|
||||
// Captured at import, this would hold whatever the first suite to require
|
||||
// the module happened to have set.
|
||||
expect(relyingParty({ PUBLIC_URL: 'https://one.example' }).id).toBe('one.example');
|
||||
expect(relyingParty({ PUBLIC_URL: 'https://two.example' }).id).toBe('two.example');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { checkSignatureCounter } from '../../src/passkeys/signatureCounter';
|
||||
|
||||
/**
|
||||
* The rule #37 deferred to the ceremony that enforces it.
|
||||
*
|
||||
* Both halves are load-bearing and they pull in opposite directions. Requiring
|
||||
* an increase from every authenticator refuses the synced passkeys most people
|
||||
* actually use, which report zero forever by design. Requiring it from none
|
||||
* throws away the only signal that a hardware credential has been cloned, which
|
||||
* is the entire reason the column exists.
|
||||
*/
|
||||
describe('checkSignatureCounter', () => {
|
||||
describe('an authenticator that does not implement counters', () => {
|
||||
it('accepts zero against zero, and keeps accepting it', () => {
|
||||
// iCloud Keychain and Google Password Manager report this on every
|
||||
// assertion. Refusing it would refuse most real customers.
|
||||
expect(checkSignatureCounter(0, 0).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an authenticator that does', () => {
|
||||
it('accepts a counter that advanced', () => {
|
||||
expect(checkSignatureCounter(5, 6).ok).toBe(true);
|
||||
expect(checkSignatureCounter(0, 1).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses one that stalled', () => {
|
||||
// Equal is not an increase. Two copies of a credential used alternately
|
||||
// produce exactly this.
|
||||
const verdict = checkSignatureCounter(7, 7);
|
||||
|
||||
expect(verdict.ok).toBe(false);
|
||||
expect(verdict.reason).toMatch(/cloned/);
|
||||
});
|
||||
|
||||
it('refuses one that went backwards', () => {
|
||||
expect(checkSignatureCounter(9, 4).ok).toBe(false);
|
||||
});
|
||||
|
||||
// The asymmetry that stops the zero rule being an escape hatch. An
|
||||
// authenticator that has ever reported a real counter is held to the strict
|
||||
// rule from then on, so a clone cannot report zero to look like a synced
|
||||
// passkey and be waved through.
|
||||
it('refuses a drop to zero from a counter that was real', () => {
|
||||
const verdict = checkSignatureCounter(12, 0);
|
||||
|
||||
expect(verdict.ok).toBe(false);
|
||||
expect(verdict.reason).toMatch(/did not advance/);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the numbers, because a refusal is only actionable with them', () => {
|
||||
expect(checkSignatureCounter(12, 3).reason).toContain('stored 12');
|
||||
expect(checkSignatureCounter(12, 3).reason).toContain('received 3');
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,13 @@
|
||||
# INTAKE_ACTION_SECRET Optional. Signs the regenerate and discard links in the
|
||||
# intake notification email (#224). Absent, the email
|
||||
# still sends and carries no shortcuts.
|
||||
# GOOGLE_CLIENT_ID Optional, and all-or-nothing with the secret below —
|
||||
# GOOGLE_CLIENT_SECRET setting one without the other refuses to boot (#340).
|
||||
# Both unset means Google sign-in is simply not offered.
|
||||
# The redirect URI is derived from PUBLIC_URL and must
|
||||
# match what is registered in the Google Auth Platform
|
||||
# exactly, so changing the domain means updating that
|
||||
# console too (#313).
|
||||
# REMBG_URL Optional. The background-removal sidecar, e.g.
|
||||
# http://rembg-syn:7000. Unset turns the feature off.
|
||||
#
|
||||
@@ -260,6 +267,11 @@ services:
|
||||
# review queue without shortcuts. Rotating it revokes every outstanding
|
||||
# link, which is how a leaked one is dealt with.
|
||||
- INTAKE_ACTION_SECRET=${INTAKE_ACTION_SECRET:-}
|
||||
# Both or neither. envValidation refuses to start on one without the
|
||||
# other, because the failure would otherwise arrive at the moment a
|
||||
# customer presses the button (#340).
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
# Production's own uploads directory. QA writes to
|
||||
# /volume1/configs/redefined-designs-qa/uploads; sharing this one would
|
||||
|
||||
@@ -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,6 +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:-}
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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,136 @@
|
||||
# Account recovery
|
||||
|
||||
What happens when a customer cannot get into their account, and what the shop
|
||||
can do about it. Written for #42, which exists because this is the piece most
|
||||
likely to be skipped and most expensive to discover missing.
|
||||
|
||||
Every other issue in the passkeys project adds a capability. This one is the
|
||||
safety net.
|
||||
|
||||
## The short version
|
||||
|
||||
| The customer has lost | They can recover by | Self-service |
|
||||
| --- | --- | --- |
|
||||
| Their password | A reset link emailed to them | Yes |
|
||||
| Their passkey or the device holding it | Signing in with their password | Yes |
|
||||
| Every passkey and their password | A reset link emailed to them | Yes |
|
||||
| Access to their email address | Contacting the shop, which moves the account | No |
|
||||
|
||||
The email address is the root of trust. Every self-service route above ends at
|
||||
it, and none of them can work without it.
|
||||
|
||||
## A password reset removes every passkey
|
||||
|
||||
This is the decision #42 existed to make, and it is deliberate.
|
||||
|
||||
A reset is the recovery path, and recovery has to be complete. The reset already
|
||||
deletes every session on the account, on the reasoning that a reset prompted by
|
||||
a compromise must not leave an intruder signed in for the remaining 30 days of
|
||||
their cookie. A passkey an intruder registered has no expiry at all. Leaving
|
||||
those behind would mean a customer can recover their password and still not have
|
||||
their account back.
|
||||
|
||||
The obvious objection is that this lets whoever controls the mailbox strip a
|
||||
customer's passkeys. It does, and it costs nothing: anyone who can complete a
|
||||
reset already controls the email address and therefore already controls the
|
||||
account. The passkeys were not protecting anything by that point.
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- The customer is told before they act. The reset email and the reset form both
|
||||
say it, unconditionally. The form is not signed in and is never told whether
|
||||
the account has passkeys, because answering that would make the reset page an
|
||||
oracle for it.
|
||||
- The customer is told after they act, with a count, and only when the count is
|
||||
more than zero. This is the one moment that count can be reported: the rows
|
||||
are gone by the time anyone could go and look. A customer told two were
|
||||
removed who only remembers registering one has just learned something they
|
||||
could not otherwise find out.
|
||||
- Any WebAuthn challenge in flight goes too. An intruder who pressed "add a
|
||||
passkey" moments before the reset could otherwise finish the ceremony
|
||||
afterwards and put a credential straight back.
|
||||
|
||||
## Changing a password does not remove passkeys
|
||||
|
||||
The asymmetry with the paragraph above is intentional.
|
||||
|
||||
`change-password` requires the current password from someone already signed in.
|
||||
Nothing about that suggests a lockout or a compromise, and it already spares the
|
||||
current session for the same reason. A customer who suspects one particular
|
||||
device can revoke that device by name from the account page, which is a better
|
||||
tool than deleting everything.
|
||||
|
||||
A reset has no idea which credential is the problem, so it takes all of them. A
|
||||
change knows the customer is present and in control, so it takes none.
|
||||
|
||||
## Losing the authenticator is not a lockout
|
||||
|
||||
A customer who loses the phone or key holding their passkey signs in with their
|
||||
password as normal, and revokes the lost credential from the account page. This
|
||||
needs no support involvement and no new capability, which is why #42 implements
|
||||
nothing for it.
|
||||
|
||||
This holds only while every account has a password. It stops holding when #332
|
||||
lands social sign-in, which creates the first customers with no password at all.
|
||||
Their recovery route is the identity provider, not a reset link, and #332 owns
|
||||
that question. The revocation guard in `backend/src/routes/passkeys.ts` already
|
||||
refuses to delete a customer's only way in, written against that condition
|
||||
rather than against today's schema, so it starts holding on its own the moment
|
||||
the condition changes.
|
||||
|
||||
## Losing the email address is a lockout
|
||||
|
||||
There is no self-service recovery, and there should not be. Recovering an
|
||||
account whose email is gone means proving identity some other way, and this shop
|
||||
holds no other way — no phone number, no security questions, no identity
|
||||
documents. Anything invented to fill that gap would be a weaker credential than
|
||||
the one it replaces, and would become the easiest way to take an account over.
|
||||
|
||||
The route is manual, and it runs through the shop owner:
|
||||
|
||||
1. The customer makes contact by whatever means they have.
|
||||
2. The owner verifies them against order history — items bought, dates, the
|
||||
shipping address on file. A stranger has none of that.
|
||||
3. The owner opens the customer in Admin, Customers, and uses **Move to a new
|
||||
address** on the detail drawer.
|
||||
|
||||
Step 3 was added by #337. Before it existed the only answer was a database edit
|
||||
by hand, which left no record of who did it or why.
|
||||
|
||||
### What the move does, and why
|
||||
|
||||
Say plainly what it is: **this operation and an account takeover are the same
|
||||
operation.** They differ only in whether the verification in step 2 was sound,
|
||||
and nothing in the software can check that. Everything the move does is aimed at
|
||||
that fact.
|
||||
|
||||
- **It asks for a written reason, and refuses without one.** The reason is
|
||||
recorded against the account and never shown to the customer. It is the only
|
||||
thing that distinguishes a genuine recovery from a takeover afterwards.
|
||||
- **It emails the address being replaced.** If the recovery was sound this
|
||||
reaches nobody, which costs nothing. If it was not, it reaches the real owner,
|
||||
who is the only person who can say so. That mail has its own wording, because
|
||||
the self-service notice says "if you did not make this change, contact us" and
|
||||
here somebody already did.
|
||||
- **It sends a confirmation link to the new address and marks it unverified.** A
|
||||
customer reading an address out over the phone has not demonstrated they can
|
||||
receive mail at it. This is the commonest way the move goes wrong harmlessly.
|
||||
- **It signs the customer out everywhere, removes every passkey, and cancels
|
||||
outstanding reset links.** Same reasoning as a password reset, with more
|
||||
force: somebody the system cannot identify asked for this, so a session or
|
||||
credential surviving it is one the new owner cannot see or revoke, and a reset
|
||||
link sitting in the old mailbox would let whoever reads it take the account
|
||||
straight back.
|
||||
- **It does not change the password.** What the customer lost was the mailbox,
|
||||
not the password, so demanding a new one adds a step for no gain.
|
||||
|
||||
The history of moves on an account is shown on the same drawer, with the reasons.
|
||||
It renders nothing at all for the overwhelming majority of customers, who have
|
||||
never been moved.
|
||||
|
||||
### What it still does not do
|
||||
|
||||
There is no per-admin identity to record. Admin access is one shared gate secret
|
||||
in front of a single operator, so a "who" column could only ever hold a constant,
|
||||
and a constant dressed up as an identity is worse than an honest absence. If
|
||||
per-admin identity ever arrives, the record gains a column then.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Feature flags: whether this project wants a manager
|
||||
|
||||
Findings for #318. Tool capabilities and pricing were checked on 2026-09-08; everything about this repository was read from the code on 2026-09-09.
|
||||
|
||||
**Status: do not adopt a feature flag manager. Not yet, and possibly not ever at this size.** This is written down so the question does not get reopened from scratch — including the conditions that would change the answer.
|
||||
|
||||
## The short version
|
||||
|
||||
This project already has two flag mechanisms, uses both idiomatically, and is not short of a third. What it lacks is a boolean in one of them, which is about twenty lines of work and needs a reason before it is worth doing.
|
||||
|
||||
No tool was compared until that was established, because a spike that starts by comparing platforms will always find one.
|
||||
|
||||
## What already exists
|
||||
|
||||
**Environment variables, for per-environment gating.** `DEMO_MODE`, `REMBG_URL`, `BREVO_TRACKER_KEY`, `ANTHROPIC_API_KEY`, the USPS and SMTP credentials. The convention is consistent and documented: unset means the feature does not exist, and the application is *working* rather than broken. QA runs with several deliberately empty, which is how it cannot reach PayPal, cannot email anyone, and cannot report browsing into the live Brevo account.
|
||||
|
||||
That is deploy-time gating, and it is genuinely a feature flag system — it just is not called one.
|
||||
|
||||
**`admin_settings`, for runtime tuning by whoever runs the shop.** A typed key/value store with an admin UI, ten settings, per-setting fallbacks, and one place to add another. Its own header says the point plainly: adding a setting means adding a row and nothing else. `drafting_model` and `intake_notify_email` are there rather than in the environment for exactly the reason a flag would be — they are changed by the person running the shop, not the person deploying it, and a redeploy to change an email address would be absurd.
|
||||
|
||||
The two are complementary and the split is already the right one.
|
||||
|
||||
## What is actually missing
|
||||
|
||||
`admin_settings` supports `hours`, `count`, `text` and `choice`. **There is no boolean.**
|
||||
|
||||
So the one thing a flag manager would give this project that it does not have — an admin-flippable on/off for a code path, without a rebuild — is a missing value type in a store that already does everything else. Adding it is a definition entry, a parse branch, and a control in the settings tab.
|
||||
|
||||
That is the whole gap. It is not a reason to run another service on the NAS.
|
||||
|
||||
## Is there anything to flag right now
|
||||
|
||||
No, and the issue said a spike that cannot name one should say so.
|
||||
|
||||
Nine issues are open. Seven are the Passkeys epic and #313, and neither wants a flag:
|
||||
|
||||
- **Passkeys (#37–#42)** is naturally incremental — schema, then registration, then the authentication ceremony, then management, then the login page. Nothing is user-visible until #41 puts it on the login form, so the ordering does the gating that a flag would otherwise do. The epic has also been untouched since 2026-08-21 and its parent #36 is closed, so its status is undecided in a way no tooling addresses.
|
||||
- **#313's `trust proxy` change cannot be flagged.** It is only correct once Cloudflare is actually in front, so it must ship with the DNS cutover. A runtime toggle would let it be wrong on purpose, which is worse than not having one.
|
||||
|
||||
**The strongest hypothetical is a kill switch for passkey login**, because #41 records that it is the one feature in the project that cannot be fully proven in QA — credentials bind to the RP ID, so production is the first place it runs for real. Being able to turn it off without a rebuild would have value there.
|
||||
|
||||
That is a real argument, and it is still not enough today: passkeys are not being built, and **production is not live**, so there is no customer-facing behaviour to protect. Revisit it when #41 is actually being written.
|
||||
|
||||
## What flags would buy here, honestly
|
||||
|
||||
The usual headline — decouple deploy from release — buys much less than it does elsewhere, because there is no CD. Every change already reaches production through a manual NAS rebuild, so the deploy is a deliberate act either way.
|
||||
|
||||
What is left is narrower:
|
||||
|
||||
- **A kill switch**, avoiding a rebuild. The strongest case, because the rebuild is this project's slowest and most error-prone step.
|
||||
- **Shipping half-finished work** behind a flag rather than holding a long branch. Weak here: branches are short and merged the same day.
|
||||
- **Per-environment behaviour** without another env var. Already solved, by the env vars.
|
||||
|
||||
Against all of it: every flag is a branch in the code that has to be removed later, and stale flags are their own debt. For a single-developer shop, that cost is paid by the same person who would benefit.
|
||||
|
||||
## If a tool is ever wanted
|
||||
|
||||
Constraints that decide it: the NAS is CPU and memory limited and already runs Gitea, the CI runner, QA and production; Portainer manages the stacks; cost should stay at zero.
|
||||
|
||||
| Tool | Fit |
|
||||
| --- | --- |
|
||||
| **Flipt** | Lightest — single binary, no heavy datastore. The only one that obviously fits this machine. |
|
||||
| **Flagsmith** | Open source, self-host free with no feature limits, Docker Compose. Heavier than Flipt. |
|
||||
| **GrowthBook** | Free self-hosted and a free cloud starter. Flags and experiments together, which is more than this project needs. |
|
||||
| **Unleash** | Best known, but **OSS Edge sunsets 2026-12-31**, after which self-hosters at scale need Enterprise Edge. A free tool acquiring a paid dependency inside a year is the wrong shape here. |
|
||||
|
||||
A hosted free tier is also possible and is worse for this specific project: it makes the storefront depend on a third party being reachable in order to decide its own behaviour, which is a new outage mode for a site that currently has none.
|
||||
|
||||
**Whatever is chosen, fail-safe behaviour matters more than features.** A flag service that takes the shop down when it is unreachable is worse than having no flags. `admin_settings` reads from the database the application already cannot run without, so it has no failure mode of its own — which is a real advantage over every option in that table.
|
||||
|
||||
## What would change this answer
|
||||
|
||||
Any one of these, and it is worth reopening:
|
||||
|
||||
- A concrete change that genuinely needs to be dark in production while it settles — passkey login being the likely first.
|
||||
- Percentage rollouts or per-customer targeting becoming something anyone actually wants. Nothing in the project has ever asked for either.
|
||||
- More than one person deploying, so "who turned that off" needs an audit trail.
|
||||
- CD arriving, which would make decoupling deploy from release mean something.
|
||||
|
||||
Until one of those, the answer is a boolean column type in `admin_settings`, written when there is a flag to put in it.
|
||||
@@ -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.
|
||||
Generated
+25
-1
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"antd": "^5.20.6",
|
||||
"react": "^18.3.1",
|
||||
@@ -37,6 +38,9 @@
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-istanbul": "^6.0.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/colors": {
|
||||
@@ -157,6 +161,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1678,6 +1683,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",
|
||||
"integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1804,6 +1815,7 @@
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1868,6 +1880,7 @@
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
@@ -2341,6 +2354,7 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2751,6 +2765,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -3199,7 +3214,8 @@
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3628,6 +3644,7 @@
|
||||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7133,6 +7150,7 @@
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
@@ -7236,6 +7254,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8041,6 +8060,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8052,6 +8072,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -9396,6 +9417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9629,6 +9651,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -10185,6 +10208,7 @@
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"antd": "^5.20.6",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -51,7 +51,22 @@ const { Title } = Typography;
|
||||
// not a state of its own worth drawing the eye to.
|
||||
const STATUS_TAG_COLORS: Record<string, string> = { sold: 'red', reserved: 'orange', pending: 'default' };
|
||||
|
||||
function Inventory() {
|
||||
/**
|
||||
* `active` is whether the Inventory tab is the one on screen (#327).
|
||||
*
|
||||
* antd keeps a tab pane mounted once it has been rendered, and this pane is
|
||||
* rendered at page load because Inventory is the default tab. So switching away,
|
||||
* publishing something from the Review queue, and switching back re-runs no
|
||||
* effect and refetches nothing: the table goes on showing the list it built when
|
||||
* the admin was first opened. It read as publishing being broken, because the
|
||||
* item really was published and really was absent from the list.
|
||||
*
|
||||
* Refetching when the tab becomes visible is the narrow fix. Setting
|
||||
* `destroyInactiveTabPane` on the Tabs would also work, by remounting — but it
|
||||
* discards every tab's state on every switch, filters and half-filled forms
|
||||
* included, which is a much larger change than this bug is worth.
|
||||
*/
|
||||
function Inventory({ active }: Readonly<{ active: boolean }>) {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Item | null>(null);
|
||||
@@ -108,8 +123,17 @@ function Inventory() {
|
||||
]).catch(() => message.error('Could not load categories and tags')), []);
|
||||
|
||||
// Refetch whenever the filters change — filtering is server-side so the
|
||||
// result stays correct regardless of how many items exist.
|
||||
useEffect(() => { void load(filters); }, [load, filters]);
|
||||
// result stays correct regardless of how many items exist — and whenever this
|
||||
// tab becomes the visible one, because anything published, discarded or
|
||||
// edited from a sibling tab happened while this list sat untouched (#327).
|
||||
//
|
||||
// Guarded on `active` rather than fetching unconditionally: this pane stays
|
||||
// mounted for the life of the page, so without the guard every filter change
|
||||
// would still refetch while the tab is hidden and nobody is looking.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void load(filters);
|
||||
}, [active, load, filters]);
|
||||
useEffect(() => { void loadOptions(); }, [loadOptions]);
|
||||
|
||||
function applyFilters(next: ItemFilters) { setFilters(next); }
|
||||
@@ -481,6 +505,10 @@ function Inventory() {
|
||||
export default function Admin() {
|
||||
const { mode, toggle } = useThemeMode();
|
||||
const { token } = theme.useToken();
|
||||
// Controlled rather than defaultActiveKey, so a pane can be told whether it is
|
||||
// the one on screen. Panes stay mounted here, so "visible" is not something a
|
||||
// child can work out for itself (#327).
|
||||
const [activeTab, setActiveTab] = useState('inventory');
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -493,9 +521,10 @@ export default function Admin() {
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
<Tabs
|
||||
defaultActiveKey="inventory"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory active={activeTab === 'inventory'} /> },
|
||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from 'antd/es/modal';
|
||||
import Form from 'antd/es/form';
|
||||
import Input from 'antd/es/input';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Button from 'antd/es/button';
|
||||
import Typography from 'antd/es/typography';
|
||||
import message from 'antd/es/message';
|
||||
import { changeCustomerEmail } from './adminCustomersApi';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type Props = Readonly<{
|
||||
customerId: number;
|
||||
currentEmail: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful change, so the drawer and the table can refresh. */
|
||||
onChanged: () => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Moving a customer's account to an address they can reach (#337).
|
||||
*
|
||||
* The last step of the only recovery route available to someone who has lost
|
||||
* their mailbox. There is deliberately no self-service equivalent, because the
|
||||
* email address is the root of trust for every other route and this shop holds
|
||||
* no second proof of identity.
|
||||
*
|
||||
* The form leads with what this costs rather than burying it, because the
|
||||
* operator is about to make a decision on someone else's behalf and the
|
||||
* consequences land on that person, not on them.
|
||||
*/
|
||||
export default function ChangeCustomerEmail({
|
||||
customerId,
|
||||
currentEmail,
|
||||
open,
|
||||
onClose,
|
||||
onChanged
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit(values: { email: string; reason: string }) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await changeCustomerEmail(customerId, values.email, values.reason);
|
||||
// Named rather than counted, because "moved to that address" is the fact
|
||||
// the operator has to repeat back to the customer on the phone.
|
||||
message.success(`Account moved to ${result.customer.email}`);
|
||||
if (result.passkeysRemoved > 0) {
|
||||
// Its own message and a long one. The customer will find their passkeys
|
||||
// gone and needs to be told why while they are still on the line —
|
||||
// finding out later looks like a second thing going wrong.
|
||||
message.warning(
|
||||
result.passkeysRemoved === 1
|
||||
? 'Their saved passkey was removed. They will need to set it up again.'
|
||||
: `Their ${result.passkeysRemoved} saved passkeys were removed. They will need to set them up again.`,
|
||||
10
|
||||
);
|
||||
}
|
||||
form.resetFields();
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Move this account to a new email address"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="Verify the customer before doing this"
|
||||
description="This is the same operation as an account takeover, and nothing here can tell the difference. Check their answers against the order history on the account first — items bought, dates, the shipping address on file."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Paragraph type="secondary">
|
||||
Moving the account signs the customer out everywhere, removes any saved passkeys, and
|
||||
cancels reset links already sent to <Text code>{currentEmail}</Text>. A notice goes to that
|
||||
address, and a confirmation link goes to the new one.
|
||||
</Paragraph>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={submit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="New email address"
|
||||
rules={[{ required: true, type: 'email', message: 'A valid email address is required' }]}
|
||||
>
|
||||
<Input autoComplete="off" placeholder="what they can actually reach" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="How you verified them"
|
||||
// The server requires this too, and refuses without it. Collected here
|
||||
// as well so the refusal is not the first the operator hears of it.
|
||||
rules={[{ required: true, min: 10, message: 'A sentence, not a word — this is the record' }]}
|
||||
extra="Recorded against the account and never shown to the customer. This is what tells a genuine recovery from a takeover afterwards."
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="Named the last two items bought and the shipping address on file." />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" danger htmlType="submit" loading={saving} block>
|
||||
Move the account
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,57 @@ import message from 'antd/es/message';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
|
||||
setCustomerDisabled,
|
||||
CustomerSummary, CustomerDetail, ReservedItem
|
||||
setCustomerDisabled, fetchCustomerEmailChanges,
|
||||
CustomerSummary, CustomerDetail, ReservedItem, CustomerEmailChange
|
||||
} from './adminCustomersApi';
|
||||
import ChangeCustomerEmail from './ChangeCustomerEmail';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* Every address this account has been moved between, and why (#337).
|
||||
*
|
||||
* Shown on the detail drawer rather than hidden behind a separate screen,
|
||||
* because the moment it matters is the moment someone is looking at this
|
||||
* customer wondering whether the account is in the right hands. Empty for
|
||||
* almost every customer, so it renders nothing at all rather than an empty
|
||||
* state that would appear on every drawer to say nothing happened.
|
||||
*/
|
||||
function EmailChangeHistory({ changes }: Readonly<{ changes: CustomerEmailChange[] }>) {
|
||||
if (changes.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<Title level={5} style={{ marginTop: 24 }}>Address changes</Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={changes}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'changed_at',
|
||||
render: (v: string) => new Date(v).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: 'Moved',
|
||||
key: 'moved',
|
||||
render: (_, row: CustomerEmailChange) => (
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{row.previous_email} → {row.new_email}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
// The whole reason the record exists, so it is not truncated behind a
|
||||
// tooltip. A reader deciding whether a change was legitimate needs the
|
||||
// sentence, not the first few words of it.
|
||||
{ title: 'Reason', dataIndex: 'reason' }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The two halves of the disable/re-enable confirm, as components rather than
|
||||
// branches inside the handler: every piece of copy differs between them, so
|
||||
// one decision up front reads better than the same condition asked five times.
|
||||
@@ -50,15 +95,32 @@ function ReEnableWarning() {
|
||||
// branches, and every one of them counted toward Customers().
|
||||
function CustomerDetailPanel({
|
||||
detail,
|
||||
loading
|
||||
}: Readonly<{ detail: CustomerDetail | null; loading: boolean }>) {
|
||||
loading,
|
||||
emailChanges,
|
||||
onChangeEmail
|
||||
}: Readonly<{
|
||||
detail: CustomerDetail | null;
|
||||
loading: boolean;
|
||||
emailChanges: CustomerEmailChange[];
|
||||
onChangeEmail: () => void;
|
||||
}>) {
|
||||
if (loading || !detail) {
|
||||
return <Spin />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
|
||||
<Descriptions.Item label="Email">
|
||||
{detail.customer.email}
|
||||
{/* Next to the address rather than among the account actions: this is
|
||||
a thing done *to* this field, and it is reached by someone already
|
||||
looking at it because a customer told them they cannot. */}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Button size="small" onClick={onChangeEmail} style={{ paddingInline: 0 }} type="link">
|
||||
Move to a new address
|
||||
</Button>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Verified">
|
||||
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
|
||||
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
|
||||
@@ -101,6 +163,8 @@ function CustomerDetailPanel({
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EmailChangeHistory changes={emailChanges} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +348,8 @@ export default function Customers() {
|
||||
const [reservedLoading, setReservedLoading] = useState(false);
|
||||
const [releasing, setReleasing] = useState<number | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
const [emailChanges, setEmailChanges] = useState<CustomerEmailChange[]>([]);
|
||||
const [movingEmail, setMovingEmail] = useState(false);
|
||||
|
||||
function load() {
|
||||
return fetchCustomers()
|
||||
@@ -298,9 +364,21 @@ export default function Customers() {
|
||||
async function openDetail(id: number) {
|
||||
setDrawerOpen(true);
|
||||
setDetailLoading(true);
|
||||
// Cleared rather than left standing: the drawer is reused for every row, and
|
||||
// one customer's address history showing under another's name is the worst
|
||||
// possible thing for this particular table to get wrong.
|
||||
setEmailChanges([]);
|
||||
const data = await fetchCustomerDetail(id);
|
||||
setDetail(data);
|
||||
setDetailLoading(false);
|
||||
// After the detail, and allowed to fail on its own. This is a rare extra
|
||||
// rather than part of the record, so a drawer that opens without it beats
|
||||
// one that does not open at all.
|
||||
try {
|
||||
setEmailChanges(await fetchCustomerEmailChanges(id));
|
||||
} catch {
|
||||
setEmailChanges([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function openReserved(customer: CustomerSummary) {
|
||||
@@ -428,9 +506,31 @@ export default function Customers() {
|
||||
onClose={() => { setDrawerOpen(false); setDetail(null); }}
|
||||
width={480}
|
||||
>
|
||||
<CustomerDetailPanel detail={detail} loading={detailLoading} />
|
||||
<CustomerDetailPanel
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
emailChanges={emailChanges}
|
||||
onChangeEmail={() => setMovingEmail(true)}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* Mounted only with a customer in hand, so the modal cannot be opened
|
||||
against a drawer that has since been closed and emptied. */}
|
||||
{detail && (
|
||||
<ChangeCustomerEmail
|
||||
customerId={detail.customer.id}
|
||||
currentEmail={detail.customer.email}
|
||||
open={movingEmail}
|
||||
onClose={() => setMovingEmail(false)}
|
||||
onChanged={() => {
|
||||
// Both, and in this order. The drawer is what the operator is
|
||||
// looking at, and the table behind it still shows the old address.
|
||||
void openDetail(detail.customer.id);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'}
|
||||
open={reservedFor !== null}
|
||||
|
||||
@@ -43,6 +43,22 @@ export interface CustomerDetail {
|
||||
orders: CustomerOrder[];
|
||||
}
|
||||
|
||||
/** One recorded admin-initiated address change (#337). */
|
||||
export interface CustomerEmailChange {
|
||||
id: number;
|
||||
previous_email: string;
|
||||
new_email: string;
|
||||
reason: string;
|
||||
changed_at: string;
|
||||
}
|
||||
|
||||
export interface EmailChangeResult {
|
||||
customer: CustomerDetail['customer'];
|
||||
previousEmail: string;
|
||||
/** Removed by the change, and worth telling the customer about. */
|
||||
passkeysRemoved: number;
|
||||
}
|
||||
|
||||
export async function fetchCustomers(): Promise<CustomerSummary[]> {
|
||||
const res = await fetch('/api/admin/customers');
|
||||
return res.json();
|
||||
@@ -71,6 +87,38 @@ export async function releaseReservedItem(customerId: number, itemId: number): P
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an account to an address its owner can reach (#337).
|
||||
*
|
||||
* The reason is required by the server, not merely collected by the form. This
|
||||
* operation and an account takeover are the same operation, and the recorded
|
||||
* reason is the only thing that tells them apart afterwards.
|
||||
*/
|
||||
export async function changeCustomerEmail(
|
||||
customerId: number,
|
||||
email: string,
|
||||
reason: string
|
||||
): Promise<EmailChangeResult> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/email`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, reason })
|
||||
});
|
||||
// Reporting success for a change that failed would leave the operator telling
|
||||
// a customer to check an inbox nothing was sent to.
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.error || 'failed to change the email address');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCustomerEmailChanges(customerId: number): Promise<CustomerEmailChange[]> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/email-changes`);
|
||||
if (!res.ok) throw new Error('failed to load the address history');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setCustomerDisabled(customerId: number, disabled: boolean): Promise<void> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/${disabled ? 'disable' : 'enable'}`, {
|
||||
method: 'POST'
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { updateConsent, updateAnalyticsConsent, exportMyData, deleteMyAccount, r
|
||||
import { setFavoriteAlerts } from './favoritesApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Passkeys from './Passkeys';
|
||||
import ConnectedAccounts from './ConnectedAccounts';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -141,7 +143,7 @@ export default function Account({ onClose }: Props) {
|
||||
|
||||
<Divider />
|
||||
<Space align="center">
|
||||
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
|
||||
<Switch aria-label="Receive emails about new items" checked={customer.marketing_consent} onChange={handleConsentToggle} />
|
||||
<Text>Receive emails about new items</Text>
|
||||
</Space>
|
||||
|
||||
@@ -149,7 +151,7 @@ export default function Account({ onClose }: Props) {
|
||||
customer can hold one without the other. */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center">
|
||||
<Switch checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
|
||||
<Switch aria-label="Email me when an item I favorited is sold" checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
|
||||
<Text>Email me when an item I favorited is sold</Text>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -161,7 +163,7 @@ export default function Account({ onClose }: Props) {
|
||||
control that has to make withdrawal as easy as consenting. */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center">
|
||||
<Switch checked={customer.analytics_consent} onChange={handleAnalyticsConsentToggle} />
|
||||
<Switch aria-label="Share what I browse and buy with Brevo" checked={customer.analytics_consent} onChange={handleAnalyticsConsentToggle} />
|
||||
<Text>Share what I browse and buy with Brevo, to make emails relevant</Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
@@ -172,6 +174,13 @@ export default function Account({ onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
{/* Renders nothing where WebAuthn is unavailable, so a browser that
|
||||
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';
|
||||
@@ -6,8 +7,11 @@ import Checkbox from 'antd/es/checkbox';
|
||||
import Tabs from 'antd/es/tabs';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Typography from 'antd/es/typography';
|
||||
import { registerCustomer, loginCustomer } from './customerApi';
|
||||
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;
|
||||
|
||||
@@ -40,17 +44,76 @@ 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
|
||||
// spinning while the browser's passkey prompt is open. The whole requirement
|
||||
// is that a dismissed prompt leaves a usable password form behind it.
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const { refresh } = useCustomerAuth();
|
||||
|
||||
// Read once at render rather than per click: a browser either implements
|
||||
// WebAuthn or it does not, and this decides whether the control exists at all
|
||||
// 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);
|
||||
@@ -65,8 +128,44 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with a passkey (#41).
|
||||
*
|
||||
* Not routed through `submit`, because the two differ in the one place that
|
||||
* matters: dismissing the browser's prompt rejects, and that is a
|
||||
* cancellation rather than a failure. Showing an error there would tell a
|
||||
* customer something went wrong when they changed their mind, and would leave
|
||||
* a red alert sitting above a password form that is working perfectly.
|
||||
*
|
||||
* Every other outcome clears back to the password form rather than a dead
|
||||
* end. The server answers every refusal identically — no such credential, a
|
||||
* disabled account, a bad assertion — so this cannot say whether an account
|
||||
* exists, and neither can the copy here.
|
||||
*/
|
||||
async function signInWithAPasskey() {
|
||||
setPasskeyLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithPasskey();
|
||||
refresh();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string }).name;
|
||||
if (name === 'NotAllowedError' || name === 'AbortError') return;
|
||||
setError('That passkey did not work. You can log in with your password instead.');
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
@@ -134,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>
|
||||
)
|
||||
},
|
||||
@@ -157,6 +277,46 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
|
||||
Forgot password?
|
||||
</Button>
|
||||
|
||||
{/* Below the password form, not above it. Passwords are how
|
||||
every existing customer signs in, and a passkey is the
|
||||
alternative — putting it first would demote the path that
|
||||
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 && (
|
||||
<>
|
||||
<Button
|
||||
block
|
||||
loading={passkeyLoading}
|
||||
onClick={signInWithAPasskey}
|
||||
>
|
||||
Sign in with a passkey
|
||||
</Button>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
{/* Says what it needs rather than naming the standard.
|
||||
"WebAuthn" means nothing to a customer, and the thing
|
||||
they recognise is the gesture their device asks for. */}
|
||||
Use your fingerprint, face or screen lock.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Button from 'antd/es/button';
|
||||
import List from 'antd/es/list';
|
||||
import Space from 'antd/es/space';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Popconfirm from 'antd/es/popconfirm';
|
||||
import message from 'antd/es/message';
|
||||
import { Passkey, fetchPasskeys, registerPasskey, revokePasskey } from './customerApi';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* The passkeys section of the account page (#40).
|
||||
*
|
||||
* Registering a passkey with no way to see or remove it is worse than not
|
||||
* offering passkeys at all, which is the whole reason this exists.
|
||||
*
|
||||
* Rendered only where WebAuthn is available. A browser without it gets nothing
|
||||
* rather than a button that cannot work — the same rule #41 applies to the login
|
||||
* form, and the reason the check is here rather than inside the click handler.
|
||||
*/
|
||||
const SUPPORTED =
|
||||
typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function';
|
||||
|
||||
function whenUsed(passkey: Passkey): string {
|
||||
// The thing that actually tells two entries apart when the names are similar.
|
||||
// A customer about to revoke one needs to know which device they are cutting
|
||||
// off, and "last used" answers that where a creation date does not.
|
||||
if (!passkey.last_used_at) return 'never used';
|
||||
return `last used ${new Date(passkey.last_used_at).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
export default function Passkeys() {
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
||||
// Seeded from support rather than always true, so the unsupported case needs
|
||||
// no effect to correct it. Setting it synchronously in the effect below would
|
||||
// be a state update during render as far as the hooks rule is concerned, and
|
||||
// suppressing that would be hiding the smell rather than removing it.
|
||||
const [loading, setLoading] = useState(SUPPORTED);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
return fetchPasskeys()
|
||||
.then(setPasskeys)
|
||||
// Distinguished from an empty list on purpose: "you have no passkeys" and
|
||||
// "we could not find out" look identical otherwise, and the first invites
|
||||
// a customer to add one they may already have.
|
||||
.catch(() => message.error('Could not load your passkeys'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// `load` raises the pending flag before fetching. The rule cannot tell that
|
||||
// from a value that was already knowable, and this is the former — the same
|
||||
// exception CustomerAuthContext and DraftQueue take, for the same reason.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (SUPPORTED) void load();
|
||||
}, [load]);
|
||||
|
||||
if (!SUPPORTED) return null;
|
||||
|
||||
async function handleAdd() {
|
||||
setAdding(true);
|
||||
try {
|
||||
setPasskeys(await registerPasskey());
|
||||
message.success('Passkey added');
|
||||
} catch (err) {
|
||||
// Dismissing the browser's prompt rejects, and that is a cancellation
|
||||
// rather than a failure. Reporting it as an error would tell a customer
|
||||
// something went wrong when they simply changed their mind.
|
||||
const name = (err as { name?: string }).name;
|
||||
if (name !== 'NotAllowedError' && name !== 'AbortError') {
|
||||
message.error(`Couldn't add a passkey — ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(passkey: Passkey) {
|
||||
try {
|
||||
await revokePasskey(passkey.id);
|
||||
} catch (err) {
|
||||
// The server's message is shown rather than replaced. Refusing to remove
|
||||
// the last way in says what to do about it, and a generic message would
|
||||
// strand the customer on a button that just does not work.
|
||||
message.error((err as Error).message);
|
||||
return;
|
||||
}
|
||||
message.success(`Removed ${passkey.name}`);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center" style={{ marginBottom: 8 }}>
|
||||
<Text strong>Passkeys</Text>
|
||||
<Button size="small" loading={adding} onClick={handleAdd}>
|
||||
Add a passkey
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Sign in with your fingerprint, face or screen lock instead of your password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={passkeys}
|
||||
locale={{ emptyText: 'No passkeys yet' }}
|
||||
renderItem={(passkey) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="remove"
|
||||
title={`Remove ${passkey.name}?`}
|
||||
description="You will not be able to sign in with this device again."
|
||||
okText="Remove"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleRevoke(passkey)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
Remove
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={passkey.name}
|
||||
description={`Added ${new Date(passkey.created_at).toLocaleDateString()} · ${whenUsed(passkey)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,11 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
const token = searchParams.get('token');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Set only when the reset actually removed passkeys (#42). A toast would be
|
||||
// the wrong shape for this: it dismisses itself, and a customer who is told
|
||||
// that credentials they do not remember registering have just been deleted
|
||||
// needs to still be looking at that when they decide what to do about it.
|
||||
const [passkeysRemoved, setPasskeysRemoved] = useState(0);
|
||||
const { refresh } = useCustomerAuth();
|
||||
|
||||
// A link without a token can't do anything, so say so rather than showing a
|
||||
@@ -48,11 +53,17 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resetPassword(token as string, values.password);
|
||||
const result = await resetPassword(token as string, values.password);
|
||||
// The server signs the customer in as part of the reset, so pick up the
|
||||
// new session before closing. Closing lands on the storefront: the link
|
||||
// came from an email, so there is no page behind to return to.
|
||||
refresh();
|
||||
// Unless there is something to say. The reset succeeded either way, so
|
||||
// this is a notice to acknowledge rather than a step still to complete.
|
||||
if (result.passkeysRemoved > 0) {
|
||||
setPasskeysRemoved(result.passkeysRemoved);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -61,6 +72,31 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
}
|
||||
}
|
||||
|
||||
// The reset is done and the customer is signed in; this is the notice about
|
||||
// what else it took with it. Shown in place of the form because there is
|
||||
// nothing left to fill in, and closed by the customer rather than by a timer.
|
||||
if (passkeysRemoved > 0) {
|
||||
return (
|
||||
<Modal title="Password changed" open onCancel={onClose} footer={null} destroyOnHidden>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
passkeysRemoved === 1
|
||||
? 'Your saved passkey was removed'
|
||||
: `Your ${passkeysRemoved} saved passkeys were removed`
|
||||
}
|
||||
description="Resetting a password removes them, so nobody who had access to your account keeps a way in. You can set them up again from your account page."
|
||||
/>
|
||||
<Paragraph style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Paragraph>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Choose a new password"
|
||||
@@ -73,6 +109,13 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
<Paragraph type="secondary">
|
||||
Signing in elsewhere will end — you'll stay signed in on this device.
|
||||
</Paragraph>
|
||||
{/* Said unconditionally, and it has to be: this form has no session and
|
||||
is not told whether the account has passkeys, because answering that
|
||||
would make the reset page an oracle for it. The wording works either
|
||||
way — someone with none reads it and has nothing to lose. */}
|
||||
<Paragraph type="secondary">
|
||||
Any passkeys saved on this account will be removed. You can add them again afterwards.
|
||||
</Paragraph>
|
||||
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from 'antd/es/modal';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Button from 'antd/es/button';
|
||||
import Space from 'antd/es/space';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Typography from 'antd/es/typography';
|
||||
import { updateConsent, updateAnalyticsConsent } from './customerApi';
|
||||
import { MARKETING_CONSENT_TEXT, ANALYTICS_CONSENT_TEXT } from './AuthForm';
|
||||
|
||||
const { Paragraph, Title } = Typography;
|
||||
|
||||
type Props = Readonly<{ onClose: () => void }>;
|
||||
|
||||
/**
|
||||
* The consent step a customer sees once, right after signing up with Google (#342).
|
||||
*
|
||||
* ## Why this screen has to exist
|
||||
*
|
||||
* Registration asks for two consents and stores their wording verbatim, and
|
||||
* marketing consent must start unticked (#56). Somebody who arrived through
|
||||
* Google has never seen those checkboxes and could not have: the redirect
|
||||
* happened before anyone knew whether they were new.
|
||||
*
|
||||
* Their account is created with both false, which is legally correct — nobody
|
||||
* agreed to anything and nothing is recorded as though they had. But leaving it
|
||||
* there would mean a Google sign-up is never asked at all, and a silent no is
|
||||
* still a decision made on someone else's behalf.
|
||||
*
|
||||
* ## Why the wording is imported rather than written here
|
||||
*
|
||||
* These two constants are the same strings the server stores against the
|
||||
* consent. The record is meant to say what the customer actually saw, so a
|
||||
* second copy of the sentence that drifted by a word would quietly defeat that.
|
||||
* Three wordings were already in circulation once before this was shared.
|
||||
*
|
||||
* ## Why skipping is a real option, not a soft refusal
|
||||
*
|
||||
* Consent has to be as easy to withhold as to give. "Not now" leaves both false
|
||||
* and closes, and nothing is sent. Both can be changed later from the account
|
||||
* page, which is where a customer who changes their mind will look.
|
||||
*/
|
||||
export default function Welcome({ onClose }: Props) {
|
||||
const [marketing, setMarketing] = useState(false);
|
||||
const [analytics, setAnalytics] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Two calls to two endpoints, which is the point rather than an
|
||||
// inefficiency: they are separate consents with separate purposes, and
|
||||
// the server stores the wording for each independently.
|
||||
await updateConsent(marketing);
|
||||
await updateAnalyticsConsent(analytics);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// The account exists and they are signed in either way, so this is not a
|
||||
// failure to recover from — only a preference that did not save.
|
||||
setError(`Those preferences didn't save — ${(err as Error).message}. You can set them on your account page.`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Welcome to Redefined Designs"
|
||||
open
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Paragraph type="secondary">
|
||||
Your account is ready and you are signed in. Two optional things, and you can change
|
||||
either of them later on your account page.
|
||||
</Paragraph>
|
||||
|
||||
{error && <Alert type="warning" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||
|
||||
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
|
||||
<Checkbox checked={marketing} onChange={(e) => setMarketing(e.target.checked)}>
|
||||
{MARKETING_CONSENT_TEXT}
|
||||
</Checkbox>
|
||||
{/* Its own checkbox and independently refusable. Someone has to be able
|
||||
to take the emails and refuse the tracking, or the consent is not
|
||||
granular and is not valid. Unticked, and never pre-ticked: Quebec's
|
||||
Law 25 requires profiling to be off until the person switches it on. */}
|
||||
<Checkbox checked={analytics} onChange={(e) => setAnalytics(e.target.checked)}>
|
||||
{ANALYTICS_CONSENT_TEXT}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
|
||||
<Space style={{ marginTop: 24 }}>
|
||||
<Button type="primary" loading={saving} onClick={save}>
|
||||
Save preferences
|
||||
</Button>
|
||||
{/* As prominent as it needs to be. Withholding consent has to be as
|
||||
easy as giving it, and a "Not now" hidden in small print is the
|
||||
pattern that makes a consent invalid. */}
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Not now
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Title level={5} style={{ marginTop: 24, fontSize: 13, opacity: 0.65 }}>
|
||||
Leaving both unticked is fine — we will not email you or share what you browse.
|
||||
</Title>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -132,12 +140,23 @@ export function requestPasswordReset(email: string): Promise<{ status: string }>
|
||||
}).then(res => handle<{ status: string }>(res));
|
||||
}
|
||||
|
||||
export function resetPassword(token: string, password: string): Promise<Customer> {
|
||||
/**
|
||||
* A completed reset, and how many passkeys it removed (#42).
|
||||
*
|
||||
* The count is part of the answer rather than something to look up afterwards:
|
||||
* the credentials are already gone by the time the form could go and ask, so
|
||||
* the only moment this can be reported is this one.
|
||||
*/
|
||||
export interface PasswordResetResult extends Customer {
|
||||
passkeysRemoved: number;
|
||||
}
|
||||
|
||||
export function resetPassword(token: string, password: string): Promise<PasswordResetResult> {
|
||||
return fetch('/api/customers/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}).then(res => handle<PasswordResetResult>(res));
|
||||
}
|
||||
|
||||
export function updateMyName(firstName: string, lastName: string): Promise<Customer> {
|
||||
@@ -172,6 +191,111 @@ 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;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export function fetchPasskeys(): Promise<Passkey[]> {
|
||||
return fetch('/api/customers/me/passkeys').then(res => handle<Passkey[]>(res));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a passkey on this device.
|
||||
*
|
||||
* Both halves of the ceremony live here rather than in the component, because
|
||||
* they are one operation: options come from the server, the browser turns them
|
||||
* into an attestation, and the server verifies it. A component holding the
|
||||
* intermediate state could leave a challenge issued and never answered.
|
||||
*
|
||||
* `startRegistration` is what prompts the customer. It throws when they dismiss
|
||||
* that prompt, which is a cancellation rather than a failure — the caller tells
|
||||
* them apart.
|
||||
*/
|
||||
export async function registerPasskey(name?: string): Promise<Passkey[]> {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser');
|
||||
|
||||
const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' });
|
||||
const options = await handle<Parameters<typeof startRegistration>[0]['optionsJSON']>(optionsRes);
|
||||
|
||||
const attestation = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const finishRes = await fetch('/api/customers/me/passkeys/register/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...attestation, name })
|
||||
});
|
||||
await handle<{ name: string }>(finishRes);
|
||||
|
||||
// The fresh list rather than the one row, so the caller cannot render a list
|
||||
// that disagrees with the server about what was just added.
|
||||
return fetchPasskeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in with a passkey (#41).
|
||||
*
|
||||
* Usernameless: nothing is sent to `begin`, and the browser offers whichever
|
||||
* accounts it holds. The customer never types an address, which is also why
|
||||
* this cannot leak whether one has an account — there is nothing to ask about.
|
||||
*
|
||||
* Rejects when the customer dismisses the prompt, which callers must treat as a
|
||||
* cancellation rather than a failure.
|
||||
*/
|
||||
export async function signInWithPasskey(): Promise<Customer> {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
||||
|
||||
const optionsRes = await fetch('/api/customers/passkeys/login/begin', { method: 'POST' });
|
||||
const options = await handle<Parameters<typeof startAuthentication>[0]['optionsJSON']>(optionsRes);
|
||||
|
||||
const assertion = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
const finishRes = await fetch('/api/customers/passkeys/login/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(assertion)
|
||||
});
|
||||
return handle<Customer>(finishRes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this browser can do WebAuthn at all.
|
||||
*
|
||||
* Checked before offering the control rather than inside its handler, so a
|
||||
* browser that cannot do this is never shown a button that fails. Password
|
||||
* login stays the fallback in every case (#41).
|
||||
*/
|
||||
export function passkeysSupported(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function';
|
||||
}
|
||||
|
||||
export function revokePasskey(id: number): Promise<void> {
|
||||
return fetch(`/api/customers/me/passkeys/${id}`, { method: 'DELETE' }).then(async (res) => {
|
||||
// 204 on success, so handle() would throw on an empty body. The failure
|
||||
// message matters here — refusing to remove the last way in says what to do
|
||||
// about it, and replacing that with something generic would strand the
|
||||
// customer on a button that simply does not work.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resendVerificationEmail(): Promise<void> {
|
||||
// 204 on success, so handle() would throw parsing an empty body. The failure
|
||||
// path must still reject: the server's message distinguishes "already
|
||||
|
||||
+12
-3
@@ -13,6 +13,7 @@ import ErrorFallback from './components/ErrorFallback';
|
||||
import DevThrow from './components/DevThrow';
|
||||
import Admin from './admin/Admin';
|
||||
import AuthRouteModal from './customer/AuthRouteModal';
|
||||
import Welcome from './customer/Welcome';
|
||||
import Account from './customer/Account';
|
||||
import PrivacyPolicy from './customer/PrivacyPolicy';
|
||||
import Submit from './intake/Submit';
|
||||
@@ -45,7 +46,7 @@ const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash
|
||||
// than as a page of their own. Each stays a real, linkable URL — bookmarkable,
|
||||
// refreshable, and closed by the browser's Back button — while never being
|
||||
// somewhere with no way out.
|
||||
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password', '/welcome'];
|
||||
|
||||
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||
// antd's transitions. Beyond the accessibility win, animated popups are a
|
||||
@@ -147,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
|
||||
@@ -192,14 +197,18 @@ 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')} />
|
||||
)}
|
||||
{/* One-time, right after a Google sign-up (#342). A route rather than
|
||||
a flag so it has an address and uses the same modal machinery as
|
||||
every other auth screen. */}
|
||||
{modalPath === '/welcome' && <Welcome onClose={closeModal} />}
|
||||
{modalPath === '/reset-password' && (
|
||||
<ResetPassword
|
||||
onClose={closeModal}
|
||||
|
||||
@@ -78,7 +78,28 @@ test.describe('My Account opens as a modal', () => {
|
||||
await expect(accountModal.orderHistoryButton).toBeVisible();
|
||||
// Scoped to the modal: the storefront behind it has a theme switch of its
|
||||
// own, so an unscoped switch locator would be ambiguous.
|
||||
await expect(accountModal.themeSwitches).toHaveCount(2);
|
||||
//
|
||||
// Three since #56 added analytics consent: marketing email, favourite
|
||||
// alerts, analytics. The count is a guard against one appearing or
|
||||
// vanishing unnoticed, so it is asserted alongside naming each of them —
|
||||
// a count alone would pass if two were swapped for each other.
|
||||
await expect(accountModal.themeSwitches).toHaveCount(3);
|
||||
await expect(accountModal.marketingConsentSwitch).toBeVisible();
|
||||
await expect(accountModal.favoriteAlertsSwitch).toBeVisible();
|
||||
await expect(accountModal.analyticsConsentSwitch).toBeVisible();
|
||||
});
|
||||
|
||||
// Quebec's Law 25 s.8.1 requires profiling to be off until the person turns
|
||||
// it on. The server defaults the column to false and the integration suite
|
||||
// asserts that; this is the half of the promise the customer can actually
|
||||
// see, and the two have to agree.
|
||||
test('analytics consent is off until the customer turns it on', async ({
|
||||
customer,
|
||||
accountModal
|
||||
}) => {
|
||||
await accountModal.open();
|
||||
|
||||
await expect(accountModal.analyticsConsentSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('deleting the account does not leave the page behind it looking signed in', async ({
|
||||
|
||||
@@ -144,4 +144,53 @@ test.describe('The review queue', () => {
|
||||
await card.getByRole('button', { name: 'Rotate right' }).click();
|
||||
expect((await rotated).status()).toBe(204);
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug reported from QA in #327: two items were published and did not
|
||||
* appear in Inventory.
|
||||
*
|
||||
* The publish itself was never at fault, which is why the test above passes —
|
||||
* it asserts against `GET /api/admin/items`, and the API was always right.
|
||||
* What was wrong was the Inventory tab: antd keeps a pane mounted once it has
|
||||
* been rendered, so a tab opened at page load and returned to later refetches
|
||||
* nothing and shows the list it built the first time.
|
||||
*
|
||||
* So this asserts through the UI and, crucially, **never reloads the page**.
|
||||
* Opening Inventory before publishing is what arms it: the list is fetched
|
||||
* while the item still has its submission-timestamp name, and the assertion
|
||||
* afterwards looks for the name given at publish — which a stale list cannot
|
||||
* contain. A `page.reload()` anywhere in here would make it pass against the
|
||||
* broken behaviour.
|
||||
*/
|
||||
test('an item published from the queue appears in Inventory without a reload', async ({
|
||||
page,
|
||||
admin
|
||||
}) => {
|
||||
const note = `Tab staleness ${RUN}`;
|
||||
const publishedName = `Vase ${RUN}`;
|
||||
await submitAnItem(page, note);
|
||||
|
||||
// Inventory first, so its pane is mounted and its list fetched before the
|
||||
// publish happens. This is the state a real admin is in: the tab has been
|
||||
// open since they arrived.
|
||||
await admin.open('Inventory');
|
||||
await expect(admin.activeTable).toBeVisible();
|
||||
|
||||
await admin.openTab('Review queue');
|
||||
const card = page.locator('.ant-card').filter({ hasText: note });
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await card.getByLabel('Name').fill(publishedName);
|
||||
// Setting a price confirms it, so publishing does not stop on the
|
||||
// unconfirmed-price dialog the test above covers.
|
||||
await card.getByLabel('Price').fill('42');
|
||||
await card.getByRole('button', { name: 'Publish', exact: true }).click();
|
||||
await expect(card.getByText('you set this price')).toBeVisible();
|
||||
|
||||
await admin.openTab('Inventory');
|
||||
|
||||
// Sorted by created_at descending and submitted moments ago, so it is on the
|
||||
// first page rather than somewhere in the accumulated dev database.
|
||||
await expect(admin.activePanel.getByText(publishedName)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ test.describe('Customer accounts', () => {
|
||||
await expect(authModal.marketingConsent).not.toBeChecked();
|
||||
});
|
||||
|
||||
// A second, separate consent (#56). Unchecked for the same reason as the one
|
||||
// above, and asserted separately because the two must be independently
|
||||
// refusable — a single control covering both is the bundling GDPR treats as
|
||||
// invalid, and Law 25 requires this one to start off.
|
||||
test('analytics consent checkbox is unchecked by default', async ({ authModal }) => {
|
||||
await authModal.gotoRegister();
|
||||
await expect(authModal.analyticsConsent).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('the consent label is the exact wording the server records', async ({ authModal }) => {
|
||||
await authModal.gotoRegister();
|
||||
|
||||
@@ -67,6 +76,90 @@ test.describe('Customer accounts', () => {
|
||||
await expect(accountModal.emailText(email)).toBeVisible();
|
||||
});
|
||||
|
||||
// #41's requirement, and the half of it that can be proven without a real
|
||||
// authenticator. Credentials bind to the Relying Party ID, so an actual
|
||||
// passkey sign-in cannot be exercised here — but "a failed passkey prompt
|
||||
// must return the customer to a usable password form, not a dead end" is
|
||||
// about what happens *after* the attempt, and that is entirely testable.
|
||||
test('a failed passkey attempt leaves the password form working', async ({
|
||||
page,
|
||||
customer,
|
||||
accountModal,
|
||||
authModal,
|
||||
header
|
||||
}) => {
|
||||
// The fixture leaves the customer signed in, and this test is about the
|
||||
// signed-out login form.
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
|
||||
await authModal.gotoLogIn();
|
||||
|
||||
const passkeyButton = authModal.logInDialog.getByRole('button', {
|
||||
name: 'Sign in with a passkey'
|
||||
});
|
||||
// Chromium implements WebAuthn, so the control is offered here. A browser
|
||||
// without it gets no button at all rather than a disabled one.
|
||||
await expect(passkeyButton).toBeVisible();
|
||||
|
||||
// Failed at the first request, before the browser prompt — so this needs no
|
||||
// authenticator and cannot hang waiting for a gesture nobody will make.
|
||||
await page.route('**/api/customers/passkeys/login/begin', (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' })
|
||||
);
|
||||
await passkeyButton.click();
|
||||
|
||||
// Says what to do next rather than only that something failed, and says
|
||||
// nothing about whether an account exists.
|
||||
await expect(page.getByText(/log in with your password instead/i)).toBeVisible();
|
||||
|
||||
// The actual requirement: not a dead end. The form behind the error still
|
||||
// signs the customer in.
|
||||
await authModal.logIn(customer.email, customer.password);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,15 @@ export class AccountModal {
|
||||
readonly orderHistoryButton: Locator;
|
||||
readonly closeButton: Locator;
|
||||
readonly resendVerificationButton: Locator;
|
||||
/**
|
||||
* Every switch in the modal. Kept for the count assertion that guards against
|
||||
* a control appearing or vanishing unnoticed — the individual switches below
|
||||
* are addressed by name, not by position.
|
||||
*/
|
||||
readonly themeSwitches: Locator;
|
||||
/** The second switch in the modal; the first is the theme. */
|
||||
readonly marketingConsentSwitch: Locator;
|
||||
readonly favoriteAlertsSwitch: Locator;
|
||||
readonly analyticsConsentSwitch: Locator;
|
||||
readonly notVerifiedNotice: Locator;
|
||||
|
||||
readonly firstName: Locator;
|
||||
@@ -46,7 +52,25 @@ export class AccountModal {
|
||||
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
||||
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
|
||||
this.themeSwitches = this.dialog.getByRole('switch');
|
||||
this.favoriteAlertsSwitch = this.dialog.getByRole('switch').last();
|
||||
// Addressed by accessible name rather than by position. `favoriteAlertsSwitch`
|
||||
// used to be `.last()`, which silently retargeted the moment the analytics
|
||||
// consent switch was added below it (#56): the test that meant to turn
|
||||
// favourite alerts off toggled analytics consent on instead, and failed on
|
||||
// the message rather than on the switch, which said nothing about why.
|
||||
//
|
||||
// antd's Switch renders a bare `role="switch"` with no accessible name — the
|
||||
// adjacent Text is a sibling, not a label — so each one carries an explicit
|
||||
// aria-label in Account.tsx. That is what makes these addressable, and it is
|
||||
// also what a screen reader needed.
|
||||
this.marketingConsentSwitch = this.dialog.getByRole('switch', {
|
||||
name: 'Receive emails about new items'
|
||||
});
|
||||
this.favoriteAlertsSwitch = this.dialog.getByRole('switch', {
|
||||
name: 'Email me when an item I favorited is sold'
|
||||
});
|
||||
this.analyticsConsentSwitch = this.dialog.getByRole('switch', {
|
||||
name: 'Share what I browse and buy with Brevo'
|
||||
});
|
||||
this.notVerifiedNotice = this.dialog.getByText('Email not verified');
|
||||
|
||||
this.firstName = this.dialog.getByLabel('First name', { exact: true });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@ export type AdminTab =
|
||||
/**
|
||||
* The admin shell: the tab strip and the panel it swaps.
|
||||
*
|
||||
* Only the active tab's panel is mounted, which is what keeps the locators
|
||||
* Only the active tab's panel is *visible*, which is what keeps the locators
|
||||
* inside each panel unambiguous — the email editors used to be stacked and a
|
||||
* locator for "Save" matched all six.
|
||||
*
|
||||
* Visible, not mounted. antd keeps a pane in the DOM once it has been rendered
|
||||
* and only hides it, so scoping to `.ant-tabs-tabpane-active` is what makes
|
||||
* these locators work — the inactive panels are still there. This comment used
|
||||
* to say "mounted", and that mistake is the shape of #327: a tab that never
|
||||
* unmounts also never re-runs its effects, so Inventory went on showing a list
|
||||
* it had fetched before anything was published into it.
|
||||
*
|
||||
* `activePanel` exists because several specs reached for
|
||||
* `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the
|
||||
* visible panel. That knowledge belongs here rather than in five spec files.
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AuthModal {
|
||||
readonly lastName: Locator;
|
||||
readonly password: Locator;
|
||||
readonly marketingConsent: Locator;
|
||||
readonly analyticsConsent: Locator;
|
||||
readonly createAccountButton: Locator;
|
||||
readonly createAccountTab: Locator;
|
||||
readonly logInTab: Locator;
|
||||
@@ -34,7 +35,17 @@ export class AuthModal {
|
||||
this.firstName = page.getByRole('textbox', { name: 'First name' });
|
||||
this.lastName = page.getByRole('textbox', { name: 'Last name' });
|
||||
this.password = page.getByLabel('Password');
|
||||
this.marketingConsent = page.getByRole('checkbox');
|
||||
// Named rather than "the checkbox on the form". There are two consents now
|
||||
// and they are deliberately separate (#56), so an unscoped checkbox locator
|
||||
// is ambiguous in strict mode — which is how this broke. Matched on the
|
||||
// opening words of each sentence so the locator survives the wording being
|
||||
// revised, which it has been once already.
|
||||
this.marketingConsent = page.getByRole('checkbox', {
|
||||
name: /^I want to receive occasional emails/
|
||||
});
|
||||
this.analyticsConsent = page.getByRole('checkbox', {
|
||||
name: /^I agree that what I browse and buy/
|
||||
});
|
||||
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
||||
this.createAccountTab = page.getByRole('tab', { name: 'Create Account' });
|
||||
this.logInTab = page.getByRole('tab', { name: 'Log In' });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { readPasswordResetToken } from './support/db';
|
||||
import { giveCustomerAPasskey, readPasswordResetToken } from './support/db';
|
||||
|
||||
const NEW_PASSWORD = 'a-brand-new-password';
|
||||
|
||||
@@ -118,4 +118,47 @@ test.describe('Password reset', () => {
|
||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||
await expect(header.myAccountButton).toHaveCount(0);
|
||||
});
|
||||
|
||||
// #42. A reset removes every passkey on the account, which is the one thing
|
||||
// it does that a customer cannot undo and might have chosen differently
|
||||
// about, so both halves of telling them are asserted here.
|
||||
test('the reset form says passkeys will be removed before the customer commits', async ({
|
||||
passwordReset,
|
||||
page
|
||||
}) => {
|
||||
await passwordReset.gotoReset('any-token-will-do');
|
||||
|
||||
// Said unconditionally, and it has to be: this form has no session and is
|
||||
// never told whether the account has passkeys, because answering that would
|
||||
// make the reset page an oracle for it. So the warning shows on a token
|
||||
// that was never issued, exactly as it would on a real one.
|
||||
await expect(page.getByText(/passkeys saved on this account will be removed/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('a reset that removed a passkey says so, and waits to be acknowledged', async ({
|
||||
page,
|
||||
request,
|
||||
customer,
|
||||
accountModal,
|
||||
header,
|
||||
passwordReset
|
||||
}) => {
|
||||
await giveCustomerAPasskey(customer.email);
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
|
||||
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
|
||||
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
|
||||
await passwordReset.setNewPassword(NEW_PASSWORD);
|
||||
|
||||
// A toast would be the wrong shape: it dismisses itself, and a customer
|
||||
// told that a credential they do not remember registering has just been
|
||||
// deleted needs to still be looking at that when they decide what to do.
|
||||
await expect(page.getByText('Your saved passkey was removed')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
// The reset still succeeded — this was a notice, not a step that failed.
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,3 +67,27 @@ export async function readPasswordResetToken(email: string): Promise<string> {
|
||||
return rows[0].token as string;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a passkey on an account, for the tests that are about what happens to it
|
||||
* (#42).
|
||||
*
|
||||
* Registering one for real needs an authenticator, which Playwright does not
|
||||
* have and which no fixture can supply. The credential is a row, and a password
|
||||
* reset acts on the row rather than on the hardware behind it, so a row is
|
||||
* enough to test the interaction — nothing here ever tries to sign in with it.
|
||||
*
|
||||
* Here rather than in a spec for the same reason as the token read above: the
|
||||
* next spec that wants a shortcut should have to come and ask for one.
|
||||
*/
|
||||
export async function giveCustomerAPasskey(email: string, name = 'Test key'): Promise<void> {
|
||||
await withClient(async (client) => {
|
||||
const { rowCount } = await client.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
SELECT id, 'e2e-' || id || '-' || floor(random() * 1e9)::text, 'not-a-real-key', $2
|
||||
FROM customers WHERE email = $1`,
|
||||
[email, name]
|
||||
);
|
||||
if (rowCount === 0) throw new Error(`no customer to give a passkey to: ${email}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Deletes completed Actions runs older than a given age.
|
||||
*
|
||||
* Gitea 1.27.3 has retention for a run's logs (`LOG_RETENTION_DAYS`) and its
|
||||
* artifacts, but none for the run record itself — so the Actions list grows
|
||||
* without limit while the entries on it become empty shells once their logs
|
||||
* expire. This removes the shells. See #324.
|
||||
*
|
||||
* Dry run unless APPLY is exactly "true". Deleting a run cannot be undone and
|
||||
* there is no confirmation step once this starts, so the default has to be the
|
||||
* harmless one.
|
||||
*
|
||||
* Environment:
|
||||
* GITEA_HOST origin of the instance, scheme included, e.g.
|
||||
* https://gitea.example.com or http://gitea:3000
|
||||
* GITEA_REPO "owner/name"
|
||||
* GITEA_ACCESS_TOKEN token permitted to delete runs
|
||||
* KEEP_DAYS keep runs newer than this many days (default 7)
|
||||
* APPLY "true" to delete; anything else reports only
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
|
||||
const HOST = process.env.GITEA_HOST;
|
||||
const REPO = process.env.GITEA_REPO;
|
||||
const TOKEN = process.env.GITEA_ACCESS_TOKEN;
|
||||
const KEEP_DAYS = Number(process.env.KEEP_DAYS || 7);
|
||||
const APPLY = process.env.APPLY === 'true';
|
||||
|
||||
for (const [name, value] of Object.entries({ GITEA_HOST: HOST, GITEA_REPO: REPO, GITEA_ACCESS_TOKEN: TOKEN })) {
|
||||
if (!value) {
|
||||
console.error(`${name} is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(KEEP_DAYS) || KEEP_DAYS < 0) {
|
||||
console.error(`KEEP_DAYS must be a non-negative number, got ${process.env.KEEP_DAYS}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const origin = new URL(HOST);
|
||||
|
||||
/**
|
||||
* The transport GITEA_HOST actually asks for, rather than the one assumed.
|
||||
*
|
||||
* This was hardcoded to https, which worked when the host was typed by hand and
|
||||
* failed the moment the workflow started taking it from `github.server_url`.
|
||||
* Inside the runner that is the address act_runner reaches Gitea on, which here
|
||||
* is plain HTTP on a container port — and a TLS handshake sent to a plaintext
|
||||
* port does not fail as a connection error. It fails as:
|
||||
*
|
||||
* write EPROTO ... ssl3_get_record:wrong version number
|
||||
*
|
||||
* which reads like a TLS misconfiguration and sends you looking at certificates
|
||||
* and protocol versions. It is neither. The server answered in cleartext and
|
||||
* OpenSSL tried to parse that as a TLS record.
|
||||
*
|
||||
* So the scheme is honoured rather than guessed, and the default port follows
|
||||
* from it. Anything other than the two is refused up front: this script only
|
||||
* speaks HTTP, and a URL naming some other scheme is a mistake worth reporting
|
||||
* as one instead of failing later inside a request.
|
||||
*/
|
||||
if (origin.protocol !== 'https:' && origin.protocol !== 'http:') {
|
||||
console.error(`GITEA_HOST must be an http or https URL, got ${HOST}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const secure = origin.protocol === 'https:';
|
||||
const transport = secure ? https : http;
|
||||
const PORT = origin.port || (secure ? 443 : 80);
|
||||
|
||||
const BASE = `/api/v1/repos/${REPO}/actions/runs`;
|
||||
|
||||
function call(method, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
{ hostname: origin.hostname, port: PORT, path, method, headers: { Authorization: `token ${TOKEN}` } },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (d) => (body += d));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body }));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function listAllRuns() {
|
||||
const runs = [];
|
||||
// Bounded rather than `while (true)`: a paging bug against an API that keeps
|
||||
// answering would otherwise loop until the job times out.
|
||||
for (let page = 1; page <= 200; page++) {
|
||||
const res = await call('GET', `${BASE}?page=${page}&limit=50`);
|
||||
if (res.status >= 400) throw new Error(`listing runs failed: ${res.status} ${res.body.slice(0, 200)}`);
|
||||
const batch = JSON.parse(res.body).workflow_runs || [];
|
||||
if (batch.length === 0) break;
|
||||
runs.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
const EPOCH_GUARD = new Date('2000-01-01').getTime();
|
||||
|
||||
/**
|
||||
* When a run happened, from whichever timestamp it actually has.
|
||||
*
|
||||
* `started_at` is preferred and is usually right, but a run **cancelled before
|
||||
* it ever started** reports an epoch value there while carrying a real
|
||||
* `completed_at`. Reading only `started_at` therefore made every cancelled run
|
||||
* look undateable, and a first pass at this left 19 of them — from three weeks
|
||||
* earlier — sitting in a list that was supposed to hold seven days.
|
||||
*
|
||||
* Returns null when neither timestamp is usable, which is the case that must
|
||||
* stay conservative: an epoch date treated as "1969, therefore old" would
|
||||
* delete precisely the runs that have not happened yet.
|
||||
*/
|
||||
function runTimeMs(run) {
|
||||
for (const stamp of [run.started_at, run.completed_at]) {
|
||||
const ms = stamp ? new Date(stamp).getTime() : NaN;
|
||||
if (Number.isFinite(ms) && ms >= EPOCH_GUARD) return ms;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs old enough to remove, and the reasons the others were left.
|
||||
*
|
||||
* A run that is not completed is never a candidate — which is also what stops
|
||||
* this deleting the very run it is executing in.
|
||||
*/
|
||||
function selectDoomed(runs, cutoffMs) {
|
||||
const doomed = [];
|
||||
const kept = { recent: 0, unfinished: 0, undated: 0 };
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') {
|
||||
kept.unfinished++;
|
||||
continue;
|
||||
}
|
||||
const ms = runTimeMs(run);
|
||||
if (ms === null) {
|
||||
kept.undated++;
|
||||
continue;
|
||||
}
|
||||
if (ms >= cutoffMs) {
|
||||
kept.recent++;
|
||||
continue;
|
||||
}
|
||||
doomed.push({ id: run.id, startedAt: run.started_at, when: new Date(ms).toISOString() });
|
||||
}
|
||||
|
||||
doomed.sort((a, b) => a.id - b.id);
|
||||
return { doomed, kept };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Printed before the first request rather than after it succeeds. A transport
|
||||
// failure here says nothing about where it was pointed, and the last one cost
|
||||
// a round trip to find out that the answer was "somewhere plaintext".
|
||||
console.log(`endpoint : ${origin.protocol}//${origin.hostname}:${PORT}`);
|
||||
|
||||
const runs = await listAllRuns();
|
||||
const cutoffMs = Date.now() - KEEP_DAYS * 86400000;
|
||||
const { doomed, kept } = selectDoomed(runs, cutoffMs);
|
||||
|
||||
console.log(`repository : ${REPO}`);
|
||||
console.log(`total runs : ${runs.length}`);
|
||||
console.log(`keeping : ${kept.recent} newer than ${KEEP_DAYS}d, ${kept.unfinished} unfinished, ${kept.undated} undated`);
|
||||
console.log(`to delete : ${doomed.length}`);
|
||||
if (doomed.length > 0) {
|
||||
console.log(` oldest : run ${doomed[0].id} (${doomed[0].when})`);
|
||||
console.log(` newest : run ${doomed[doomed.length - 1].id} (${doomed[doomed.length - 1].when})`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('DRY RUN — nothing deleted. Re-run with apply set to "true".');
|
||||
return;
|
||||
}
|
||||
if (doomed.length === 0) {
|
||||
console.log('Nothing to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
const failures = [];
|
||||
for (const run of doomed) {
|
||||
const res = await call('DELETE', `${BASE}/${run.id}`);
|
||||
if (res.status >= 200 && res.status < 300) deleted++;
|
||||
else failures.push(`${run.id}:${res.status}`);
|
||||
|
||||
const done = deleted + failures.length;
|
||||
if (done % 50 === 0) console.log(` ...${done}/${doomed.length}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`deleted : ${deleted}`);
|
||||
console.log(`failed : ${failures.length}`);
|
||||
if (failures.length > 0) {
|
||||
console.log(` ${failures.slice(0, 20).join(' ')}${failures.length > 20 ? ' …' : ''}`);
|
||||
// A partial delete is not a success. Most likely the token cannot delete
|
||||
// runs, and reporting green here would hide that behind a job that
|
||||
// appeared to work.
|
||||
process.exitCode = 1;
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error(`FAILED: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+29
-4
@@ -280,11 +280,36 @@ Reserved ranges: netsh interface ipv4 show excludedportrange protocol=tcp
|
||||
function Install-IfMissing {
|
||||
param([string]$Directory)
|
||||
$name = Split-Path -Leaf $Directory
|
||||
if (Test-Path (Join-Path $Directory 'node_modules')) {
|
||||
Write-Note "$name dependencies already installed"
|
||||
return
|
||||
|
||||
# Whether node_modules matches the lockfile, not merely whether it exists.
|
||||
#
|
||||
# This used to ask only `Test-Path node_modules`, which meant a branch that
|
||||
# ADDED a dependency never installed it for anyone who already had the
|
||||
# directory — and almost everyone always does. The build then failed on
|
||||
# "Cannot find module", naming a package that is right there in
|
||||
# package.json, which reads as a broken checkout rather than a missing
|
||||
# install. It cost an afternoon the first time #37 added @simplewebauthn.
|
||||
#
|
||||
# npm writes node_modules/.package-lock.json describing exactly what it put
|
||||
# there, so comparing its timestamp against package-lock.json answers the
|
||||
# real question: is what is installed what is currently asked for. A pull
|
||||
# that changes dependencies makes the lockfile newer, and this notices.
|
||||
$lockfile = Join-Path $Directory 'package-lock.json'
|
||||
$installed = Join-Path $Directory 'node_modules/.package-lock.json'
|
||||
|
||||
if ((Test-Path $installed) -and (Test-Path $lockfile)) {
|
||||
$lockTime = (Get-Item $lockfile).LastWriteTimeUtc
|
||||
$installedTime = (Get-Item $installed).LastWriteTimeUtc
|
||||
if ($installedTime -ge $lockTime) {
|
||||
Write-Note "$name dependencies are up to date"
|
||||
return
|
||||
}
|
||||
Write-Step "Installing $name dependencies (the lockfile has changed)"
|
||||
}
|
||||
Write-Step "Installing $name dependencies"
|
||||
else {
|
||||
Write-Step "Installing $name dependencies"
|
||||
}
|
||||
|
||||
Push-Location $Directory
|
||||
try { Invoke-Checked { npm install } "$name npm install" } finally { Pop-Location }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user