Files
redefined-designs/backend/tests/unit/relyingParty.test.ts
T
synAdminandClaude Opus 5 6d320fd867 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>
2026-09-09 14:08:29 -05:00

76 lines
3.5 KiB
TypeScript

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');
});
});