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 ( + +