feat(auth): harden the login-approval gate (BUNYIP-375) #373
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/BUNYIP-375-approval-gate-hardening"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Follow-up hardening for the suspicious-login approval gate (umbrella issue BUNYIP-375). Collects the four deferred code-review items from #372 (BUNYIP-373, now merged to
main) into one pass. Targetsmaindirectly; the diff is just the four items.Changes
Bind each challenge to its own code row.
complete_login_approvalmatched the emitted code against the user's latest validlogin_approval_codesrow, so with two concurrent gated logins for one user only the newest code could complete. A new migration addslogin_approval_codes.challenge_jti;JwtService::create_login_approval_challenge_tokennow returns itsjti; the row stores it and completion resolves by(user_id, challenge_jti). Each emitted code completes only its own challenge.Fail closed on an approval-email send failure.
begin_login_approvallogged the failure but still returnedApprovalRequired, leaving the client waiting for a code that never arrives. It now returns a retryableAppError::upstreamso the UI can prompt a fresh attempt; the abandoned challenge row expires via TTL and is swept by the cleanup below. (Fail-closed is the correct posture for a security gate; a restart already works by re-POSTing/login.)Constant-time code comparison via
subtle::ConstantTimeEq(the cratebunyip-oidcalready uses for PKCE). Defense-in-depth only, documented as such: the compared values are already SHA-256 hashes, so a timing leak would expose a hash prefix that still needs a preimage to yield the code.Purge spent challenges.
cleanup_expired_tokensnow deletes expired-or-usedlogin_approval_codesrows.login_devicesis intentionally left intact: it is the persistent known-device baseline, and pruning it would re-gate returning users. (Alogin_devicesstaleness prune is a deferred YAGNI item, noted on BUNYIP-375.)Testing
just check-containergreen (fmt + clippy-D warnings+cargo test --workspace --all-targets). The env-gated integration test (bunyip-api/tests/login_approval.rs, skips withoutRLS_TEST_DATABASE_URL) gains a direct check of the jti binding: a code cannot complete a different challenge, even the newer one. The fail-closed email branch is not exercised in-test becauseEmailService::new_dev()suppresses sending (no failure path to trip); the branch is small and covered by review.Deferred (noted on BUNYIP-375)
login_devicesstaleness/size bound - left unbounded until real row counts warrant it.