Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/passkey-second-factor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/shared': minor
'@clerk/clerk-js': minor
'@clerk/ui': minor
'@clerk/localizations': minor
'@clerk/react': minor
---

Support passkeys as a second factor during sign-in and session reverification. When the instance allows passkeys to satisfy the second factor and the user has a registered passkey, FAPI advertises a `passkey` entry in `supported_second_factors`; `signIn.authenticateWithPasskey()` now completes the second factor of an in-progress sign-in that offers it (and falls back to the first-factor flow when it doesn't, as on older clients), `session.verifyWithPasskey({ level: 'second_factor' })` completes a multi-factor reverification (any other `level` value is rejected), the `signIn.mfa.passkey()` future API is exposed through `@clerk/react`, and the prebuilt `<SignIn/>` and `<UserVerification/>` flows preselect the passkey ahead of the enrolled code-based second factors, which stay reachable under "Use another method".
112 changes: 105 additions & 7 deletions integration/tests/passkeys.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BrowserContext } from '@playwright/test';
import type { BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
Expand All @@ -14,6 +14,9 @@ type SavedCredential = {
publicKey: string;
};

// Any valid base32 secret works — no test ever generates a code from it.
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';

// clerk-js gates WebAuthn behind isValidBrowser(), which bails when
// navigator.webdriver is true, so mask it before installing the virtual authenticator.
const installVirtualAuthenticator = async (context: BrowserContext) => {
Expand All @@ -23,26 +26,33 @@ const installVirtualAuthenticator = async (context: BrowserContext) => {
await context.credentials.install();
};

let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.commit();
await app.setup();
await app.withEnv(appConfigs.envs.withPasskeys);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test.describe('passkeys @generic', () => {
test.describe.configure({ mode: 'serial' });

let app: Application;
let fakeUser: FakeUser;
let savedCredential: SavedCredential;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.commit();
await app.setup();
await app.withEnv(appConfigs.envs.withPasskeys);
await app.dev();
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: true, withPassword: true });
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('registers a passkey through UserProfile', async ({ page, context }) => {
Expand Down Expand Up @@ -108,3 +118,91 @@ test.describe('passkeys @generic', () => {
await u.po.expect.toBeSignedIn();
});
});

test.describe('passkeys as a second factor @generic', () => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let userId: string;
let savedCredential: SavedCredential;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: true, withPassword: true });
const user = await u.services.users.createBapiUser(fakeUser);
userId = user.id;
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
});

const signInToSecondFactor = async (page: Page, context: BrowserContext) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email!,
password: fakeUser.password,
waitForSession: false,
});
await u.page.waitForURL(/\/sign-in\/factor-two/);
return u;
};

test('registers a passkey, then enrolls the user in two-factor', async ({ page, context }) => {
await installVirtualAuthenticator(context);

const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email!, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.po.userProfile.goTo();
await u.po.userProfile.switchToSecurityTab();
await u.page.getByRole('button', { name: /add a passkey/i }).click();

await expect(u.page.locator('.cl-profileSectionItem__passkeys')).toBeVisible();

const credentials = await context.credentials.get();
expect(credentials).toHaveLength(1);
savedCredential = credentials[0];

// A passkey on its own never creates a 2FA requirement, so enroll TOTP to park
// later sign-ins at needs_second_factor. It has to happen after the passkey is
// registered, since the remaining tests never enter a TOTP code.
await u.services.clerk.users.updateUser(userId, { totpSecret: TOTP_SECRET });
});

test('offers the passkey as the starting second factor', async ({ page, context }) => {
await installVirtualAuthenticator(context);

const u = await signInToSecondFactor(page, context);

// Passkey outranks the enrolled authenticator app once the backend advertises it.
await expect(u.page.getByText('Use your passkey')).toBeVisible();
// Seed only now: the start page's conditional-UI autofill would otherwise
// answer with this credential and verify it as the first factor instead.
await context.credentials.create(savedCredential.rpId, savedCredential);
await u.po.signIn.continue();

await u.po.expect.toBeSignedIn();
});

