Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-second-factor-passkeys.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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' }),
Expand All @@ -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({
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -104,7 +104,7 @@ function toResult(
return {
status: resource.status,
methods,
startingMethod: pickStartingMethod(methods, preferredSignInStrategy, webAuthnSupported),
startingMethod: pickStartingMethod(methods, preferredSignInStrategy),
};
}

Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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))
);
}
Loading