mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
refactor(experience): generate webauthn options before switching to the webauthn page (#4794)
This commit is contained in:
parent
a982e997c3
commit
eaac1a5c60
9 changed files with 195 additions and 135 deletions
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
|
||||
import MfaFactorButton from '@/components/Button/MfaFactorButton';
|
||||
import useStartTotpBinding from '@/hooks/use-start-totp-binding';
|
||||
import useStartWebAuthnProcessing from '@/hooks/use-start-webauthn-processing';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type MfaFlowState } from '@/types/guard';
|
||||
|
||||
|
@ -16,19 +17,23 @@ type Props = {
|
|||
|
||||
const MfaFactorList = ({ flow, flowState }: Props) => {
|
||||
const startTotpBinding = useStartTotpBinding();
|
||||
const startWebAuthnProcessing = useStartWebAuthnProcessing();
|
||||
const navigate = useNavigate();
|
||||
const { availableFactors } = flowState;
|
||||
|
||||
const handleSelectFactor = useCallback(
|
||||
(factor: MfaFactor) => {
|
||||
async (factor: MfaFactor) => {
|
||||
if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
|
||||
void startTotpBinding(flowState);
|
||||
return;
|
||||
return startTotpBinding(flowState);
|
||||
}
|
||||
|
||||
if (factor === MfaFactor.WebAuthn) {
|
||||
return startWebAuthnProcessing(flow, flowState);
|
||||
}
|
||||
|
||||
navigate(`/${flow}/${factor}`, { state: flowState });
|
||||
},
|
||||
[flow, flowState, navigate, startTotpBinding]
|
||||
[flow, flowState, navigate, startTotpBinding, startWebAuthnProcessing]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -38,8 +43,8 @@ const MfaFactorList = ({ flow, flowState }: Props) => {
|
|||
key={factor}
|
||||
factor={factor}
|
||||
isBinding={flow === UserMfaFlow.MfaBinding}
|
||||
onClick={() => {
|
||||
handleSelectFactor(factor);
|
||||
onClick={async () => {
|
||||
await handleSelectFactor(factor);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -15,6 +15,7 @@ import { isNativeWebview } from '@/utils/native-sdk';
|
|||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useStartTotpBinding from './use-start-totp-binding';
|
||||
import useStartWebAuthnProcessing from './use-start-webauthn-processing';
|
||||
import useToast from './use-toast';
|
||||
|
||||
export type Options = {
|
||||
|
@ -26,6 +27,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
const { t } = useTranslation();
|
||||
const { setToast } = useToast();
|
||||
const startTotpBinding = useStartTotpBinding({ replace });
|
||||
const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace });
|
||||
|
||||
/**
|
||||
* Redirect the user to the corresponding MFA page.
|
||||
|
@ -45,7 +47,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
* - Verification: redirect to the last used specific factor page.
|
||||
*/
|
||||
const handleMfaRedirect = useCallback(
|
||||
(flow: UserMfaFlow, state: MfaFlowState) => {
|
||||
async (flow: UserMfaFlow, state: MfaFlowState) => {
|
||||
const { availableFactors } = state;
|
||||
|
||||
if (availableFactors.length > 1 && flow === UserMfaFlow.MfaBinding) {
|
||||
|
@ -74,8 +76,14 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
/**
|
||||
* Start TOTP binding process if only TOTP is available.
|
||||
*/
|
||||
void startTotpBinding(state);
|
||||
return;
|
||||
return startTotpBinding(state);
|
||||
}
|
||||
|
||||
if (factor === MfaFactor.WebAuthn) {
|
||||
/**
|
||||
* Start WebAuthn processing if only TOTP is available.
|
||||
*/
|
||||
return startWebAuthnProcessing(flow, state);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -83,12 +91,12 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
*/
|
||||
navigate({ pathname: `/${flow}/${factor}` }, { replace, state });
|
||||
},
|
||||
[navigate, replace, setToast, startTotpBinding, t]
|
||||
[navigate, replace, setToast, startTotpBinding, startWebAuthnProcessing, t]
|
||||
);
|
||||
|
||||
const handleMfaError = useCallback(
|
||||
(flow: UserMfaFlow) => {
|
||||
return (error: RequestErrorBody) => {
|
||||
return async (error: RequestErrorBody) => {
|
||||
const [_, data] = validate(error.data, mfaErrorDataGuard);
|
||||
const factors = data?.availableFactors ?? [];
|
||||
const skippable = data?.skippable;
|
||||
|
@ -104,7 +112,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
? factors.filter((factor) => factor !== MfaFactor.WebAuthn)
|
||||
: factors;
|
||||
|
||||
handleMfaRedirect(flow, { availableFactors, skippable });
|
||||
await handleMfaRedirect(flow, { availableFactors, skippable });
|
||||
};
|
||||
},
|
||||
[handleMfaRedirect, setToast]
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
createWebAuthnRegistrationOptions,
|
||||
generateWebAuthnAuthnOptions,
|
||||
} from '@/apis/interaction';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type WebAuthnState, type MfaFlowState } from '@/types/guard';
|
||||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
|
||||
type Options = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistrationOptions);
|
||||
const asyncGenerateAuthnOptions = useApi(generateWebAuthnAuthnOptions);
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
return useCallback(
|
||||
async (flow: UserMfaFlow, flowState: MfaFlowState) => {
|
||||
const [error, options] =
|
||||
flow === UserMfaFlow.MfaBinding
|
||||
? await asyncCreateRegistrationOptions()
|
||||
: await asyncGenerateAuthnOptions();
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options) {
|
||||
const state: WebAuthnState = {
|
||||
options,
|
||||
...flowState,
|
||||
};
|
||||
|
||||
navigate({ pathname: `/${flow}/${MfaFactor.WebAuthn}` }, { replace, state });
|
||||
}
|
||||
},
|
||||
[asyncCreateRegistrationOptions, asyncGenerateAuthnOptions, handleError, navigate, replace]
|
||||
);
|
||||
};
|
||||
|
||||
export default useStartWebAuthnProcessing;
|
|
@ -14,134 +14,74 @@ import type {
|
|||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
} from '@simplewebauthn/typescript-types';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
createWebAuthnRegistrationOptions,
|
||||
generateWebAuthnAuthnOptions,
|
||||
} from '@/apis/interaction';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
import useSendMfaPayload from './use-send-mfa-payload';
|
||||
import useToast from './use-toast';
|
||||
|
||||
type WebAuthnOptions = WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions;
|
||||
|
||||
const isAuthenticationResponseJSON = (
|
||||
responseJson: RegistrationResponseJSON | AuthenticationResponseJSON
|
||||
): responseJson is AuthenticationResponseJSON => {
|
||||
return 'signature' in responseJson.response;
|
||||
};
|
||||
|
||||
const useWebAuthnOperation = (flow: UserMfaFlow) => {
|
||||
const useWebAuthnOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setToast } = useToast();
|
||||
const [webAuthnOptions, setWebAuthnOptions] = useState<
|
||||
WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions
|
||||
>();
|
||||
|
||||
const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistrationOptions);
|
||||
const asyncGenerateAuthnOptions = useApi(generateWebAuthnAuthnOptions);
|
||||
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
const handleRawWebAuthnError = useCallback(() => {
|
||||
setToast(
|
||||
t(
|
||||
flow === UserMfaFlow.MfaBinding
|
||||
? 'mfa.webauthn_failed_to_create'
|
||||
: 'mfa.webauthn_failed_to_verify'
|
||||
)
|
||||
);
|
||||
}, [flow, setToast, t]);
|
||||
|
||||
/**
|
||||
* Note:
|
||||
* Due to limitations in the iOS system, user interaction is required for the use of the WebAuthn API.
|
||||
* Therefore, we should avoid asynchronous operations before invoking the WebAuthn API.
|
||||
* Otherwise, the operating system may consider the WebAuthn authorization is not initiated by the user.
|
||||
* So, we need to prepare the necessary WebAuthn options before calling the WebAuthn API.
|
||||
*/
|
||||
const prepareWebAuthnOptions = useCallback(async () => {
|
||||
if (webAuthnOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [error, options] =
|
||||
flow === UserMfaFlow.MfaBinding
|
||||
? await asyncCreateRegistrationOptions()
|
||||
: await asyncGenerateAuthnOptions();
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setWebAuthnOptions(options);
|
||||
}, [
|
||||
asyncCreateRegistrationOptions,
|
||||
asyncGenerateAuthnOptions,
|
||||
flow,
|
||||
handleError,
|
||||
webAuthnOptions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webAuthnOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
void prepareWebAuthnOptions();
|
||||
}, [prepareWebAuthnOptions, webAuthnOptions]);
|
||||
|
||||
const handleWebAuthnProcess = useCallback(
|
||||
async (options: WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions) => {
|
||||
const parsedOptions = webAuthnRegistrationOptionsGuard.safeParse(options);
|
||||
|
||||
return trySafe(
|
||||
async () =>
|
||||
parsedOptions.success
|
||||
? startRegistration(parsedOptions.data)
|
||||
: startAuthentication(options),
|
||||
handleRawWebAuthnError
|
||||
);
|
||||
},
|
||||
[handleRawWebAuthnError]
|
||||
);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
setToast(t('mfa.webauthn_not_supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webAuthnOptions) {
|
||||
/**
|
||||
* This error message is just for program robustness; in practice, this issue is unlikely to occur.
|
||||
*/
|
||||
setToast(t('mfa.webauthn_not_ready'));
|
||||
void prepareWebAuthnOptions();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleWebAuthnProcess(webAuthnOptions);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
return useCallback(
|
||||
/**
|
||||
* Assert type manually to get the correct type
|
||||
* Note:
|
||||
* Due to limitations in the iOS system, user interaction is required for the use of the WebAuthn API.
|
||||
* Therefore, we should avoid asynchronous operations before invoking the WebAuthn API or the os may consider the WebAuthn authorization is not initiated by the user.
|
||||
* So, we need to prepare the necessary WebAuthn options before calling the WebAuthn API, this is why we don't generate the options in this function.
|
||||
*/
|
||||
void sendMfaPayload(
|
||||
isAuthenticationResponseJSON(response)
|
||||
? { flow: UserMfaFlow.MfaVerification, payload: { ...response, type: MfaFactor.WebAuthn } }
|
||||
: { flow: UserMfaFlow.MfaBinding, payload: { ...response, type: MfaFactor.WebAuthn } }
|
||||
);
|
||||
}, [handleWebAuthnProcess, prepareWebAuthnOptions, sendMfaPayload, setToast, t, webAuthnOptions]);
|
||||
async (options: WebAuthnOptions) => {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
setToast(t('mfa.webauthn_not_supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedRegistrationOptions = webAuthnRegistrationOptionsGuard.safeParse(options);
|
||||
|
||||
const response = await trySafe(
|
||||
async () =>
|
||||
parsedRegistrationOptions.success
|
||||
? startRegistration(parsedRegistrationOptions.data)
|
||||
: startAuthentication(options),
|
||||
() => {
|
||||
setToast(
|
||||
t(
|
||||
parsedRegistrationOptions.success
|
||||
? 'mfa.webauthn_failed_to_create'
|
||||
: 'mfa.webauthn_failed_to_verify'
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (response) {
|
||||
/**
|
||||
* Assert type manually to get the correct type
|
||||
*/
|
||||
void sendMfaPayload(
|
||||
isAuthenticationResponseJSON(response)
|
||||
? {
|
||||
flow: UserMfaFlow.MfaVerification,
|
||||
payload: { ...response, type: MfaFactor.WebAuthn },
|
||||
}
|
||||
: { flow: UserMfaFlow.MfaBinding, payload: { ...response, type: MfaFactor.WebAuthn } }
|
||||
);
|
||||
}
|
||||
},
|
||||
[sendMfaPayload, setToast, t]
|
||||
);
|
||||
};
|
||||
|
||||
export default useWebAuthnOperation;
|
||||
|
|
|
@ -1,26 +1,34 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import Button from '@/components/Button';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
|
||||
import useSkipMfa from '@/hooks/use-skip-mfa';
|
||||
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { webAuthnStateGuard } from '@/types/guard';
|
||||
import { isWebAuthnOptions } from '@/utils/webauthn';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const WebAuthnBinding = () => {
|
||||
const flowState = useMfaFlowState();
|
||||
const bindWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaBinding);
|
||||
const { state } = useLocation();
|
||||
const [, webAuthnState] = validate(state, webAuthnStateGuard);
|
||||
const handleWebAuthn = useWebAuthnOperation();
|
||||
const skipMfa = useSkipMfa();
|
||||
|
||||
if (!flowState) {
|
||||
if (!webAuthnState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const { skippable } = flowState;
|
||||
const { options, availableFactors, skippable } = webAuthnState;
|
||||
|
||||
if (!isWebAuthnOptions(options)) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
|
@ -28,10 +36,15 @@ const WebAuthnBinding = () => {
|
|||
description="mfa.create_passkey_description"
|
||||
onSkip={conditional(skippable && skipMfa)}
|
||||
>
|
||||
<Button title="mfa.create_a_passkey" onClick={bindWebAuthn} />
|
||||
<Button
|
||||
title="mfa.create_a_passkey"
|
||||
onClick={() => {
|
||||
void handleWebAuthn(options);
|
||||
}}
|
||||
/>
|
||||
<SwitchMfaFactorsLink
|
||||
flow={UserMfaFlow.MfaBinding}
|
||||
flowState={flowState}
|
||||
flowState={{ availableFactors, skippable }}
|
||||
className={styles.switchLink}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
|
|
|
@ -1,19 +1,30 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import Button from '@/components/Button';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
|
||||
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { webAuthnStateGuard } from '@/types/guard';
|
||||
import { isWebAuthnOptions } from '@/utils/webauthn';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const WebAuthnVerification = () => {
|
||||
const flowState = useMfaFlowState();
|
||||
const verifyWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaVerification);
|
||||
const { state } = useLocation();
|
||||
const [, webAuthnState] = validate(state, webAuthnStateGuard);
|
||||
const handleWebAuthn = useWebAuthnOperation();
|
||||
|
||||
if (!flowState) {
|
||||
if (!webAuthnState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const { options, availableFactors, skippable } = webAuthnState;
|
||||
|
||||
if (!isWebAuthnOptions(options)) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
|
@ -26,10 +37,15 @@ const WebAuthnVerification = () => {
|
|||
<Button
|
||||
title="action.verify_via_passkey"
|
||||
className={styles.verifyButton}
|
||||
onClick={verifyWebAuthn}
|
||||
onClick={() => {
|
||||
void handleWebAuthn(options);
|
||||
}}
|
||||
/>
|
||||
</SectionLayout>
|
||||
<SwitchMfaFactorsLink flow={UserMfaFlow.MfaVerification} flowState={flowState} />
|
||||
<SwitchMfaFactorsLink
|
||||
flow={UserMfaFlow.MfaVerification}
|
||||
flowState={{ availableFactors, skippable }}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -97,3 +97,12 @@ export const backupCodeErrorDataGuard = s.object({
|
|||
export const backupCodeBindingStateGuard = backupCodeErrorDataGuard;
|
||||
|
||||
export type BackupCodeBindingState = s.Infer<typeof backupCodeBindingStateGuard>;
|
||||
|
||||
export const webAuthnStateGuard = s.assign(
|
||||
s.object({
|
||||
options: s.record(s.string(), s.unknown()),
|
||||
}),
|
||||
mfaFlowStateGuard
|
||||
);
|
||||
|
||||
export type WebAuthnState = s.Infer<typeof webAuthnStateGuard>;
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import type { SignInExperience, ConnectorMetadata, SignInIdentifier, Theme } from '@logto/schemas';
|
||||
import type {
|
||||
SignInExperience,
|
||||
ConnectorMetadata,
|
||||
SignInIdentifier,
|
||||
Theme,
|
||||
WebAuthnRegistrationOptions,
|
||||
WebAuthnAuthenticationOptions,
|
||||
} from '@logto/schemas';
|
||||
|
||||
export enum UserFlow {
|
||||
SignIn = 'sign-in',
|
||||
|
@ -45,3 +52,5 @@ export type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType exten
|
|||
>
|
||||
? ElementType
|
||||
: never;
|
||||
|
||||
export type WebAuthnOptions = WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions;
|
||||
|
|
10
packages/experience/src/utils/webauthn.ts
Normal file
10
packages/experience/src/utils/webauthn.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {
|
||||
webAuthnRegistrationOptionsGuard,
|
||||
webAuthnAuthenticationOptionsGuard,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { type WebAuthnOptions } from '@/types';
|
||||
|
||||
export const isWebAuthnOptions = (options: Record<string, unknown>): options is WebAuthnOptions =>
|
||||
webAuthnRegistrationOptionsGuard.safeParse(options).success ||
|
||||
webAuthnAuthenticationOptionsGuard.safeParse(options).success;
|
Loading…
Add table
Reference in a new issue