From 9d40d4b99cfa1a3e5fd150f4ac07358da466ca6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Cerm=C3=A1k?= Date: Fri, 22 Nov 2024 09:38:33 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Handle=20email=20code=20verificatio?= =?UTF-8?q?n=20in=20the=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PasskeysPage/hooks/useAddPasskey.ts | 7 ++- .../EmailVerificationCode.tsx | 49 +++++++++++++++++++ .../EmailVerificationCode/index.ts | 1 + .../LoginWithEmailAndPasswordPage.tsx | 17 ++++++- .../hooks/useLoginWithEmailAndPassword.ts | 10 +++- .../PasskeysPage/hooks/useAddPasskey.ts | 9 +++- .../hooks/useRegisterWithEmailAndPassword.tsx | 4 +- .../UpgradeExample/UpgradeExample.tsx | 5 +- .../src/pages/api/webauthn/link/options.ts | 17 +++++-- .../clipboard/hooks/useCopyTextToClipboard.ts | 4 -- turbo.json | 2 +- 11 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/EmailVerificationCode.tsx create mode 100644 examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/index.ts diff --git a/examples/webauthn-default/src/components/WebAuthnDefaultExamplePage/DefaultExample/PasskeysPage/hooks/useAddPasskey.ts b/examples/webauthn-default/src/components/WebAuthnDefaultExamplePage/DefaultExample/PasskeysPage/hooks/useAddPasskey.ts index aa9ba8f..b686615 100644 --- a/examples/webauthn-default/src/components/WebAuthnDefaultExamplePage/DefaultExample/PasskeysPage/hooks/useAddPasskey.ts +++ b/examples/webauthn-default/src/components/WebAuthnDefaultExamplePage/DefaultExample/PasskeysPage/hooks/useAddPasskey.ts @@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query'; import { queryClient } from '@workspace/common/client/api/components'; import { fetcher } from '@workspace/common/client/api/fetcher'; +import { parseUnknownError } from '@workspace/common/client/errors'; import { useSnack } from '@workspace/common/client/snackbar/hooks'; import { logger } from '@workspace/common/logger'; @@ -41,7 +42,11 @@ export function useAddPasskey() { }); }, async onError(error: Error) { - snack('error', error.message); + const parsedError = await parseUnknownError(error); + + logger.error(parsedError); + + snack('error', parsedError.message); }, onSuccess() { snack('success', 'Passkey has been successfully added.'); diff --git a/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/EmailVerificationCode.tsx b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/EmailVerificationCode.tsx new file mode 100644 index 0000000..14756e1 --- /dev/null +++ b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/EmailVerificationCode.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import { useRouter } from 'next/router'; +import { useQuery } from '@tanstack/react-query'; +import { applyActionCode } from 'firebase/auth'; +import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'; + +import { QueryLoader } from '@workspace/common/client/api/components'; +import { env } from '@workspace/common/client/env'; +import { auth } from '@workspace/common/client/firebase/config'; + +import { useExampleRouter } from '../DefaultExampleRouter'; + +export interface EmailVerificationCodeProps { + children: ReactNode; +} + +export function EmailVerificationCode({ children }: EmailVerificationCodeProps) { + const [params] = useQueryStates({ + apiKey: parseAsString, + mode: parseAsStringLiteral(['verifyEmail'] as const), + oobCode: parseAsString, + continueUrl: parseAsString, + }); + + const { push } = useRouter(); + const { redirect } = useExampleRouter(); + + const result = useQuery({ + queryKey: ['emailVerification', params], + queryFn: async () => { + const { mode, oobCode, continueUrl, apiKey } = params; + + if (mode !== 'verifyEmail' || !oobCode || apiKey !== env.NEXT_PUBLIC_FIREBASE_API_KEY || !continueUrl) { + return null; + } + + await applyActionCode(auth(), oobCode); + await auth().currentUser?.reload(); + + await push(continueUrl); + + redirect('/login-with-password'); + + return null; + }, + }); + + return {children}; +} diff --git a/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/index.ts b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/index.ts new file mode 100644 index 0000000..9df64dc --- /dev/null +++ b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/EmailVerificationCode/index.ts @@ -0,0 +1 @@ +export * from './EmailVerificationCode'; diff --git a/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/LoginWithEmailAndPasswordPage.tsx b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/LoginWithEmailAndPasswordPage.tsx index 7518772..3262cce 100644 --- a/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/LoginWithEmailAndPasswordPage.tsx +++ b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/LoginWithEmailAndPasswordPage.tsx @@ -1,3 +1,5 @@ +import { useQueryState } from 'nuqs'; + import { EmailField, FieldsStack, @@ -15,7 +17,13 @@ import { useLoginWithEmailAndPassword } from './hooks/useLoginWithEmailAndPasswo import { loginFormSchema, type LoginFormSchema, type LoginFormValues } from './schema'; export const LoginWithEmailAndPasswordPage = () => { - const login = useLoginWithEmailAndPassword(); + const [email, setEmail] = useQueryState('email'); + const login = useLoginWithEmailAndPassword({ + onSuccess() { + setEmail(null); + }, + }); + const { redirect } = useExampleRouter(); return ( @@ -24,7 +32,12 @@ export const LoginWithEmailAndPasswordPage = () => { Back - schema={loginFormSchema} onSubmit={login} mode='onTouched'> + + schema={loginFormSchema} + onSubmit={login} + mode='onTouched' + defaultValues={email ? { email } : {}} + > name='email' autoComplete='email' /> diff --git a/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/hooks/useLoginWithEmailAndPassword.ts b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/hooks/useLoginWithEmailAndPassword.ts index 7345b02..1ba5363 100644 --- a/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/hooks/useLoginWithEmailAndPassword.ts +++ b/examples/webauthn-upgrade/src/components/WebAuthnUpgradeExamplePage/UpgradeExample/LoginWithEmailAndPasswordPage/hooks/useLoginWithEmailAndPassword.ts @@ -13,7 +13,13 @@ import type { VerifyLoginRequestData, VerifyLoginResponseData } from '~pages/api import { useExampleRouter } from '../../DefaultExampleRouter'; import type { LoginFormSchema, LoginFormValues } from '../schema'; -export function useLoginWithEmailAndPassword(): FormProps['onSubmit'] { +export interface UseLoginWithEmailAndPasswordProps { + onSuccess: () => void; +} + +export function useLoginWithEmailAndPassword({ + onSuccess, +}: UseLoginWithEmailAndPasswordProps): FormProps['onSubmit'] { const { redirect } = useExampleRouter(); return async function loginWithEmailAndPassword({ email, password }, { setError }) { @@ -52,6 +58,8 @@ export function useLoginWithEmailAndPassword(): FormProps { - + + + diff --git a/examples/webauthn-upgrade/src/pages/api/webauthn/link/options.ts b/examples/webauthn-upgrade/src/pages/api/webauthn/link/options.ts index b5d438b..41b2d92 100644 --- a/examples/webauthn-upgrade/src/pages/api/webauthn/link/options.ts +++ b/examples/webauthn-upgrade/src/pages/api/webauthn/link/options.ts @@ -10,9 +10,9 @@ import { RP_NAME } from '@workspace/common/server/constants/relyingParty'; import { initializeChallengeSession } from '@workspace/common/server/services/challenge-session'; import { getPasskeys } from '@workspace/common/server/services/passkeys'; import { createUserWithNoPasskeys, getUser } from '@workspace/common/server/services/users'; -import { getRpId } from '@workspace/common/server/utils'; +import { getRpId, parseAndVerifyIdToken } from '@workspace/common/server/utils'; -import { parseAndVerifyIdTokenForMFA } from '~server/utils/parseAndVerifyIdTokenForMFA'; +import { tokenClaims } from '~server/constans/tokenClaims'; export type StartLinkingResponseData = { publicKeyOptions: PublicKeyCredentialCreationOptionsJSON; @@ -26,9 +26,11 @@ export type StartLinkingResponseData = { */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - const idTokenResult = await parseAndVerifyIdTokenForMFA(req.headers.authorization); + const idTokenResult = await parseAndVerifyIdToken(req.headers.authorization); + + if (!idTokenResult || !idTokenResult.email_verified) { + logger.error('User not authenticated. No ID token or email not verified.'); - if (!idTokenResult) { return res.status(401).end('User not authenticated.'); } @@ -44,6 +46,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< const passkeys = await getPasskeys(userId); + // ID token claims must include 'mfa_enabled: true' once at least one passkey has been added. + if (passkeys.length > 0 && !idTokenResult[tokenClaims.MFA_ENABLED]) { + logger.error('User has passkeys but MFA is not enabled.'); + + return res.status(401).end('User not authenticated.'); + } + /** * Generate a random string with enough entropy to be signed by the authenticator to prevent replay attacks. */ diff --git a/packages/common/src/client/clipboard/hooks/useCopyTextToClipboard.ts b/packages/common/src/client/clipboard/hooks/useCopyTextToClipboard.ts index de837a9..fd7c47c 100644 --- a/packages/common/src/client/clipboard/hooks/useCopyTextToClipboard.ts +++ b/packages/common/src/client/clipboard/hooks/useCopyTextToClipboard.ts @@ -1,7 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { useSnack } from '~client/snackbar/hooks'; -import { logger } from '~logger'; export function useCopyTextToClipboard() { const snack = useSnack(); @@ -16,9 +15,6 @@ export function useCopyTextToClipboard() { await navigator.clipboard.write([clipboardItem]); }, - onError(error) { - logger.error(error); - }, onSuccess: () => { snack('success', 'Copied to clipboard'); }, diff --git a/turbo.json b/turbo.json index 8e50f70..1bfe8f4 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "cacheDir": ".cache/turbo", - "ui": "tui", + "ui": "stream", "tasks": { "test:ci": { "dependsOn": ["//#audit", "build"]