Repository navigation
docs(auth): record the two-factor authentication design - #1334
Merged
Merged
Conversation
Decides #1274 items 1 to 23: the login state machine with a signed challenge and a per-user watermark, step-up with the password and a code, key encryption with the #794 ring, recovery-code digests, recovery and administration, audit and security events, the QR library and the login callers. Cites Identity 10.0.13 source and two probes.
… attempt limit Answers a review of the decision trail: enrolment confirm now has its own verifier operation, an Owner can replace an authenticator with a recovery code, a reused code is not charged, the per-user attempt limit is atomic, two guard mutations reach the check they claim, and the DML-only test is a precedent to extend, not a proof.
One class, TwoFactorCredentials, owns all two-factor state and locks the user row, so lockout and a durable daily budget of wrong authenticator codes are exact and survive restarts. The challenge is no longer single-use on its own, recovery codes are one row each and stay outside the budget, the step-up counter reset is skipped for enrolled users, the #1281 gate reads live state and lets the forced password change through, and guards move to the slice that builds the code they guard.
TwoFactorCredentials takes ids, locks the user row without loading an entity and reloads it, and writes AspNetUsers only through UserManager saves. An accepted authenticator code no longer clears the daily budget, wrong enrolment confirms are capped at five, the #1281 gate does its own read and refuses with 403, and the guards name mutations that can turn them red. Net smaller than the previous head.
One commit rule: the caller always commits what TwoFactorCredentials wrote, callers save before they call it, and reads never lock. Confirm returns its recovery codes, step-up opens its transaction after the password check and answers 503 on a commit failure, #1278 lands before #1277 so no build lets a user enrol into a factor login ignores, the #1281 gate reads a two_factor claim, and the guards name mutations that can turn them red. Net smaller than the previous head.
Settles the build order as #1276, #1278, #1279, #1280, #1277, #1281, so the enrolment routes are mapped only after step-up enforcement and every recovery path exist, and re-places the guards to match. ClearAsync now owns its epoch bump and returns whether it cleared a factor, confirm has typed outcomes and a status table, the commit rule names its dependence on staying outside IdempotentUnit, and the recovery-code digest format is pinned. Net smaller than the previous head.
This was referenced Oct 21, 2026
Open
This was referenced Oct 21, 2026
…nd harden the grace start
…tes, and log a failing grace start
…word-change answers
…ll user id in the label
…fixture free of secrets
…me each fault's status
…he retry its own code
…d name the user types
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.
This PR adds the decision record for two-factor authentication (epic #1273). TOTP becomes both a login second factor and a step-up factor, and every Owner must enrol after a grace period. The PR has no code. Slices #1276 to #1281 build from this record, and each one adds its rules to
src/AGENTS.mdwhen it lands.Closes #1274
Where to start
The PR changes two files:
docs/decisions/1274-two-factor-authentication.mdholds the whole design.docs/decisions/README.mdgains one index row.Read the record from "The rule" through "The shape" first. Its sections then give the decisions, the existing tests the design touches, the guards each slice adds, and the glossary terms. The branch has 39 commits: the initial draft and index, review fixes, recorded decisions, and one merge of main. Read the final file, not each commit, because the PR merges as one squash commit.
The design in short
Auth.SecondFactorRequiredwith a signed 5-minute challenge, and onlyPOST /api/v1/auth/login/second-factormints the session. Login for an unenrolled user is byte-identical to today.TwoFactorCredentialsis the only code that checks an authenticator code or a recovery code, at login, at step-up and at change-password. It locks the user row, so its checks are exact under concurrency.recover-adminclears it for an Owner. Each path that clears a factor ends the user's other sessions and connected apps.My decisions
These are the answers I gave, quoted as I gave them.
On 2026-10-20:
recover-adminforces a password change: "Force a change (Recommended)". Today it clears the flag, so the temporary password it prints is never forced to change.On 2026-10-21:
:replacement and trimming and are short enough not to be cut.RateLimiting:SecondFactor:PermitLimitto 20 per 15 minutes.Choices the agents made, which I did not decide
The coordinator agent and the worker agent made these choices while carrying out the review rounds. Each line names its source.
two_factortoken claim (the coordinator, round 3). The gate later moved to the credential check (the coordinator, round 14).Users.CurrentPasswordIncorrect(the worker, round 17).Slice order
The record changes the epic's original order to #1276, #1278, #1279, #1280, #1277, #1281. #1277, the slice that lets users enrol, now comes after login, step-up enforcement and every recovery path. No deployable build on
mainthen lets a user enrol while step-up enforcement or a way back is missing. #1278 builds the enrolment methods without routes, so its tests enrol users through the one real writer. The coordinator proposed this order, and I approved it on 2026-10-21.Every two-factor column ships in #1276, so #1278 to #1281 ship no database migration. Rolling one of those slices back is an image rollback. Rolling back past #1276 also needs its migration reversed.
Existing tests
The record's "Existing tests the design touches" section lists each test group by
grep.bootstrap-admin,provision-accountand operator-verb tests.AspNetUserTokenstenancy tests copy theUserRole*set.LoginAsynccallers unwrap the new result type.IssueAsynccallers passcode: null, and the two test decorators that overrideChangeOwnPasswordAsynctake its new parameters.recover-admintests assertMustChangePassword = true.Review
The record went through 11 local review rounds before the first push. Rounds 1 to 5 read the whole record. Rounds 6 to 11 read only the change since the round before. The reviewers were Claude Opus 5.5 (A), Claude Fable 5.1 (B), Claude Sonnet 5.5 (C), Codex gpt-6-astra (D) and GLM-5.3-Flash on my homelab vLLM (E). The syntheses, reviews and deltas are in
/home/mforce/.cluckwork-slices/totp/interrogate-1274/.There is no code yet, so the reviewers traced every guard and mutation by hand against the design and the existing code. Nothing in the table proves a test runs.
The "Synthesized" column gives the initial synthesis count for each round, without items that GLM addenda added later. "Raw" counts each reviewer's findings separately, and only rounds 7 to 11 reported it.
two_factortoken claim and proposed the slice reorder.IgnoresAmbientPrincipalAttributefrom the code step, which reopened the #532 lockout bypass. The record restored it with a guard modelled on the #532 test, and gaveClearAsynca three-state result.After the first push, every round read only the change since the round before:
ea975c11, which held round 27's nits, and found one nit. A separate one-line check by Codex reviewed its fix,8f70b8d9, and was clean.Guards
Both guards walk tracked files. I ran them at
1fe72f3f, the merge ofmain, after rebuilding both test projects: