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, `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 offer the passkey alongside the enrolled second factors.
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 @@ -46,7 +47,7 @@ import { unixEpochToDate } from '@/utils/date';
import { debugLogger } from '@/utils/debug';
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 @@ -316,10 +317,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 @@ -349,6 +369,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 @@ -372,11 +399,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
111 changes: 110 additions & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {
clerkInvalidFAPIResponse,
clerkInvalidStrategy,
clerkMissingOptionError,
clerkMissingPasskeySecondFactor,
clerkMissingWebAuthnPublicKeyOptions,
clerkVerifyEmailAddressCalledBeforeCreate,
clerkVerifyPasskeyCalledBeforeCreate,
Expand Down Expand Up @@ -360,8 +361,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 @@ -541,6 +553,22 @@ 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`), 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.
*
* Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails, and a
* validation error when passkey is not among the available factors.
* @returns The updated `SignIn` resource.
*/
public authenticateWithPasskey = async (params?: AuthenticateWithPasskeyParams): Promise<SignInResource> => {
const { flow } = params || {};

Expand All @@ -560,6 +588,37 @@ export class SignIn extends BaseResource implements SignInResource {
});
}

const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust';
if (isSecondFactor) {
const passkeySecondFactor = (this.supportedSecondFactors || []).find(f => f.strategy === 'passkey');
if (!passkeySecondFactor) {
clerkMissingPasskeySecondFactor();
}

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 @@ -777,6 +836,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 @@ -1501,6 +1561,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
131 changes: 131 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,137 @@ describe('Session', () => {
});
});

describe('verifyWithPasskey()', () => {
const mockPublicKeyCredential = {
id: 'credential_123',
rawId: new ArrayBuffer(32),
response: {
authenticatorData: new ArrayBuffer(37),
clientDataJSON: new ArrayBuffer(121),
signature: new ArrayBuffer(64),
userHandle: null,
},
type: 'public-key',
};

const sessionJSON = {
status: 'active',
id: 'session_1',
object: 'session',
user: createUser({}),
last_active_organization_id: null,
actor: null,
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
} as SessionJSON;

let originalFetch: typeof BaseResource._fetch;

beforeEach(() => {
originalFetch = BaseResource._fetch;
BaseResource.clerk = clerkMock({
__internal_isWebAuthnSupported: vi.fn().mockReturnValue(true),
__internal_getPublicCredentials: vi.fn().mockResolvedValue({
publicKeyCredential: mockPublicKeyCredential,
error: null,
}),
} as any);
});

afterEach(() => {
BaseResource._fetch = originalFetch;
BaseResource.clerk = null as any;
});

it('runs the first-factor verification flow by default', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
response: {
id: 'sv_123',
first_factor_verification: { nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }) },
},
})
.mockResolvedValueOnce({
response: { id: 'sv_123', status: 'complete' },
});
BaseResource._fetch = mockFetch;

const session = new Session(sessionJSON);
await session.verifyWithPasskey();

expect(mockFetch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: 'POST',
path: '/client/sessions/session_1/verify/prepare_first_factor',
body: expect.objectContaining({ strategy: 'passkey' }),
}),
);
expect(mockFetch).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: 'POST',
path: '/client/sessions/session_1/verify/attempt_first_factor',
body: expect.objectContaining({
strategy: 'passkey',
publicKeyCredential: expect.any(String),
}),
}),
);
});

it('runs the second-factor verification flow when level is second_factor', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
response: {
id: 'sv_123',
second_factor_verification: { nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }) },
},
})
.mockResolvedValueOnce({
response: { id: 'sv_123', status: 'complete' },
});
BaseResource._fetch = mockFetch;

const session = new Session(sessionJSON);
await session.verifyWithPasskey({ level: 'second_factor' });

expect(mockFetch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: 'POST',
path: '/client/sessions/session_1/verify/prepare_second_factor',
body: expect.objectContaining({ strategy: 'passkey' }),
}),
);
expect(mockFetch).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: 'POST',
path: '/client/sessions/session_1/verify/attempt_second_factor',
body: expect.objectContaining({
strategy: 'passkey',
publicKeyCredential: expect.any(String),
}),
}),
);
});

it('rejects invalid verification levels without calling the API', async () => {
const mockFetch = vi.fn();
BaseResource._fetch = mockFetch;

const session = new Session(sessionJSON);
await expect(session.verifyWithPasskey({ level: 'third_factor' as any })).rejects.toThrow(
'not a valid verification level',
);

expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('touch()', () => {
let dispatchSpy: ReturnType<typeof vi.spyOn>;

Expand Down
Loading
Loading