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] 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 */