fix(passkey): don't strand an account when a registration is submitted twice - #91
Merged
ralyodio merged 1 commit intoJul 30, 2026
Conversation
…d twice mc_c_reg is only cleared on the way out of a successful verify, so two requests that are already in flight - a double click, or a client retry after a slow first attempt - both pass the ceremony check and both create a user before either writes its credential. The credential id is the primary key, so the second insert always loses. Unhandled, that left behind an account with no passkey to sign in with, paid the 100-credit signup bonus a second time, and rejected inside the request: express 4 does not catch async rejections, so the losing request never got a response and the rejection took the process down with it. Catch the failed insert, undo the user that attempt created (channels and the signup bonus cascade with the row), and answer the loser with a plain 400 instead.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
mc_c_regis only cleared on the way out of a successful verify, so two requests that are already in flight both pass the ceremony check at the top of/auth/passkey/register/verify. That happens on an ordinary double click, or when a client retries after a slow first attempt — no crafted cookies, the browser legitimately still holdsmc_c_reguntil the first reply lands.Both requests then call
createUserPasskey()before either writes its credential.webauthn_credentials.idis the primary key, so the second insert always loses, and the loss is unhandled:seedDefaults()already paid the 100-creditSIGNUP_BONUSto it — a second time, for one registrationRepro
Against unmodified
main(b03f3b1), realpasskeyRouter, realverifyRegistrationResponse, throwaway libsql, no stubs and no module mocking. One registration response, submitted twice concurrently:The test builds a genuine
"none"-attestation response (P-256 COSE key,UP|UV|ATflags) with the library's own CBOR encoder, so your real verifier runs — nothing about the WebAuthn path is faked.The fix
Catch the failed insert, undo the user that attempt created, and answer the loser with a plain 400.
channelsandcredit_ledgerare bothON DELETE CASCADEandPRAGMA foreign_keysis on under libsql (checked both, didn't assume), so the row takes the seeded channels and the signup bonus with it. The two users have distinct ids, so the loser only ever deletes its own.ceremony.existingskips the delete — that path never created a user.Non-constraint failures get a 500 and a different message, so a transient DB error isn't reported to the user as "already registered".
28 insertions / 11 deletions, 11 of the removals being the re-indent of the existing INSERT into the
try.Tests
New
apps/pwa/test/passkey-register-duplicate.test.mjs, 7 tests, same shape assessions-output-seq.test.mjs(real router, throwaway db, skips cleanly when the PWA deps aren't installed).3 are the bug:
unhandledRejection, since that one is fatal in production4 are controls that pass both ways:
mc_sess, clearsmc_c_regFail-before by
git checkout --: 3 fail / 4 pass unpatched, 7/7 patched. apps/pwa 97 → 104, root 313 → 320, 0 failures.Deliberately scoped out
userById()returning null on theceremony.existingpath (account deleted mid-ceremony) throws onuser.idand crashes the same way. One line to guard, but it is a different defect — say the word and I will send it.