diff --git a/packages/experience/package.json b/packages/experience/package.json
index 286e4396f..7c1df523f 100644
--- a/packages/experience/package.json
+++ b/packages/experience/package.json
@@ -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",
diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx
index 46a2923c6..1c6e39631 100644
--- a/packages/experience/src/App.tsx
+++ b/packages/experience/src/App.tsx
@@ -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 = () => {
} />
} />
+ } />
{/* Mfa verification */}
@@ -87,6 +90,7 @@ const App = () => {
} />
} />
+ } />
>
)}
diff --git a/packages/experience/src/apis/interaction.ts b/packages/experience/src/apis/interaction.ts
index aa61437d2..9417ad27b 100644
--- a/packages/experience/src/apis/interaction.ts
+++ b/packages/experience/src/apis/interaction.ts
@@ -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();
+
+export const generateWebAuthnAuthnOptions = async () =>
+ api
+ .post(`${interactionPrefix}/${verificationPath}/webauthn-authentication`)
+ .json();
+
export const bindMfa = async (payload: BindMfaPayload) => {
await api.put(`${interactionPrefix}/bind-mfa`, { json: payload });
diff --git a/packages/experience/src/containers/MfaFactorList/index.tsx b/packages/experience/src/containers/MfaFactorList/index.tsx
index c48caed03..09eebf139 100644
--- a/packages/experience/src/containers/MfaFactorList/index.tsx
+++ b/packages/experience/src/containers/MfaFactorList/index.tsx
@@ -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);
- }
-
- if (flow === UserMfaFlow.MfaVerification) {
- const state: MfaFactorsState = { availableFactors: factors };
- navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state });
- }
+ (factor: MfaFactor) => {
+ if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
+ void startTotpBinding(factors);
+ return;
}
- // 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);
}}
/>
))}
diff --git a/packages/experience/src/containers/TotpCodeVerification/index.tsx b/packages/experience/src/containers/TotpCodeVerification/index.tsx
index e4b14ccbd..2030dc042 100644
--- a/packages/experience/src/containers/TotpCodeVerification/index.tsx
+++ b/packages/experience/src/containers/TotpCodeVerification/index.tsx
@@ -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([]);
- const { errorMessage, onSubmit } = useTotpCodeVerification({ flow });
+ const { errorMessage, onSubmit } = useTotpCodeVerification(flow);
return (
{
setCode(code);
if (code.length === totpCodeLength && code.every(Boolean)) {
- void onSubmit(code.join(''));
+ onSubmit(code.join(''));
}
}}
/>
diff --git a/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts b/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts
index de87351e8..f9a5636c2 100644
--- a/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts
+++ b/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts
@@ -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();
- 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 {
diff --git a/packages/experience/src/hooks/use-mfa-error-handler.ts b/packages/experience/src/hooks/use-mfa-error-handler.ts
new file mode 100644
index 000000000..8f08eaa1b
--- /dev/null
+++ b/packages/experience/src/hooks/use-mfa-error-handler.ts
@@ -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(
+ () => ({
+ 'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding),
+ 'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification),
+ }),
+ [handleMfaError]
+ );
+
+ return mfaVerificationErrorHandler;
+};
+
+export default useMfaErrorHandler;
diff --git a/packages/experience/src/hooks/use-mfa-factors-state.ts b/packages/experience/src/hooks/use-mfa-factors-state.ts
new file mode 100644
index 000000000..5b264c814
--- /dev/null
+++ b/packages/experience/src/hooks/use-mfa-factors-state.ts
@@ -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;
diff --git a/packages/experience/src/hooks/use-mfa-verification-error-handler.ts b/packages/experience/src/hooks/use-mfa-verification-error-handler.ts
deleted file mode 100644
index fdb0186fb..000000000
--- a/packages/experience/src/hooks/use-mfa-verification-error-handler.ts
+++ /dev/null
@@ -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(
- () => ({
- '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;
diff --git a/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts b/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts
index 9e4e59b97..5f03eabb3 100644
--- a/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts
+++ b/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts
@@ -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]
);
};
diff --git a/packages/experience/src/hooks/use-send-mfa-payload.ts b/packages/experience/src/hooks/use-send-mfa-payload.ts
new file mode 100644
index 000000000..60a173855
--- /dev/null
+++ b/packages/experience/src/hooks/use-send-mfa-payload.ts
@@ -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;
diff --git a/packages/experience/src/hooks/use-webauthn-operation.ts b/packages/experience/src/hooks/use-webauthn-operation.ts
new file mode 100644
index 000000000..c7bb22379
--- /dev/null
+++ b/packages/experience/src/hooks/use-webauthn-operation.ts
@@ -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;
diff --git a/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.module.scss b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.module.scss
new file mode 100644
index 000000000..5ced7e4d5
--- /dev/null
+++ b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.module.scss
@@ -0,0 +1,5 @@
+@use '@/scss/underscore' as _;
+
+.switchLink {
+ margin-top: _.unit(6);
+}
diff --git a/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx
new file mode 100644
index 000000000..dc2508c55
--- /dev/null
+++ b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx
@@ -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 ;
+ }
+
+ const { availableFactors } = mfaFactorsState;
+
+ return (
+
+
+ {availableFactors.length > 1 && (
+
+ )}
+
+ );
+};
+
+export default WebAuthnBinding;
diff --git a/packages/experience/src/pages/MfaBinding/index.tsx b/packages/experience/src/pages/MfaBinding/index.tsx
index eeaebcb1f..3c9670321 100644
--- a/packages/experience/src/pages/MfaBinding/index.tsx
+++ b/packages/experience/src/pages/MfaBinding/index.tsx
@@ -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 ;
diff --git a/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx b/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx
index d18e701da..6f3e80e9a 100644
--- a/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx
+++ b/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx
@@ -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 ;
diff --git a/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.module.scss b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.module.scss
new file mode 100644
index 000000000..05b588997
--- /dev/null
+++ b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.module.scss
@@ -0,0 +1,5 @@
+@use '@/scss/underscore' as _;
+
+.verifyButton {
+ margin: _.unit(4) 0;
+}
diff --git a/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx
new file mode 100644
index 000000000..807a19e1b
--- /dev/null
+++ b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx
@@ -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 ;
+ }
+
+ const { availableFactors } = mfaFactorsState;
+
+ return (
+
+
+
+
+ {availableFactors.length > 1 && (
+
+ )}
+
+ );
+};
+
+export default WebAuthnVerification;
diff --git a/packages/experience/src/pages/MfaVerification/index.tsx b/packages/experience/src/pages/MfaVerification/index.tsx
index 1fbe9929c..2fb6bccb0 100644
--- a/packages/experience/src/pages/MfaVerification/index.tsx
+++ b/packages/experience/src/pages/MfaVerification/index.tsx
@@ -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 ;
diff --git a/packages/experience/src/pages/RegisterPassword/index.tsx b/packages/experience/src/pages/RegisterPassword/index.tsx
index e082ed22d..dc498f7a1 100644
--- a/packages/experience/src/pages/RegisterPassword/index.tsx
+++ b/packages/experience/src/pages/RegisterPassword/index.tsx
@@ -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 = useCallback((result) => {
diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts
index 8e67c839e..c0cdb452a 100644
--- a/packages/experience/src/types/guard.ts
+++ b/packages/experience/src/types/guard.ts
@@ -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;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5ffed86af..c24e54187 100644
--- a/pnpm-lock.yaml
+++ b/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}