Skip to content

fix(auth): a user with two-factor sign-in on is asked for their code after the password - #29338

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-022htu
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-022htu

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

Fixes #29105

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:开启了双重验证(身份验证器 TOTP)的用户用密码登录后,Console 登录页会出现第二步:输入身份验证器里的 6 位验证码,或改用备用码;验证通过即登录。验证码错误会在原处提示并可重试;"返回登录"回到密码步骤。Console 自己的登录页和 DefaultLoginPage 都有这一步,十种语言包都有文案。
  • 为什么改:服务端早已支持两步登录(密码通过后返回 twoFactorRedirect,再由 /two-factor/verify-totp 或 /verify-backup-code 完成),但 Console 把这个回答丢掉了,登录页原样不动、没有输入框也没有任何提示,这类用户完全进不了 Console。
  • 风险与回滚:只改前端(@object-ui/auth、app-shell、i18n、console),服务端零改动;未开启双重验证的用户登录路径不变(有测试钉住)。@object-ui/auth 的公开类型新增了成员(拓宽)。回滚即 revert 本 PR。
  • 先看:ui/packages/auth/src/AuthProvider.tsx(signIn 收到挑战时不登录、保存挑战;verifySignInTwoFactor 完成登录,挑战被服务端作废时清掉);ui/packages/auth/src/TwoFactorChallengeForm.tsx(新的验证码步骤);ui/packages/auth/src/createAuthClient.ts(识别 twoFactorRedirect、丢弃那次回答里已被删掉的会话 token)。
  • 两分钟验证:OS_AUTH_TWO_FACTOR=true pnpm dev:crm -- --fresh -p 38417,给 admin 开启 TOTP 后在 Console /login 用密码登录:出现验证码步骤;输错显示"That code is not valid…",输对进入 /home。
  • 需要你做的:无,席位落地。

What changes

A password sign-in for an account with two-factor sign-in on is answered by better-auth's twoFactor() plugin with { twoFactorRedirect: true, twoFactorMethods }, no session, and a signed two_factor cookie that holds the pending sign-in for ten minutes. The Console ignored that answer. Now:

  • @object-ui/auth client. AuthClient.signIn answers { twoFactor: { methods } } for that case (new exported SignInResult, TwoFactorChallenge, TwoFactorMethod). The same answer carries set-auth-token for the session the server just deleted (measured: it resolves to no session), which the bearer fetch had stored; the client clears it. New AuthClient.verifySignInTwoFactor(code, 'totp' | 'backup-code') posts to the sign-in lane of /two-factor/verify-totp or /two-factor/verify-backup-code and stores the session token it answers.
  • Provider. useAuth() gains twoFactorChallenge, verifySignInTwoFactor and cancelTwoFactorChallenge; signIn resolves { twoFactorRequired }. The challenge clears on success, on "Back to sign in", on sign-out, and when the server reports it spent or expired (TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE, INVALID_TWO_FACTOR_COOKIE); a wrong code (INVALID_CODE, INVALID_BACKUP_CODE) keeps it.
  • LoginForm. While a challenge is held it draws the new internal TwoFactorChallengeForm in place of its form: the code field (inputMode="numeric", autocomplete="one-time-code", spaces stripped), a link to a backup code and back, "Back to sign in", and the error in place. onSuccess fires only once the code is verified. Because the challenge lives in the provider, the console's dev-admin chip leads to the same step (its spinner now stops there). Controls use the phone floor fix(auth): sign-in, password reset and MFA set-up work at phone width #29136 introduced (AUTH_TOUCH_TARGET_CLASS, 44 px, 16 px input text).
  • Strings. Ten LoginFormLabels members (twoFactor*), passed by both login pages from auth.login.twoFactor*, and five refusal codes in signInRefusalMessages from auth.login.errors.twoFactor*, in all ten packs.

No server package is touched; no metadata key or option is added.

Tests

  • New ui/packages/auth/src/__tests__/twoFactorSignIn-29105.test.tsx (real AuthProvider + LoginForm over a stub client): a TOTP user signs in with password then code (onSuccess only after the code); a wrong code shows the mapped error in place and a retry succeeds; a backup code works; "Back to sign in" returns to the password step; a dropped challenge returns there with the reason; no authenticator method offers backup codes only; a user without two-factor is signed straight in, unchanged.
  • New ui/packages/auth/src/__tests__/twoFactorSignIn-client-29105.test.ts: the client maps twoFactorRedirect to a challenge and stores no token, keeps the plain session path, posts each code kind to its endpoint, and keeps the server code on a refusal.
  • createAuthClient.test.ts: one assertion reads result.user?.email for the widened signIn answer.
  • Ablation, after committing: LoginForm's challenge branch disabled reds 6 of the 7 LoginForm cases (the unchanged non-two-factor case stays green, as it should); the client's twoFactorRedirect check disabled reds 2 of 6 client cases. ablation-replace.mjs restored each file (empty git diff HEAD).

