Repository navigation
fix: consume one-time credentials atomically - #484
Merged
Merged
Conversation
Email and phone OTP clears, the TOTP counter update and TOTP enrollment now use conditional updates, and zero affected rows fails the request the way an invalid code does. Adds a Postgres suite spec that submits one valid code concurrently and expects a single winner.
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 #472.
Summary
Single-use checks read, verified, then wrote, so concurrent submissions of one valid value could all succeed. Each write is now conditional and zero affected rows fails the request.
src/utils/otp.ts): after the constant-time comparison, a transaction clears the token withUPDATE ... WHERE id = ? AND <token> = <compared hash> AND <expiry> = <compared expiry>, then saves the verification. Expiry is part of the match because a review account is reissued the same code. A lost claim returnsverified: false, so controllers take the existing invalid-code path (same status, body andverify_otp_failedaudit event). The in-memory user is only mutated after the claim succeeds.verifyEnabledTotp):lastUsedCounteris written withWHERE id = ? AND enabled AND (lastUsedCounter IS NULL OR lastUsedCounter < ?). A lost claim returnsinvalid_code, the same reason a replayed code gets today.verifyTotpEnrollment): in a transaction, disables the user's other enabled credentials (excluding this one), then enables the pending oneWHERE id = ? AND enabled = false; zero rows rolls back and returnsinvalid_code. Previously a concurrent enrollment could disable a credential another request just enabled, and a pending row destroyed by a newer enrollment still returned success.used_at/redeemed_at, WebAuthn challenge consumption, refresh rotation (claimSessionRotation) already use conditional updates.postgres.ymlpath filter now includessrc/utils/otp.tsandsrc/services/totpService.tsso the concurrency spec runs when they change.Not a contract change: no route, schema, token or status code changes.
Tests
whereclauses asserted for email OTP, phone OTP, TOTP counter and enrollment; a zero-row result fails each one without saving or mutating the user/credential. All 11 new or updated race tests fail on the old code.tests/postgres/oneTimeCredentials.spec.ts): five concurrent submissions of one valid email OTP, phone OTP, TOTP code, and TOTP enrollment code each yield exactly one success. On the old code: 5 of 5 succeeded for OTP and TOTP login, and enrollment failed the single-winner check.Checks
npm run typecheck: passnpm run lint: passnpx prettier --check .: passnpm run build: passnpm run coverage: 141 files, 1987 passed, 1 todo; statements 98.9%, branches 95.41%, functions 99.21%, lines 99.14%TEST_DB=postgresagainst a scratch Postgres (migration chain): 18 passed (4 new), repeated 3 more times on the new spec without a flake