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
8 changes: 8 additions & 0 deletions .changeset/passkey-second-factor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/shared': minor
'@clerk/clerk-js': minor
'@clerk/ui': minor
'@clerk/localizations': 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, and the prebuilt `<SignIn/>` and `<UserVerification/>` flows offer the passkey alongside the enrolled second factors.
34 changes: 30 additions & 4 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 Down Expand Up @@ -316,10 +317,16 @@ export class Session extends BaseResource implements SessionResource {
return new SessionVerification(json);
};

verifyWithPasskey = async (): Promise<SessionVerificationResource> => {
const prepareResponse = await this.prepareFirstFactorVerification({ strategy: 'passkey' });
verifyWithPasskey = async (params?: SessionVerifyWithPasskeyParams): Promise<SessionVerificationResource> => {
const level = params?.level || 'first_factor';

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

const { nonce = null } =
level === 'second_factor' ? prepareResponse.secondFactorVerification : prepareResponse.firstFactorVerification;
Comment on lines +320 to +329

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject invalid verification levels instead of silently downgrading.

Line 321 uses ||, while Lines 324-329 treat every value except 'second_factor' as first factor. Invalid JavaScript/config input can therefore request step-up verification but receive lower-assurance first-factor verification. Use ?? for the default and explicitly reject values outside the two supported levels.

As per coding guidelines, “Validate all inputs” and “Follow OWASP security best practices.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/resources/Session.ts` around lines 320 - 329, The
verifyWithPasskey method silently treats invalid levels as first-factor
verification. Default level with nullish coalescing, then explicitly validate
that it is either 'first_factor' or 'second_factor' and reject any other value
before calling prepareFirstFactorVerification or
prepareSecondFactorVerification.

Source: Coding guidelines


📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the new public verification-level contract.

Add JSDoc covering params.level, its default, return value, and error behavior. This API change may also require Docs-team review.

As per coding guidelines, “All public APIs must be documented with JSDoc” and “Update documentation for API changes.” As per path instructions, public API JSDoc is customer-facing generated documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/resources/Session.ts` around lines 320 - 329,
Document the public verifyWithPasskey API with JSDoc directly above the
verifyWithPasskey method in Session, describing the params.level options and
default, the returned SessionVerificationResource, and possible error behavior;
flag the contract change for Docs-team review or accompanying generated
documentation updates.

Sources: Coding guidelines, Path instructions


/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
Expand Down Expand Up @@ -349,6 +356,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 +386,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
100 changes: 99 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 @@ -560,6 +572,42 @@ export class SignIn extends BaseResource implements SignInResource {
});
}

// When the sign-in already awaits its second factor, the passkey acts as
// the second factor: run the discrete prepare/attempt second-factor flow
// against the in-progress sign-in. `flow` is ignored here — autofill and
// discoverable are identifier-first concepts, and their `create()` call
// would discard the in-progress sign-in.
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',
});
}

Comment on lines +575 to +610

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the new second-factor behavior of authenticateWithPasskey.

The public method now reuses in-progress second-factor/client-trust sign-ins and ignores flow in that state. Add JSDoc describing these semantics and request Docs-team review.

As per coding guidelines, “All public APIs must be documented with JSDoc” and “Update documentation for API changes.” As per path instructions, changed public API JSDoc may affect generated Clerk Docs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/resources/SignIn.ts` around lines 575 - 610, Add
or update JSDoc for the public authenticateWithPasskey method to document that
needs_second_factor and needs_client_trust sign-ins reuse the in-progress
sign-in via the second-factor flow, and that the flow option is ignored in those
states. Include the API behavior change clearly and mark the documentation for
Docs-team review.

Sources: Coding guidelines, Path instructions

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 +825,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 +1550,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
119 changes: 119 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,125 @@ 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),
}),
}),
);
});
});

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

Expand Down
Loading
Loading