fix(webauthn): give challenges their own store, with expiry and one-time use - #201
Merged
Conversation
…ime use Challenges lived in a single users.challenge column shared by registration, login and step-up, so the three flows clobbered each other and a second tab invalidated the first. The column also had no lifetime: the timeout in the credential options is only a hint to the browser, and nothing server side ever enforced it, so a challenge stayed valid until some later flow happened to overwrite it. Challenges now live in webauthn_challenges, keyed by user and flow, with a server enforced five minute life. That is comfortably longer than the sixty second client hint, so a user hunting for a security key is not cut off. They are also spent when verification reads them, before anything else can fail, so no outcome leaves a redeemable challenge behind. That closes the replay finding tracked privately alongside this issue, whose fix belonged in the same change. The per-flow context that used to sit in users.challengeContext travels with the challenge it was issued for. users.challenge and users.challengeContext are no longer read or written. They are left in place so this release can be rolled back, and should be dropped once it has run in production. Migration verified up and down against Postgres 17. Closes #164
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.
Closes #164, and fixes the replay finding tracked privately alongside it, which
#164 said belonged in the same change.
What was wrong
Challenges lived in one
users.challengecolumn shared by registration, loginand step-up.
registration already in flight for the same user. A second tab invalidated the
first.
timeoutin thecredential options is only a hint to the browser and was never enforced server
side, so a challenge stayed valid until some later flow happened to overwrite
it.
all, which is the private finding.
What it looks like now
A
webauthn_challengestable keyed by user and flow, withexpires_atandconsumed_at. Registration, login and step-up can each be outstanding at once.Two design points worth a look:
Consume on read, not on success.
consumeChallengeis called once at thetop of verification and spends the challenge before anything else can fail. That
makes "spent on every terminal path" a property of the shape of the code rather
than something each error branch has to remember. Every future exit added below
it is covered for free.
A five minute life. Comfortably longer than the sixty second client hint, so
a user hunting for a security key or waiting on a biometric prompt is not cut
off, while bounding a captured challenge to minutes instead of however long it
took some later flow to overwrite it.
The per-flow context that used to sit in
users.challengeContext, currently thePRF flags, travels with the challenge it was issued for rather than sitting on
the user.
A magic link completing spends any half-finished ceremony for that user, which
preserves what the old
user.challenge = ''line was doing.Regression guards, mutation checked
Two tests assert the challenge is spent on a failed login and on a
successful one. I confirmed they are not vacuous: removing the
record.update({ consumedAt })line fails exactly those two and nothing else.A third asserts a second attempt after the challenge is spent gets a 401.
Migration verified, not assumed
Run against a real Postgres 17: table created with both indexes and the cascade
foreign key, then
db:migrate:undoconfirmed to drop it.Left deliberately
users.challengeandusers.challengeContextare no longer read or written butthe columns remain, so this release can be rolled back without data loss. They
should be dropped in a follow-up once it has run in production. Cutting over and
dropping in the same release would leave no way back.
Verification
92 test files, 1006 passed, 1 skipped. Coverage 98.78% lines.
typecheck,lint,format:checkclean. Contract regenerated, no change to it.Existing tests were updated rather than deleted where they encoded the old
storage: the PRF-required case now puts that requirement on the challenge, and
"stored challenge is missing" becomes "no live challenge exists". Both still
assert the same behaviour.