Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): 2FA re-enrollment must not inherit verified from the previous enrollment - #10994
Conversation
…the previous enrollment better-auth's `/two-factor/enable` writes `verified: existingTwoFactor?.verified === true` alongside a freshly generated secret, and `sys_two_factor` declares `user_id` unique — so re-enrolling rewrote the account's one row with a secret nobody had confirmed while carrying the prior enrollment's flag over. The flag stopped describing the stored secret, and the sign-in challenge honoured the replacement immediately. The vendor already gates that challenge on the flag (`TOTP_NOT_ENABLED` in `totp/index.mjs`, and `twoFactorMethods` in the post-sign-in hook) — it is what makes a first enrollment inert until confirmed. Re-enrollment slipped past it only because the value handed to the gate was inherited, so this restores the flag instead of adding a second owner of the same decision. Tightening only: same body, same response shape, same status; first-time enrollment unaffected; rotation still reachable, now via the same confirmation step a first enrollment takes. Part of #10700 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…ame confirmation step The 2FA section already listed TOTP confirmation as part of a complete opt-in UX, but said nothing about what a SECOND `enable` does to an account that is already enrolled. Now it does: the replacement secret is unconfirmed, the sign-in challenge refuses it (`400 TOTP_NOT_ENABLED`) and omits `totp` from `twoFactorMethods` until the session-lane verify succeeds, the replaced secret stops working when `enable` returns, and the backup codes in that same response are the recovery path. Part of #10700 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d75727a28cfbf824d894f27cabc39d0fd646f85c && git checkout d75727a28cfbf824d894f27cabc39d0fd646f85c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ab286e44aa6626e91e87f9d7d4a7ce881fec34a b3c9ea3dbdcd1b0ba89b55372e3df692bc465a9a && git checkout -B drift-repro 7ab286e44aa6626e91e87f9d7d4a7ce881fec34a && git merge --no-ff b3c9ea3dbdcd1b0ba89b55372e3df692bc465a9a
node scripts/docs-audit/affected-docs.mjs --json 7ab286e44aa6626e91e87f9d7d4a7ce881fec34a
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32560346051 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Part of #10700 — the integrity half is discharged here; one design question is left open below, so this deliberately does not carry a closing keyword.
needs:contract-reviewhangs on the card as the compensating control; this seat does not clear it, does not mark ready, and does not arm auto-merge.🔒 Auth/authz disclosure carve-out. Mechanism and verdict only — no reproduction recipe here, on the card, or in any commit message. The withheld detail lives in QA session #10663.
What was wrong
better-auth's
/two-factor/enablehandler computes the row it is about to write asand
updates that onto the existing row when one is there (dist/plugins/two-factor/index.mjs, measured against the installedbetter-auth@1.7.1on this branch).sys_two_factordeclaresuser_idunique, so that is an in-place rewrite of the one row the account has: the secret is brand new, the flag is inherited from the enrollment before it.verifiedstopped describing the secret stored beside it.Why the fix is one flag and not a new gate
The vendor already gates the sign-in challenge on this flag, in both places that matter —
totp/index.mjsrefuses an unconfirmed factor withTOTP_NOT_ENABLEDbefore any lockout bookkeeping, and the post-sign-in hook offerstotpintwoFactorMethodsonly when the flag is notfalse. That gate is exactly what makes a first enrollment inert until it is confirmed. Re-enrollment was the one path that slipped past it, and not because the gate was missing — because the value handed to the gate was inherited.So this restores the flag rather than adding a second owner of the same decision (AGENTS.md · Route & surface ownership #1). After a successful
method: 'totp'enable,verifiedis set tofalse; the secret the endpoint just handed out becomes live only once the caller proves possession of it through/two-factor/verify-totp.Both strict
=== falsecomparisons hold here becauseverifiedis declaredtype: 'boolean'in the plugin's own schema and this repo's adapter runssupportsBooleans: false, so better-auth's factory converts the stored0/1back to a real boolean on read. That is measured, not assumed — reading the engine table directly returns0where the gate seesfalse, and the tests assert both spellings for that reason.skipVerificationOnEnableis honoured rather than overridden: under it,verified: truebeside a fresh secret is the operator's declared intent.AuthManagernever sets it.Tightening only
Same request body, same response shape, same status. Nothing widens. A first-time enrollment is untouched (better-auth already wrote
falsethere), and a rotation is still reachable and still completes — it now takes the same confirmation step a first enrollment takes.The card asks for two things. This closes the first and does not close the second, and the PR says so rather than implying otherwise:
verifieddescribes the stored secret at every point in the flow.enablerewrites the account's single row unconditionally, so the previously confirmed secret stops working when the call returns. That is true before this change and after it.What does change on the availability axis is where the caller finds out: with a live session in hand, at the confirmation step, instead of at the next sign-in with none. And the floor that survives the window is pinned — the backup codes the same
enableresponse issues still complete a sign-in.Making the strict property true needs somewhere to park an unconfirmed secret, and
sys_two_factorhas no room for one (user_idis unique). Every route to it is a call this lane may not make alone: staging the pending secret elsewhere widens what/two-factor/verify-totpaccepts (a code from a secret that is not on the row), and adding a column adds persisted state to amanagedBy: 'better-auth',lock: 'full'table. The remaining alternative — refusingenablewhile already enabled — is the card's remedy 2, which triage ruled needs a maintainer decision. Reviewer's call, not this seat's.Out of scope: #10681 (
generate_backup_codesre-provisioning, same family, deliberately held back). Nothing here touches it.Evidence
Ablation — signature predicted in writing before mutating, then observed:
2 failed | 12 passed (14)2 failed | 12 passed (14)verified must describe the secret stored beside it, not the enrollment before it: expected true to be falseexpected { status: 401, code: 'INVALID_CODE' } to deeply equal { status: 400, code: 'TOTP_NOT_ENABLED' }Mutation was the single wiring line in the after-hook. No build sits between the edit and the run:
packages/plugins/plugin-authhas nodist/directory at all and itsvitest.config.tsdeclares no alias, and the suite imports./auth-managerrelatively — so the file edited is the file executed, and the verdict flipped with no build step. Restore proved byte-identical:git hash-objectreadsce4e2711ed503b8aa608bee7a146e0aa222b4565before the mutation and after the restore. Restore leg re-run:14 passed (14).Suites (
b3c9ea3db):pnpm --filter @objectstack/plugin-auth test→Test Files 68 passed (68) · Tests 1414 passed (1414).pnpm --filter @objectstack/plugin-auth typecheck→ exit 0.Gate union derived on this final commit with a clean tree,
node scripts/pm/dispatch-gates.mjswith no path arguments (6 path(s)→26matched families + 5 convention-triggered). All green, exit codes captured before any pipe. Class #10309:check:route-envelope,check:dispatcher-error-vocabularyandcheck:error-code-casingwere run explicitly and the derivation named none of the three.check:type-check-debt --re-measure(built closure,33 ledger entr(ies) re-measured in 332.6s, 1908 raw tsc error(s) total, none above its recorded number) reports plugin-auth atTEST_DEBT records 109, tsc now reports 97 (-12)— a pre-existing surplus, already tracked by the gate's own reference to #6376, not lowered here because it is unrelated to this diff.Generated by Claude Code