Skip to content

plugin-auth: POST /two-factor/verify-totp on the enrolment lane echoes user.twoFactorEnabled: false after the flag has flipped — the vendor's pre-rotation snapshot, which two-factor-rotated-token-echo repairs for token only #16535

Description

@os-sales

Found while binding the auth.* return types for #14313 (not fixed there: that card narrows published return types and does not touch wire bytes).

Reproducible defect

When a SIGNED-IN user confirms a new TOTP factor (POST /api/v1/auth/two-factor/verify-totp, the enrolment lane), better-auth's handler writes twoFactorEnabled: true, rotates the session, and only then calls the valid(ctx) closure that verifyTwoFactor() built at entry — which still holds the PRE-rotation session and serialises parseUserOutput(session.user) from it (better-auth/dist/plugins/two-factor/verify-two-factor.mjs, the signed-in branch). So the 200 body reports the user with twoFactorEnabled: false although the flag has just flipped server-side.

plugin-auth already knows this closure is stale: packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts (#10701) repairs the token member of that same body from the response's own Set-Cookie. It leaves user as the vendor echoed it.

Measured on 2026-09-07, twice — the real AuthManager (better-auth 1.7.2) on the in-memory engine, and again on a real SqlDriver (better-sqlite3) driven through the real ObjectStackClient:

POST /two-factor/enable {password}         -> 200 { method: "totp", totpURI, backupCodes }
POST /two-factor/verify-totp {code}        -> 200 { token: "(live rotated token)", user: { …, "twoFactorEnabled": false, … } }   ← stale
POST /two-factor/verify-backup-code {code} -> 200 { token, user: { …, "twoFactorEnabled": true, … } }                            ← the next read shows the flip

(verify-backup-code on the signed-in lane does not rotate and echoes the live row, which is how the stale value is visible without a second request.)

Why it matters

The Account portal's enrolment flow reads this body to render "2FA is on"; a client that trusts user.twoFactorEnabled here renders the factor as still OFF right after the user enabled it, and a bearer client that caches the echoed user carries the wrong flag until its next get-session.

Expected

On the rotating 2FA verify routes, when the response staged a session cookie whose token differs from the one echoed (the exact condition two-factor-rotated-token-echo.ts already keys on), the echoed user should be the post-flip row — re-read from the adapter, or at minimum twoFactorEnabled set from the write the handler just made — so the body and the row agree.

Where

  • packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts — the after-hook that repairs token on ROTATING_TWO_FACTOR_VERIFY_PATHS.
  • Vendor: better-auth/dist/plugins/two-factor/verify-two-factor.mjs (closure over the entry-time session) and two-factor/totp/index.mjs (rotation before valid(ctx)).

Dedup: open domain:cli, domain:services and domain:runtime cards listed via REST and grepped for twoFactorEnabled, verify-totp / verifyTotp and bearer/rotation — zero hits, with the control terms exported-any-returns and better-auth answering.

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:services / bug / p2 / pm:queue / type Bug

    车道(读代码判定,不猜标题):修复面是 packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts —— packages/plugins/plugin-auth 在车道表 domain:services 行(plugin-auth)。⛔ 不是 domain:engine:这张卡不碰 objectql/metadata,只碰 auth 插件的 after-hook。

    准入类别 (a) 可复现缺陷 —— 卡面自带两次驱动(in-memory engine + 真实 SqlDriver 经真实 ObjectStackClient),且带差分对照:同一次会话里 verify-backup-code(不轮转)回显 twoFactorEnabled: true,证明 flag 确实已翻,回显的 false 是 pre-rotation 快照而非"还没写"。有对照的读数才是读数。

    我独立复核了"修复点已在位、且只修了 token"这一条(origin/main 5e53d73d):

    target  : grep -c 'twoFactorEnabled|payload.user|\.user\b'  two-factor-rotated-token-echo.ts  -> 0
    positive: grep -c 'token'                                    two-factor-rotated-token-echo.ts  -> 21
    control : grep -rln 'twoFactorEnabled' plugin-auth/src/  -> auth-schema-config.ts, auth-manager.ts,
              managed-extension-fields.test.ts, objectql-adapter.test.ts, auth-manager.test.ts
    

    对照答话(该词在本包里确实是这个拼法、确实存在),所以 target 的 0 是真零而不是拼错。echoInstalledSessionToken() 命中 ROTATING_TWO_FACTOR_VERIFY_PATHS 后只改 payload.token,user 原样透出 —— 卡面主张成立,且修复条件已经在文件里算好了("staged 了 session cookie 且其 token ≠ 回显 token"就是同一个判据),扩到 user 不需要新的判定逻辑。

    p2 不是 p1 的理由:服务端状态是对的 —— flag 真的翻了,下一次 get-session 就正确。坏的只有这一次成功响应的回显,客户端有一次额外请求的规避路径。所以不是"无法绕过的功能不可用",但它落在安全相关字段的成功路径上,会让刚开启 2FA 的用户看到"仍关闭",缓存了回显 user 的 bearer 客户端还会一直带错标志 —— 高于 p3。

    验收口径(承接 PR 请照抄进 ## 验收备注):

    1. 同一次 verify-totp 成功响应里,user.twoFactorEnabled 与 adapter 里该行的真实值必须一致;断言要读行、不要只断言字面量 true。
    2. verify-otp 走同一份 vendor 轮转块(文件头注释已声明),修 token 时它就在同一个路径表里 —— 修 user 也必须覆盖它,⛔ 不留"已知同形的洞"。
    3. verify-backup-code(不轮转)必须不受影响:它今天就回显活行,回归后仍须回显活行。这条是防止用"无条件重读"覆盖掉本来正确的路径。
    4. 承袭原文件的失败姿态:整理响应体失败 ⛔ 不得把已经成功的验证变成失败(echoInstalledSessionToken 的 catch 契约)。

    本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱:它既不迁移存量数据形状、也不删除已发布能力,且方向唯一(回显与行对齐),没有需要维护者裁的岔路。


    Generated by Claude Code

  3. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    更正:上一条评论里"origin/main 5e53d73d"这句是错的(结论不变)

    我上一条把复核标称为在 origin/main 5e53d73d 上做的。实际上那三条 grep 跑在工作区检出上,而该检出落后 origin/main 584 个提交(HEAD 27875fc7,git rev-list --count HEAD..origin/main = 584)。落后的树上读出来的数字,不能挂 origin/main 的名字。

    已在 origin/main 上重跑同一组(这次用 git grep <pat> origin/main -- <path>,读的是提交对象而不是磁盘):

    file    : two-factor-rotated-token-echo.ts + .test.ts   都在 origin/main 上
    target  : git grep -c twoFactorEnabled  origin/main -- two-factor-rotated-token-echo.ts  -> 0
    positive: git grep -c token             origin/main -- two-factor-rotated-token-echo.ts  -> 21
    control : git grep -l twoFactorEnabled  origin/main -- plugin-auth/src/  -> auth-manager.ts,
              auth-schema-config.ts, managed-extension-fields.test.ts, objectql-adapter.test.ts,
              auth-manager.test.ts
    

    读数与结论完全一致,所以分诊等级(domain:services / bug / p2 / pm:queue)不变,验收口径也不变。改的只有一件事:这些数字现在真的出自它们所声称的那棵树。

    留这条不是走形式。在错的树上读出对的数,和在对的树上读出对的数,是两件事 —— 前者这次恰好一致,下次未必;承接这张卡的人如果照抄我上一条的"5e53d73d"去复现,会以为自己复现了我做过的事,而其实没有。凡是引用了具体提交号的读数,必须是对那个提交号读的。


    Generated by Claude Code

  4. added and removed on Sep 8, 2026
  5. github-actions commented on Sep 9, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34325389984 · trigger schedule

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions