From 97def3a6413eaf03c78fafb7f676575af09acc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Tue, 29 Sep 2026 14:48:52 +0200 Subject: [PATCH 1/6] authorize mfa setup via oidc and fido2 - functionality --- .../ConfigureMfaVerificatorFactorSelector.tsx | 10 +- .../hooks/useConfigureMfaStore.tsx | 41 +- .../hooks/useMfaConfigErrorHandler.ts | 28 ++ .../src/pages/full/ConfigureMfaPage/types.ts | 9 +- .../src/pages/full/ConfigureMfaPage/utils.ts | 13 +- .../verify/ConfigureMfaVerify.tsx | 26 +- .../ConfigureSelectVerificationStep.tsx | 12 +- .../ConfigureVerifyEmailStep.tsx | 8 + .../ConfigureVerifyFido2Step.tsx | 126 ++++++ .../useConfigureVerifyFido2.test.ts | 120 ++++++ .../useConfigureVerifyFido2.ts | 105 +++++ .../ConfigureVerifyOidcStep.tsx | 86 ++++ .../useConfigureVerifyOidc.ts | 85 ++++ .../ConfigureVerifyTotpStep.tsx | 8 + .../full/ConfigureMfaPage/verify/style.scss | 21 + .../ConfigureFinishStep.tsx | 11 + ...groundTestMfaVerificatorFactorSelector.tsx | 7 +- new-ui/src/shared/rust-api/api.ts | 29 +- new-ui/src/shared/rust-api/mfaError.ts | 22 +- new-ui/src/shared/rust-api/types.ts | 4 + src-tauri/client-proto/build.rs | 4 + src-tauri/core/src/mfa.rs | 8 +- src-tauri/core/src/mfa_config.rs | 194 ++++++++- src-tauri/core/src/mfa_config/tests.rs | 403 +++++++++++++++++- src-tauri/permissions/default.toml | 4 + src-tauri/proto | 2 +- src-tauri/src/commands.rs | 214 ++++++++-- src-tauri/src/gui.rs | 4 + 28 files changed, 1512 insertions(+), 92 deletions(-) create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/ConfigureVerifyFido2Step.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.test.ts create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.ts create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/useConfigureVerifyOidc.ts diff --git a/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector.tsx b/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector.tsx index 3a640cdd2..1b44494a4 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector.tsx @@ -23,13 +23,19 @@ const factorText = (method: MfaMethodValue): FactorText => { description: `We'll send a temporary security code to your email. Enter the code to confirm it's you and continue.`, }; case MfaMethod.Oidc: - return { title: '', description: '' }; + return { + title: 'OpenID sign-in', + description: `Sign in with your identity provider in the browser to confirm it's you.`, + }; case MfaMethod.Biometric: return { title: '', description: '' }; case MfaMethod.MobileApprove: return { title: '', description: '' }; case MfaMethod.Fido2: - return { title: '', description: '' }; + return { + title: 'Security key', + description: `Use a security key registered to your account, such as a YubiKey, to confirm it's you.`, + }; } }; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx index ab4d7411f..5dad147ee 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx @@ -25,6 +25,7 @@ import { isMfaFactorOfferable, isMfaSetupStep, mfaFactorStep, + verificationMethodsOf, } from '../utils'; type StoreValues = { @@ -41,7 +42,9 @@ type StoreValues = { selectedMethods: MfaMethodValue[] | null; /** Pre-ticked in the selection step, from the entry point or an earlier pass. */ initialSelection: MfaMethodValue[]; - /** Null until picked. Asked only when more than one code factor is configured. */ + /** most preferred first, from the session since an older Core rejects some factors */ + verificationMethods: MfaVerificationMethod[]; + /** null until picked, asked only when the session offers more than one method */ verificationMethod: MfaVerificationMethod | null; /** No factor was configured, so an emailed code was the only way in. */ emailFallback: boolean; @@ -103,6 +106,7 @@ const defaults: StoreValues = { completedMethods: [], selectedMethods: null, initialSelection: [], + verificationMethods: [], verificationMethod: null, emailFallback: false, deadline: null, @@ -125,6 +129,7 @@ interface Store extends StoreValues { selectVerificationMethod: (method: MfaVerificationMethod) => void; /** Keeps the current picks ticked and the session alive. */ backToSelection: () => void; + backFromVerification: () => void; /** The fresh deadline bounds every setup still to come, not just the next one. */ authorize: (response: MfaConfigAuthorizeResult) => void; factorConfigured: (method: MfaMethodValue, recoveryCodes: string[]) => void; @@ -139,20 +144,24 @@ export const useConfigureMfaStore = create()( ...defaults, start: (instance, response, origin) => { // The fallback mails a code to the address on file, registering email along the way. - const codeFactors = response.email_fallback + const sessionMethods = response.email_fallback ? [MfaMethod.Email] : response.available_methods; + // the session and the snapshot may both list FIDO2 const configuredMethods = [ - ...codeFactors, - ...(instance.mfa_configured_methods ?? []).filter( - (method) => !isCodeMfaMethod(method), - ), + ...new Set([ + ...sessionMethods, + ...(instance.mfa_configured_methods ?? []).filter( + (method) => !isCodeMfaMethod(method), + ), + ]), ]; set({ ...defaults, instance, sessionId: response.session_id, configuredMethods, + verificationMethods: verificationMethodsOf(sessionMethods), emailFallback: response.email_fallback, deadline: dayjs.unix(response.deadline_timestamp).toISOString(), ...origin, @@ -175,6 +184,13 @@ export const useConfigureMfaStore = create()( activeStep: defaults.activeStep, })); }, + backFromVerification: () => { + if (isPresent(get().verificationMethod)) { + set({ verificationMethod: null }); + return; + } + get().backToSelection(); + }, authorize: (response) => { set((current) => { // The fallback enables email as it verifies, so only this authorization issues codes. @@ -234,7 +250,7 @@ export const useConfigureMfaStore = create()( name: 'configure-mfa-store', storage: createJSONStorage(() => sessionStorage), // Bumped on every shape change: a stored session is never resumable across one. - version: 11, + version: 12, }, ), ); @@ -264,6 +280,17 @@ export const startMfaConfiguration = async ( }; /** A copy the proxy still holds expires on its own, so a failed cancel is not worth raising. */ +/** applied even after the asking step unmounts, Core has authorized the session either way. + * a cancel resets sessionId, so a late answer for a discarded session is dropped */ +export const applyAuthorization = ( + sessionId: string, + result: MfaConfigAuthorizeResult, +): void => { + const store = useConfigureMfaStore.getState(); + if (store.sessionId !== sessionId) return; + store.authorize(result); +}; + export const discardMfaConfiguration = async (): Promise => { const { sessionId } = useConfigureMfaStore.getState(); useConfigureMfaStore.getState().reset(); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts index 6fed37832..ad95430c9 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts @@ -1,12 +1,17 @@ import { error as logError } from '@tauri-apps/plugin-log'; import { useCallback } from 'react'; import { + isMfaConfigAlreadyAuthorized, isMfaConfigCancelled, + isMfaConfigFailedPrecondition, + isMfaConfigForbidden, isMfaConfigInvalidCode, + isMfaConfigMethodNotConfigured, isMfaConfigNetworkError, isMfaConfigProxyError, isMfaConfigSecurityKeyError, isMfaConfigSessionExpired, + isMfaConfigTimeout, mfaErrorMessage, } from '../../../../shared/rust-api/mfaError'; import { showEdgeComsError } from '../components/EdgeComsError/useEdgeComsErrorStore'; @@ -41,11 +46,34 @@ export const useMfaConfigErrorHandler = ({ setError(hasCodeInput ? 'Invalid code' : mfaErrorMessage(err)); return; } + // an OpenID login as another user lands here too, Core ends the session for it if (isMfaConfigSessionExpired(err)) { setError('Configuration session expired, start again.'); onSessionExpired(); return; } + if (isMfaConfigAlreadyAuthorized(err)) { + setError('This session was already verified, start again.'); + onSessionExpired(); + return; + } + if (isMfaConfigMethodNotConfigured(err)) { + setError('This method is no longer set up for your account.'); + return; + } + if (isMfaConfigForbidden(err)) { + setError(mfaErrorMessage(err)); + return; + } + // a consumed or replaced challenge, the next attempt fetches a fresh one + if (isMfaConfigFailedPrecondition(err)) { + setError('Verification expired, try again.'); + return; + } + if (isMfaConfigTimeout(err)) { + setError('Sign-in timed out, try again.'); + return; + } // The backend writes these for the user (no key, wrong PIN, no touch), so show as is. if (isMfaConfigSecurityKeyError(err)) { setError(mfaErrorMessage(err)); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/types.ts b/new-ui/src/pages/full/ConfigureMfaPage/types.ts index 4880a77f2..209085a51 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/types.ts +++ b/new-ui/src/pages/full/ConfigureMfaPage/types.ts @@ -26,7 +26,12 @@ export type MfaFactor = { repeatable: boolean; }; -/** Factors that can authorize a session, most preferred first. Core only accepts code factors. */ -export const MFA_VERIFICATION_METHODS = [MfaMethod.Totp, MfaMethod.Email] as const; +/** most preferred first */ +export const MFA_VERIFICATION_METHODS = [ + MfaMethod.Totp, + MfaMethod.Email, + MfaMethod.Fido2, + MfaMethod.Oidc, +] as const; export type MfaVerificationMethod = (typeof MFA_VERIFICATION_METHODS)[number]; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/utils.ts b/new-ui/src/pages/full/ConfigureMfaPage/utils.ts index cb6cee57f..c0eeeef06 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/utils.ts +++ b/new-ui/src/pages/full/ConfigureMfaPage/utils.ts @@ -48,12 +48,15 @@ export const isMfaFactorOfferable = ( return factor.repeatable || !configuredMethods.includes(method); }; -/** A session reports only code factors, others come from the instance snapshot. */ +const CODE_MFA_METHODS: MfaMethodValue[] = [MfaMethod.Totp, MfaMethod.Email]; + +/** every Core reports code factors in a session but older ones leave out FIDO2, + * so the instance snapshot stays the source for the rest */ export const isCodeMfaMethod = (method: MfaMethodValue): boolean => - MFA_VERIFICATION_METHODS.some((code) => code === method); + CODE_MFA_METHODS.includes(method); -/** Most preferred first. */ +/** ordered by preference, not by the input */ export const verificationMethodsOf = ( - configuredMethods: MfaMethodValue[], + availableMethods: MfaMethodValue[], ): MfaVerificationMethod[] => - MFA_VERIFICATION_METHODS.filter((method) => configuredMethods.includes(method)); + MFA_VERIFICATION_METHODS.filter((method) => availableMethods.includes(method)); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx index 8ddb79d79..fdfabc0fa 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx @@ -1,12 +1,12 @@ -import { useMemo } from 'react'; import { MfaMethod } from '../../../../shared/rust-api/types'; import { isPresent } from '../../../../shared/utils/isPresent'; import { useConfigureMfaStore } from '../hooks/useConfigureMfaStore'; import type { MfaVerificationMethod } from '../types'; -import { verificationMethodsOf } from '../utils'; import { ConfigureSelectMethodsStep } from './ConfigureSelectMethodsStep/ConfigureSelectMethodsStep'; import { ConfigureSelectVerificationStep } from './ConfigureSelectVerificationStep/ConfigureSelectVerificationStep'; import { ConfigureVerifyEmailStep } from './ConfigureVerifyEmailStep/ConfigureVerifyEmailStep'; +import { ConfigureVerifyFido2Step } from './ConfigureVerifyFido2Step/ConfigureVerifyFido2Step'; +import { ConfigureVerifyOidcStep } from './ConfigureVerifyOidcStep/ConfigureVerifyOidcStep'; import { ConfigureVerifyTotpStep } from './ConfigureVerifyTotpStep/ConfigureVerifyTotpStep'; type Props = { @@ -16,15 +16,10 @@ type Props = { /** Picks what the wizard sets up, then verifies the session with an existing factor. */ export const ConfigureMfaVerify = ({ onCancel, onSessionExpired }: Props) => { - const configuredMethods = useConfigureMfaStore((s) => s.configuredMethods); + const candidates = useConfigureMfaStore((s) => s.verificationMethods); const methodsSelected = useConfigureMfaStore((s) => isPresent(s.selectedMethods)); const verificationMethod = useConfigureMfaStore((s) => s.verificationMethod); - const candidates = useMemo( - () => verificationMethodsOf(configuredMethods), - [configuredMethods], - ); - if (!methodsSelected) { return ; } @@ -33,6 +28,7 @@ export const ConfigureMfaVerify = ({ onCancel, onSessionExpired }: Props) => { return ; } + // for the type only, a session always offers a method or falls back to email const method: MfaVerificationMethod = verificationMethod ?? candidates[0] ?? MfaMethod.Email; @@ -51,5 +47,19 @@ export const ConfigureMfaVerify = ({ onCancel, onSessionExpired }: Props) => { onSessionExpired={onSessionExpired} /> ); + case MfaMethod.Fido2: + return ( + + ); + case MfaMethod.Oidc: + return ( + + ); } }; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectVerificationStep/ConfigureSelectVerificationStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectVerificationStep/ConfigureSelectVerificationStep.tsx index 9d1834469..891111769 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectVerificationStep/ConfigureSelectVerificationStep.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectVerificationStep/ConfigureSelectVerificationStep.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Button } from '../../../../../shared/components/Button/Button'; import { ButtonVariant } from '../../../../../shared/components/Button/types'; import { Controls } from '../../../../../shared/components/Controls/Controls'; @@ -7,18 +7,12 @@ import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage'; import { ConfigureMfaVerificatorFactorSelector } from '../../components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector'; import { useConfigureMfaStore } from '../../hooks/useConfigureMfaStore'; import type { MfaVerificationMethod } from '../../types'; -import { verificationMethodsOf } from '../../utils'; import '../style.scss'; import './style.scss'; -/** Shown only when more than one code factor is configured. */ +/** shown only when the session offers more than one method */ export const ConfigureSelectVerificationStep = () => { - const configuredMethods = useConfigureMfaStore((s) => s.configuredMethods); - - const methods = useMemo( - () => verificationMethodsOf(configuredMethods), - [configuredMethods], - ); + const methods = useConfigureMfaStore((s) => s.verificationMethods); const [selected, setSelected] = useState(methods[0]); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx index a68937e71..94df236ef 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx @@ -141,6 +141,14 @@ export const ConfigureVerifyEmailStep = ({ onCancel, onSessionExpired }: Props) }} />
+
+ + + ); +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.test.ts b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.test.ts new file mode 100644 index 000000000..ed482b614 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useConfigureMfaStore } from '../../hooks/useConfigureMfaStore'; +import { useConfigureVerifyFido2 } from './useConfigureVerifyFido2'; + +const mocks = vi.hoisted(() => ({ + error: vi.fn(), + listen: vi.fn(), + mfaConfigAbortAttempt: vi.fn(), + mfaConfigAuthorizeFido2: vi.fn(), +})); + +vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen })); +vi.mock('@tauri-apps/plugin-log', () => ({ error: mocks.error })); +vi.mock('../../../../../shared/rust-api/api', () => ({ + api: { + mfaConfigAbortAttempt: mocks.mfaConfigAbortAttempt, + mfaConfigAuthorizeFido2: mocks.mfaConfigAuthorizeFido2, + }, +})); + +const SESSION_ID = 'session-1'; +const authorizeResult = { deadline_timestamp: 1_900_000_000, recovery_codes: [] }; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const renderFido2 = () => + renderHook(() => + useConfigureVerifyFido2({ onSessionExpired: vi.fn(), autoStart: false }), + ); + +describe('useConfigureVerifyFido2', () => { + beforeEach(() => { + vi.clearAllMocks(); + useConfigureMfaStore.getState().reset(); + useConfigureMfaStore.setState({ sessionId: SESSION_ID }); + mocks.listen.mockResolvedValue(vi.fn()); + mocks.mfaConfigAbortAttempt.mockResolvedValue(undefined); + mocks.mfaConfigAuthorizeFido2.mockResolvedValue(authorizeResult); + }); + + it('does not start a ceremony when unmounted while listen is pending', async () => { + const listening = deferred<() => void>(); + const unlisten = vi.fn(); + mocks.listen.mockReturnValue(listening.promise); + const { result, unmount } = renderFido2(); + + let verifying: Promise | undefined; + act(() => { + verifying = result.current.verify(null); + }); + unmount(); + listening.resolve(unlisten); + await verifying; + + expect(mocks.mfaConfigAuthorizeFido2).not.toHaveBeenCalled(); + expect(unlisten).toHaveBeenCalledTimes(1); + }); + + it('recovers when listen rejects', async () => { + mocks.listen.mockRejectedValueOnce(new Error('no event bridge')); + const { result } = renderFido2(); + + await act(async () => { + await result.current.verify(null); + }); + expect(result.current.isVerifying).toBe(false); + + await act(async () => { + await result.current.verify(null); + }); + expect(mocks.mfaConfigAuthorizeFido2).toHaveBeenCalledTimes(1); + }); + + it('keeps an authorization that lands after unmount', async () => { + const authorizing = deferred(); + mocks.mfaConfigAuthorizeFido2.mockReturnValue(authorizing.promise); + const { result, unmount } = renderFido2(); + + let verifying: Promise | undefined; + await act(async () => { + verifying = result.current.verify(null); + await Promise.resolve(); + }); + unmount(); + authorizing.resolve(authorizeResult); + await verifying; + + expect(useConfigureMfaStore.getState().authorized).toBe(true); + }); + + it('drops an authorization for a discarded session', async () => { + const authorizing = deferred(); + mocks.mfaConfigAuthorizeFido2.mockReturnValue(authorizing.promise); + const { result } = renderFido2(); + + let verifying: Promise | undefined; + await act(async () => { + verifying = result.current.verify(null); + await Promise.resolve(); + }); + act(() => { + useConfigureMfaStore.getState().reset(); + }); + await act(async () => { + authorizing.resolve(authorizeResult); + await verifying; + }); + + expect(useConfigureMfaStore.getState().authorized).toBe(false); + }); +}); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.ts b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.ts new file mode 100644 index 000000000..405737fd3 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyFido2Step/useConfigureVerifyFido2.ts @@ -0,0 +1,105 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { error as logError } from '@tauri-apps/plugin-log'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../../../../../shared/rust-api/api'; +import { fido2ShowsTouchPrompt } from '../../../../../shared/rust-api/fido2'; +import { isMfaConfigInvalidCode } from '../../../../../shared/rust-api/mfaError'; +import { TauriEvent } from '../../../../../shared/rust-api/types'; +import { isPresent } from '../../../../../shared/utils/isPresent'; +import { + applyAuthorization, + useConfigureMfaStore, +} from '../../hooks/useConfigureMfaStore'; +import { useMfaConfigErrorHandler } from '../../hooks/useMfaConfigErrorHandler'; + +type Options = { + onSessionExpired: () => void; + autoStart: boolean; +}; + +/** each verify fetches a fresh single-use challenge, so a retry is just another call */ +export const useConfigureVerifyFido2 = ({ onSessionExpired, autoStart }: Options) => { + const [isVerifying, setIsVerifying] = useState(false); + const [isAwaitingTouch, setIsAwaitingTouch] = useState(false); + const [error, setError] = useState(null); + const running = useRef(false); + const mounted = useRef(true); + + const handleApiError = useMfaConfigErrorHandler({ + context: 'Security key MFA configuration verification failed', + setError, + onSessionExpired, + fallback: 'Verification failed', + hasCodeInput: false, + }); + + const verify = useCallback( + async (pin: string | null) => { + const { sessionId } = useConfigureMfaStore.getState(); + if (running.current || !isPresent(sessionId)) return; + running.current = true; + setIsVerifying(true); + setError(null); + let unlisten: UnlistenFn | undefined; + try { + // listen before invoking, the touch event fires while the call is still running + unlisten = await listen(TauriEvent.MfaConfigFido2Touch, () => { + // a platform that runs the ceremony shows its own prompt, ours would sit behind it + if (mounted.current) setIsAwaitingTouch(fido2ShowsTouchPrompt()); + }); + // an abort sent while listen was pending found no ceremony to stop + if (!mounted.current) return; + const result = await api.mfaConfigAuthorizeFido2(sessionId, pin); + applyAuthorization(sessionId, result); + } catch (err) { + if (!mounted.current) return; + // Core says "invalid code", which means nothing next to a security key + if (isMfaConfigInvalidCode(err)) { + void logError(`Security key MFA configuration verification rejected: ${err}`); + setError('Security key verification failed, try again.'); + return; + } + handleApiError(err); + } finally { + unlisten?.(); + running.current = false; + if (mounted.current) { + setIsAwaitingTouch(false); + setIsVerifying(false); + } + } + }, + [handleApiError], + ); + + const abort = useCallback(async () => { + const { sessionId } = useConfigureMfaStore.getState(); + if (!running.current || !isPresent(sessionId)) return; + try { + await api.mfaConfigAbortAttempt(sessionId); + } catch (err) { + void logError(`Failed to abort security key verification: ${err}`); + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: aborts on unmount only + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + void abort(); + }; + }, []); + + // deferred a tick so a StrictMode replay or an instant unmount clears it before it runs + // biome-ignore lint/correctness/useExhaustiveDependencies: auto-start only on mount + useEffect(() => { + if (!autoStart) return; + const timer = window.setTimeout(() => { + void verify(null); + }, 0); + return () => window.clearTimeout(timer); + }, [autoStart]); + + return { verify, abort, isVerifying, isAwaitingTouch, error, setError }; +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx new file mode 100644 index 000000000..64a261d0d --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx @@ -0,0 +1,86 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle'; +import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage'; +import { isPresent } from '../../../../../shared/utils/isPresent'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../../hooks/useConfigureMfaStore'; +import { useConfigureVerifyOidc } from './useConfigureVerifyOidc'; +import '../style.scss'; + +interface Props { + onCancel: () => void; + onSessionExpired: () => void; +} + +export const ConfigureVerifyOidcStep = ({ onCancel, onSessionExpired }: Props) => { + const { start, abort, isOpening, isPolling, error } = useConfigureVerifyOidc({ + onSessionExpired, + }); + + const { mutate: cancel, isPending: isCancelling } = useMutation({ + mutationFn: discardMfaConfiguration, + onSettled: onCancel, + }); + + const handleBack = useCallback(async () => { + // Core holds one pending attempt per session, so abort it before another method starts + await abort(); + useConfigureMfaStore.getState().backFromVerification(); + }, [abort]); + + return ( + + +

+ {isPolling ? ( + {`Complete the sign-in in your browser. This page will update automatically.`} + ) : ( + {`Authenticate via your OpenID provider. A browser window will open for you to sign in.`} + )} +

+ {isPresent(error) &&

{error}

} +
+
+ +