mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(experience): implement webauthn experience flow (#4642)
This commit is contained in:
parent
15ab4d587e
commit
bc62370db5
22 changed files with 422 additions and 321 deletions
|
@ -40,6 +40,8 @@
|
|||
"@silverhand/essentials": "^2.8.4",
|
||||
"@silverhand/ts-config": "4.0.0",
|
||||
"@silverhand/ts-config-react": "4.0.0",
|
||||
"@simplewebauthn/browser": "^8.3.1",
|
||||
"@simplewebauthn/typescript-types": "^8.0.0",
|
||||
"@swc/core": "^1.3.52",
|
||||
"@swc/jest": "^0.2.26",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
|
|
|
@ -15,8 +15,10 @@ import ErrorPage from './pages/ErrorPage';
|
|||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import MfaBinding from './pages/MfaBinding';
|
||||
import TotpBinding from './pages/MfaBinding/TotpBinding';
|
||||
import WebAuthnBinding from './pages/MfaBinding/WebAuthnBinding';
|
||||
import MfaVerification from './pages/MfaVerification';
|
||||
import TotpVerification from './pages/MfaVerification/TotpVerification';
|
||||
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
|
||||
import Register from './pages/Register';
|
||||
import RegisterPassword from './pages/RegisterPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
|
@ -80,6 +82,7 @@ const App = () => {
|
|||
<Route path={UserMfaFlow.MfaBinding}>
|
||||
<Route index element={<MfaBinding />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpBinding />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnBinding />} />
|
||||
</Route>
|
||||
|
||||
{/* Mfa verification */}
|
||||
|
@ -87,6 +90,7 @@ const App = () => {
|
|||
<Route path={UserMfaFlow.MfaVerification}>
|
||||
<Route index element={<MfaVerification />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnVerification />} />
|
||||
</Route>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
/* istanbul ignore file */
|
||||
|
||||
import { InteractionEvent } from '@logto/schemas';
|
||||
import type {
|
||||
SignInIdentifier,
|
||||
EmailVerificationCodePayload,
|
||||
PhoneVerificationCodePayload,
|
||||
SocialConnectorPayload,
|
||||
SocialEmailPayload,
|
||||
SocialPhonePayload,
|
||||
BindMfaPayload,
|
||||
VerifyMfaPayload,
|
||||
import {
|
||||
InteractionEvent,
|
||||
type SignInIdentifier,
|
||||
type EmailVerificationCodePayload,
|
||||
type PhoneVerificationCodePayload,
|
||||
type SocialConnectorPayload,
|
||||
type SocialEmailPayload,
|
||||
type SocialPhonePayload,
|
||||
type BindMfaPayload,
|
||||
type VerifyMfaPayload,
|
||||
type WebAuthnRegistrationOptions,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
} from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
|
@ -230,6 +232,16 @@ export const createTotpSecret = async () =>
|
|||
.post(`${interactionPrefix}/${verificationPath}/totp`)
|
||||
.json<{ secret: string; secretQrCode: string }>();
|
||||
|
||||
export const createWebAuthnRegistrationOptions = async () =>
|
||||
api
|
||||
.post(`${interactionPrefix}/${verificationPath}/webauthn-registration`)
|
||||
.json<WebAuthnRegistrationOptions>();
|
||||
|
||||
export const generateWebAuthnAuthnOptions = async () =>
|
||||
api
|
||||
.post(`${interactionPrefix}/${verificationPath}/webauthn-authentication`)
|
||||
.json<WebAuthnAuthenticationOptions>();
|
||||
|
||||
export const bindMfa = async (payload: BindMfaPayload) => {
|
||||
await api.put(`${interactionPrefix}/bind-mfa`, { json: payload });
|
||||
|
||||
|
|
|
@ -19,18 +19,15 @@ const MfaFactorList = ({ flow, factors }: Props) => {
|
|||
const navigate = useNavigate();
|
||||
|
||||
const handleSelectFactor = useCallback(
|
||||
async (factor: MfaFactor) => {
|
||||
if (factor === MfaFactor.TOTP) {
|
||||
if (flow === UserMfaFlow.MfaBinding) {
|
||||
await startTotpBinding(factors);
|
||||
(factor: MfaFactor) => {
|
||||
if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
|
||||
void startTotpBinding(factors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow === UserMfaFlow.MfaVerification) {
|
||||
const state: MfaFactorsState = { availableFactors: factors };
|
||||
navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state });
|
||||
}
|
||||
}
|
||||
// Todo @xiaoyijun implement other factors
|
||||
navigate(`/${flow}/${factor}`, {
|
||||
state: { availableFactors: factors } satisfies MfaFactorsState,
|
||||
});
|
||||
},
|
||||
[factors, flow, navigate, startTotpBinding]
|
||||
);
|
||||
|
@ -43,7 +40,7 @@ const MfaFactorList = ({ flow, factors }: Props) => {
|
|||
factor={factor}
|
||||
isBinding={flow === UserMfaFlow.MfaBinding}
|
||||
onClick={() => {
|
||||
void handleSelectFactor(factor);
|
||||
handleSelectFactor(factor);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -8,13 +8,13 @@ import useTotpCodeVerification from './use-totp-code-verification';
|
|||
|
||||
const totpCodeLength = 6;
|
||||
|
||||
type Options = {
|
||||
type Props = {
|
||||
flow: UserMfaFlow;
|
||||
};
|
||||
|
||||
const TotpCodeVerification = ({ flow }: Options) => {
|
||||
const TotpCodeVerification = ({ flow }: Props) => {
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
const { errorMessage, onSubmit } = useTotpCodeVerification({ flow });
|
||||
const { errorMessage, onSubmit } = useTotpCodeVerification(flow);
|
||||
|
||||
return (
|
||||
<VerificationCodeInput
|
||||
|
@ -26,7 +26,7 @@ const TotpCodeVerification = ({ flow }: Options) => {
|
|||
setCode(code);
|
||||
|
||||
if (code.length === totpCodeLength && code.every(Boolean)) {
|
||||
void onSubmit(code.join(''));
|
||||
onSubmit(code.join(''));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,62 +1,31 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { bindMfa, verifyMfa } from '@/apis/interaction';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
|
||||
import { type UserMfaFlow } from '@/types';
|
||||
|
||||
type Options = {
|
||||
flow: UserMfaFlow;
|
||||
};
|
||||
const useTotpCodeVerification = ({ flow }: Options) => {
|
||||
const useTotpCodeVerification = (flow: UserMfaFlow) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const asyncBindMfa = useApi(bindMfa);
|
||||
const asyncVerifyMfa = useApi(verifyMfa);
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
const invalidCodeErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.mfa.invalid_totp_code': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
...preSignInErrorHandler,
|
||||
}),
|
||||
[preSignInErrorHandler]
|
||||
[]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (code: string) => {
|
||||
// Todo @xiaoyijun refactor this logic
|
||||
if (flow === UserMfaFlow.MfaBinding) {
|
||||
const [error, result] = await asyncBindMfa({ type: MfaFactor.TOTP, code });
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify TOTP
|
||||
const [error, result] = await asyncVerifyMfa({ type: MfaFactor.TOTP, code });
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
(code: string) => {
|
||||
void sendMfaPayload(
|
||||
{ flow, payload: { type: MfaFactor.TOTP, code } },
|
||||
invalidCodeErrorHandlers
|
||||
);
|
||||
},
|
||||
[asyncBindMfa, asyncVerifyMfa, errorHandlers, flow, handleError]
|
||||
[flow, invalidCodeErrorHandlers, sendMfaPayload]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
77
packages/experience/src/hooks/use-mfa-error-handler.ts
Normal file
77
packages/experience/src/hooks/use-mfa-error-handler.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { MfaFactor, type RequestErrorBody } from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type MfaFactorsState, mfaErrorDataGuard } from '@/types/guard';
|
||||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useStartTotpBinding from './use-start-totp-binding';
|
||||
import useToast from './use-toast';
|
||||
|
||||
export type Options = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const { setToast } = useToast();
|
||||
const startTotpBinding = useStartTotpBinding({ replace });
|
||||
|
||||
const handleMfaRedirect = useCallback(
|
||||
(flow: UserMfaFlow, availableFactors: MfaFactor[]) => {
|
||||
const mfaFactorsState: MfaFactorsState = {
|
||||
availableFactors,
|
||||
};
|
||||
|
||||
if (availableFactors.length > 1) {
|
||||
navigate({ pathname: `/${flow}` }, { replace, state: mfaFactorsState });
|
||||
return;
|
||||
}
|
||||
|
||||
const factor = availableFactors[0];
|
||||
|
||||
if (!factor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
|
||||
void startTotpBinding(availableFactors);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({ pathname: `/${flow}/${factor}` }, { replace, state: mfaFactorsState });
|
||||
},
|
||||
[navigate, replace, startTotpBinding]
|
||||
);
|
||||
|
||||
const handleMfaError = useCallback(
|
||||
(flow: UserMfaFlow) => {
|
||||
return (error: RequestErrorBody) => {
|
||||
const [_, data] = validate(error.data, mfaErrorDataGuard);
|
||||
const availableFactors = data?.availableFactors ?? [];
|
||||
|
||||
if (availableFactors.length === 0) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
handleMfaRedirect(flow, availableFactors);
|
||||
};
|
||||
},
|
||||
[handleMfaRedirect, setToast]
|
||||
);
|
||||
|
||||
const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding),
|
||||
'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification),
|
||||
}),
|
||||
[handleMfaError]
|
||||
);
|
||||
|
||||
return mfaVerificationErrorHandler;
|
||||
};
|
||||
|
||||
export default useMfaErrorHandler;
|
13
packages/experience/src/hooks/use-mfa-factors-state.ts
Normal file
13
packages/experience/src/hooks/use-mfa-factors-state.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { mfaFactorsStateGuard } from '@/types/guard';
|
||||
|
||||
const useMfaFactorsState = () => {
|
||||
const { state } = useLocation();
|
||||
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
|
||||
|
||||
return mfaFactorsState;
|
||||
};
|
||||
|
||||
export default useMfaFactorsState;
|
|
@ -1,83 +0,0 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import {
|
||||
type MfaFactorsState,
|
||||
missingMfaFactorsErrorDataGuard,
|
||||
requireMfaFactorsErrorDataGuard,
|
||||
} from '@/types/guard';
|
||||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useStartTotpBinding from './use-start-totp-binding';
|
||||
import useToast from './use-toast';
|
||||
|
||||
export type Options = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const { setToast } = useToast();
|
||||
const startTotpBinding = useStartTotpBinding({ replace });
|
||||
|
||||
const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.missing_mfa': (error) => {
|
||||
const [_, data] = validate(error.data, missingMfaFactorsErrorDataGuard);
|
||||
const availableFactors = data?.availableFactors ?? [];
|
||||
|
||||
if (availableFactors.length === 0) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableFactors.length > 1) {
|
||||
const state: MfaFactorsState = { availableFactors };
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaBinding}` }, { replace, state });
|
||||
return;
|
||||
}
|
||||
|
||||
const factor = availableFactors[0];
|
||||
|
||||
if (factor === MfaFactor.TOTP) {
|
||||
void startTotpBinding(availableFactors);
|
||||
}
|
||||
// Todo: @xiaoyijun handle other factors
|
||||
},
|
||||
'session.mfa.require_mfa_verification': async (error) => {
|
||||
const [_, data] = validate(error.data, requireMfaFactorsErrorDataGuard);
|
||||
const availableFactors = data?.availableFactors ?? [];
|
||||
if (availableFactors.length === 0) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableFactors.length > 1) {
|
||||
const state: MfaFactorsState = { availableFactors };
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaVerification}` }, { replace, state });
|
||||
return;
|
||||
}
|
||||
|
||||
const factor = availableFactors[0];
|
||||
if (!factor) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (factor === MfaFactor.TOTP) {
|
||||
const state: MfaFactorsState = { availableFactors };
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaVerification}/${factor}` }, { replace, state });
|
||||
}
|
||||
// Todo: @xiaoyijun handle other factors
|
||||
},
|
||||
}),
|
||||
[navigate, replace, setToast, startTotpBinding]
|
||||
);
|
||||
|
||||
return mfaVerificationErrorHandler;
|
||||
};
|
||||
|
||||
export default useMfaVerificationErrorHandler;
|
|
@ -4,9 +4,9 @@ import { useMemo } from 'react';
|
|||
import { isDevelopmentFeaturesEnabled } from '@/constants/env';
|
||||
|
||||
import { type ErrorHandlers } from './use-error-handler';
|
||||
import useMfaVerificationErrorHandler, {
|
||||
import useMfaErrorHandler, {
|
||||
type Options as UseMfaVerificationErrorHandlerOptions,
|
||||
} from './use-mfa-verification-error-handler';
|
||||
} from './use-mfa-error-handler';
|
||||
import useRequiredProfileErrorHandler, {
|
||||
type Options as UseRequiredProfileErrorHandlerOptions,
|
||||
} from './use-required-profile-error-handler';
|
||||
|
@ -15,14 +15,14 @@ type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHa
|
|||
|
||||
const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => {
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial });
|
||||
const mfaVerificationErrorHandler = useMfaVerificationErrorHandler({ replace });
|
||||
const mfaErrorHandler = useMfaErrorHandler({ replace });
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...requiredProfileErrorHandler,
|
||||
...conditional(isDevelopmentFeaturesEnabled && mfaVerificationErrorHandler),
|
||||
...conditional(isDevelopmentFeaturesEnabled && mfaErrorHandler),
|
||||
}),
|
||||
[mfaVerificationErrorHandler, requiredProfileErrorHandler]
|
||||
[mfaErrorHandler, requiredProfileErrorHandler]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
53
packages/experience/src/hooks/use-send-mfa-payload.ts
Normal file
53
packages/experience/src/hooks/use-send-mfa-payload.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { type BindMfaPayload, type VerifyMfaPayload } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { bindMfa, verifyMfa } from '@/apis/interaction';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler, { type ErrorHandlers } from './use-error-handler';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
|
||||
export type SendMfaPayloadApiOptions =
|
||||
| {
|
||||
flow: UserMfaFlow.MfaBinding;
|
||||
payload: BindMfaPayload;
|
||||
}
|
||||
| {
|
||||
flow: UserMfaFlow.MfaVerification;
|
||||
payload: VerifyMfaPayload;
|
||||
};
|
||||
|
||||
const sendMfaPayloadApi = async ({ flow, payload }: SendMfaPayloadApiOptions) => {
|
||||
if (flow === UserMfaFlow.MfaBinding) {
|
||||
return bindMfa(payload);
|
||||
}
|
||||
return verifyMfa(payload);
|
||||
};
|
||||
|
||||
const useSendMfaPayload = () => {
|
||||
const asyncSendMfaPayload = useApi(sendMfaPayloadApi);
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
return useCallback(
|
||||
async (apiOptions: SendMfaPayloadApiOptions, errorHandlers?: ErrorHandlers) => {
|
||||
const [error, result] = await asyncSendMfaPayload(apiOptions);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, {
|
||||
...errorHandlers,
|
||||
...preSignInErrorHandler,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncSendMfaPayload, handleError, preSignInErrorHandler]
|
||||
);
|
||||
};
|
||||
|
||||
export default useSendMfaPayload;
|
109
packages/experience/src/hooks/use-webauthn-operation.ts
Normal file
109
packages/experience/src/hooks/use-webauthn-operation.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {
|
||||
MfaFactor,
|
||||
webAuthnRegistrationOptionsGuard,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
||||
import type {
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
} from '@simplewebauthn/typescript-types';
|
||||
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';
|
||||
|
||||
const isAuthenticationResponseJSON = (
|
||||
responseJson: RegistrationResponseJSON | AuthenticationResponseJSON
|
||||
): responseJson is AuthenticationResponseJSON => {
|
||||
return 'signature' in responseJson.response;
|
||||
};
|
||||
|
||||
const useWebAuthnOperation = (flow: UserMfaFlow) => {
|
||||
const { t } = useTranslation();
|
||||
const { setToast } = useToast();
|
||||
|
||||
const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistrationOptions);
|
||||
const asyncGenerateAuthnOptions = useApi(generateWebAuthnAuthnOptions);
|
||||
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
const handleRawWebAuthnError = useCallback(
|
||||
(error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setToast(t('error.unknown'));
|
||||
},
|
||||
[setToast, t]
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const [error, options] =
|
||||
flow === UserMfaFlow.MfaBinding
|
||||
? await asyncCreateRegistrationOptions()
|
||||
: await asyncGenerateAuthnOptions();
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleWebAuthnProcess(options);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } }
|
||||
);
|
||||
}, [
|
||||
asyncCreateRegistrationOptions,
|
||||
asyncGenerateAuthnOptions,
|
||||
flow,
|
||||
handleError,
|
||||
handleWebAuthnProcess,
|
||||
sendMfaPayload,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useWebAuthnOperation;
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.switchLink {
|
||||
margin-top: _.unit(6);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import Button from '@/components/Button';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
|
||||
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const WebAuthnBinding = () => {
|
||||
const mfaFactorsState = useMfaFactorsState();
|
||||
const bindWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaBinding);
|
||||
|
||||
if (!mfaFactorsState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const { availableFactors } = mfaFactorsState;
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.create_a_passkey" description="mfa.create_passkey_description">
|
||||
<Button title="mfa.create_a_passkey" onClick={bindWebAuthn} />
|
||||
{availableFactors.length > 1 && (
|
||||
<SwitchMfaFactorsLink
|
||||
flow={UserMfaFlow.MfaBinding}
|
||||
factors={availableFactors}
|
||||
className={styles.switchLink}
|
||||
/>
|
||||
)}
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebAuthnBinding;
|
|
@ -1,17 +1,12 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import MfaFactorList from '@/containers/MfaFactorList';
|
||||
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { mfaFactorsStateGuard } from '@/types/guard';
|
||||
|
||||
import ErrorPage from '../ErrorPage';
|
||||
|
||||
const MfaBinding = () => {
|
||||
const { state } = useLocation();
|
||||
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
|
||||
const { availableFactors } = mfaFactorsState ?? {};
|
||||
const { availableFactors } = useMfaFactorsState() ?? {};
|
||||
|
||||
if (!availableFactors || availableFactors.length === 0) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import TotpCodeVerification from '@/containers/TotpCodeVerification';
|
||||
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { mfaFactorsStateGuard } from '@/types/guard';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const TotpVerification = () => {
|
||||
const { state } = useLocation();
|
||||
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
|
||||
const mfaFactorsState = useMfaFactorsState();
|
||||
|
||||
if (!mfaFactorsState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.verifyButton {
|
||||
margin: _.unit(4) 0;
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import Button from '@/components/Button';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
|
||||
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const WebAuthnVerification = () => {
|
||||
const mfaFactorsState = useMfaFactorsState();
|
||||
const verifyWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaVerification);
|
||||
|
||||
if (!mfaFactorsState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const { availableFactors } = mfaFactorsState;
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.verify_mfa_factors">
|
||||
<SectionLayout
|
||||
title="mfa.verify_via_passkey"
|
||||
description="mfa.verify_via_passkey_description"
|
||||
>
|
||||
<Button
|
||||
title="action.verify_via_passkey"
|
||||
className={styles.verifyButton}
|
||||
onClick={verifyWebAuthn}
|
||||
/>
|
||||
</SectionLayout>
|
||||
{availableFactors.length > 1 && (
|
||||
<SwitchMfaFactorsLink flow={UserMfaFlow.MfaVerification} factors={availableFactors} />
|
||||
)}
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebAuthnVerification;
|
|
@ -1,17 +1,12 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import MfaFactorList from '@/containers/MfaFactorList';
|
||||
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { mfaFactorsStateGuard } from '@/types/guard';
|
||||
|
||||
import ErrorPage from '../ErrorPage';
|
||||
|
||||
const MfaVerification = () => {
|
||||
const { state } = useLocation();
|
||||
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
|
||||
const { availableFactors } = mfaFactorsState ?? {};
|
||||
const { availableFactors } = useMfaFactorsState() ?? {};
|
||||
|
||||
if (!availableFactors || availableFactors.length === 0) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
|
|
|
@ -7,7 +7,7 @@ import { setUserPassword } from '@/apis/interaction';
|
|||
import SetPassword from '@/containers/SetPassword';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useMfaVerificationErrorHandler from '@/hooks/use-mfa-verification-error-handler';
|
||||
import useMfaErrorHandler from '@/hooks/use-mfa-error-handler';
|
||||
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
|
||||
import { usePasswordPolicy, useSieMethods } from '@/hooks/use-sie';
|
||||
|
||||
|
@ -23,7 +23,7 @@ const RegisterPassword = () => {
|
|||
setErrorMessage(undefined);
|
||||
}, []);
|
||||
|
||||
const mfaVerificationErrorHandler = useMfaVerificationErrorHandler({ replace: true });
|
||||
const mfaErrorHandler = useMfaErrorHandler({ replace: true });
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
|
@ -32,9 +32,9 @@ const RegisterPassword = () => {
|
|||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
...mfaVerificationErrorHandler,
|
||||
...mfaErrorHandler,
|
||||
}),
|
||||
[navigate, mfaVerificationErrorHandler, show]
|
||||
[navigate, mfaErrorHandler, show]
|
||||
);
|
||||
|
||||
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback((result) => {
|
||||
|
|
|
@ -71,17 +71,11 @@ const mfaFactorsGuard = s.array(
|
|||
])
|
||||
);
|
||||
|
||||
export const missingMfaFactorsErrorDataGuard = s.object({
|
||||
export const mfaErrorDataGuard = s.object({
|
||||
availableFactors: mfaFactorsGuard,
|
||||
});
|
||||
|
||||
export const requireMfaFactorsErrorDataGuard = s.object({
|
||||
availableFactors: mfaFactorsGuard,
|
||||
});
|
||||
|
||||
export const mfaFactorsStateGuard = s.object({
|
||||
availableFactors: mfaFactorsGuard,
|
||||
});
|
||||
export const mfaFactorsStateGuard = mfaErrorDataGuard;
|
||||
|
||||
export type MfaFactorsState = s.Infer<typeof mfaFactorsStateGuard>;
|
||||
|
||||
|
|
144
pnpm-lock.yaml
144
pnpm-lock.yaml
|
@ -3364,7 +3364,7 @@ importers:
|
|||
version: 8.44.0
|
||||
jest:
|
||||
specifier: ^29.5.0
|
||||
version: 29.5.0(@types/node@18.11.18)
|
||||
version: 29.5.0(@types/node@18.11.18)(ts-node@10.9.1)
|
||||
jest-matcher-specific-error:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
|
@ -3551,6 +3551,12 @@ importers:
|
|||
'@silverhand/ts-config-react':
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0(typescript@5.0.2)
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.1
|
||||
'@simplewebauthn/typescript-types':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
'@swc/core':
|
||||
specifier: ^1.3.52
|
||||
version: 1.3.52
|
||||
|
@ -6957,48 +6963,6 @@ packages:
|
|||
slash: 3.0.0
|
||||
dev: true
|
||||
|
||||
/@jest/core@29.5.0:
|
||||
resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
peerDependencies:
|
||||
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
|
||||
peerDependenciesMeta:
|
||||
node-notifier:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@jest/console': 29.5.0
|
||||
'@jest/reporters': 29.5.0
|
||||
'@jest/test-result': 29.5.0
|
||||
'@jest/transform': 29.5.0
|
||||
'@jest/types': 29.5.0
|
||||
'@types/node': 18.11.18
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.8.0
|
||||
exit: 0.1.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-changed-files: 29.5.0
|
||||
jest-config: 29.5.0(@types/node@18.11.18)
|
||||
jest-haste-map: 29.5.0
|
||||
jest-message-util: 29.5.0
|
||||
jest-regex-util: 29.4.3
|
||||
jest-resolve: 29.5.0
|
||||
jest-resolve-dependencies: 29.5.0
|
||||
jest-runner: 29.5.0
|
||||
jest-runtime: 29.5.0
|
||||
jest-snapshot: 29.5.0
|
||||
jest-util: 29.5.0
|
||||
jest-validate: 29.5.0
|
||||
jest-watcher: 29.5.0
|
||||
micromatch: 4.0.5
|
||||
pretty-format: 29.5.0
|
||||
slash: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/@jest/core@29.5.0(ts-node@10.9.1):
|
||||
resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
@ -9177,6 +9141,12 @@ packages:
|
|||
typescript: 5.0.2
|
||||
dev: true
|
||||
|
||||
/@simplewebauthn/browser@8.3.1:
|
||||
resolution: {integrity: sha512-bMW7oOkxX4ydRAkkPtJ1do2k9yOoIGc/hZYebcuEOVdJoC6wwVpu97mYY7Mz8B9hLlcaR5WFgBsLl5tSJVzm8A==}
|
||||
dependencies:
|
||||
'@simplewebauthn/typescript-types': 8.0.0
|
||||
dev: true
|
||||
|
||||
/@simplewebauthn/server@8.2.0:
|
||||
resolution: {integrity: sha512-nknf7kCa5V61Kk2zn1vTuKeAlyut9aWduIcbHNQWpMCEJqH/m8cXpb+9UV42MEQRIk8JVC1GSNeEx56QVTfJHw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
@ -9196,7 +9166,6 @@ packages:
|
|||
|
||||
/@simplewebauthn/typescript-types@8.0.0:
|
||||
resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
|
||||
dev: false
|
||||
|
||||
/@sinclair/typebox@0.24.46:
|
||||
resolution: {integrity: sha512-ng4ut1z2MCBhK/NwDVwIQp3pAUOCs/KNaW3cBxdFB2xTDrOuo1xuNmpr/9HHFhxqIvHrs1NTH3KJg6q+JSy1Kw==}
|
||||
|
@ -14488,34 +14457,6 @@ packages:
|
|||
- supports-color
|
||||
dev: true
|
||||
|
||||
/jest-cli@29.5.0(@types/node@18.11.18):
|
||||
resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
|
||||
peerDependenciesMeta:
|
||||
node-notifier:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@jest/core': 29.5.0
|
||||
'@jest/test-result': 29.5.0
|
||||
'@jest/types': 29.5.0
|
||||
chalk: 4.1.2
|
||||
exit: 0.1.2
|
||||
graceful-fs: 4.2.11
|
||||
import-local: 3.1.0
|
||||
jest-config: 29.5.0(@types/node@18.11.18)
|
||||
jest-util: 29.5.0
|
||||
jest-validate: 29.5.0
|
||||
prompts: 2.4.2
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/jest-cli@29.5.0(@types/node@18.11.18)(ts-node@10.9.1):
|
||||
resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
@ -14544,45 +14485,6 @@ packages:
|
|||
- ts-node
|
||||
dev: true
|
||||
|
||||
/jest-config@29.5.0(@types/node@18.11.18):
|
||||
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
ts-node: '>=9.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
ts-node:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/core': 7.20.2
|
||||
'@jest/test-sequencer': 29.5.0
|
||||
'@jest/types': 29.5.0
|
||||
'@types/node': 18.11.18
|
||||
babel-jest: 29.5.0(@babel/core@7.20.2)
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.8.0
|
||||
deepmerge: 4.3.1
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
jest-circus: 29.5.0
|
||||
jest-environment-node: 29.5.0
|
||||
jest-get-type: 29.4.3
|
||||
jest-regex-util: 29.4.3
|
||||
jest-resolve: 29.5.0
|
||||
jest-runner: 29.5.0
|
||||
jest-util: 29.5.0
|
||||
jest-validate: 29.5.0
|
||||
micromatch: 4.0.5
|
||||
parse-json: 5.2.0
|
||||
pretty-format: 29.5.0
|
||||
slash: 3.0.0
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/jest-config@29.5.0(@types/node@18.11.18)(ts-node@10.9.1):
|
||||
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
@ -14994,26 +14896,6 @@ packages:
|
|||
supports-color: 8.1.1
|
||||
dev: true
|
||||
|
||||
/jest@29.5.0(@types/node@18.11.18):
|
||||
resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
|
||||
peerDependenciesMeta:
|
||||
node-notifier:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@jest/core': 29.5.0
|
||||
'@jest/types': 29.5.0
|
||||
import-local: 3.1.0
|
||||
jest-cli: 29.5.0(@types/node@18.11.18)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/jest@29.5.0(@types/node@18.11.18)(ts-node@10.9.1):
|
||||
resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
|
Loading…
Reference in a new issue