diff --git a/.changeset/mosaic-second-factor-passkeys.md b/.changeset/mosaic-second-factor-passkeys.md new file mode 100644 index 00000000000..b39d413b38d --- /dev/null +++ b/.changeset/mosaic-second-factor-passkeys.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +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 f10644438ed..91f6773d4e5 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,22 @@ 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(); + } }); }); @@ -789,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', () => { @@ -838,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() @@ -882,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' }), @@ -917,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({ @@ -955,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); @@ -975,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(); diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 6d15d121b76..3e702928e62 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') { @@ -104,7 +104,7 @@ function toResult( return { status: resource.status, methods, - startingMethod: pickStartingMethod(methods, preferredSignInStrategy, webAuthnSupported), + startingMethod: pickStartingMethod(methods, preferredSignInStrategy), }; } @@ -183,7 +183,9 @@ export function useReverificationModel(reverificationState: ReverificationState) await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), ); case 'passkey': - return handleResponse(await session.verifyWithPasskey()); + return handleResponse( + await session.verifyWithPasskey({ level: method.stage === 'second' ? 'second_factor' : 'first_factor' }), + ); } }, 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..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; } @@ -61,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)) + ); }