Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): a 2FA verification echoes the session it installed, not the one it deleted - #10954
Conversation
…10701) `/two-factor/verify-totp` answered 200 with two credentials that disagreed: the `Set-Cookie` named the caller's rotated session, while the JSON `token` named the session row the same request had just deleted. better-auth's `verifyTwoFactor` resolves the caller's session once at entry and closes over it, so `valid(ctx)` echoes the PRE-rotation token on the enrolment lane, where the route rotates the session before answering. Because `bearer()` overwrites the request's session cookie with whatever the Authorization header carries, a client that stored the echoed token did not merely fail to authenticate with it -- presenting it destroyed the still-valid rotated cookie and dropped the request to anonymous. The echoed value is now read back out of the response's own session cookie. Shape and meaning of the field are unchanged; only the value moves, from a deleted row to the live one. Resolver precedence is untouched, and two pins hold that line: anonymous is still refused, and a bogus bearer still overrides a valid cookie and still fails closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ 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 d41ee399eae6b87de6b5ca6898f3556d7b04b40d && git checkout d41ee399eae6b87de6b5ca6898f3556d7b04b40d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c2b97c2a188d3a5798f5ee224a943ccb413c6396 d5330290f12f7c9b9fc57257e20d40ffc7a22927 && git checkout -B drift-repro c2b97c2a188d3a5798f5ee224a943ccb413c6396 && git merge --no-ff d5330290f12f7c9b9fc57257e20d40ffc7a22927
node scripts/docs-audit/affected-docs.mjs --json c2b97c2a188d3a5798f5ee224a943ccb413c6396 |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32543625194 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10701
A successful 2FA verification handed the caller a session token it had just
deleted, and a client that believed the echo locked itself out of a session it
still held.
What was measured
POST /api/v1/auth/two-factor/verify-totpanswers200carrying twocredentials that disagree. The
Set-Cookienames the caller's rotated session;the JSON
tokennames the session row the same request deleted.Reproduced from the mechanism description (the full reproduction is withheld
under the auth/authz disclosure carve-out and stays in the QA session). All
three cases from the report were measured side by side in one tree, one run,
one arrangement — before the fix:
get-sessionget-totp-uri200tokenas bearer alone200and empty401token200and empty401set-auth-tokenbearer alone (control)200The third row is the finding. It is not that the bearer is useless — it is that
a useless bearer destroys an otherwise valid cookie session. The fourth row
is the control that says so: the bearer seam itself works fine, so nothing about
credential resolution is broken. Only the echoed value is wrong.
What the
tokenturned out to beNot an internal identifier, and not a malformed credential: it is the caller's
pre-rotation session token — a credential that was valid until moments
earlier in the same request.
better-auth's
verifyTwoFactorresolves the caller's session once, at entry,and closes over it:
On the enrolment lane,
/two-factor/verify-totprotates that session before itanswers — mints a new session, installs it with
setSessionCookie, then deletesthe caller's original row — and only afterwards calls
valid(ctx), which stillholds the pre-rotation session. Measured on the installed better-auth
1.7.1(
dist/plugins/two-factor/verify-two-factor.mjs,dist/plugins/two-factor/totp/index.mjs).The reason a dead echo is worse than no echo is
bearer(): its before-hookoverwrites the request's session cookie with whatever the
Authorizationheader carries, so presenting the dead token does not merely fail — it discards
the good cookie and drops the request to anonymous.
The fix
Direction (a), the "make the echoed token an actually-accepted bearer" arm. The
value is now read back out of the response's own session cookie, so
tokennames the session the response actually installed.
The arm was chosen on measurement, not preference: every other auth response in
this repo echoes
tokenas the unsigned token of a live session, andbearer()accepts exactly that (presented without a signature it signs thevalue itself before verifying). Measured on
/sign-up/email, that body'stokenresolves to the user as a bearer. Soverify-totpis the only endpointbreaking a contract the rest of the surface already keeps — which makes this a
restoration rather than a change, and made "stop echoing the token" the wrong
arm: the field is right, the value was wrong.
Keyed on the mechanism rather than the enrolment branch: the echo is repaired
only when the response staged a session cookie whose token differs from the one
being echoed. Nothing is invented — the corrected value comes out of the
response's own cookie, so this cannot hand a caller a credential the request did
not already grant it.
/two-factor/verify-otpcarries the byte-identical rotate-then-answer block andis covered by the same guard rather than left as a known-identical hole; it is
not pinned by a test here, because exercising it needs OTP transport
configuration this package's harness does not wire.
/two-factor/verify-backup-codedoes not rotate and is unaffected.Resolver precedence is untouched
Direction (b) from the report — the resolver falling back to the cookie when the
bearer is unusable — was ruled out of scope because it stops an invalid
credential from failing loud, and this change does not go near it. Two pins hold
that line so a later attempt to loosen it reddens this suite: anonymous is still
refused, and a bogus bearer still overrides a valid cookie and still fails
closed.
Pins
tokenis an accepted bearer — resolves to the exact user id, andget-totp-urianswers200401401sSet-CookieAssertions end at which principal does the next request resolve to, never at
status alone:
get-sessionanswers200for anonymous, which is exactly howthis defect read in the field.
Ablation
Predicted signature written down before mutating; both directions run.
Neutralising the repair predicted 4 failed / 13 passed of 17, naming the four
tests and their first failing assertions. Measured: 4 failed / 13 passed,
the same four, with the predicted messages —
AssertionError: expected null to bethe user id, andAssertionError: expected 401 to be 200. The load-bearingprediction held too: the sign-in lane test passed under ablation,
independently confirming the repair is a genuine no-op there rather than an
untested claim.
Restore proved byte-identical by
git hash-object(
6b352697eabe4b4320cd61c84696cf6cbfce98bebefore and after), and the restoredtree re-ran 17/17 green.
Positive control for
srcvsdist, settled rather than asserted: the packagehas no
dist/directory at all and its vitest config declares no alias,while the suite ran 1394 tests green — a suite resolving the subject through a
built
dist/could not have run.Gates
Union derived after the final commit on a clean tree, with no path arguments
(
node scripts/pm/dispatch-gates.mjs, 4 paths vs merge base376b3dc0b), atd5330290f. Exit codes captured before any pipe. All 18 exit0; none returned254, printedCommand not found, or refused withPREREQUISITE NOT MET.Selected verdict lines, as each gate printed them:
✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check-changeset-no-major: "This diff introduces nomajorbump."✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check-engine-double-contract: OK — 376 pinned, 133 in the DEBT ledger, 2 exempt.✓ where-matcher conformance holds: 275 matcher(s) discovered, 275 answer the combinator battery correctly or refuse it loudly (165 refuse).✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.check-nul-bytes: OK (scanned 6316 text file(s) ... no raw ASCII control bytes).✓ check-route-envelope self-test passedcheck-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK (vocabulary + #9098 door typing)The last two were not named by the path derivation; they were run explicitly
with
--self-testbecause this change sits in the route/envelope class.Package-scoped:
@objectstack/plugin-authpnpm test66 files / 1394 testspassed;
pnpm typecheck(tsc --noEmit) clean.Declared narrowing.
check:type-check-debt --re-measurewas not run — itneeds the whole workspace closure built. What matters for that ratchet was
measured directly instead: with the package's test files included in the tsc
program, the new test file contributes 0 raw errors, so it cannot drift the
frozen
TEST_DEBTcount. The ledger entry was not edited and--lowerwas notrun. CI runs the gate itself.
Versioning note for review
Shipped as
patch, deliberately, and this is the call most worth a second pairof eyes. The dispatch expected
minoron the assumption that the credentialsemantics of a published endpoint change. What the measurement shows is a
contract restoration: the field keeps its shape (the unsigned session token)
and its meaning ("the session you now hold"), only the value moves from a
deleted row to the live one, and the previous value was not a usable credential
for anything — so no consumer expression has to be rewritten and nothing could
have depended on it. No breaking declaration is made, and consequently no
ADR-0087 disposition marker is required. If review reads it the other way, the
flip is a one-file edit.
Generated by Claude Code