diff --git a/apps/studio/components/interfaces/Account/WebAuthnFactors/AddNewWebAuthnModal.tsx b/apps/studio/components/interfaces/Account/WebAuthnFactors/AddNewWebAuthnModal.tsx index 931134b2244..d85e1dfe3ac 100644 --- a/apps/studio/components/interfaces/Account/WebAuthnFactors/AddNewWebAuthnModal.tsx +++ b/apps/studio/components/interfaces/Account/WebAuthnFactors/AddNewWebAuthnModal.tsx @@ -59,7 +59,10 @@ export const AddNewWebAuthnModal = ({ visible, onClose }: AddNewWebAuthnModalPro confirmLabelLoading="Registering..." disabled={name.length === 0 || isSuccess} loading={isVerifying} - onCancel={reset} + onCancel={() => { + reset() + onClose() + }} onConfirm={() => register({ friendlyName: name, diff --git a/apps/studio/components/interfaces/Account/WebAuthnFactors/index.tsx b/apps/studio/components/interfaces/Account/WebAuthnFactors/index.tsx index 700c49c58b3..f15ec835ac9 100644 --- a/apps/studio/components/interfaces/Account/WebAuthnFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/WebAuthnFactors/index.tsx @@ -4,10 +4,13 @@ import { useState } from 'react' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { DATETIME_FORMAT } from 'lib/constants' -import { Button, CardContent, CardFooter } from 'ui' +import { Badge, Button, CardContent, CardFooter } from 'ui' import { AddNewWebAuthnModal } from './AddNewWebAuthnModal' import DeleteWebAuthnModal from './DeleteWebAuthnModal' +import { toast } from 'sonner' +import { useMfaWebAuthnChallengeAndVerifyMutation } from 'data/profile/mfa-webauthn-challenge-and-verify-mutation' import type { AuthMFAListFactorsResponse } from '@supabase/auth-js' +import { Loader2 } from 'lucide-react' const WebAuthnFactors = ({ data, @@ -25,7 +28,27 @@ const WebAuthnFactors = ({ const [isAddNewWebAuthnOpen, setIsAddNewWebAuthnOpen] = useState(false) const [factorToBeDeleted, setFactorToBeDeleted] = useState(null) - const webauthnFactors = data?.webauthn ?? [] + const webauthnFactors = data?.all.filter((factor) => factor.factor_type === 'webauthn') ?? [] + + const { + mutate: mfaWebAuthnChallengeAndVerify, + isLoading: isWebAuthnVerifying, + isSuccess: isWebAuthnSuccess, + } = useMfaWebAuthnChallengeAndVerifyMutation({ + onSuccess: () => { + toast.success(`Successfully added a second factor authentication`) + }, + onError: (error) => { + toast.error(`Failed to verify factor: ${error?.message}`) + }, + }) + + const handleVerifyFactor = async (factorId: string) => { + mfaWebAuthnChallengeAndVerify({ + factorId: factorId, + webauthn: { rpId: window.location.hostname, rpOrigins: [window.location.origin] }, + }) + } return ( <> @@ -50,18 +73,30 @@ const WebAuthnFactors = ({

Name:{' '} {factor.friendly_name ?? 'No name provided'} + {factor.status === 'unverified' && Unverified}

Added on {dayjs(factor.updated_at).format(DATETIME_FORMAT)}

- +
+ {factor.status === 'unverified' && ( + + )} + +
) diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 85e0c8b3a5c..358215481b1 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -293,7 +293,12 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { )}
  • - Force sign out and clear cookies + + Force sign out and clear cookies +
  • { const { error, data } = await auth.mfa.unenroll(params) @@ -19,28 +23,40 @@ export const useMfaUnenrollMutation = ({ onError, ...options }: Omit< - UseMutationOptions, + UseMutationOptions< + CustomMFAUnenrollResponse, + CustomMFAUnenrollError, + MFAWebAuthnChallengeAndVerifyVariables + >, 'mutationFn' > = {}) => { const queryClient = useQueryClient() - return useMutation((vars) => mfaUnenroll(vars), { - async onSuccess(data, variables, context) { - // when a factor is unenrolled, the aaLevel is bumped down if it's the last factor - await Promise.all([ - queryClient.invalidateQueries(profileKeys.mfaFactors()), - queryClient.invalidateQueries(profileKeys.aaLevel()), - ]) + return useMutation( + (vars) => { + const { refreshFactors, ...params } = vars + return mfaUnenroll(params) + }, + { + async onSuccess(data, variables, context) { + // when a factor is unenrolled, the aaLevel is bumped down if it's the last factor + const refreshFactors = variables.refreshFactors ?? true - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to delete factor: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - }) + await Promise.all([ + ...(refreshFactors ? [queryClient.invalidateQueries(profileKeys.mfaFactors())] : []), + queryClient.invalidateQueries(profileKeys.aaLevel()), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete factor: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) } diff --git a/apps/studio/data/profile/mfa-webauthn-challenge-and-verify-mutation.ts b/apps/studio/data/profile/mfa-webauthn-challenge-and-verify-mutation.ts new file mode 100644 index 00000000000..3270f0caa47 --- /dev/null +++ b/apps/studio/data/profile/mfa-webauthn-challenge-and-verify-mutation.ts @@ -0,0 +1,87 @@ +import * as Sentry from '@sentry/nextjs' +import type { AuthMFAVerifyResponse, MFAChallengeWebauthnParams } from '@supabase/auth-js' +import { UseMutationOptions, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { auth } from 'lib/gotrue' +import { profileKeys } from './keys' + +const WHITELIST_ERRORS = ['Invalid TOTP code entered'] + +interface MFAWebAuthnChallengeAndVerifyVariables extends MFAChallengeWebauthnParams { + refreshFactors?: boolean +} + +export const mfaWebAuthnChallengeAndVerify = async (params: MFAChallengeWebauthnParams) => { + const challenge = await auth.mfa.webauthn.challenge(params) + + if (challenge.error) { + throw challenge.error + } + + const verify = await auth.mfa.webauthn.verify({ + factorId: challenge.data.factorId, + challengeId: challenge.data.challengeId, + webauthn: { + rpId: window.location.hostname, + rpOrigins: [window.location.origin], + type: challenge.data.webauthn.type, + credential_response: challenge.data.webauthn.credential_response, + }, + }) + + if (verify.error) { + throw verify.error + } + + return verify.data +} + +type CustomMFAVerifyResponse = NonNullable +type CustomMFAVerifyError = NonNullable + +export const useMfaWebAuthnChallengeAndVerifyMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + CustomMFAVerifyResponse, + CustomMFAVerifyError, + MFAWebAuthnChallengeAndVerifyVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => { + const { refreshFactors, ...params } = vars + return mfaWebAuthnChallengeAndVerify(params) + }, + { + async onSuccess(data, variables, context) { + // when a MFA is added, the aaLevel is bumped up + const refreshFactors = variables.refreshFactors ?? true + + await Promise.all([ + ...(refreshFactors ? [queryClient.invalidateQueries(profileKeys.mfaFactors())] : []), + queryClient.invalidateQueries(profileKeys.aaLevel()), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to sign in: ${data.message}`) + } else { + onError(data, variables, context) + } + if (!WHITELIST_ERRORS.some((error) => data.message.includes(error))) { + Sentry.captureMessage('[CRITICAL] Failed to sign in via MFA: ' + data.message) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/pages/account/security.tsx b/apps/studio/pages/account/security.tsx index 0324d2ed49f..ac558dbb5f8 100644 --- a/apps/studio/pages/account/security.tsx +++ b/apps/studio/pages/account/security.tsx @@ -1,5 +1,5 @@ +import dynamic from 'next/dynamic' import { AlertCircle, ChevronRightIcon, Fingerprint, Smartphone } from 'lucide-react' -import { TOTPFactors, WebAuthnFactors } from 'components/interfaces/Account' import AccountLayout from 'components/layouts/AccountLayout/AccountLayout' import AppLayout from 'components/layouts/AppLayout/AppLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -25,6 +25,13 @@ import type { NextPageWithLayout } from 'types' import { useState } from 'react' import { useFlag } from 'common' +const TOTPFactors = dynamic(() => + import('components/interfaces/Account').then((mod) => mod.TOTPFactors) +) +const WebAuthnFactors = dynamic(() => + import('components/interfaces/Account').then((mod) => mod.WebAuthnFactors) +) + const collapsibleClasses = [ 'bg-surface-100', 'hover:bg-surface-200',