feat(passkeys): registration ceremony (#38) #331

Merged
bermudalamb merged 1 commits from feature/38-passkey-registration into main 2026-09-09 14:21:49 -05:00
Owner

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.

A column #37 did not have

name arrives here rather than in the groundwork. #37 listed the columns the ceremony needs; 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; otherwise it is derived from the authenticator's transports. Those are 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 a model name would not be.

How single use is enforced

By deleting the challenge and treating the delete as the check — one statement, with the expiry condition in the same WHERE.

A replayed response finds nothing to delete and is refused. Two requests racing cannot both see the row and both proceed. And beginning a second registration replaces any in-flight challenge, so pressing the button twice cannot leave the first 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 identically. From the server they are one condition — no challenge this customer may still complete — and separating them would tell someone guessing which guess was closest.

Two details worth flagging

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, not 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 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 work here.

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

Verification

tsc clean for src and tests · lint 0 errors, no new warnings · 514 unit tests across 35 suites (20 new, covering the naming rules) · schema mirror updated with name in the position codegen would put it.

Not verified: neither migration has run against a database, and the ceremony cannot be exercised without a browser and a real authenticator. CI can prove the routes exist, are wrapped, and refuse an unauthenticated caller. It cannot prove a real attestation — that needs a device, and per #41 that is a standing limitation of this whole feature rather than a gap in this change.

Closes #38

🤖 Generated with Claude Code

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. ## A column #37 did not have `name` arrives here rather than in the groundwork. #37 listed the columns the *ceremony* needs; 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; otherwise it is derived from the authenticator's transports. Those are 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 a model name would not be. ## How single use is enforced **By deleting the challenge and treating the delete as the check** — one statement, with the expiry condition in the same `WHERE`. A replayed response finds nothing to delete and is refused. Two requests racing cannot both see the row and both proceed. And beginning a second registration replaces any in-flight challenge, so pressing the button twice cannot leave the first 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 identically.** From the server they are one condition — no challenge this customer may still complete — and separating them would tell someone guessing which guess was closest. ## Two details worth flagging `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, **not 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 `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 work here. Mounted before `/api/customers`, like the addresses router — Express matches mounts in order and the broader prefix would swallow these. ## Verification `tsc` clean for src and tests · lint 0 errors, no new warnings · **514 unit tests across 35 suites** (20 new, covering the naming rules) · schema mirror updated with `name` in the position codegen would put it. **Not verified:** neither migration has run against a database, and the ceremony cannot be exercised without a browser and a real authenticator. CI can prove the routes exist, are wrapped, and refuse an unauthenticated caller. It cannot prove a real attestation — that needs a device, and per #41 that is a standing limitation of this whole feature rather than a gap in this change. Closes #38 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bermudalamb added 1 commit 2026-09-09 14:16:18 -05:00
feat(passkeys): registration ceremony (#38)
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Linting / lint (pull_request) Canceled after 0s
88f76926d5
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>
bermudalamb merged commit fc674561e9 into main 2026-09-09 14:21:49 -05:00
bermudalamb deleted branch feature/38-passkey-registration 2026-09-09 14:21:49 -05:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#331