From 7647ea9255c4ba186385a6c12b26bfac2f0ac460 Mon Sep 17 00:00:00 2001 From: ketanchexy <262376399+ketanchexy@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:22:09 -0500 Subject: [PATCH 1/6] feat(expo-passkeys): support preferImmediatelyAvailableCredentials for passkey sign-in --- .../expo-passkeys-prefer-immediately-available.md | 8 ++++++++ packages/clerk-js/src/core/clerk.ts | 1 + packages/clerk-js/src/core/resources/SignIn.ts | 7 +++++-- .../clerkexpopasskeys/ClerkExpoPasskeysModule.kt | 4 ++-- .../modules/clerkexpopasskeys/CredentialManager.kt | 6 +++++- packages/expo-passkeys/ios/AccountManager.swift | 8 ++++++-- .../expo-passkeys/ios/ClerkExpoPasskeysModule.swift | 4 ++-- packages/expo-passkeys/src/index.ts | 12 ++++++++++-- .../src/provider/singleton/createClerkInstance.ts | 4 +++- packages/expo/src/provider/singleton/types.ts | 2 ++ packages/shared/src/internal/clerk-js/passkeys.ts | 1 + packages/shared/src/types/signInCommon.ts | 4 ++++ packages/shared/src/types/signInFuture.ts | 4 ++++ 13 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 .changeset/expo-passkeys-prefer-immediately-available.md diff --git a/.changeset/expo-passkeys-prefer-immediately-available.md b/.changeset/expo-passkeys-prefer-immediately-available.md new file mode 100644 index 00000000000..adcb60c87cf --- /dev/null +++ b/.changeset/expo-passkeys-prefer-immediately-available.md @@ -0,0 +1,8 @@ +--- +'@clerk/expo-passkeys': minor +'@clerk/expo': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add a `preferImmediatelyAvailableCredentials` option to `signIn.authenticateWithPasskey()` and `signIn.passkey()`. In Expo apps on iOS and Android, it limits passkey sign-in to passkeys already on the device. When there are none, the request fails with `passkey_registration_cancelled` and the system's "use a passkey from another device" sheet doesn't appear, so an app can prompt for a passkey automatically on sign-in. Browsers ignore it. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9ada08772fe..83b41df7f59 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -361,6 +361,7 @@ export class Clerk implements ClerkInterface { publicKeyOptions, }: { publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + preferImmediatelyAvailableCredentials?: boolean; }) => Promise>) | undefined; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 83caee52941..ee3f2e02cbe 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -665,7 +665,7 @@ export class SignIn extends BaseResource implements SignInResource { * @returns The updated `SignIn` resource. */ public authenticateWithPasskey = async (params?: AuthenticateWithPasskeyParams): Promise => { - const { flow } = params || {}; + const { flow, preferImmediatelyAvailableCredentials } = params || {}; /** * The UI should always prevent from this method being called if WebAuthn is not supported. @@ -699,6 +699,7 @@ export class SignIn extends BaseResource implements SignInResource { const { publicKeyCredential, error } = await webAuthnGetCredential({ publicKeyOptions: secondFactorPublicKeyOptions, conditionalUI: false, + preferImmediatelyAvailableCredentials, }); if (!publicKeyCredential) { throw error; @@ -748,6 +749,7 @@ export class SignIn extends BaseResource implements SignInResource { const { publicKeyCredential, error } = await webAuthnGetCredential({ publicKeyOptions, conditionalUI: canUseConditionalUI, + preferImmediatelyAvailableCredentials, }); if (!publicKeyCredential) { @@ -1578,7 +1580,7 @@ class SignInFuture implements SignInFutureResource { } async passkey(params?: SignInFuturePasskeyParams): Promise<{ error: ClerkError | null }> { - const { flow } = params || {}; + const { flow, preferImmediatelyAvailableCredentials } = params || {}; /** * The UI should always prevent from this method being called if WebAuthn is not supported. @@ -1633,6 +1635,7 @@ class SignInFuture implements SignInFutureResource { const { publicKeyCredential, error } = await webAuthnGetCredential({ publicKeyOptions, conditionalUI: canUseConditionalUI, + preferImmediatelyAvailableCredentials, }); if (!publicKeyCredential) { diff --git a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt index a03c4e61120..fab66684651 100644 --- a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt +++ b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt @@ -29,11 +29,11 @@ class ClerkExpoPasskeysModule : Module() { } - AsyncFunction("get") { request: String, promise: Promise -> + AsyncFunction("get") { request: String, preferImmediatelyAvailableCredentials: Boolean, promise: Promise -> mCoroutine.launch { try { - val response = getPasskey(request, appContext) + val response = getPasskey(request, preferImmediatelyAvailableCredentials, appContext) promise.resolve(response) } catch (e: GetCredentialException) { handleGetFailure(e, promise) diff --git a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/CredentialManager.kt b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/CredentialManager.kt index 8c727f0734f..ee403bf3a24 100644 --- a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/CredentialManager.kt +++ b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/CredentialManager.kt @@ -22,12 +22,16 @@ suspend fun createPasskey( suspend fun getPasskey( request: String, + preferImmediatelyAvailableCredentials: Boolean, appContext: AppContext, ): String? { val credentialManager = CredentialManager.create(appContext.reactContext?.applicationContext!!) val getCredentialRequest = - GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request))) + GetCredentialRequest( + credentialOptions = listOf(GetPublicKeyCredentialOption(request)), + preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials, + ) val result = appContext.activityProvider?.currentActivity?.let { credentialManager.getCredential(it, getCredentialRequest) diff --git a/packages/expo-passkeys/ios/AccountManager.swift b/packages/expo-passkeys/ios/AccountManager.swift index 9dfcbede8d4..198e0071b11 100644 --- a/packages/expo-passkeys/ios/AccountManager.swift +++ b/packages/expo-passkeys/ios/AccountManager.swift @@ -58,7 +58,7 @@ class AccountManager: NSObject, ASAuthorizationControllerPresentationContextProv } - func getPasskey(challengeBase64URL: String, rpId: String, promise: Promise) { + func getPasskey(challengeBase64URL: String, rpId: String, preferImmediatelyAvailableCredentials: Bool, promise: Promise) { let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId) let challenge = dataFromBase64URL(base64urlString: challengeBase64URL)! @@ -68,7 +68,11 @@ class AccountManager: NSObject, ASAuthorizationControllerPresentationContextProv let authController = ASAuthorizationController(authorizationRequests: [ assertionRequest ] ) authController.delegate = self authController.presentationContextProvider = self - authController.performRequests() + if preferImmediatelyAvailableCredentials { + authController.performRequests(options: .preferImmediatelyAvailableCredentials) + } else { + authController.performRequests() + } self.authCallback = { result,error in if (error != nil) { diff --git a/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift b/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift index d845a2220ad..b30f2c33366 100644 --- a/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift +++ b/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift @@ -11,8 +11,8 @@ public class ClerkExpoPasskeysModule: Module { self.credentialManager.createPasskey(challengeBase64: challenge, rpId:rpId, displayName:displayName, userIdBase64: userId, excludeCredentialIdsBase64: excludeCredentialIds, promise: promise) } - AsyncFunction("get") { (challenge: String, rpId:String, promise: Promise) in - self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, promise: promise) + AsyncFunction("get") { (challenge: String, rpId:String, preferImmediatelyAvailableCredentials: Bool, promise: Promise) in + self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials, promise: promise) } AsyncFunction("autofill") { (challenge: String, rpId:String, promise: Promise) in diff --git a/packages/expo-passkeys/src/index.ts b/packages/expo-passkeys/src/index.ts index 24bfc82e521..ab7dfa7ea30 100644 --- a/packages/expo-passkeys/src/index.ts +++ b/packages/expo-passkeys/src/index.ts @@ -115,8 +115,10 @@ const makeSerializedGetResponse = ( export async function get({ publicKeyOptions, + preferImmediatelyAvailableCredentials = false, }: { publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + preferImmediatelyAvailableCredentials?: boolean; }): Promise> { if (!publicKeyOptions) { throw new Error('publicKeyCredential has not been provided'); @@ -129,8 +131,14 @@ export async function get({ }; const getPasskeyModule = Platform.select({ - android: async () => ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential)), - ios: async () => ClerkExpoPasskeys.get(serializedPublicCredential.challenge, serializedPublicCredential.rpId), + android: async () => + ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential), preferImmediatelyAvailableCredentials), + ios: async () => + ClerkExpoPasskeys.get( + serializedPublicCredential.challenge, + serializedPublicCredential.rpId, + preferImmediatelyAvailableCredentials, + ), default: null, }); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 36c72dfd148..dce0f1740da 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -167,11 +167,13 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { // @ts-expect-error - This is an internal API __internal_clerk.__internal_getPublicCredentials = ({ publicKeyOptions, + preferImmediatelyAvailableCredentials, }: { publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + preferImmediatelyAvailableCredentials?: boolean; }) => { return options?.__experimental_passkeys?.get - ? options?.__experimental_passkeys?.get({ publicKeyOptions }) + ? options?.__experimental_passkeys?.get({ publicKeyOptions, preferImmediatelyAvailableCredentials }) : errorThrower.throw('get() for passkeys is missing'); }; // @ts-expect-error - This is an internal API diff --git a/packages/expo/src/provider/singleton/types.ts b/packages/expo/src/provider/singleton/types.ts index ea83ee912d3..4766bd0314b 100644 --- a/packages/expo/src/provider/singleton/types.ts +++ b/packages/expo/src/provider/singleton/types.ts @@ -32,8 +32,10 @@ export type BuildClerkOptions = { */ get: ({ publicKeyOptions, + preferImmediatelyAvailableCredentials, }: { publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + preferImmediatelyAvailableCredentials?: boolean; }) => Promise>; create: ( publicKeyCredential: PublicKeyCredentialCreationOptionsWithoutExtensions, diff --git a/packages/shared/src/internal/clerk-js/passkeys.ts b/packages/shared/src/internal/clerk-js/passkeys.ts index ca6851b706d..71f0c7ee904 100644 --- a/packages/shared/src/internal/clerk-js/passkeys.ts +++ b/packages/shared/src/internal/clerk-js/passkeys.ts @@ -91,6 +91,7 @@ async function webAuthnGetCredential({ }: { publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; conditionalUI: boolean; + preferImmediatelyAvailableCredentials?: boolean; }): Promise { try { // Typescript types are not aligned with the spec. These type assertions are required to comply with the spec. diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 8f0d39e194c..7afc13ac65e 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -221,6 +221,10 @@ export type AuthenticateWithPasskeyParams = { * `passkey` among its supported second factors. */ flow?: 'autofill' | 'discoverable'; + /** + * Only offer passkeys already available on this device. When none are, the request fails with `passkey_registration_cancelled` instead of showing the system's "use a passkey from another device" sheet. Supported by the native Expo passkeys module on iOS 16+ and Android, and ignored in the browser. Defaults to `false`. + */ + preferImmediatelyAvailableCredentials?: boolean; }; export interface SignInStartEmailLinkFlowParams extends StartEmailLinkFlowParams { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 079e414e242..e743f8e7bb8 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -329,6 +329,10 @@ export interface SignInFuturePasskeyParams { * */ flow?: 'autofill' | 'discoverable'; + /** + * Only offer passkeys already available on this device. When none are, the request fails with `passkey_registration_cancelled` instead of showing the system's "use a passkey from another device" sheet. Supported by the native Expo passkeys module on iOS 16+ and Android, and ignored in the browser. Defaults to `false`. + */ + preferImmediatelyAvailableCredentials?: boolean; } /** @generateWithEmptyComment */ From 41bca7a15c39e346ed1b115db91bd67899fa8e44 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 11 Nov 2026 10:45:03 -0800 Subject: [PATCH 2/6] fix(expo-passkeys): guard the iOS 16 immediate-credentials option Co-authored-by: ketanchexy <262376399+ketanchexy@users.noreply.github.com> --- packages/expo-passkeys/ios/AccountManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expo-passkeys/ios/AccountManager.swift b/packages/expo-passkeys/ios/AccountManager.swift index 198e0071b11..efd3904fce2 100644 --- a/packages/expo-passkeys/ios/AccountManager.swift +++ b/packages/expo-passkeys/ios/AccountManager.swift @@ -68,7 +68,7 @@ class AccountManager: NSObject, ASAuthorizationControllerPresentationContextProv let authController = ASAuthorizationController(authorizationRequests: [ assertionRequest ] ) authController.delegate = self authController.presentationContextProvider = self - if preferImmediatelyAvailableCredentials { + if preferImmediatelyAvailableCredentials, #available(iOS 16.0, *) { authController.performRequests(options: .preferImmediatelyAvailableCredentials) } else { authController.performRequests() From 93928fb80adf6a609334db200a3344f883601b69 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 11 Nov 2026 10:45:37 -0800 Subject: [PATCH 3/6] fix(expo-passkeys): keep passkey sign-in working on binaries built before the new option Co-authored-by: ketanchexy <262376399+ketanchexy@users.noreply.github.com> --- .../modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt | 4 ++-- packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift | 4 ++-- packages/expo-passkeys/src/index.ts | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt index fab66684651..51726b919ce 100644 --- a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt +++ b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt @@ -29,11 +29,11 @@ class ClerkExpoPasskeysModule : Module() { } - AsyncFunction("get") { request: String, preferImmediatelyAvailableCredentials: Boolean, promise: Promise -> + AsyncFunction("get") { request: String, preferImmediatelyAvailableCredentials: Boolean?, promise: Promise -> mCoroutine.launch { try { - val response = getPasskey(request, preferImmediatelyAvailableCredentials, appContext) + val response = getPasskey(request, preferImmediatelyAvailableCredentials ?: false, appContext) promise.resolve(response) } catch (e: GetCredentialException) { handleGetFailure(e, promise) diff --git a/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift b/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift index b30f2c33366..7282e3773c3 100644 --- a/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift +++ b/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift @@ -11,8 +11,8 @@ public class ClerkExpoPasskeysModule: Module { self.credentialManager.createPasskey(challengeBase64: challenge, rpId:rpId, displayName:displayName, userIdBase64: userId, excludeCredentialIdsBase64: excludeCredentialIds, promise: promise) } - AsyncFunction("get") { (challenge: String, rpId:String, preferImmediatelyAvailableCredentials: Bool, promise: Promise) in - self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials, promise: promise) + AsyncFunction("get") { (challenge: String, rpId:String, preferImmediatelyAvailableCredentials: Bool?, promise: Promise) in + self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials ?? false, promise: promise) } AsyncFunction("autofill") { (challenge: String, rpId:String, promise: Promise) in diff --git a/packages/expo-passkeys/src/index.ts b/packages/expo-passkeys/src/index.ts index ab7dfa7ea30..efceb3b2664 100644 --- a/packages/expo-passkeys/src/index.ts +++ b/packages/expo-passkeys/src/index.ts @@ -115,7 +115,7 @@ const makeSerializedGetResponse = ( export async function get({ publicKeyOptions, - preferImmediatelyAvailableCredentials = false, + preferImmediatelyAvailableCredentials, }: { publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; preferImmediatelyAvailableCredentials?: boolean; @@ -130,14 +130,15 @@ export async function get({ challenge: arrayBufferToBase64Url(publicKeyOptions.challenge), }; + const immediateCredentialsArgs = preferImmediatelyAvailableCredentials ? [true] : []; + const getPasskeyModule = Platform.select({ - android: async () => - ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential), preferImmediatelyAvailableCredentials), + android: async () => ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential), ...immediateCredentialsArgs), ios: async () => ClerkExpoPasskeys.get( serializedPublicCredential.challenge, serializedPublicCredential.rpId, - preferImmediatelyAvailableCredentials, + ...immediateCredentialsArgs, ), default: null, }); From e8bfc62ddb364edad9c38d8e6dd3d61b53708950 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 11 Nov 2026 10:45:45 -0800 Subject: [PATCH 4/6] docs(shared): describe how immediate-credential passkey requests fail Co-authored-by: ketanchexy <262376399+ketanchexy@users.noreply.github.com> --- .changeset/expo-passkeys-prefer-immediately-available.md | 2 +- packages/shared/src/types/signInCommon.ts | 2 +- packages/shared/src/types/signInFuture.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/expo-passkeys-prefer-immediately-available.md b/.changeset/expo-passkeys-prefer-immediately-available.md index adcb60c87cf..32d8a8770c7 100644 --- a/.changeset/expo-passkeys-prefer-immediately-available.md +++ b/.changeset/expo-passkeys-prefer-immediately-available.md @@ -5,4 +5,4 @@ '@clerk/shared': minor --- -Add a `preferImmediatelyAvailableCredentials` option to `signIn.authenticateWithPasskey()` and `signIn.passkey()`. In Expo apps on iOS and Android, it limits passkey sign-in to passkeys already on the device. When there are none, the request fails with `passkey_registration_cancelled` and the system's "use a passkey from another device" sheet doesn't appear, so an app can prompt for a passkey automatically on sign-in. Browsers ignore it. +Add a `preferImmediatelyAvailableCredentials` option to `signIn.authenticateWithPasskey()` and `signIn.passkey()`. With `@clerk/expo-passkeys` on iOS 16+ and Android, it limits sign-in to passkeys already on the device, and the request fails without showing the "use a passkey from another device" sheet when there are none. This lets an app prompt for a passkey automatically when its sign-in screen opens. Browsers ignore it. diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 7afc13ac65e..5541ad200d9 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -222,7 +222,7 @@ export type AuthenticateWithPasskeyParams = { */ flow?: 'autofill' | 'discoverable'; /** - * Only offer passkeys already available on this device. When none are, the request fails with `passkey_registration_cancelled` instead of showing the system's "use a passkey from another device" sheet. Supported by the native Expo passkeys module on iOS 16+ and Android, and ignored in the browser. Defaults to `false`. + * Only offer passkeys that are already on the device. When there are none, the request fails without showing the system's "use a passkey from another device" sheet. Supported by `@clerk/expo-passkeys` on iOS 16+ and Android. Browsers ignore it. Defaults to `false`. */ preferImmediatelyAvailableCredentials?: boolean; }; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index e743f8e7bb8..719b2065e19 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -330,7 +330,7 @@ export interface SignInFuturePasskeyParams { */ flow?: 'autofill' | 'discoverable'; /** - * Only offer passkeys already available on this device. When none are, the request fails with `passkey_registration_cancelled` instead of showing the system's "use a passkey from another device" sheet. Supported by the native Expo passkeys module on iOS 16+ and Android, and ignored in the browser. Defaults to `false`. + * Only offer passkeys that are already on the device. When there are none, the request fails without showing the system's "use a passkey from another device" sheet. Supported by `@clerk/expo-passkeys` on iOS 16+ and Android. Browsers ignore it. Defaults to `false`. */ preferImmediatelyAvailableCredentials?: boolean; } From e1e472d395f50338d16fbb0718e06c609635ff9e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 11 Nov 2026 10:47:10 -0800 Subject: [PATCH 5/6] test(clerk-js,expo): cover forwarding of preferImmediatelyAvailableCredentials Co-authored-by: ketanchexy <262376399+ketanchexy@users.noreply.github.com> --- .../core/resources/__tests__/SignIn.test.ts | 139 ++++++++++++++++++ .../__tests__/createClerkInstance.test.ts | 28 ++++ 2 files changed, 167 insertions(+) diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 53a50f0baa0..720b43dfc81 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1020,6 +1020,93 @@ describe('SignIn', () => { }), ); }); + + it('forwards preferImmediatelyAvailableCredentials to the credential request in the discoverable flow', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: mockPublicKeyCredential, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_456', + status: 'needs_first_factor', + first_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_456', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.authenticateWithPasskey({ flow: 'discoverable', preferImmediatelyAvailableCredentials: true }); + + expect(mockWebAuthnGetCredential).toHaveBeenCalledWith( + expect.objectContaining({ preferImmediatelyAvailableCredentials: true }), + ); + }); + + it('forwards preferImmediatelyAvailableCredentials to the credential request for a passkey second factor', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: mockPublicKeyCredential, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }], + second_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }, { strategy: 'totp' }], + } as any); + await signIn.authenticateWithPasskey({ preferImmediatelyAvailableCredentials: true }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + path: '/client/sign_ins/signin_123/prepare_second_factor', + }), + ); + expect(mockWebAuthnGetCredential).toHaveBeenCalledWith( + expect.objectContaining({ preferImmediatelyAvailableCredentials: true }), + ); + }); }); describe('trusted_device strategy', () => { @@ -2593,6 +2680,58 @@ describe('SignIn', () => { expect(mockWebAuthnGetCredential).toHaveBeenCalled(); }); + it('forwards preferImmediatelyAvailableCredentials to the credential request', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: { + 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', + }, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.__internal_future.passkey({ flow: 'discoverable', preferImmediatelyAvailableCredentials: true }); + + expect(mockWebAuthnGetCredential).toHaveBeenCalledWith( + expect.objectContaining({ preferImmediatelyAvailableCredentials: true }), + ); + }); + it('throws error when WebAuthn is not supported', async () => { const mockIsWebAuthnSupported = vi.fn().mockReturnValue(false); diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 18886229d6c..b877a44acdd 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -223,6 +223,34 @@ describe('createClerkInstance', () => { expect(get).toHaveBeenNthCalledWith(2, { publicKeyOptions: sessionSecondFactorOptions }); }); + test('forwards preferImmediatelyAvailableCredentials to the native passkey adapter', async () => { + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const get = vi.fn().mockResolvedValue({ publicKeyCredential: null, error: null }); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_passkeys: { + get, + create: vi.fn(), + isSupported: vi.fn(() => true), + isAutoFillSupported: vi.fn(() => Promise.resolve(false)), + }, + }) as unknown as MockClerk & { + __internal_getPublicCredentials: (params: { + publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + preferImmediatelyAvailableCredentials?: boolean; + }) => Promise; + }; + const publicKeyOptions = { + challenge: new Uint8Array([1]).buffer, + rpId: 'example.com', + } as PublicKeyCredentialRequestOptionsWithoutExtensions; + + await clerk.__internal_getPublicCredentials({ publicKeyOptions, preferImmediatelyAvailableCredentials: true }); + + expect(get).toHaveBeenCalledWith({ publicKeyOptions, preferImmediatelyAvailableCredentials: true }); + }); + test('recreates the singleton when proxyUrl changes', async () => { const createClerkInstance = await loadCreateClerkInstance(); const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); From ea5b8157aa5a103c397e0c28d059235237e028dd Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 11 Nov 2026 11:16:48 -0800 Subject: [PATCH 6/6] fix(expo-passkeys): keep the shipped get signature and add getWithOptions get keeps its shipped two-argument (iOS) and one-argument (Android) signature, and the option goes through a new getWithOptions that JS calls only when the option is on and the binary has it. Co-authored-by: ketanchexy <262376399+ketanchexy@users.noreply.github.com> --- .../ClerkExpoPasskeysModule.kt | 31 +++++++++++++------ .../ios/ClerkExpoPasskeysModule.swift | 12 +++++-- packages/expo-passkeys/src/index.ts | 14 ++++----- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt index 51726b919ce..3946a31b7cc 100644 --- a/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt +++ b/packages/expo-passkeys/android/src/main/java/expo/modules/clerkexpopasskeys/ClerkExpoPasskeysModule.kt @@ -5,10 +5,16 @@ import androidx.credentials.exceptions.GetCredentialException import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +class GetPasskeyOptionsArgument : Record { + @Field var preferImmediatelyAvailableCredentials: Boolean = false +} + class ClerkExpoPasskeysModule : Module() { private val mCoroutine = CoroutineScope(Dispatchers.Default) @@ -29,18 +35,25 @@ class ClerkExpoPasskeysModule : Module() { } - AsyncFunction("get") { request: String, preferImmediatelyAvailableCredentials: Boolean?, promise: Promise -> + AsyncFunction("get") { request: String, promise: Promise -> + launchGet(request, false, promise) + } - mCoroutine.launch { - try { - val response = getPasskey(request, preferImmediatelyAvailableCredentials ?: false, appContext) - promise.resolve(response) - } catch (e: GetCredentialException) { - handleGetFailure(e, promise) - } - } + AsyncFunction("getWithOptions") { request: String, options: GetPasskeyOptionsArgument, promise: Promise -> + launchGet(request, options.preferImmediatelyAvailableCredentials, promise) } } + + private fun launchGet(request: String, preferImmediatelyAvailableCredentials: Boolean, promise: Promise) { + mCoroutine.launch { + try { + val response = getPasskey(request, preferImmediatelyAvailableCredentials, appContext) + promise.resolve(response) + } catch (e: GetCredentialException) { + handleGetFailure(e, promise) + } + } + } } diff --git a/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift b/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift index 7282e3773c3..a9f773ad1f1 100644 --- a/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift +++ b/packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift @@ -1,5 +1,9 @@ import ExpoModulesCore +struct GetPasskeyOptions: Record { + @Field var preferImmediatelyAvailableCredentials: Bool = false +} + @available(iOS 15.0, *) public class ClerkExpoPasskeysModule: Module { let credentialManager = AccountManager() @@ -11,8 +15,12 @@ public class ClerkExpoPasskeysModule: Module { self.credentialManager.createPasskey(challengeBase64: challenge, rpId:rpId, displayName:displayName, userIdBase64: userId, excludeCredentialIdsBase64: excludeCredentialIds, promise: promise) } - AsyncFunction("get") { (challenge: String, rpId:String, preferImmediatelyAvailableCredentials: Bool?, promise: Promise) in - self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials ?? false, promise: promise) + AsyncFunction("get") { (challenge: String, rpId:String, promise: Promise) in + self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: false, promise: promise) + } + + AsyncFunction("getWithOptions") { (challenge: String, rpId: String, options: GetPasskeyOptions, promise: Promise) in + self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: options.preferImmediatelyAvailableCredentials, promise: promise) } AsyncFunction("autofill") { (challenge: String, rpId:String, promise: Promise) in diff --git a/packages/expo-passkeys/src/index.ts b/packages/expo-passkeys/src/index.ts index efceb3b2664..ece4fd962ac 100644 --- a/packages/expo-passkeys/src/index.ts +++ b/packages/expo-passkeys/src/index.ts @@ -130,16 +130,14 @@ export async function get({ challenge: arrayBufferToBase64Url(publicKeyOptions.challenge), }; - const immediateCredentialsArgs = preferImmediatelyAvailableCredentials ? [true] : []; + const callGet = (...args: unknown[]) => + preferImmediatelyAvailableCredentials && ClerkExpoPasskeys.getWithOptions + ? ClerkExpoPasskeys.getWithOptions(...args, { preferImmediatelyAvailableCredentials: true }) + : ClerkExpoPasskeys.get(...args); const getPasskeyModule = Platform.select({ - android: async () => ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential), ...immediateCredentialsArgs), - ios: async () => - ClerkExpoPasskeys.get( - serializedPublicCredential.challenge, - serializedPublicCredential.rpId, - ...immediateCredentialsArgs, - ), + android: async () => callGet(JSON.stringify(serializedPublicCredential)), + ios: async () => callGet(serializedPublicCredential.challenge, serializedPublicCredential.rpId), default: null, });