Repository navigation
fix(auth): a user with two-factor sign-in on is asked for their code after the password - #29338
Conversation
…and a challenge step Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C
…ests 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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C
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
|
domain:ui#2 · zhuangjianguo · session_01QsR2jRFuJz9QBZqPVHYVhJ Contract reviewServed-tier: Face: the ① Derived judgments
② Semver level
③ Boundary flagsNo Implemented-by: VERDICT: PASS |
|
domain:ui#2 · zhuangjianguo · session_01QsR2jRFuJz9QBZqPVHYVhJ 维护者速读
席位意见: 真后端实测( 席位核对 — ACCEPT ·
|
Requested by Steedos · project thread
Fixes #29105
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
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 signedtwo_factorcookie that holds the pending sign-in for ten minutes. The Console ignored that answer. Now:@object-ui/authclient.AuthClient.signInanswers{ twoFactor: { methods } }for that case (new exportedSignInResult,TwoFactorChallenge,TwoFactorMethod). The same answer carriesset-auth-tokenfor the session the server just deleted (measured: it resolves to no session), which the bearer fetch had stored; the client clears it. NewAuthClient.verifySignInTwoFactor(code, 'totp' | 'backup-code')posts to the sign-in lane of/two-factor/verify-totpor/two-factor/verify-backup-codeand stores the session token it answers.useAuth()gainstwoFactorChallenge,verifySignInTwoFactorandcancelTwoFactorChallenge;signInresolves{ 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 internalTwoFactorChallengeFormin 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.onSuccessfires 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).LoginFormLabelsmembers (twoFactor*), passed by both login pages fromauth.login.twoFactor*, and five refusal codes insignInRefusalMessagesfromauth.login.errors.twoFactor*, in all ten packs.No server package is touched; no metadata key or option is added.
Tests
ui/packages/auth/src/__tests__/twoFactorSignIn-29105.test.tsx(realAuthProvider+LoginFormover a stub client): a TOTP user signs in with password then code (onSuccessonly 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.ui/packages/auth/src/__tests__/twoFactorSignIn-client-29105.test.ts: the client mapstwoFactorRedirectto a challenge and stores no token, keeps the plain session path, posts each code kind to its endpoint, and keeps the servercodeon a refusal.createAuthClient.test.ts: one assertion readsresult.user?.emailfor the widenedsignInanswer.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'stwoFactorRedirectcheck disabled reds 2 of 6 client cases.ablation-replace.mjsrestored each file (emptygit diff HEAD).Run record
Live, on
pnpm dev:crm -- --freshwithOS_AUTH_TWO_FACTOR=true, the seeded admin enrolled through/two-factor/enablewith a TOTP computed from the served secret.{"twoFactorRedirect":true,"twoFactorMethods":["totp"]}, session cookies expired,two_factorcookie setINVALID_CODE{ token, user }, session cookie set,two_factorexpired;get-sessionwith the token answers the admin{ token, user }/ 401INVALID_BACKUP_CODEINVALID_TWO_FACTOR_COOKIEReal browser (Playwright Chromium, the Console from
ui/apps/consolesource 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 folderissue-29105/, not on GitHub, as the card asks.Acceptance notes
AuthClientgains a required method,verifySignInTwoFactor, like every earlier addition to that interface: a third-partyAuthClientimplementation must add it to type-check. DeclaredClause-②: yes (widening)as claimed.ACCOUNT_LOCKEDhas no entry insignInRefusalMessages(before this PR too), so it shows the server's English message; this PR adds only the two-factor step's codes.{ 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(mainf2bf2938merged, which carries #29136 and #29159). Main5780fa49was then merged cleanly (headd35fdccc); its only overlap is the ten locale packs, so ond35fdcccthe 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-registrationandcheck-closing-keyword-parityread 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 orsignInRefusalMessages(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.!in the title and no(narrowing)arm.🤖 Generated with Claude Code
https://claude.ai/code/session_01QsR2jU7rCo15awmBUAqJ2C