Repository navigation
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
Open
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. 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 15 commits are review rounds. 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 and at step-up. 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
Items 1 to 5 are from 2026-10-20, and items 6 to 12 are from 2026-10-21.
recover-adminor another Owner.recover-adminsetsMustChangePassword = true. Today it clears the flag, so the temporary password it prints is never forced to change.RateLimiting:SecondFactor:PermitLimit.TwoFactor:OwnerGraceDaysdays, 14 by default, counted from that Owner's first token after 2FA slice 8: every Owner must use two-factor authentication #1281, a connected app's token included, or from a promotion to Owner. Until then the Owner keeps full access and sees "Set up two-factor by ". If a bug blocks enrolment, the operator raises the days and restarts, which moves every deadline out. 2FA slice 8: every Owner must use two-factor authentication #1281 ships no database migration, so rolling back the image always works.Auth:EnrolledSessionMaxDays), even while it is in use. Unenrolled sessions and connected apps keep today's behaviour.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. I still need to approve this order.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.recover-admintests assertMustChangePassword = true.Review
I ran 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/.Two narrow checks (rounds 12 and 13) read 571d680..e5edc56; round 12 found that self-service recovery needs two codes when signed out, round 13 is clean.
"Raw" counts each reviewer's findings separately, and only rounds 7 to 11 reported that number. "Synthesized" counts the deduplicated items that the synthesis asked me to act on, plus its cheap fixes.
two_factortoken claim and reordered the slices.IgnoresAmbientPrincipalAttributefrom the code step, which reopened the #532 lockout bypass. I restored it with a guard modelled on the #532 test, and gaveClearAsynca three-state result.Guards
Both guards walk tracked files, and I ran them at the head of this branch: