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
7 changes: 7 additions & 0 deletions .changeset/honest-insects-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

[Experimental] Add support for sign-in with passkey to new APIs
72 changes: 72 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
SignInFutureEmailLinkSendParams,
SignInFutureFinalizeParams,
SignInFutureMFAPhoneCodeVerifyParams,
SignInFuturePasskeyParams,
SignInFuturePasswordParams,
SignInFuturePhoneCodeSendParams,
SignInFuturePhoneCodeVerifyParams,
Expand Down Expand Up @@ -979,6 +980,77 @@ class SignInFuture implements SignInFutureResource {
});
}

async passkey(params?: SignInFuturePasskeyParams): Promise<{ error: unknown }> {
const { flow } = params || {};

/**
* 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;
const isWebAuthnAutofillSupported =
SignIn.clerk.__internal_isWebAuthnAutofillSupported || isWebAuthnAutofillSupportedOnWindow;

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

return runAsyncResourceTask(this.resource, async () => {
if (flow === 'autofill' || flow === 'discoverable') {
Copy link
Member

Choose a reason for hiding this comment

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

Could we have called this.resource.authenticateWithPasskey() instead of duplicating ?

Is the only difference that we don't return the result ?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is following the same pattern we've used in other methods, which is not to create strong interdependencies between SignIn and SignInFuture, since the goal is to reverse the structure in the next clerk-js major. It also allows us to implement divergent behavior between the new APIs and the legacy APIs.

In this case the difference is that SignInFuture.passkey() returns a { error } object rather than the SignIn resource.

await this._create({ strategy: 'passkey' });
} else {
const passKeyFactor = this.supportedFirstFactors.find(f => f.strategy === 'passkey') as PasskeyFactor;

if (!passKeyFactor) {
clerkVerifyPasskeyCalledBeforeCreate();
}
await this.resource.__internal_basePost({
body: { strategy: 'passkey' },
action: 'prepare_first_factor',
});
}

const { nonce } = this.firstFactorVerification;
const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;

if (!publicKeyOptions) {
clerkMissingWebAuthnPublicKeyOptions('get');
}

let canUseConditionalUI = false;

if (flow === 'autofill') {
/**
* If autofill is not supported gracefully handle the result, we don't need to throw.
* The caller should always check this before calling this method.
*/
canUseConditionalUI = await isWebAuthnAutofillSupported();
}

// Invoke the navigator.create.get() method.
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: canUseConditionalUI,
});

if (!publicKeyCredential) {
throw error;
}

await this.resource.__internal_basePost({
body: {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)),
strategy: 'passkey',
},
action: 'attempt_first_factor',
});
});
}
Comment on lines +983 to +1052
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Harden error handling when WebAuthn returns no credential

Avoid throwing possibly undefined; provide a fallback error.

Apply:

-      // Invoke the navigator.create.get() method.
+      // Invoke navigator.credentials.get()
       const { publicKeyCredential, error } = await webAuthnGetCredential({
         publicKeyOptions,
         conditionalUI: canUseConditionalUI,
       });

       if (!publicKeyCredential) {
-        throw error;
+        throw (error ?? new Error('Failed to obtain WebAuthn credential'));
       }
  • Optional: simplify presence check; no need to cast when only checking existence.
-      const passKeyFactor = this.supportedFirstFactors.find(f => f.strategy === 'passkey') as PasskeyFactor;
-      if (!passKeyFactor) {
+      const hasPasskeyFactor = this.supportedFirstFactors.some(f => f.strategy === 'passkey');
+      if (!hasPasskeyFactor) {
         clerkVerifyPasskeyCalledBeforeCreate();
       }
  • Optional: dedupe with SignIn.authenticateWithPasskey by extracting a shared helper to reduce drift.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async passkey(params?: SignInFuturePasskeyParams): Promise<{ error: unknown }> {
const { flow } = params || {};
/**
* 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;
const isWebAuthnAutofillSupported =
SignIn.clerk.__internal_isWebAuthnAutofillSupported || isWebAuthnAutofillSupportedOnWindow;
if (!isWebAuthnSupported()) {
throw new ClerkWebAuthnError('Passkeys are not supported', {
code: 'passkey_not_supported',
});
}
return runAsyncResourceTask(this.resource, async () => {
if (flow === 'autofill' || flow === 'discoverable') {
await this._create({ strategy: 'passkey' });
} else {
const passKeyFactor = this.supportedFirstFactors.find(f => f.strategy === 'passkey') as PasskeyFactor;
if (!passKeyFactor) {
clerkVerifyPasskeyCalledBeforeCreate();
}
await this.resource.__internal_basePost({
body: { strategy: 'passkey' },
action: 'prepare_first_factor',
});
}
const { nonce } = this.firstFactorVerification;
const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;
if (!publicKeyOptions) {
clerkMissingWebAuthnPublicKeyOptions('get');
}
let canUseConditionalUI = false;
if (flow === 'autofill') {
/**
* If autofill is not supported gracefully handle the result, we don't need to throw.
* The caller should always check this before calling this method.
*/
canUseConditionalUI = await isWebAuthnAutofillSupported();
}
// Invoke the navigator.create.get() method.
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: canUseConditionalUI,
});
if (!publicKeyCredential) {
throw error;
}
await this.resource.__internal_basePost({
body: {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)),
strategy: 'passkey',
},
action: 'attempt_first_factor',
});
});
}
async passkey(params?: SignInFuturePasskeyParams): Promise<{ error: unknown }> {
const { flow } = params || {};
/**
* 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;
const isWebAuthnAutofillSupported =
SignIn.clerk.__internal_isWebAuthnAutofillSupported || isWebAuthnAutofillSupportedOnWindow;
if (!isWebAuthnSupported()) {
throw new ClerkWebAuthnError('Passkeys are not supported', {
code: 'passkey_not_supported',
});
}
return runAsyncResourceTask(this.resource, async () => {
if (flow === 'autofill' || flow === 'discoverable') {
await this._create({ strategy: 'passkey' });
} else {
const hasPasskeyFactor = this.supportedFirstFactors.some(f => f.strategy === 'passkey');
if (!hasPasskeyFactor) {
clerkVerifyPasskeyCalledBeforeCreate();
}
await this.resource.__internal_basePost({
body: { strategy: 'passkey' },
action: 'prepare_first_factor',
});
}
const { nonce } = this.firstFactorVerification;
const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;
if (!publicKeyOptions) {
clerkMissingWebAuthnPublicKeyOptions('get');
}
let canUseConditionalUI = false;
if (flow === 'autofill') {
/**
* If autofill is not supported gracefully handle the result, we don't need to throw.
* The caller should always check this before calling this method.
*/
canUseConditionalUI = await isWebAuthnAutofillSupported();
}
// Invoke navigator.credentials.get()
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: canUseConditionalUI,
});
if (!publicKeyCredential) {
throw (error ?? new Error('Failed to obtain WebAuthn credential'));
}
await this.resource.__internal_basePost({
body: {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)),
strategy: 'passkey',
},
action: 'attempt_first_factor',
});
});
}


async sendMFAPhoneCode(): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const phoneCodeFactor = this.resource.supportedSecondFactors?.find(f => f.strategy === 'phone_code');
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/stateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class StateProxy implements State {
'verifyBackupCode',
] as const),
ticket: this.gateMethod(target, 'ticket'),
passkey: this.gateMethod(target, 'passkey'),
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add JSDoc documentation for the new public API method.

The passkey method follows the established pattern and is correctly implemented. However, public API methods should include JSDoc documentation per the coding guidelines.

Consider adding JSDoc documentation above the method, similar to how other public methods might be documented:

+        /**
+         * Initiates passkey-based authentication flow.
+         * @param params - Optional passkey authentication parameters
+         * @returns Promise that resolves with authentication result
+         * @experimental This API is experimental and subject to change
+         */
         passkey: this.gateMethod(target, 'passkey'),

As per coding guidelines.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
passkey: this.gateMethod(target, 'passkey'),
/**
* Initiates passkey-based authentication flow.
* @param params - Optional passkey authentication parameters
* @returns Promise that resolves with authentication result
* @experimental This API is experimental and subject to change
*/
passkey: this.gateMethod(target, 'passkey'),
🤖 Prompt for AI Agents
In packages/react/src/stateProxy.ts around line 134, the new public API method
`passkey: this.gateMethod(target, 'passkey'),` is missing JSDoc; add a JSDoc
block immediately above that line following the project’s existing pattern for
public methods — include a short description of what `passkey` does, annotate
parameters (e.g., target and any inputs if applicable), the return type, and any
relevant tags like @public or @since to match neighboring method docs so the
method is properly documented for consumers.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify underlying implementation and test coverage.

Ensure that:

  1. The TypeScript types for SignInFuturePasskeyParams are properly defined and exported from @clerk/types
  2. The underlying SignInFuture.passkey() method is implemented in packages/clerk-js/src/core/resources/SignIn.ts
  3. Tests are added for the new passkey functionality

Run the following script to verify the implementation:


🏁 Script executed:

#!/bin/bash
# Verify passkey implementation and types

echo "=== Checking type definitions ==="
# Check if SignInFuturePasskeyParams type exists
rg -nP --type=ts 'SignInFuturePasskeyParams' packages/types/

echo -e "\n=== Checking SignIn implementation ==="
# Check if passkey method exists in SignInFuture
ast-grep --pattern $'class $_ {
  $$$
  passkey($$$) {
    $$$
  }
  $$$
}'

echo -e "\n=== Checking for test files ==="
# Look for test files related to passkey
fd -e test.ts -e test.tsx -e spec.ts -e spec.tsx | xargs rg -l 'passkey'

echo -e "\n=== Checking stateProxy test coverage ==="
# Check if stateProxy tests exist
fd 'stateProxy.*test|stateProxy.*spec' packages/react/

Length of output: 789


Please verify that SignInFuture.passkey() is implemented in the core resources:


🏁 Script executed:

#!/bin/bash
echo "=== Inspecting SignInFuture implementation in SignIn.ts ==="
# Locate the SignInFuture class
rg -nP 'class SignInFuture' -C3 packages/clerk-js/src/core/resources/SignIn.ts
# Search for the passkey() method
rg -nP '\bpasskey\s*\(' -n packages/clerk-js/src/core/resources/SignIn.ts

Length of output: 497


Add missing tests for passkey proxy in stateProxy.ts
No tests currently cover the new passkey entry in packages/react/src/stateProxy.ts; add unit tests to validate its proxy behavior.

🤖 Prompt for AI Agents
In packages/react/src/stateProxy.ts around line 134, add unit tests covering the
newly added passkey proxy entry: create a test that stubs/mocks gateMethod and
asserts it's called with (target, 'passkey') when accessing state.passkey; add
tests that verify passkey read returns the value gateMethod returns, that
writing to state.passkey invokes the proxy/set behavior (or throws) consistent
with other proxied keys, and include edge cases (undefined target, absent
gateMethod) to mirror existing proxy tests for other keys; place tests alongside
existing stateProxy tests, use the same test helpers/mocks and assertions for
consistency.

web3: this.gateMethod(target, 'web3'),
},
};
Expand Down
22 changes: 20 additions & 2 deletions packages/types/src/signInFuture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SetActiveNavigate } from './clerk';
import type { PhoneCodeChannel } from './phoneCodeChannel';
import type { SignInFirstFactor, SignInSecondFactor, SignInStatus, UserData } from './signInCommon';
import type { OAuthStrategy, Web3Strategy } from './strategies';
import type { OAuthStrategy, PasskeyStrategy, Web3Strategy } from './strategies';
import type { VerificationResource } from './verification';

export interface SignInFutureCreateParams {
Expand All @@ -14,7 +14,7 @@ export interface SignInFutureCreateParams {
* The first factor verification strategy to use in the sign-in flow. Depends on the `identifier` value. Each
* authentication identifier supports different verification strategies.
*/
strategy?: OAuthStrategy | 'saml' | 'enterprise_sso';
strategy?: OAuthStrategy | 'saml' | 'enterprise_sso' | PasskeyStrategy;
/**
* The full URL or path that the OAuth provider should redirect to after successful authorization on their part.
*/
Expand Down Expand Up @@ -215,6 +215,16 @@ export interface SignInFutureWeb3Params {
strategy: Web3Strategy;
}

export interface SignInFuturePasskeyParams {
/**
* The flow to use for the passkey sign-in.
*
* - `'autofill'`: The client prompts your users to select a passkey before they interact with your app.
* - `'discoverable'`: The client requires the user to interact with the client.
*/
flow?: 'autofill' | 'discoverable';
}

export interface SignInFutureFinalizeParams {
navigate?: SetActiveNavigate;
}
Expand Down Expand Up @@ -432,6 +442,14 @@ export interface SignInFutureResource {
*/
web3: (params: SignInFutureWeb3Params) => Promise<{ error: unknown }>;

/**
* Initiates a passkey-based authentication flow, enabling users to authenticate using a previously
* registered passkey. When called without parameters, this method requires a prior call to
* `SignIn.create({ strategy: 'passkey' })` to initialize the sign-in context. This pattern is particularly useful in
* scenarios where the authentication strategy needs to be determined dynamically at runtime.
*/
passkey: (params?: SignInFuturePasskeyParams) => Promise<{ error: unknown }>;

/**
* Used to convert a sign-in with `status === 'complete'` into an active session. Will cause anything observing the
* session state (such as the `useUser()` hook) to update automatically.
Expand Down
Loading