Run record

Live, on pnpm dev:crm -- --fresh with OS_AUTH_TWO_FACTOR=true, the seeded admin enrolled through /two-factor/enable with a TOTP computed from the served secret.

Step Answer
password sign-in, enrolled 200 {"twoFactorRedirect":true,"twoFactorMethods":["totp"]}, session cookies expired, two_factor cookie set
verify-totp, wrong code 401 INVALID_CODE
verify-totp, right code 200 { token, user }, session cookie set, two_factor expired; get-session with the token answers the admin
verify-backup-code / wrong 200 { token, user } / 401 INVALID_BACKUP_CODE
verify-totp with no challenge cookie 401 INVALID_TWO_FACTOR_COOKIE

Real browser (Playwright Chromium, the Console from ui/apps/console source through Vite over that backend): at 1280 px the password step leads to the code step with no token stored; a wrong code shows "That code is not valid. Check your authenticator app and try again." in place; the right code (typed as "123 456") lands on /home; "Back to sign in" returns to the password step; a backup code signs in. At 375 x 812 (isMobile): the field, Verify and both links are 44 px tall, the field text is 16 px, no horizontal scroll, and with the viewport shrunk by a 336 px keyboard the field (263 to 307) and Verify (323 to 367) sit inside the 476 px left. Screenshots are in the project folder issue-29105/, not on GitHub, as the card asks.

Acceptance notes

  • AuthClient gains a required method, verifySignInTwoFactor, like every earlier addition to that interface: a third-party AuthClient implementation must add it to type-check. Declared Clause-②: yes (widening) as claimed.
  • The password step's own lockout code ACCOUNT_LOCKED has no entry in signInRefusalMessages (before this PR too), so it shows the server's English message; this PR adds only the two-factor step's codes.
  • After verification the session is { token }, as phone sign-in already does: the verify endpoints answer { token, user } with no session envelope, and the provider's session reads only the token.

Verification

