Groundwork only. Nothing reads any of this yet, and no behaviour changes. The Relying Party ID is derived from PUBLIC_URL rather than written down, because it is the one value in this feature that cannot be corrected afterwards: a credential is bound to it permanently, and a wrong one surfaces only as a customer unable to sign in with a passkey that no longer matches anything. PUBLIC_URL is what every customer-facing link is already built from, so the ID is correct wherever those links are, and wrong only where they were already wrong. hostname rather than host, so a port cannot reach an ID that must not contain one. Local development is the exception the issue's table did not cover. envValidation requires PUBLIC_URL only when SMTP is configured, so a local setup that cannot send mail legitimately has none and falls back to localhost, which browsers treat as a secure context. Two origins there rather than one: the app is served by Vite on 5173 during development and by Express on 3000 once built, and those differ only by port, which is not part of the RP ID. The challenge table is separate from customer_tokens, and the reason is structural rather than preference. 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 exists. Storing it there would mean making that column nullable for every other kind of token. Two of the issue's open decisions are deliberately not made here, because they belong to the ceremony that enforces them rather than to the schema. What to do when the signature counter fails to increase is #39's: many synced passkeys report zero forever, so treating a non-increase as cloning is wrong for them and right for a hardware key, and this only has to hold the value. Whether a disabled account can authenticate is also #39's, and the schema takes the position that it should not cost the customer their devices: credentials survive disabling and are refused at the ceremony, so re-enabling does not mean re-registering everything. Deletion is different and is settled here — credentials cascade with the customer, since one outliving its owner could authenticate as an account that no longer exists. signature_counter is BIGINT because the spec allows a 32-bit unsigned value, which overflows a signed INTEGER at half its range. That is the first bigint column in this schema, so the generated mirror gains the Int8 alias with it. Both tables are added to resetDb's TRUNCATE list and to REQUIRED_TABLES, and the schema mirror is updated by hand to match what kysely-codegen emits — placement and all, so a real regenerate produces no diff. Skipping either is how #56 turned a green local run into a red main; the mirror drift guard exists precisely to catch it, and schemaLoss's count moves from 18 to 20 with them. Verified: tsc clean for src and tests, lint 0 errors with no new warnings, 494 unit tests across 34 suites including nine new ones for the RP derivation, frontend build green, and the migration parses. Not verified: the migration has not been run against a database, and the integration suite needs one this machine cannot provide. Worth knowing before this goes further: #313 changes the domain, and every passkey registered before that cutover stops working at it. This code needs no change — it follows PUBLIC_URL — but the credentials do not survive. That is free while production is not live and nobody holds one, and it stops being free the day the shop opens. Closes #37 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
4.4 KiB
JavaScript
98 lines
4.4 KiB
JavaScript
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;
|
|
`);
|
|
};
|