From 9f688f42f88b97e674b5dc399b813b0f108e9728 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sun, 1 Nov 2026 21:49:03 -0700 Subject: [PATCH 1/3] fix(mosaic): support second-factor passkeys in reverification --- .changeset/mosaic-second-factor-passkeys.md | 5 + .../__tests__/reverification.feature.test.tsx | 159 +++++++++++++++++- .../reverification/reverification.model.tsx | 10 +- .../reverification/reverification.types.ts | 3 +- .../reverification/reverification.utils.ts | 1 + 5 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 .changeset/mosaic-second-factor-passkeys.md diff --git a/.changeset/mosaic-second-factor-passkeys.md b/.changeset/mosaic-second-factor-passkeys.md new file mode 100644 index 00000000000..d0b5d8f2edf --- /dev/null +++ b/.changeset/mosaic-second-factor-passkeys.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Reverification now offers an eligible second-factor passkey when the browser supports WebAuthn. diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index f10644438ed..524fddd6363 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -82,6 +82,7 @@ const secondPhoneFactor: SignInSecondFactorJSON = { phone_number_id: 'idn_phone', safe_identifier: '+1 555 0100', }; +const secondPasskeyFactor: SignInSecondFactorJSON = { strategy: 'passkey' }; const TITLE = 'Verification required'; const NO_FACTORS_TITLE = 'Cannot verify your account'; @@ -497,9 +498,161 @@ describe('Reverification', () => { expect(await screen.findByText(STEP.backupCode)).toBeVisible(); }); - // This is a future behavior we will need to account for, here's the PR that builds it for the existing reverification: https://github.com/clerk/javascript/pull/9127 - it.todo('offer a passkey listed as a second factor', () => { - // The passkey is not offered on the second factor step + it('starts a supported second factor with passkey before code factors', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [totpFactor, secondPasskeyFactor, secondPhoneFactor] }), + ); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Use your authenticator app' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Send SMS code to +1 555 0100' })).toBeVisible(); + } finally { + restore(); + } + }); + }); + + describe('second-factor passkey', () => { + it('verifies the second factor, refreshes the session, and resumes the action once', async () => { + const { action, clerk, fapi } = await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [secondPasskeyFactor] }), + ); + const getCredential = vi.fn().mockResolvedValue({ publicKeyCredential: fakeAssertion(), error: null }); + clerk.__internal_getPublicCredentials = getCredential; + const restore = pretendToBeARealBrowser(); + const touch = holdRequests('post', TOUCH); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(fapi.verification.secondFactorVerification?.strategy).toBe('passkey')); + expect(fapi.verification.firstFactorVerification).toBeNull(); + await waitFor(() => expect(touch.requests).toHaveLength(1)); + expect(action).toHaveBeenCalledTimes(1); + + touch.release(); + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); + expect(getCredential).toHaveBeenCalledTimes(1); + } finally { + touch.release(); + restore(); + } + }); + + it('offers an advertised passkey after the first factor of a multi-factor request', async () => { + const { action, clerk } = await renderReverification( + guardedAction({ level: 'multi_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor, secondPasskeyFactor] }), + ); + clerk.__internal_getPublicCredentials = vi.fn().mockResolvedValue({ + publicKeyCredential: fakeAssertion(), + error: null, + }); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.type(passwordField(), `${PASSWORD}{Enter}`); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); + } finally { + restore(); + } + }); + + it('does not offer a second-factor passkey omitted by the server', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [totpFactor, backupCodeFactor] }), + ); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.totp)).toBeVisible(); + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); + } finally { + restore(); + } + }); + + it('does not offer an advertised second-factor passkey without WebAuthn', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [totpFactor, secondPasskeyFactor, backupCodeFactor] }), + ); + const restore = pretendWebAuthnIsUnsupported(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.totp)).toBeVisible(); + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); + } finally { + restore(); + } + }); + + it('recovers from a cancelled prompt, method switch, dismissal, and reopening', async () => { + const hint = reverificationError({ level: 'second_factor', afterMinutes: 10 }); + const action = vi.fn().mockResolvedValueOnce(hint).mockResolvedValueOnce(hint).mockResolvedValue({ done: true }); + const { clerk } = await renderReverification( + action, + verificationSeed({ secondFactors: [secondPasskeyFactor, totpFactor] }), + ); + const getCredential = vi + .fn() + .mockResolvedValueOnce({ + publicKeyCredential: null, + error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_cancelled' }), + }) + .mockResolvedValue({ publicKeyCredential: fakeAssertion(), error: null }); + clerk.__internal_getPublicCredentials = getCredential; + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Passkey verification was cancelled')); + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); + + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Use your authenticator app' })); + expect(await screen.findByText(STEP.totp)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Use your passkey' })); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + expect(screen.getByRole('alert').textContent).toBe(''); + + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + + await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await untilResolved(); + expect(action).toHaveBeenCalledTimes(3); + expect(getCredential).toHaveBeenCalledTimes(2); + } finally { + restore(); + } }); }); diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 6d15d121b76..7913f32139f 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -40,10 +40,10 @@ function toMethod( webAuthnSupported: boolean, ): ReverificationMethod | null { if (factor.strategy === 'passkey') { - if (stage !== 'first' || !webAuthnSupported) { + if (!webAuthnSupported) { return null; } - return { id: 'passkey', stage: 'first', strategy: 'passkey' }; + return { id: 'passkey', stage, strategy: 'passkey' }; } if (factor.strategy === 'password') { @@ -183,7 +183,11 @@ export function useReverificationModel(reverificationState: ReverificationState) await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), ); case 'passkey': - return handleResponse(await session.verifyWithPasskey()); + return handleResponse( + await (method.stage === 'second' + ? session.verifyWithPasskey({ level: 'second_factor' }) + : session.verifyWithPasskey()), + ); } }, finish: async () => { diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index ada8be5fc0d..7472c5302d6 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -13,7 +13,8 @@ export type ReverificationErrorReason = 'noFactors' | 'generic'; export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; export type ReverificationMethod = - | { id: string; stage: 'first'; strategy: 'password' | 'passkey' } + | { id: string; stage: 'first'; strategy: 'password' } + | { id: string; stage: 'first' | 'second'; strategy: 'passkey' } | { id: string; stage: 'first'; strategy: 'email_code'; emailAddressId: string; identifier: string } | { id: string; stage: 'first' | 'second'; strategy: 'phone_code'; phoneNumberId: string; identifier: string } | { id: string; stage: 'second'; strategy: 'totp' | 'backup_code' }; diff --git a/packages/mosaic/src/features/reverification/reverification.utils.ts b/packages/mosaic/src/features/reverification/reverification.utils.ts index 42f07b27c3e..90c59714603 100644 --- a/packages/mosaic/src/features/reverification/reverification.utils.ts +++ b/packages/mosaic/src/features/reverification/reverification.utils.ts @@ -51,6 +51,7 @@ function pickStartingFirstFactor( function pickStartingSecondFactor(methods: readonly ReverificationMethod[]): ReverificationMethod | null { return ( + methods.find(method => method.strategy === 'passkey') ?? methods.find(method => method.strategy === 'totp') ?? methods.find(method => method.strategy === 'phone_code') ?? methods[0] ?? From c63953d52174943ebaf368cc8b42ed2a2f5aa02c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sun, 1 Nov 2026 22:28:40 -0700 Subject: [PATCH 2/3] refactor(mosaic): pick passkey first once in reverification Cover a passkey-only second factor without WebAuthn. --- .changeset/mosaic-second-factor-passkeys.md | 2 +- .../__tests__/reverification.feature.test.tsx | 22 ++++++++++++++++++ .../reverification/reverification.model.tsx | 6 ++--- .../reverification/reverification.utils.ts | 23 +++++-------------- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.changeset/mosaic-second-factor-passkeys.md b/.changeset/mosaic-second-factor-passkeys.md index d0b5d8f2edf..b39d413b38d 100644 --- a/.changeset/mosaic-second-factor-passkeys.md +++ b/.changeset/mosaic-second-factor-passkeys.md @@ -2,4 +2,4 @@ '@clerk/mosaic': patch --- -Reverification now offers an eligible second-factor passkey when the browser supports WebAuthn. +When users are asked to confirm their identity again, they can now use a passkey set up as their second step, as long as their browser supports passkeys. diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 524fddd6363..69b02f5f813 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -608,6 +608,28 @@ describe('Reverification', () => { } }); + it('shows the no factors card when the only second factor is a passkey without WebAuthn', async () => { + const { action } = await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [secondPasskeyFactor] }), + ); + const restore = pretendWebAuthnIsUnsupported(); + + try { + const user = await startVerification(); + expect(await screen.findByText(NO_FACTORS_TITLE)).toBeVisible(); + expect(screen.queryByText(STEP.passkey)).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); + expect(action).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + it('recovers from a cancelled prompt, method switch, dismissal, and reopening', async () => { const hint = reverificationError({ level: 'second_factor', afterMinutes: 10 }); const action = vi.fn().mockResolvedValueOnce(hint).mockResolvedValueOnce(hint).mockResolvedValue({ done: true }); diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 7913f32139f..3e702928e62 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -104,7 +104,7 @@ function toResult( return { status: resource.status, methods, - startingMethod: pickStartingMethod(methods, preferredSignInStrategy, webAuthnSupported), + startingMethod: pickStartingMethod(methods, preferredSignInStrategy), }; } @@ -184,9 +184,7 @@ export function useReverificationModel(reverificationState: ReverificationState) ); case 'passkey': return handleResponse( - await (method.stage === 'second' - ? session.verifyWithPasskey({ level: 'second_factor' }) - : session.verifyWithPasskey()), + await session.verifyWithPasskey({ level: method.stage === 'second' ? 'second_factor' : 'first_factor' }), ); } }, diff --git a/packages/mosaic/src/features/reverification/reverification.utils.ts b/packages/mosaic/src/features/reverification/reverification.utils.ts index 90c59714603..56d1ccca665 100644 --- a/packages/mosaic/src/features/reverification/reverification.utils.ts +++ b/packages/mosaic/src/features/reverification/reverification.utils.ts @@ -27,19 +27,7 @@ export function needsPrepare(method: ReverificationMethod): method is Reverifica function pickStartingFirstFactor( methods: readonly ReverificationMethod[], preferredSignInStrategy: PreferredSignInStrategy | undefined, - webAuthnSupported: boolean, ): ReverificationMethod | null { - if (methods.length === 0) { - return null; - } - - if (webAuthnSupported) { - const passkey = methods.find(method => method.strategy === 'passkey'); - if (passkey) { - return passkey; - } - } - if (preferredSignInStrategy === 'password') { return methods.find(method => method.strategy === 'password') ?? methods[0] ?? null; } @@ -51,7 +39,6 @@ function pickStartingFirstFactor( function pickStartingSecondFactor(methods: readonly ReverificationMethod[]): ReverificationMethod | null { return ( - methods.find(method => method.strategy === 'passkey') ?? methods.find(method => method.strategy === 'totp') ?? methods.find(method => method.strategy === 'phone_code') ?? methods[0] ?? @@ -62,9 +49,11 @@ function pickStartingSecondFactor(methods: readonly ReverificationMethod[]): Rev export function pickStartingMethod( methods: readonly ReverificationMethod[], preferredSignInStrategy: PreferredSignInStrategy | undefined, - webAuthnSupported: boolean, ): ReverificationMethod | null { - return methods[0]?.stage === 'second' - ? pickStartingSecondFactor(methods) - : pickStartingFirstFactor(methods, preferredSignInStrategy, webAuthnSupported); + return ( + methods.find(method => method.strategy === 'passkey') ?? + (methods[0]?.stage === 'second' + ? pickStartingSecondFactor(methods) + : pickStartingFirstFactor(methods, preferredSignInStrategy)) + ); } From 747481a26137b732e7ec333bd536d867a6e89f02 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 2 Nov 2026 11:06:27 -0700 Subject: [PATCH 3/3] test(mosaic): run passkey reverification tests for both factors --- .../__tests__/reverification.feature.test.tsx | 246 +++++------------- 1 file changed, 63 insertions(+), 183 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 69b02f5f813..91f6773d4e5 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -517,167 +517,6 @@ describe('Reverification', () => { }); }); - describe('second-factor passkey', () => { - it('verifies the second factor, refreshes the session, and resumes the action once', async () => { - const { action, clerk, fapi } = await renderReverification( - guardedAction({ level: 'second_factor', afterMinutes: 10 }), - verificationSeed({ secondFactors: [secondPasskeyFactor] }), - ); - const getCredential = vi.fn().mockResolvedValue({ publicKeyCredential: fakeAssertion(), error: null }); - clerk.__internal_getPublicCredentials = getCredential; - const restore = pretendToBeARealBrowser(); - const touch = holdRequests('post', TOUCH); - - try { - const user = await startVerification(); - expect(await screen.findByText(STEP.passkey)).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Continue' })); - - await waitFor(() => expect(fapi.verification.secondFactorVerification?.strategy).toBe('passkey')); - expect(fapi.verification.firstFactorVerification).toBeNull(); - await waitFor(() => expect(touch.requests).toHaveLength(1)); - expect(action).toHaveBeenCalledTimes(1); - - touch.release(); - await untilResolved(); - expect(action).toHaveBeenCalledTimes(2); - expect(getCredential).toHaveBeenCalledTimes(1); - } finally { - touch.release(); - restore(); - } - }); - - it('offers an advertised passkey after the first factor of a multi-factor request', async () => { - const { action, clerk } = await renderReverification( - guardedAction({ level: 'multi_factor', afterMinutes: 10 }), - verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor, secondPasskeyFactor] }), - ); - clerk.__internal_getPublicCredentials = vi.fn().mockResolvedValue({ - publicKeyCredential: fakeAssertion(), - error: null, - }); - const restore = pretendToBeARealBrowser(); - - try { - const user = await startVerification(); - expect(await screen.findByText(STEP.password)).toBeVisible(); - await user.type(passwordField(), `${PASSWORD}{Enter}`); - expect(await screen.findByText(STEP.passkey)).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Continue' })); - await untilResolved(); - expect(action).toHaveBeenCalledTimes(2); - } finally { - restore(); - } - }); - - it('does not offer a second-factor passkey omitted by the server', async () => { - await renderReverification( - guardedAction({ level: 'second_factor', afterMinutes: 10 }), - verificationSeed({ secondFactors: [totpFactor, backupCodeFactor] }), - ); - const restore = pretendToBeARealBrowser(); - - try { - const user = await startVerification(); - expect(await screen.findByText(STEP.totp)).toBeVisible(); - await useAnotherMethod(user); - expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); - } finally { - restore(); - } - }); - - it('does not offer an advertised second-factor passkey without WebAuthn', async () => { - await renderReverification( - guardedAction({ level: 'second_factor', afterMinutes: 10 }), - verificationSeed({ secondFactors: [totpFactor, secondPasskeyFactor, backupCodeFactor] }), - ); - const restore = pretendWebAuthnIsUnsupported(); - - try { - const user = await startVerification(); - expect(await screen.findByText(STEP.totp)).toBeVisible(); - await useAnotherMethod(user); - expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); - } finally { - restore(); - } - }); - - it('shows the no factors card when the only second factor is a passkey without WebAuthn', async () => { - const { action } = await renderReverification( - guardedAction({ level: 'second_factor', afterMinutes: 10 }), - verificationSeed({ secondFactors: [secondPasskeyFactor] }), - ); - const restore = pretendWebAuthnIsUnsupported(); - - try { - const user = await startVerification(); - expect(await screen.findByText(NO_FACTORS_TITLE)).toBeVisible(); - expect(screen.queryByText(STEP.passkey)).toBeNull(); - - await user.click(screen.getByRole('button', { name: 'Close' })); - await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); - await untilClosed(); - expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); - expect(action).toHaveBeenCalledTimes(1); - } finally { - restore(); - } - }); - - it('recovers from a cancelled prompt, method switch, dismissal, and reopening', async () => { - const hint = reverificationError({ level: 'second_factor', afterMinutes: 10 }); - const action = vi.fn().mockResolvedValueOnce(hint).mockResolvedValueOnce(hint).mockResolvedValue({ done: true }); - const { clerk } = await renderReverification( - action, - verificationSeed({ secondFactors: [secondPasskeyFactor, totpFactor] }), - ); - const getCredential = vi - .fn() - .mockResolvedValueOnce({ - publicKeyCredential: null, - error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_cancelled' }), - }) - .mockResolvedValue({ publicKeyCredential: fakeAssertion(), error: null }); - clerk.__internal_getPublicCredentials = getCredential; - const restore = pretendToBeARealBrowser(); - - try { - const user = await startVerification(); - expect(await screen.findByText(STEP.passkey)).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Passkey verification was cancelled')); - expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); - - await useAnotherMethod(user); - await user.click(await screen.findByRole('button', { name: 'Use your authenticator app' })); - expect(await screen.findByText(STEP.totp)).toBeVisible(); - await useAnotherMethod(user); - await user.click(await screen.findByRole('button', { name: 'Use your passkey' })); - expect(await screen.findByText(STEP.passkey)).toBeVisible(); - expect(screen.getByRole('alert').textContent).toBe(''); - - await dismiss(user); - await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); - await untilClosed(); - - await startVerification(); - expect(await screen.findByText(STEP.passkey)).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Continue' })); - await untilResolved(); - expect(action).toHaveBeenCalledTimes(3); - expect(getCredential).toHaveBeenCalledTimes(2); - } finally { - restore(); - } - }); - }); - describe('password', () => { it('recovers from a wrong password and completes the challenge', async () => { const { action } = await renderReverification( @@ -964,6 +803,30 @@ describe('Reverification', () => { await pasteCode(user, CODE); await untilResolved(); }); + + it('offers an advertised passkey after the first factor of a multi-factor request', async () => { + const { action, clerk } = await renderReverification( + guardedAction({ level: 'multi_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor, secondPasskeyFactor] }), + ); + clerk.__internal_getPublicCredentials = vi.fn().mockResolvedValue({ + publicKeyCredential: fakeAssertion(), + error: null, + }); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.type(passwordField(), `${PASSWORD}{Enter}`); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); + } finally { + restore(); + } + }); }); describe('second factor alternatives', () => { @@ -1013,12 +876,36 @@ describe('Reverification', () => { }); }); - describe('passkey', () => { + describe.each([ + { + stage: 'first factor', + action: () => guardedAction(), + seed: { firstFactors: [passwordFactor, passkeyFactor] }, + verifiedOn: 'firstFactorVerification' as const, + skippedOn: 'secondFactorVerification' as const, + otherMethod: { name: 'Continue with your password', step: STEP.password }, + withoutWebAuthn: { + seed: { firstFactors: [passwordFactor, passkeyFactor, emailFactor] }, + step: STEP.password, + alternative: 'Email code to alice@example.com', + }, + }, + { + stage: 'second factor', + action: () => guardedAction({ level: 'second_factor', afterMinutes: 10 }), + seed: { secondFactors: [totpFactor, secondPasskeyFactor] }, + verifiedOn: 'secondFactorVerification' as const, + skippedOn: 'firstFactorVerification' as const, + otherMethod: { name: 'Use your authenticator app', step: STEP.totp }, + withoutWebAuthn: { + seed: { secondFactors: [totpFactor, secondPasskeyFactor, backupCodeFactor] }, + step: STEP.totp, + alternative: 'Use a backup code', + }, + }, + ])('passkey as the $stage', ({ action, seed, verifiedOn, skippedOn, otherMethod, withoutWebAuthn }) => { it('completes the challenge with the passkey', async () => { - const { clerk } = await renderReverification( - guardedAction(), - verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), - ); + const { clerk, fapi } = await renderReverification(action(), verificationSeed(seed)); let acceptCredential: () => void = () => {}; const getCredential = vi .fn() @@ -1057,16 +944,15 @@ describe('Reverification', () => { await waitFor(() => expect(getCredential).toHaveBeenCalledTimes(2)); acceptCredential(); await untilResolved(); + expect(fapi.verification[verifiedOn]?.strategy).toBe('passkey'); + expect(fapi.verification[skippedOn]).toBeNull(); } finally { restore(); } }); it('shows no error when the passkey prompt is aborted', async () => { - const { clerk } = await renderReverification( - guardedAction(), - verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), - ); + const { clerk } = await renderReverification(action(), verificationSeed(seed)); const getCredential = vi.fn().mockResolvedValue({ publicKeyCredential: null, error: new ClerkWebAuthnError('The operation was aborted', { code: 'passkey_operation_aborted' }), @@ -1092,10 +978,7 @@ describe('Reverification', () => { }); it('clears an earlier passkey error when the next prompt is aborted', async () => { - const { clerk } = await renderReverification( - guardedAction(), - verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), - ); + const { clerk } = await renderReverification(action(), verificationSeed(seed)); const getCredential = vi .fn() .mockResolvedValueOnce({ @@ -1130,15 +1013,15 @@ describe('Reverification', () => { }); it('offers the passkey from the picker when another method is showing', async () => { - await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] })); + await renderReverification(action(), verificationSeed(seed)); const restore = pretendToBeARealBrowser(); try { const user = await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); await useAnotherMethod(user); - await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); - expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.click(await screen.findByRole('button', { name: otherMethod.name })); + expect(await screen.findByText(otherMethod.step)).toBeVisible(); // The passkey is listed once the user is on another method await useAnotherMethod(user); @@ -1150,20 +1033,17 @@ describe('Reverification', () => { }); it('does not offer a passkey when the browser does not support WebAuthn', async () => { - await renderReverification( - guardedAction(), - verificationSeed({ firstFactors: [passwordFactor, passkeyFactor, emailFactor] }), - ); + await renderReverification(action(), verificationSeed(withoutWebAuthn.seed)); const restore = pretendWebAuthnIsUnsupported(); try { // The passkey is not the starting method const user = await startVerification(); - expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(await screen.findByText(withoutWebAuthn.step)).toBeVisible(); // The passkey is not listed in the picker await useAnotherMethod(user); - expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(await screen.findByRole('button', { name: withoutWebAuthn.alternative })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); } finally { restore();