0
Fork 0
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:
Xiao Yijun 2023-11-01 16:06:07 +08:00 committed by GitHub
parent a982e997c3
commit eaac1a5c60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 195 additions and 135 deletions

View file

@ -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);
}}
/>
))}

View file

@ -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]

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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>;

View file

@ -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;

View 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;