verify unverified webauthn key

This commit is contained in:
Francesco Sansalvadore committed 2025-09-29 18:14:22 +02:00
1 parent e9990705e1
commit dd8a12a00a
7 files changed
+185 -32

No files matched your search

@@ -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,
@@ -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<string | null>(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 = ({
<p className="text-sm text-foreground flex items-center space-x-2">
<span className="text-foreground-light">Name:</span>{' '}
<span>{factor.friendly_name ?? 'No name provided'}</span>
{factor.status === 'unverified' && <Badge>Unverified</Badge>}
</p>
<div className="flex items-center gap-4">
<p className="text-sm text-foreground-light">
Added on {dayjs(factor.updated_at).format(DATETIME_FORMAT)}
</p>
<Button
size="tiny"
type="default"
onClick={() => setFactorToBeDeleted(factor.id)}
>
Remove
</Button>
<div className="flex items-center gap-1">
{factor.status === 'unverified' && (
<Button
size="tiny"
type="primary"
onClick={() => handleVerifyFactor(factor.id)}
>
{isWebAuthnVerifying ? <Loader2 className="animate-spin" /> : 'Verify'}
</Button>
)}
<Button
size="tiny"
type="default"
onClick={() => setFactorToBeDeleted(factor.id)}
>
Remove
</Button>
</div>
</div>
</div>
)
@@ -293,7 +293,12 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
</li>
)}
<li>
<Link href="/logout">Force sign out and clear cookies</Link>
<Link
href="/logout"
className="text-sm transition text-foreground-light hover:text-foreground"
>
Force sign out and clear cookies
</Link>
</li>
<li>
<Link
@@ -42,7 +42,7 @@ export const useMfaChallengeAndVerifyMutation = ({
const refreshFactors = variables.refreshFactors ?? true
await Promise.all([
queryClient.invalidateQueries(profileKeys.mfaFactors()),
...(refreshFactors ? [queryClient.invalidateQueries(profileKeys.mfaFactors())] : []),
queryClient.invalidateQueries(profileKeys.aaLevel()),
])
@@ -4,6 +4,10 @@ import { auth } from 'lib/gotrue'
import { toast } from 'sonner'
import { profileKeys } from './keys'
interface MFAWebAuthnChallengeAndVerifyVariables extends MFAUnenrollParams {
refreshFactors?: boolean
}
const mfaUnenroll = async (params: MFAUnenrollParams) => {
const { error, data } = await auth.mfa.unenroll(params)
@@ -19,28 +23,40 @@ export const useMfaUnenrollMutation = ({
onError,
...options
}: Omit<
UseMutationOptions<CustomMFAUnenrollResponse, CustomMFAUnenrollError, MFAUnenrollParams>,
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,
}
)
}
@@ -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<AuthMFAVerifyResponse['data']>
type CustomMFAVerifyError = NonNullable<AuthMFAVerifyResponse['error']>
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,
}
)
}
+8 -1
View File
@@ -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',