From 4682bd3628df30e8e46926a35edf353b41affda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Abdullah=20G=C3=BCltekin?= Date: Tue, 2 Jun 2026 18:56:08 +0300 Subject: [PATCH 1/2] fix(verify): identifier-first preflight resolves user's tenant flow on hosted page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted login page (verify.fivucsas.com) resolved its initial login-config by OAuth clientId, which maps to the client's bound tenant — the dashboard/mobile client lives on the `system` sentinel tenant, so a fivucsas user saw the SYSTEM flow shape (and 'system' branding) until they submitted. LoginMfaFlow already called /auth/login/preflight at the identifier step but discarded the resolved LoginConfig; now it hands it up via onPreflightResolved and HostedLoginApp swaps the displayed config to the user's ACTUAL tenant flow (step list / methods / branding). Display-only: the backend already enforces the user's flow at /auth/login. Enumeration-safe (unknown email -> platform-default config) and fallback-preserving (null/older API leaves the config untouched). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/verify-app/HostedLoginApp.tsx | 21 ++++++ src/verify-app/LoginMfaFlow.tsx | 33 ++++++++- .../__tests__/LoginMfaFlow.config.test.tsx | 69 +++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/verify-app/HostedLoginApp.tsx b/src/verify-app/HostedLoginApp.tsx index 8b4c958..ec92ed3 100644 --- a/src/verify-app/HostedLoginApp.tsx +++ b/src/verify-app/HostedLoginApp.tsx @@ -601,6 +601,26 @@ export default function HostedLoginApp() { [handleLoginComplete], ) + // Identifier-first preflight propagation. The INITIAL `loginConfig` above was + // resolved by OAuth `clientId`, which maps to the OAuth client's bound tenant + // (the dashboard/mobile client is on the `system` sentinel tenant) — so before + // the user types an email the page previews the SYSTEM flow. When the user + // submits their identifier, `LoginMfaFlow` calls `/auth/login/preflight`, which + // returns the USER's ACTUAL tenant login-config; we swap the displayed config to + // it so the step list / methods / branding shown from that point match the user's + // real tenant (the backend already enforces that flow at /auth/login — this only + // fixes the DISPLAY mismatch). The backend keeps unknown emails enumeration-safe + // by returning a platform-default-looking config, and `LoginMfaFlow` only fires + // this with a non-null resolved config, so a preflight error / older API leaves + // the displayed config untouched (fallback preserved). Gated to the opening + // identity-entry phase so we never re-shape a flow the user has already started. + const handlePreflightResolved = useCallback( + (resolved: LoginConfig) => { + if (onInitialLoginPhase) setLoginConfig(resolved) + }, + [onInitialLoginPhase], + ) + const handleCancel = useCallback(() => { // Best-effort: return user to the origin of the redirect URI. if (config.redirectUri) { @@ -870,6 +890,7 @@ export default function HostedLoginApp() { onComplete={handleLoginComplete} onCancel={handleCancel} onInitialPhaseChange={setOnInitialLoginPhase} + onPreflightResolved={handlePreflightResolved} loginConfig={loginConfig} /> diff --git a/src/verify-app/LoginMfaFlow.tsx b/src/verify-app/LoginMfaFlow.tsx index 3e716ca..d71cc26 100644 --- a/src/verify-app/LoginMfaFlow.tsx +++ b/src/verify-app/LoginMfaFlow.tsx @@ -75,9 +75,25 @@ interface LoginMfaFlowProps { * legacy behaviour (always start on the password step). */ loginConfig?: LoginConfig | null + /** + * Identifier-first preflight propagation. The hosted page's INITIAL config + * is resolved by OAuth `clientId`, which maps to the client's bound tenant + * (the dashboard/mobile client is on the `system` sentinel tenant) — so the + * page previews the SYSTEM flow until the user enters an email. When the user + * submits their identifier we call `/auth/login/preflight`, which returns the + * USER's ACTUAL tenant login-config; this callback hands that resolved config + * up to the shell (HostedLoginApp) so the displayed flow (step list / methods + * / branding) switches to the user's real tenant from that point on. + * + * Only fired with a non-null resolved config (an older API returning only + * `{eligible}`, or any failure, yields null → the displayed config is left + * unchanged, preserving the fallback). The backend keeps unknown emails + * enumeration-safe by returning a platform-default-looking config. + */ + onPreflightResolved?: (loginConfig: LoginConfig) => void } -export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig }: LoginMfaFlowProps) { +export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig, onPreflightResolved }: LoginMfaFlowProps) { const { t } = useTranslation() const authRepository = useService(TYPES.AuthRepository) const httpClient = useService(TYPES.HttpClient) @@ -307,7 +323,18 @@ export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepCha setLoading(true) setError(undefined) try { - await authRepository.checkLoginEligibility(identifier.trim(), clientId || undefined) + // The hosted page's INITIAL config was resolved by OAuth clientId + // → the client's bound tenant (typically the `system` sentinel), + // so the previewed flow may be the SYSTEM flow, not the user's. + // The preflight resolves the USER's ACTUAL tenant login-config; + // hand it up so the displayed flow switches to the user's tenant. + // A null result (older API / failure) leaves the displayed config + // unchanged — fallback preserved. + const resolved = await authRepository.checkLoginEligibility( + identifier.trim(), + clientId || undefined, + ) + if (resolved && onPreflightResolved) onPreflightResolved(resolved) setPhase(FlowPhase.Password) } catch (err) { // TENANT_MISMATCH (or any pre-flight error) is shown inline on the @@ -351,7 +378,7 @@ export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepCha } finally { setLoading(false) } - }, [authRepository, identifier, clientId, configLayer1Methods, engineActive, passwordIsLayer1, layer1IsChoice, t]) + }, [authRepository, identifier, clientId, configLayer1Methods, engineActive, passwordIsLayer1, layer1IsChoice, onPreflightResolved, t]) // ─── Method Selection ─────────────────────────────────────── diff --git a/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx b/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx index 8d2175c..928a4c1 100644 --- a/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx +++ b/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx @@ -127,6 +127,75 @@ describe('LoginMfaFlow — config-driven Layer 1', () => { expect(mockLogin).not.toHaveBeenCalled() }) + it('propagates the RESOLVED tenant login-config from the preflight to onPreflightResolved (identifier-first display fix)', async () => { + // The hosted page's INITIAL config is resolved by OAuth clientId → the + // client's bound (system) tenant. The preflight returns the USER's ACTUAL + // tenant login-config; LoginMfaFlow must hand it UP so the shell can swap + // the displayed flow to the user's real tenant. + const initialConfig = normalizeLoginConfig({ + engineActive: true, + layer1: { identifierRequired: true, methods: [{ type: 'PASSWORD' }] }, + }) + // Resolved (user's real tenant) config returned by /auth/login/preflight. + const resolvedConfig = normalizeLoginConfig({ + engineActive: true, + tenantName: 'Marmara University', + totalSteps: 3, + layer1: { identifierRequired: true, methods: [{ type: 'PASSWORD' }] }, + }) + mockCheckEligibility.mockResolvedValue(resolvedConfig) + const onPreflightResolved = vi.fn() + + render( + , + ) + fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'staff@marmara.edu.tr' } }) + fireEvent.click(screen.getByRole('button', { name: /continue/i })) + + await waitFor(() => { + expect(onPreflightResolved).toHaveBeenCalledWith(resolvedConfig) + }) + // The password screen is still revealed (login is not broken). + await waitFor(() => { + expect(screen.getByLabelText('Password')).toBeInTheDocument() + }) + }) + + it('does NOT call onPreflightResolved when the preflight returns null (older API / failure → display unchanged)', async () => { + // Enumeration-safety + reversibility: a null resolved config (older API + // returning only {eligible}, or a normalize failure) must leave the + // displayed config untouched and still reveal the password screen. + const initialConfig = normalizeLoginConfig({ + engineActive: true, + layer1: { identifierRequired: true, methods: [{ type: 'PASSWORD' }] }, + }) + mockCheckEligibility.mockResolvedValue(null) + const onPreflightResolved = vi.fn() + + render( + , + ) + fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'unknown@example.com' } }) + fireEvent.click(screen.getByRole('button', { name: /continue/i })) + + await waitFor(() => { + expect(mockCheckEligibility).toHaveBeenCalled() + }) + // Null resolved config ⇒ no propagation; password screen still revealed. + await waitFor(() => { + expect(screen.getByLabelText('Password')).toBeInTheDocument() + }) + expect(onPreflightResolved).not.toHaveBeenCalled() + }) + it('shows the tenant-mismatch error on the EMAIL step and does NOT reveal the password screen', async () => { mockCheckEligibility.mockRejectedValue( Object.assign(new Error('tenant'), { From 5b51508bdcc87f51f256efe09be3d9d51e98443d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Abdullah=20G=C3=BCltekin?= Date: Wed, 3 Jun 2026 18:22:28 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20web=20multi-step=20bridge=20?= =?UTF-8?q?=E2=80=94=20resume=20MFA=20after=20usernameless=20approve/QR=20?= =?UTF-8?q?(#2,#6)=20[WIP=20=E2=80=94=20integrate=20onto=20fix/predemo-web?= =?UTF-8?q?-2026-06-03=20+=20verify=20before=20deploy]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApproveLoginPanel onMfaPending + LoginMfaFlow resumeSession inject the approved mfaSessionToken+availableMethods into the step-up flow instead of dead-ending at 'continue here'. Additive/reversible. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/features/auth/approve-login.ts | 18 +++++++ .../auth/components/ApproveLoginPanel.tsx | 40 ++++++++++++++ src/features/auth/components/LoginPage.tsx | 45 ++++++++++++++++ src/verify-app/HostedLoginApp.tsx | 29 ++++++++++ src/verify-app/LoginMfaFlow.tsx | 54 ++++++++++++++++++- 5 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/features/auth/approve-login.ts b/src/features/auth/approve-login.ts index f411cb9..e205ac1 100644 --- a/src/features/auth/approve-login.ts +++ b/src/features/auth/approve-login.ts @@ -51,6 +51,24 @@ export interface ApproveLoginPoll { refreshToken?: string expiresIn?: number role?: string + /** + * Multi-step bridge (Contract A). When the approved Layer-1 factor belongs to + * a MULTI-STEP tenant flow, the session is APPROVED but NOT complete: there + * are remaining MFA steps. In that case the poll carries the MFA session + * handoff instead of tokens, so the web can continue into the remaining steps + * (the method picker) rather than dead-ending at "extra step needed". + * - `mfaRequired` — true when remaining steps exist. + * - `mfaSessionToken` — the session token to drive `/auth/mfa/step`. + * - `currentStep` / `totalSteps` — backend-authoritative step counts. + * - `availableMethods` — AuthMethodType enum NAME strings for the NEXT step. + * Only present when the config-driven engine is ON for the tenant + * (APP_AUTH_CONFIG_DRIVEN_LOGIN[_TENANTS]) — that flag is the kill-switch. + */ + mfaRequired?: boolean + mfaSessionToken?: string + currentStep?: number + totalSteps?: number + availableMethods?: string[] } /** How often to poll the session status, in milliseconds. */ diff --git a/src/features/auth/components/ApproveLoginPanel.tsx b/src/features/auth/components/ApproveLoginPanel.tsx index a54d6c9..9d74a2e 100644 --- a/src/features/auth/components/ApproveLoginPanel.tsx +++ b/src/features/auth/components/ApproveLoginPanel.tsx @@ -47,6 +47,20 @@ export interface ApproveLoginPanelProps { initialEmail?: string /** Called with tokens when the request is APPROVED on the other device. */ onApproved: (result: ApproveLoginResult) => void + /** + * Multi-step bridge (Contract A). Called when the request is APPROVED on the + * other device but the tenant's flow has REMAINING MFA steps — so instead of + * tokens the poll returns an MFA session handoff. The caller seeds its MFA + * flow from this and continues into the method picker, rather than + * dead-ending at "extra step needed". When unset, the panel keeps its + * current behaviour (a multi-step approval simply never resolves here). + */ + onMfaPending?: (p: { + mfaSessionToken: string + currentStep?: number + totalSteps?: number + availableMethods?: string[] + }) => void /** Dismiss the panel (back to the main login form). */ onCancel: () => void } @@ -64,6 +78,7 @@ function formatTime(seconds: number): string { export default function ApproveLoginPanel({ initialEmail = '', onApproved, + onMfaPending, onCancel, }: ApproveLoginPanelProps) { const { t } = useTranslation() @@ -80,6 +95,11 @@ export default function ApproveLoginPanel({ const onApprovedRef = useRef(onApproved) onApprovedRef.current = onApproved + // Same for the multi-step-bridge callback (Contract A), so the polling effect + // doesn't have to depend on it. + const onMfaPendingRef = useRef(onMfaPending) + onMfaPendingRef.current = onMfaPending + const handleStart = useCallback(async () => { const trimmed = email.trim() if (!trimmed) { @@ -135,6 +155,26 @@ export default function ApproveLoginPanel({ if (cancelled) return setError(null) + // Multi-step bridge (Contract A): APPROVED on the other device but the + // tenant flow has REMAINING MFA steps, so the poll carries an MFA + // session handoff instead of tokens. Hand it to the caller (which seeds + // its MFA flow and continues into the method picker) and stop here — + // before the token branch, since a pending response has no accessToken. + if ( + poll.status === 'APPROVED' && + poll.mfaRequired && + poll.mfaSessionToken && + onMfaPendingRef.current + ) { + onMfaPendingRef.current({ + mfaSessionToken: poll.mfaSessionToken, + currentStep: poll.currentStep, + totalSteps: poll.totalSteps, + availableMethods: poll.availableMethods, + }) + return + } + if (poll.status === 'APPROVED' && poll.accessToken) { onApprovedRef.current({ accessToken: poll.accessToken, diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx index dd697d8..db0488e 100644 --- a/src/features/auth/components/LoginPage.tsx +++ b/src/features/auth/components/LoginPage.tsx @@ -516,6 +516,50 @@ export default function LoginPage() { [completeTokenLogin], ) + // Multi-step bridge (Contract A) — dashboard surface. The approve-login was + // approved on the other device but the tenant flow has REMAINING MFA steps, so + // the panel handed back an MFA session instead of tokens. Seed the same + // method-picker / TwoFactorDispatcher state the password & identifier-first + // paths use (mfaSessionToken + availableMethods + completed/step counts) and + // route into the method picker, so the phone-approved login CONTINUES into the + // remaining steps instead of dead-ending. Mirrors `onSubmit`'s twoFactorRequired + // branch. Methods arrive as AuthMethodType NAME strings → AvailableMfaMethod[]. + const handleApproveLoginMfaPending = useCallback( + (p: { mfaSessionToken: string; currentStep?: number; totalSteps?: number; availableMethods?: string[] }) => { + setLoginError(null) + setShowApproveLogin(false) + setMfaSessionToken(p.mfaSessionToken) + if (p.totalSteps) setFlowTotalSteps(p.totalSteps) + // The approved usernameless factor satisfied Layer 1; the server is + // authoritative on completed methods, but we have only step counts here, + // so seed an empty completed set and let the next /auth/mfa/step response + // reaffirm it (parity with the identifier-first path). + setCompletedMfaMethods([]) + const methods: AvailableMfaMethod[] = (p.availableMethods ?? []).map((m) => ({ + methodType: m, + name: m, + category: '', + enrolled: true, + preferred: false, + requiresEnrollment: false, + })) + setAvailableMethods(methods) + const enrolled = methods.filter((m) => m.enrolled) + if (enrolled.length > 1) { + setShowMethodPicker(true) + } else if (enrolled.length === 1) { + setTwoFactorMethod(enrolled[0].methodType) + setSelectedMethod(enrolled[0].methodType) + setShowSecondaryAuth(true) + } else { + // No methods reported — show the picker (it renders the empty/“set up” + // hint) rather than guessing a factor. + setShowMethodPicker(true) + } + }, + [], + ) + // Step/layer progress (parity with verify.fivucsas LoginMfaFlow). The DASHBOARD // login uses the PLATFORM login-config, which reports totalSteps=1 — MFA here is // dynamic per-user (not a configured layer), so we can't know upfront. We treat @@ -580,6 +624,7 @@ export default function LoginPage() { setShowApproveLogin(false)} /> diff --git a/src/verify-app/HostedLoginApp.tsx b/src/verify-app/HostedLoginApp.tsx index ec92ed3..fa49157 100644 --- a/src/verify-app/HostedLoginApp.tsx +++ b/src/verify-app/HostedLoginApp.tsx @@ -261,6 +261,18 @@ export default function HostedLoginApp() { // can be active at a time; `null` means the default email/password+MFA flow. const [altFlow, setAltFlow] = useState<'approveLogin' | null>(null) const [altError, setAltError] = useState(null) + // Multi-step bridge (Contract A). When an approve-login is approved on another + // device but the tenant flow has REMAINING MFA steps, the panel hands back an + // MFA session (not tokens). We stash it here and feed it to LoginMfaFlow as + // `resumeSession`, which seeds its MFA state and jumps to the method picker so + // the phone-approved login CONTINUES into the remaining steps. `null` ⇒ no + // resume in flight (the default password/MFA flow). + const [resumeSession, setResumeSession] = useState<{ + mfaSessionToken: string + currentStep?: number + totalSteps?: number + availableMethods?: string[] + } | null>(null) // Whether the inner LoginMfaFlow is on its OPENING identity-entry screen. // The usernameless shortcuts (passkey / approve) are ALTERNATIVES to typing // an identifier, so — mirroring the dashboard's `onInitialIdentityEntry` @@ -601,6 +613,21 @@ export default function HostedLoginApp() { [handleLoginComplete], ) + // Multi-step bridge (Contract A). The approve-login was approved on the other + // device, but the tenant flow has REMAINING MFA steps, so the panel handed back + // an MFA session instead of tokens. Close the approve-login surface and stash + // the handoff in `resumeSession`; LoginMfaFlow consumes it (`resumeSession` + // prop) to seed its MFA state and continue into the method picker for the next + // step — instead of the user dead-ending at "extra step needed, continue here". + const handleApproveLoginMfaPending = useCallback( + (p: { mfaSessionToken: string; currentStep?: number; totalSteps?: number; availableMethods?: string[] }) => { + setAltError(null) + setAltFlow(null) + setResumeSession(p) + }, + [], + ) + // Identifier-first preflight propagation. The INITIAL `loginConfig` above was // resolved by OAuth `clientId`, which maps to the OAuth client's bound tenant // (the dashboard/mobile client is on the `system` sentinel tenant) — so before @@ -872,6 +899,7 @@ export default function HostedLoginApp() { ) : altFlow === 'approveLogin' ? ( { setAltError(null) setAltFlow(null) @@ -892,6 +920,7 @@ export default function HostedLoginApp() { onInitialPhaseChange={setOnInitialLoginPhase} onPreflightResolved={handlePreflightResolved} loginConfig={loginConfig} + resumeSession={resumeSession} /> {/* Config-driven usernameless shortcuts (G-web). diff --git a/src/verify-app/LoginMfaFlow.tsx b/src/verify-app/LoginMfaFlow.tsx index d71cc26..0d3ab63 100644 --- a/src/verify-app/LoginMfaFlow.tsx +++ b/src/verify-app/LoginMfaFlow.tsx @@ -91,9 +91,25 @@ interface LoginMfaFlowProps { * enumeration-safe by returning a platform-default-looking config. */ onPreflightResolved?: (loginConfig: LoginConfig) => void + /** + * Multi-step bridge (Contract A). A usernameless Layer-1 factor approved on + * another device (APPROVE_LOGIN / QR_CODE) for a MULTI-STEP tenant flow hands + * back an MFA session — NOT tokens — because remaining steps follow. The shell + * passes that handoff here so this flow RESUMES into the MFA leg (the method + * picker for the next step) instead of the user dead-ending at "extra step + * needed, continue here". `null`/undefined ⇒ no resume (unchanged behaviour). + * Seeded into MFA state exactly once, and only while no MFA session is already + * open, so it can never yank a user out of an in-progress flow. + */ + resumeSession?: { + mfaSessionToken: string + currentStep?: number + totalSteps?: number + availableMethods?: string[] + } | null } -export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig, onPreflightResolved }: LoginMfaFlowProps) { +export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig, onPreflightResolved, resumeSession }: LoginMfaFlowProps) { const { t } = useTranslation() const authRepository = useService(TYPES.AuthRepository) const httpClient = useService(TYPES.HttpClient) @@ -225,6 +241,42 @@ export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepCha // eslint-disable-next-line react-hooks/exhaustive-deps }, [loginConfig]) + // ─── Multi-step bridge resume (Contract A) ────────────────── + // + // When a usernameless Layer-1 factor (APPROVE_LOGIN / QR_CODE) is approved on + // another device for a MULTI-STEP tenant, the shell hands us the resulting MFA + // session via `resumeSession`. Seed the MFA state from it and jump straight to + // the method picker for the next step — mirroring how `handleMfaResult` seeds + // the same state on STEP_COMPLETED. This is what turns "approved on phone but + // web dead-ends at 'extra step needed'" into a continuing flow. + // + // Guards: seed ONCE (ref latch) and ONLY when no MFA session is already open, + // so a resume can never pull a user out of an in-progress flow. + const resumeSyncedRef = useRef(false) + useEffect(() => { + if (resumeSyncedRef.current) return + if (!resumeSession || !resumeSession.mfaSessionToken) return + if (mfaSessionToken) return // already in an MFA flow — never interrupt it + resumeSyncedRef.current = true + + setMfaSessionToken(resumeSession.mfaSessionToken) + const methods: AvailableMfaMethod[] = (resumeSession.availableMethods ?? []).map( + (m) => ({ + methodType: m, + name: m, + category: '', + enrolled: true, + preferred: false, + requiresEnrollment: false, + }), + ) + setAvailableMethods(methods) + if (resumeSession.currentStep) setCurrentStep(resumeSession.currentStep) + if (resumeSession.totalSteps) setTotalSteps((prev) => Math.max(prev, resumeSession.totalSteps!)) + setPhase(FlowPhase.MethodPicker) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resumeSession]) + // ─── Password Submit ──────────────────────────────────────── const handlePasswordSubmit = useCallback(async (data: { email: string; password: string }) => {