Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/features/auth/approve-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
40 changes: 40 additions & 0 deletions src/features/auth/components/ApproveLoginPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -64,6 +78,7 @@ function formatTime(seconds: number): string {
export default function ApproveLoginPanel({
initialEmail = '',
onApproved,
onMfaPending,
onCancel,
}: ApproveLoginPanelProps) {
const { t } = useTranslation()
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions src/features/auth/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -580,6 +624,7 @@ export default function LoginPage() {
<CardContent sx={{ p: { xs: 3, sm: 4 } }}>
<ApproveLoginPanel
onApproved={handleApproveLoginApproved}
onMfaPending={handleApproveLoginMfaPending}
onCancel={() => setShowApproveLogin(false)}
/>
</CardContent>
Expand Down
50 changes: 50 additions & 0 deletions src/verify-app/HostedLoginApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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`
Expand Down Expand Up @@ -601,6 +613,41 @@ 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
// 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) {
Expand Down Expand Up @@ -852,6 +899,7 @@ export default function HostedLoginApp() {
) : altFlow === 'approveLogin' ? (
<ApproveLoginPanel
onApproved={handleApproveLoginApproved}
onMfaPending={handleApproveLoginMfaPending}
onCancel={() => {
setAltError(null)
setAltFlow(null)
Expand All @@ -870,7 +918,9 @@ export default function HostedLoginApp() {
onComplete={handleLoginComplete}
onCancel={handleCancel}
onInitialPhaseChange={setOnInitialLoginPhase}
onPreflightResolved={handlePreflightResolved}
loginConfig={loginConfig}
resumeSession={resumeSession}
/>

{/* Config-driven usernameless shortcuts (G-web).
Expand Down
85 changes: 82 additions & 3 deletions src/verify-app/LoginMfaFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,41 @@ 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
/**
* 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 }: LoginMfaFlowProps) {
export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig, onPreflightResolved, resumeSession }: LoginMfaFlowProps) {
const { t } = useTranslation()
const authRepository = useService<IAuthRepository>(TYPES.AuthRepository)
const httpClient = useService<IHttpClient>(TYPES.HttpClient)
Expand Down Expand Up @@ -209,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 }) => {
Expand Down Expand Up @@ -307,7 +375,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
Expand Down Expand Up @@ -351,7 +430,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 ───────────────────────────────────────

Expand Down
Loading