test('lists the passkey under "Use another method"', async ({ page, context }) => {
await installVirtualAuthenticator(context);

const u = await signInToSecondFactor(page, context);

await u.po.signIn.getUseAnotherMethodLink().click();

const passkeyButton = u.page.getByRole('button', { name: /sign in with your passkey/i });
await expect(passkeyButton).toBeVisible();
await expect(u.page.getByRole('button', { name: /use your authenticator app/i })).toBeVisible();

await context.credentials.create(savedCredential.rpId, savedCredential);
await passkeyButton.click();
await u.po.signIn.continue();

await u.po.expect.toBeSignedIn();
});
});
49 changes: 44 additions & 5 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
SessionVerifyCreateParams,
SessionVerifyPrepareFirstFactorParams,
SessionVerifyPrepareSecondFactorParams,
SessionVerifyWithPasskeyParams,
TokenResource,
UserResource,
} from '@clerk/shared/types';
Expand All @@ -47,7 +48,7 @@ import { debugLogger } from '@/utils/debug';
import { getTabState, isTabFocused } from '@/utils/isTabFocused';
import { TokenId } from '@/utils/tokenId';

import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors';
import { clerkInvalidStrategy, clerkInvalidVerificationLevel, clerkMissingWebAuthnPublicKeyOptions } from '../errors';
import { eventBus, events } from '../events';
import type { FapiResponseJSON } from '../fapiClient';
import { SessionTokenCache } from '../tokenCache';
Expand Down Expand Up @@ -321,10 +322,29 @@ export class Session extends BaseResource implements SessionResource {
return new SessionVerification(json);
};

verifyWithPasskey = async (): Promise<SessionVerificationResource> => {
const prepareResponse = await this.prepareFirstFactorVerification({ strategy: 'passkey' });
/**
* Initiates a reverification flow using passkeys.
*
* By default the passkey verifies the first factor. Pass `{ level: 'second_factor' }` to satisfy
* the second factor of an in-progress multi-factor reverification instead. Any other `level`
* value is rejected.
*
* Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails.
* @returns A `SessionVerification` instance with its status and supported factors.
*/
verifyWithPasskey = async (params?: SessionVerifyWithPasskeyParams): Promise<SessionVerificationResource> => {
const level = params?.level ?? 'first_factor';
if (level !== 'first_factor' && level !== 'second_factor') {
clerkInvalidVerificationLevel('Session.verifyWithPasskey', level);
}

const prepareResponse =
level === 'second_factor'
? await this.prepareSecondFactorVerification({ strategy: 'passkey' })
: await this.prepareFirstFactorVerification({ strategy: 'passkey' });

const { nonce = null } = prepareResponse.firstFactorVerification;
const { nonce = null } =
level === 'second_factor' ? prepareResponse.secondFactorVerification : prepareResponse.firstFactorVerification;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
Expand Down Expand Up @@ -354,6 +374,13 @@ export class Session extends BaseResource implements SessionResource {
throw error;
}

if (level === 'second_factor') {
return this.attemptSecondFactorVerification({
strategy: 'passkey',
publicKeyCredential,
});
}

return this.attemptFirstFactorVerification({
strategy: 'passkey',
publicKeyCredential,
Expand All @@ -377,11 +404,23 @@ export class Session extends BaseResource implements SessionResource {
attemptSecondFactorVerification = async (
attemptFactor: SessionVerifyAttemptSecondFactorParams,
): Promise<SessionVerificationResource> => {
let config;
switch (attemptFactor.strategy) {
case 'passkey': {
config = {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)),
};
break;
}
default:
config = { ...attemptFactor };
}

const json = (
await BaseResource._fetch({
method: 'POST',
path: `/client/sessions/${this.id}/verify/attempt_second_factor`,
body: attemptFactor as any,
body: { ...config, strategy: attemptFactor.strategy } as any,
})
)?.response as unknown as SessionVerificationJSON;

Expand Down
110 changes: 109 additions & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,19 @@ export class SignIn extends BaseResource implements SignInResource {

attemptSecondFactor = (params: AttemptSecondFactorParams): Promise<SignInResource> => {
debugLogger.debug('SignIn.attemptSecondFactor', { id: this.id, strategy: params.strategy });
let config;
switch (params.strategy) {
case 'passkey':
config = {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(params.publicKeyCredential)),
};
break;
default:
config = { ...params };
}

return this._basePost({
body: params,
body: { ...config, strategy: params.strategy },
action: 'attempt_second_factor',
});
};
Expand Down Expand Up @@ -553,6 +564,26 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

/**
* Authenticates the sign-in with a passkey.
*
* When the sign-in status is `needs_second_factor` (or `needs_client_trust`) and the sign-in
* offers `passkey` among its `supportedSecondFactors`, the passkey acts as the second factor:
* the in-progress sign-in is reused via the discrete prepare/attempt second-factor flow, and
* `params.flow` is ignored — `'autofill'` and `'discoverable'` are identifier-first concepts
* whose `create()` call would discard the in-progress sign-in.
*
* Otherwise the passkey verifies the first factor, with `params.flow` selecting how the ceremony
* starts: `'autofill'`/`'discoverable'` create a new sign-in and identify the user from the
* passkey itself, while the default requires a sign-in created beforehand. A sign-in parked at a
* second-factor status that does NOT offer passkey (the backend advertises it only when the
* instance allows passkeys to satisfy the second factor, the user has one registered, and the
* client version supports it) also takes this path, matching clients that predate passkey second
* factors: the ceremony starts over instead of failing.
*
* Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails.
* @returns The updated `SignIn` resource.
*/
public authenticateWithPasskey = async (params?: AuthenticateWithPasskeyParams): Promise<SignInResource> => {
const { flow } = params || {};

Expand All @@ -572,6 +603,33 @@ export class SignIn extends BaseResource implements SignInResource {
});
}

const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust';
const hasPasskeySecondFactor = (this.supportedSecondFactors || []).some(f => f.strategy === 'passkey');
if (isSecondFactor && hasPasskeySecondFactor) {
await this.prepareSecondFactor({ strategy: 'passkey' });

const { nonce: secondFactorNonce } = this.secondFactorVerification;
const secondFactorPublicKeyOptions = secondFactorNonce
? convertJSONToPublicKeyRequestOptions(JSON.parse(secondFactorNonce))
: null;
if (!secondFactorPublicKeyOptions) {
clerkMissingWebAuthnPublicKeyOptions('get');
}

const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions: secondFactorPublicKeyOptions,
conditionalUI: false,
});
if (!publicKeyCredential) {
throw error;
}

return this.attemptSecondFactor({
publicKeyCredential,
strategy: 'passkey',
});
}

if (flow === 'autofill' || flow === 'discoverable') {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.create({ strategy: 'passkey' });
Expand Down Expand Up @@ -789,6 +847,7 @@ class SignInFuture implements SignInFutureResource {
verifyEmailCode: this.verifyMFAEmailCode.bind(this),
verifyTOTP: this.verifyTOTP.bind(this),
verifyBackupCode: this.verifyBackupCode.bind(this),
passkey: this.verifyMFAPasskey.bind(this),
};

#canBeDiscarded = false;
Expand Down Expand Up @@ -1522,6 +1581,55 @@ class SignInFuture implements SignInFutureResource {
});
}

