Skip to content

fix: consume one-time credentials atomically - #484

Merged
Bccorb merged 2 commits into
mainfrom
fix/atomic-one-time-credentials
Oct 23, 2026
Merged

Bccorb merged 2 commits into
mainfrom
fix/atomic-one-time-credentials

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

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.

  • Email and phone OTP (src/utils/otp.ts): after the constant-time comparison, a transaction clears the token with UPDATE ... 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 returns verified: false, so controllers take the existing invalid-code path (same status, body and verify_otp_failed audit event). The in-memory user is only mutated after the claim succeeds.
  • TOTP login and step-up (verifyEnabledTotp): lastUsedCounter is written with WHERE id = ? AND enabled AND (lastUsedCounter IS NULL OR lastUsedCounter < ?). A lost claim returns invalid_code, the same reason a replayed code gets today.
  • TOTP enrollment (verifyTotpEnrollment): in a transaction, disables the user's other enabled credentials (excluding this one), then enables the pending one WHERE id = ? AND enabled = false; zero rows rolls back and returns invalid_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.
  • Comparison, lockout accounting, error responses and audit events are unchanged.
  • Verified, left alone: magic link used_at/redeemed_at, WebAuthn challenge consumption, refresh rotation (claimSessionRotation) already use conditional updates.
  • postgres.yml path filter now includes src/utils/otp.ts and src/services/totpService.ts so the concurrency spec runs when they change.

Not a contract change: no route, schema, token or status code changes.

Tests

  • Unit (mock DB): conditional where clauses 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.
  • Postgres (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: pass
  • npm run lint: pass
  • npx prettier --check .: pass
  • npm run build: pass
  • npm run coverage: 141 files, 1987 passed, 1 todo; statements 98.9%, branches 95.41%, functions 99.21%, lines 99.14%
  • TEST_DB=postgres against a scratch Postgres (migration chain): 18 passed (4 new), repeated 3 more times on the new spec without a flake

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.
@Bccorb
Bccorb merged commit 2121976 into main Oct 23, 2026
9 checks passed
@Bccorb
Bccorb deleted the fix/atomic-one-time-credentials branch October 23, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: consume one-time credentials atomically

1 participant