Readings at head 606f6f61 (main f2bf2938 merged, which carries #29136 and #29159). Main 5780fa49 was then merged cleanly (head d35fdccc); its only overlap is the ten locale packs, so on d35fdccc the auth and i18n suites plus the every-member label pin were re-run (139 files, 1821 tests passed) with the five PR-body and changeset gates (all exit 0). CI's check-runs carry every family CI runs; they are not re-run here.

  • node scripts/pm/dispatch-gates.mjs --ran: 34 derived, 34 run, every exit 0 (check-changeset-no-major, check-adr-0087-registration and check-closing-keyword-parity read this body).
  • node scripts/check-sdui-manifest.mjs: exit 0.
  • pnpm --dir ui exec vitest run scripts/: 169 files passed, 2 skipped.
  • pnpm --dir ui check:new-line-citations: 0 new citations.
Suites and type-check
  • pnpm --dir ui exec vitest run packages/auth/ packages/i18n/ plus the 33 console and app-shell test files that name the login pages or signInRefusalMessages (the every-member label pin among them): Test Files 171 passed, Tests 2013 passed, 13 skipped.
  • turbo run type-check --filter=@object-ui/auth --filter=@object-ui/app-shell --filter=@object-ui/console --filter=@object-ui/i18n: 74 tasks successful.
  • Dogfood battery: not owed, no ! in the title and no (narrowing) arm.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C

…and a challenge step

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C
LoginForm draws the two-factor step while the provider holds a challenge,
and both login pages pass its labels.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C
…-022htu

# Conflicts:
#	ui/apps/console/src/pages/auth/LoginPage.tsx
#	ui/packages/app-shell/src/console/auth/LoginPage.tsx
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · zhuangjianguo · session_01QsR2jRFuJz9QBZqPVHYVhJ

Contract review

Served-tier: TIER_DEFAULT
Head-sha: d35fdccca14974a124d7cb5284648531d1cca448
Local-runs: none

Face: the Clause-②: yes (widening) declaration only (claim amendment 6572905231). The diff touches no packages/spec/src/**, no server package and no governed path (check-governed-merges --pr 29338: 0 of 26 paths governed), and declares no narrowing. Read: the PR's 26-file diff at this head, card #29105 with its triage and claim 6572863374 / amendment 6572905231, the os-dev report 6576982053, and the head's check-runs.

① Derived judgments

  • @object-ui/auth (published) gains four exported types: SignInResult, SignInTwoFactorCodeKind ('totp' | 'backup-code'), TwoFactorChallenge ({ methods }) and TwoFactorMethod ('totp' | 'otp'). Additive; nothing removed or renamed.
  • AuthClient.verifySignInTwoFactor(code, kind) is a required member. A third-party AuthClient passed to AuthProvider must add it to type-check. That is the shape every earlier addition to this interface took (signInWithPhoneOtp, enrollTotp, describeDelegableScope from objectui#2868, b5609cb, all required on main), each shipped as a widening. Same reading here; the changeset's "What to do" says it in one line.
  • AuthClient.signIn now answers the union SignInResult: the old { user, session } arm, or { twoFactor } with user/session undefined. A direct TS reader of signIn(...).user now sees AuthUser | undefined (the PR's own createAuthClient.test.ts reads result.user?.email). I read this as a widening, not a narrowing. The old type promised a user that the server never sent for an account with two-factor on: measured in the run record, that answer is { twoFactorRedirect, twoFactorMethods } with no user, which the old code cast to { user, session }. The union states existing server behaviour; it does not withdraw a guarantee that held. For every account without two-factor, the answer and the user it carries are unchanged (pinned by the "signed straight in" case).
  • AuthContextValue (useAuth()) gains twoFactorChallenge, verifySignInTwoFactor and cancelTwoFactorChallenge. Its signIn resolves { twoFactorRequired } where it resolved void; a caller that awaited void is unaffected. The no-provider fallback in useAuth.ts fills all three.
  • LoginFormLabels gains ten optional twoFactor* members, each with an English default in LoginForm. Both login pages pass all ten from auth.login.twoFactor* (the every-member pin loginFormLabels-everyMember-12762 requires it).
  • @object-ui/i18n gains auth.login.twoFactor* (10) and auth.login.errors.twoFactor* (5) in all ten packs. Additive keys.
  • signInRefusalMessages maps five more server codes: INVALID_CODE, INVALID_BACKUP_CODE, TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE, INVALID_TWO_FACTOR_COOKIE and ACCOUNT_TEMPORARILY_LOCKED. On main the only INVALID_CODE producer is the twoFactor() plugin, so no other sign-in refusal changes its text. The provider drops the challenge only on the two codes after which the server has dropped it (two-factor-codes.ts); a wrong code keeps it. That matches the measured refusals.
  • Token handling: the challenge answer's set-auth-token names the session the server deleted (measured: it resolves to no session). The client clears it, so no credential exists between steps. Verify stores the body token. Correct.

② Semver level

patch for @object-ui/auth, @object-ui/app-shell, @object-ui/i18n and @object-ui/console in .changeset/29105-two-factor-sign-in.md, which carries Clause-②: yes (widening) and Metadata-protocol: none verbatim. AGENTS.md (changeset levels): none takes patch, and a non-protocol change, widening or breaking, ships patch. Consistent.

③ Boundary flags

No open_questions. One noted out-of-scope finding: the password step's own ACCOUNT_LOCKED has no entry in the refusal map. It predates this PR and has no measured wrong result beyond showing the server's English text, so it is recorded, not filed. TwoFactorMethod admits 'otp', but the step only offers the authenticator code and backup codes. An account whose only factor is a sent OTP gets the backup-code path, which the "no authenticator method" test pins. That is in scope for a later card if a deployment turns on server-sent OTP; nothing in main does today. PR author is objectstack-fleet[bot] (relay write).

Implemented-by: claude/project-thread-022htu
Reviewed-by: session_01QsR2jRFuJz9QBZqPVHYVhJ

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · zhuangjianguo · session_01QsR2jRFuJz9QBZqPVHYVhJ

维护者速读

  • 改了什么:开启了双重验证(身份验证器 TOTP)的用户输完密码后,Console 登录页出现第二步:输入 6 位验证码或改用备用码,通过即登录;输错原地提示可重试,"返回登录"回到密码步骤。两个登录页都有这一步,十种语言都有文案。
  • 为什么改:服务端早已支持两步登录,但 Console 丢掉了"需要第二步"的答复,登录页原样不动、没有任何提示,这类用户完全进不了 Console。
  • 风险与回滚:低。只改前端(@object-ui/auth、app-shell、i18n、console),服务端零改动;未开双重验证的用户登录路径不变(有测试钉住)。AuthClient 新增一个必需方法,自带 AuthClient 的第三方实现需补上。回滚即 revert 本 PR。
  • 先看:ui/packages/auth/src/AuthProvider.tsx(挑战的保存与清除),ui/packages/auth/src/TwoFactorChallengeForm.tsx(验证码步骤),ui/packages/auth/src/createAuthClient.ts(识别挑战、丢弃已作废的 token)。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/auth/src/__tests__/twoFactorSignIn-29105.test.tsx packages/auth/src/__tests__/twoFactorSignIn-client-29105.test.ts,预期全绿。
  • 需要你做的:无,席位验收后进合并队列。

席位意见: 真后端实测(OS_AUTH_TWO_FACTOR=true,admin 绑定 TOTP)密码通过后得到挑战、错码 401、对码登录、备用码登录、挑战过期 401,浏览器在 1280 px 和 375 px 都走通。公开面只增(四个类型、一个必需方法、十个标签、十五个语言键),按 Clause-②: yes (widening) 走默认档复核,已 PASS(6577066552)。

席位核对 — ACCEPT · domain:ui#2 · head d35fdccc · 2026-11-02T12:36Z

  • 形态: draft PR on main, Fixes #29105 on its own line and no other card number beside a closing keyword. 26 files, +1041/−14, Tier S (check-governed-merges --pr 29338: 0 of 26 paths governed; under 3000 lines). All inside claim 6572863374 as amended by 6572905231 (DefaultLoginPage and signInRefusalMessages added to the surface; Clause-② widening). The serial wait on PR fix(auth): sign-in, password reset and MFA set-up work at phone width #29136 held: the branch merged main carrying fix(auth): sign-in, password reset and MFA set-up work at phone width #29136 before touching LoginForm.tsx.
  • 前提实测: on dev:crm --fresh with OS_AUTH_TWO_FACTOR=true and the admin enrolled, password sign-in answers 200 {twoFactorRedirect:true,twoFactorMethods:["totp"]} with a two_factor cookie and no session; on origin/main the Console stays on the password page with nothing shown. On this head the code step appears; wrong TOTP 401 INVALID_CODE shown in place; right TOTP (typed "123 456") lands on /home; backup code signs in; "Back to sign in" returns; no challenge cookie 401 INVALID_TWO_FACTOR_COOKIE. At 375 x 812 every control is 44 px, input text 16 px, no horizontal scroll, and the field and Verify fit above a 336 px keyboard.
  • 门禁 check-runs (pinned to head d35fdccca14974a124d7cb5284648531d1cca448): 44 success, 11 skipped, 0 failed, 0 pending, including Type Check UI, Lint UI, Test UI (16 shards), Build & E2E UI and the Lint · PM dispatch-gates family. Dev local gates at 606f6f61: 34/34 derived families exit 0, 0 NOT-MEASURED; after the clean main merge to d35fdccc the auth + i18n suites and the every-member label pin re-ran (139 files, 1821 passed) with the five body/changeset gates exit 0. Type-check 74/74. Ablations: LoginForm challenge branch off reds 6 of 7 (the non-two-factor case stays green); client twoFactorRedirect check off reds 2 of 6.
  • 接受的偏差: the dev did not assign the PR or request a reviewer through GitHub MCP as its session context asked, because the seat's common rules forbid MCP writes; correct. After verification the session is { token } only, as phone sign-in already does, since the verify endpoints answer { token, user } with no session envelope.
  • 范围外去向: the password step's own ACCOUNT_LOCKED has no entry in signInRefusalMessages (pre-existing) and shows the server's English text; noted, not filed (no measured wrong result beyond the untranslated text). TwoFactorMethod admits 'otp' but no deployment on main configures otpOptions, so the step offers authenticator and backup codes only. No Skills-docs: line is owed.
  • 落地条款: contract review TIER_DEFAULT PASS on this head (6577066552) covers the Clause-②: yes (widening) declaration, including the required AuthClient.verifySignInTwoFactor and the signIn result union; patch is the AGENTS.md level for a non-protocol widening. mergeable_state is clean. Land by pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 12:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit d8b7ef4 Nov 2, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-022htu branch November 2, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant