Merge pull request 'feat(passkeys): schema, dependency and per-environment Relying Party (#37)' (#330) from feature/37-passkeys-groundwork into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s

Reviewed-on: #330
This commit was merged in pull request #330.
This commit is contained in:
2026-09-09 14:08:29 -05:00
10 changed files with 675 additions and 5 deletions
@@ -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;
`);
};
+338 -2
View File
@@ -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"
}
+1
View File
@@ -32,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",
+22
View File
@@ -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;
}
+111
View File
@@ -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]
};
}
@@ -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 20 of 20 tables/);
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/items/);
await migrate();
+4 -1
View File
@@ -37,6 +37,7 @@ const REQUIRED_TABLES = [
'categories',
'checkout_items',
'checkouts',
'customer_credentials',
'customer_sessions',
'customer_tokens',
'customers',
@@ -48,7 +49,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. */
@@ -151,6 +153,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,
customers, item_tags, item_images, items, tags, categories
RESTART IDENTITY CASCADE
`);
+75
View File
@@ -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');
});
});
+25 -1
View File
@@ -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"
}
+1
View File
@@ -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",