async verifyMFAPasskey(): Promise<{ error: ClerkError | null }> {
/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
* As a precaution we need to check if WebAuthn is supported.
*/
const isWebAuthnSupported = SignIn.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow;
const webAuthnGetCredential = SignIn.clerk.__internal_getPublicCredentials || webAuthnGetCredentialOnWindow;

if (!isWebAuthnSupported()) {
throw new ClerkWebAuthnError('Passkeys are not supported', {
code: 'passkey_not_supported',
});
}

return runAsyncResourceTask(this.#resource, async () => {
const passkeyFactor = this.#resource.supportedSecondFactors?.find(f => f.strategy === 'passkey');
if (!passkeyFactor) {
throw new ClerkRuntimeError('Passkey factor not found', { code: 'factor_not_found' });
}

await this.#resource.__internal_basePost({
body: { strategy: 'passkey' },
action: 'prepare_second_factor',
});

const { nonce } = this.#resource.secondFactorVerification;
const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;
if (!publicKeyOptions) {
throw new ClerkRuntimeError('Missing public key options', { code: 'missing_public_key_options' });
}

const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: false,
});
if (!publicKeyCredential) {
throw new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_failed' });
}

await this.#resource.__internal_basePost({
body: {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)),
strategy: 'passkey',
},
action: 'attempt_second_factor',
});
});
}

async ticket(params?: SignInFutureTicketParams): Promise<{ error: ClerkError | null }> {
const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket');
return this.create({ strategy: 'ticket', ticket: ticket ?? undefined });
Expand Down
Loading
Loading