feat(passkeys): schema, dependency and per-environment Relying Party (#37)
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a2143696a
commit
6d320fd867
@@ -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,6 +73,17 @@ 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;
|
||||
public_key: string;
|
||||
signature_counter: Generated<Int8>;
|
||||
transports: string | null;
|
||||
}
|
||||
|
||||
export interface Customers {
|
||||
analytics_consent: Generated<boolean>;
|
||||
analytics_consent_at: Timestamp | null;
|
||||
@@ -212,6 +225,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;
|
||||
@@ -219,6 +239,7 @@ export interface DB {
|
||||
categories: Categories;
|
||||
checkout_items: CheckoutItems;
|
||||
checkouts: Checkouts;
|
||||
customer_credentials: CustomerCredentials;
|
||||
customer_sessions: CustomerSessions;
|
||||
customer_tokens: CustomerTokens;
|
||||
customers: Customers;
|
||||
@@ -231,4 +252,5 @@ export interface DB {
|
||||
shipping_addresses: ShippingAddresses;
|
||||
tags: Tags;
|
||||
upload_links: UploadLinks;
|
||||
webauthn_challenges: WebauthnChallenges;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user