Skip to content

Commit

Permalink
✨ Handle email code verification in the app (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
cermakjiri authored Nov 22, 2024
2 parents 288cf0b + 9d40d4b commit c13bbe0
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.');
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <QueryLoader result={result}>{children}</QueryLoader>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './EmailVerificationCode';
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useQueryState } from 'nuqs';

import {
EmailField,
FieldsStack,
Expand All @@ -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 (
Expand All @@ -24,7 +32,12 @@ export const LoginWithEmailAndPasswordPage = () => {
Back
</Button>
<AuthFormContainer sx={{ justifyContent: 'flex-start', mt: 15, height: 'unset' }}>
<Form<LoginFormSchema, LoginFormValues> schema={loginFormSchema} onSubmit={login} mode='onTouched'>
<Form<LoginFormSchema, LoginFormValues>
schema={loginFormSchema}
onSubmit={login}
mode='onTouched'
defaultValues={email ? { email } : {}}
>
<FieldsStack>
<FormError />
<EmailField<LoginFormValues> name='email' autoComplete='email' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoginFormSchema, LoginFormValues>['onSubmit'] {
export interface UseLoginWithEmailAndPasswordProps {
onSuccess: () => void;
}

export function useLoginWithEmailAndPassword({
onSuccess,
}: UseLoginWithEmailAndPasswordProps): FormProps<LoginFormSchema, LoginFormValues>['onSubmit'] {
const { redirect } = useExampleRouter();

return async function loginWithEmailAndPassword({ email, password }, { setError }) {
Expand Down Expand Up @@ -52,6 +58,8 @@ export function useLoginWithEmailAndPassword(): FormProps<LoginFormSchema, Login

await signInWithCustomToken(auth(), webAuthnVerifiedResult.customToken);

onSuccess();

redirect('/passkeys');
} catch (error) {
const parsedError = await parseUnknownError(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -47,8 +48,12 @@ export function useAddPasskey() {
queryKey: ['passkeys'],
});
},
onError(error: Error) {
snack('error', error.message);
async onError(error: Error) {
const parsedError = await parseUnknownError(error);

logger.error(parsedError);

snack('error', parsedError.message);
},
onSuccess() {
snack('success', 'Passkey has been successfully added.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ async function sendUserEmailVerification(user: User) {
const returnUrl = new URL('/', window.location.origin);

returnUrl.searchParams.set('verified', 'true');
returnUrl.searchParams.set('email', user.email!);

await sendEmailVerification(user, {
// Let Firebase Auth provide UI for displaying email verification result.
handleCodeInApp: false,
handleCodeInApp: true,
url: returnUrl.toString(),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ExampleAuth, ExampleBody, ExampleFrame } from '@workspace/common/client

import { CurrentExampleRoute, DefaultExampleRouter } from './DefaultExampleRouter';
import { DefaultExampleTopBar } from './DefaultExampleTopBar';
import { EmailVerificationCode } from './EmailVerificationCode';
import { EmailVerifiedAlert } from './EmailVerifiedAlert';
import { exampleRoutes } from './routes';

Expand All @@ -18,7 +19,9 @@ export const UpgradeExample = () => {

<ExampleBody>
<EmailVerifiedAlert />
<CurrentExampleRoute />
<EmailVerificationCode>
<CurrentExampleRoute />
</EmailVerificationCode>
</ExampleBody>
</DefaultExampleRouter>
</ExampleAuth>
Expand Down
17 changes: 13 additions & 4 deletions examples/webauthn-upgrade/src/pages/api/webauthn/link/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,9 +26,11 @@ export type StartLinkingResponseData = {
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse<StartLinkingResponseData>) {
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.');
}

Expand All @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -16,9 +15,6 @@ export function useCopyTextToClipboard() {

await navigator.clipboard.write([clipboardItem]);
},
onError(error) {
logger.error(error);
},
onSuccess: () => {
snack('success', 'Copied to clipboard');
},
Expand Down
2 changes: 1 addition & 1 deletion turbo.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://turbo.build/schema.json",
"cacheDir": ".cache/turbo",
"ui": "tui",
"ui": "stream",
"tasks": {
"test:ci": {
"dependsOn": ["//#audit", "build"]
Expand Down

0 comments on commit c13bbe0

Please sign in to comment.