feat(passkeys): registration ceremony (#38)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s

A signed-in customer can register a passkey. Signing in with one is #39 and the management screen is #40, so nothing reads these credentials yet.

The name column arrives here rather than in #37. That issue listed the columns the ceremony needs and this one is for the person: #40 shows a list and offers to revoke from it, and "phone" against "laptop" is the only thing that makes two rows tellable apart. Without it a customer revoking a credential is choosing between identical entries. The customer may name it, and otherwise it is derived from the authenticator's transports — a hint rather than a fact, so the defaults are deliberately vague. "This device" is honest about a platform authenticator in a way that guessing at a model name would not be.

Single use is enforced by deleting the challenge and treating the delete as the check, in one statement with the expiry condition. A replayed response finds nothing to delete and is refused, and two requests racing cannot both see the row and both proceed. Beginning a second registration replaces any in-flight challenge for that customer, so pressing the button twice cannot leave the first one usable.

The challenge is consumed before the response is verified, deliberately. A failed attempt must not leave one available for a second try, so an invalid response costs the ceremony rather than merely failing it.

Never started, already used and expired all answer the same way. From the server they are one condition — no challenge this customer may still complete — and distinguishing them would tell someone guessing which guess was closest.

excludeCredentials stops the same authenticator being enrolled twice, but it is a hint the browser may ignore, so the unique constraint on credential_id is what actually holds. Hitting it answers 409: the credential is already registered, which is not a failure of anything.

userID is the customer id rather than the email. A userID is meant to be stable and opaque, and an email is neither — a customer changing theirs would otherwise look like a different person to their own authenticator.

Discoverable credentials are requested as preferred rather than required, because #39 wants sign-in without the customer first saying who they are, and an authenticator that cannot store one should still be usable here.

Mounted before /api/customers, like the addresses router: Express matches mounts in order and the broader prefix would otherwise swallow these.

Verified: tsc clean for src and tests, lint 0 errors with no new warnings, 514 unit tests across 35 suites — twenty new, covering the naming rules. The schema mirror gains the name column, placed where kysely-codegen would put it.

Not verified: neither migration has been run against a database, and the ceremony itself cannot be exercised without a browser and an authenticator. What CI can prove is that the routes exist, are wrapped, and refuse an unauthenticated caller; what it cannot prove is a real attestation, which needs a device.

Closes #38

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
synAdmin
2026-09-09 14:15:56 -05:00
co-authored by Claude Opus 5
parent c6f6ddc6dd
commit 88f76926d5
6 changed files with 340 additions and 0 deletions
+66
View File
@@ -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);
});
});