feat(passkeys): list, add and revoke from the account page (#40) #334

Merged
bermudalamb merged 1 commits from feature/40-manage-passkeys into main 2026-09-09 15:44:49 -05:00
Owner

The issue calls this the smallest one in the project and the one that makes the rest usable. That is right — registering a passkey with no way to see or remove it is worse than not offering passkeys at all.

Revocation is the row going away

#39 looks a credential up by id on every sign-in, so a deleted one is refused immediately and by construction rather than by a flag something has to remember to check.

The delete is scoped to the signed-in customer in the same statement that removes the row. A credential id is not a secret, so the WHERE clause is the only thing making this safe — reading first and deleting after would leave a window.

Both "no such credential" and "not yours" answer 404. The second is the interesting case, and distinguishing it would confirm that some other customer holds that id.

The lockout check, which cannot fire yet

password_hash is NOT NULL, so every customer has a password and removing every passkey still leaves a way in. The check is written anyway, because the issue asks for it and it is written against the condition rather than against today's schema — so it starts holding on its own the moment that changes.

#332 is what changes it. Social sign-in makes password_hash nullable and creates the first customers with no password. At that point a customer whose only credential is a passkey can genuinely lock themselves out with this button, and this branch starts running for real.

What the list returns, and what it does not

Name, added, last used. No public key, no credential id, no counter — the customer cannot act on any of them, and the credential id is the one value that identifies an authenticator to anyone holding it.

last_used_at is what actually tells two entries apart when the names are similar. Someone about to revoke one needs to know which device they are cutting off, and a creation date does not answer that.

Two small things that matter in use

Dismissing the browser prompt is a cancellation, not a failure. NotAllowedError and AbortError are swallowed; everything else is shown. Reporting a dismissal as an error tells a customer something went wrong when they simply changed their mind.

The server's refusal message is shown as it arrives. When the lockout check eventually fires it says what to do about it, and replacing that with something generic would strand the customer on a button that just does not work.

The whole ceremony lives in customerApi rather than the component, because it is one operation — options, attestation, verification. A component holding that intermediate state could leave a challenge issued and never answered.

The section renders nothing where WebAuthn is unavailable, rather than offering a button that cannot work. Same rule #41 applies to the login form.

Verification

tsc clean for src and tests in both workspaces · backend lint at its 7 pre-existing warnings, none added · frontend lint completely clean · 521 unit tests across 36 suites · frontend build green.

One note on that: the effect needed the same set-state-in-effect exception CustomerAuthContext and DraftQueue already take. I first tried restructuring to avoid it, which did not work because the loader raises the pending flag itself — so it is the house precedent, scoped to the exact line, with the reason written down.

Not verified: registration needs a browser and a real authenticator — per #41 a standing limitation of this feature. The list and revoke endpoints are ordinary routes and CI can prove those.

Closes #40

🤖 Generated with Claude Code

The issue calls this the smallest one in the project and the one that makes the rest usable. That is right — registering a passkey with no way to see or remove it is worse than not offering passkeys at all. ## Revocation is the row going away #39 looks a credential up by id on every sign-in, so a deleted one is refused **immediately and by construction** rather than by a flag something has to remember to check. The delete is scoped to the signed-in customer **in the same statement that removes the row**. A credential id is not a secret, so the `WHERE` clause is the only thing making this safe — reading first and deleting after would leave a window. Both "no such credential" and "not yours" answer **404**. The second is the interesting case, and distinguishing it would confirm that some other customer holds that id. ## The lockout check, which cannot fire yet `password_hash` is `NOT NULL`, so every customer has a password and removing every passkey still leaves a way in. **The check is written anyway**, because the issue asks for it and it is written against the *condition* rather than against today's schema — so it starts holding on its own the moment that changes. **#332 is what changes it.** Social sign-in makes `password_hash` nullable and creates the first customers with no password. At that point a customer whose only credential is a passkey can genuinely lock themselves out with this button, and this branch starts running for real. ## What the list returns, and what it does not Name, added, last used. **No public key, no credential id, no counter** — the customer cannot act on any of them, and the credential id is the one value that identifies an authenticator to anyone holding it. `last_used_at` is what actually tells two entries apart when the names are similar. Someone about to revoke one needs to know which device they are cutting off, and a creation date does not answer that. ## Two small things that matter in use **Dismissing the browser prompt is a cancellation, not a failure.** `NotAllowedError` and `AbortError` are swallowed; everything else is shown. Reporting a dismissal as an error tells a customer something went wrong when they simply changed their mind. **The server's refusal message is shown as it arrives.** When the lockout check eventually fires it says what to do about it, and replacing that with something generic would strand the customer on a button that just does not work. The whole ceremony lives in `customerApi` rather than the component, because it is one operation — options, attestation, verification. A component holding that intermediate state could leave a challenge issued and never answered. The section **renders nothing where WebAuthn is unavailable**, rather than offering a button that cannot work. Same rule #41 applies to the login form. ## Verification `tsc` clean for src and tests in **both** workspaces · backend lint at its 7 pre-existing warnings, none added · **frontend lint completely clean** · 521 unit tests across 36 suites · frontend build green. One note on that: the effect needed the same `set-state-in-effect` exception `CustomerAuthContext` and `DraftQueue` already take. I first tried restructuring to avoid it, which did not work because the loader raises the pending flag itself — so it is the house precedent, scoped to the exact line, with the reason written down. **Not verified:** registration needs a browser and a real authenticator — per #41 a standing limitation of this feature. The list and revoke endpoints are ordinary routes and CI can prove those. Closes #40 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bermudalamb added 1 commit 2026-09-09 14:42:06 -05:00
feat(passkeys): list, add and revoke from the account page (#40)
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Linting / lint (pull_request) Canceled after 0s
1c8763f30f
The issue calls this the smallest one in the project and the one that makes the rest usable, and that is right: registering a passkey with no way to see or remove it is worse than not offering passkeys at all.

Revocation is the row going away. #39 looks a credential up by id on every sign-in, so a deleted one is refused immediately and by construction rather than by a flag something has to remember to check. The delete is scoped to the signed-in customer in the same statement that removes the row, because a credential id is not a secret and the WHERE clause is the only thing making this safe. Reading first and deleting after would leave a window.

Both "no such credential" and "not yours" answer 404. The second is the interesting case, and saying so would confirm that some other customer holds that id.

The lockout check is written even though it cannot fire. password_hash is NOT NULL, so every customer has a password and removing every passkey still leaves a way in. The issue asks for the check anyway and that is the right call, because it is written against the condition rather than against today's schema — it starts holding on its own the moment the condition changes. #332 is what changes it: social sign-in makes password_hash nullable and creates the first customers with no password, and at that point this branch starts running for real.

The list returns name, added and last used, and nothing else. No public key, no credential id, no counter — the customer cannot act on any of them, and the credential id is the one value that identifies an authenticator to anyone holding it. Last used is what actually tells two entries apart when the names are similar: someone about to revoke one needs to know which device they are cutting off, and a creation date does not answer that.

The whole ceremony lives in customerApi rather than the component, because it is one operation: options from the server, an attestation from the browser, verification back at the server. A component holding that intermediate state could leave a challenge issued and never answered.

Dismissing the browser's prompt rejects, and that is a cancellation rather than a failure. Reporting it as an error would tell a customer something went wrong when they simply changed their mind, so NotAllowedError and AbortError are swallowed and everything else is shown. The server's message on a refused revoke is shown as it arrives too — it says what to do about the last way in, and a generic message would strand the customer on a button that just does not work.

The section renders nothing where WebAuthn is unavailable, rather than offering a button that cannot work. Same rule #41 applies to the login form.

Verified: tsc clean for src and tests in both workspaces, backend lint at the seven pre-existing warnings with none added, frontend lint clean, 521 unit tests across 36 suites, frontend build green.

Not verified: the registration ceremony needs a browser and a real authenticator, which per #41 is a standing limitation of this feature. What CI can prove is the list and revoke endpoints, which are ordinary routes.

Closes #40

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bermudalamb merged commit 1da4dc7acc into main 2026-09-09 15:44:49 -05:00
bermudalamb deleted branch feature/40-manage-passkeys 2026-09-09 15:44:50 -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#334