From 2ed98fe308a9e4a8acc1cc2fdbcb97fe402a780d Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Thu, 2 Apr 2026 15:40:21 +0200 Subject: [PATCH 1/7] feat(android): expose Credential Manager options and fix authenticator selection --- .../com/reactnativepasskey/PasskeyModule.kt | 110 +++++++++++++---- src/Passkey.ts | 111 +++++++++++------- src/PasskeyTypes.ts | 12 ++ src/index.ts | 4 + 4 files changed, 174 insertions(+), 63 deletions(-) diff --git a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt index 9c20377..b52a4c7 100644 --- a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt +++ b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt @@ -4,11 +4,13 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReadableMap import androidx.credentials.CredentialManager import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PrepareGetCredentialResponse import androidx.credentials.exceptions.* import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException @@ -17,17 +19,32 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import android.os.Build +import org.json.JSONObject + class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val mainScope = CoroutineScope(Dispatchers.Default) + private var preparedGetResponse: PrepareGetCredentialResponse? = null override fun getName(): String { return "Passkey" } @ReactMethod - fun create(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { + fun create(requestJson: String, options: ReadableMap, promise: Promise) { + val preferImmediatelyAvailable = options.getBoolean("preferImmediatelyAvailableCredentials") + val isConditional = options.getBoolean("isConditional") + val forcePlatformKey = options.getBoolean("forcePlatformKey") + val forceSecurityKey = options.getBoolean("forceSecurityKey") + + val adjustedJson = adjustAuthenticatorAttachment(requestJson, forcePlatformKey, forceSecurityKey) + val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest(requestJson) + val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( + requestJson = adjustedJson, + preferImmediatelyAvailableCredentials = preferImmediatelyAvailable, + isConditional = isConditional + ) mainScope.launch { try { @@ -43,6 +60,74 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } } + @ReactMethod + fun get(requestJson: String, options: ReadableMap, promise: Promise) { + val preferImmediatelyAvailable = options.getBoolean("preferImmediatelyAvailableCredentials") + + val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) + val getCredentialRequest = GetCredentialRequest( + credentialOptions = listOf(GetPublicKeyCredentialOption(requestJson)), + preferImmediatelyAvailableCredentials = preferImmediatelyAvailable + ) + + mainScope.launch { + try { + val storedPreparedResponse = preparedGetResponse + val result = if (storedPreparedResponse != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + preparedGetResponse = null + reactApplicationContext.currentActivity?.let { + credentialManager.getCredential(it, getCredentialRequest, storedPreparedResponse) + } + } else { + reactApplicationContext.currentActivity?.let { + credentialManager.getCredential(it, getCredentialRequest) + } + } + + val response = + result?.credential?.data?.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON") + promise.resolve(response) + } catch (e: GetCredentialException) { + val errorCode = handleAuthenticationException(e) + promise.reject(errorCode, errorCode) + } + } + } + + @ReactMethod + fun prepareGet(requestJson: String, promise: Promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + promise.resolve(null) + return + } + + val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) + val getCredentialRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))) + + mainScope.launch { + try { + preparedGetResponse = credentialManager.prepareGetCredential(getCredentialRequest) + promise.resolve(null) + } catch (e: GetCredentialException) { + val errorCode = handleAuthenticationException(e) + promise.reject(errorCode, errorCode) + } + } + } + + private fun adjustAuthenticatorAttachment( + requestJson: String, + forcePlatformKey: Boolean, + forceSecurityKey: Boolean + ): String { + if (!forcePlatformKey && !forceSecurityKey) return requestJson + val json = JSONObject(requestJson) + val authSelection = json.optJSONObject("authenticatorSelection") ?: JSONObject() + authSelection.put("authenticatorAttachment", if (forcePlatformKey) "platform" else "cross-platform") + json.put("authenticatorSelection", authSelection) + return json.toString() + } + private fun handleRegistrationException(e: CreateCredentialException): String { e.printStackTrace() when (e) { @@ -70,27 +155,6 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } } - @ReactMethod - fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { - val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val getCredentialRequest = - GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))) - - mainScope.launch { - try { - val result = - reactApplicationContext.currentActivity?.let { credentialManager.getCredential(it, getCredentialRequest) } - - val response = - result?.credential?.data?.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON") - promise.resolve(response) - } catch (e: GetCredentialException) { - val errorCode = handleAuthenticationException(e) - promise.reject(errorCode, errorCode) - } - } - } - private fun handleAuthenticationException(e: GetCredentialException): String { e.printStackTrace() when (e) { diff --git a/src/Passkey.ts b/src/Passkey.ts index 45318fe..41746f2 100644 --- a/src/Passkey.ts +++ b/src/Passkey.ts @@ -5,8 +5,10 @@ import { } from './PasskeyError'; import { Platform } from 'react-native'; import type { + PasskeyCreateOptions, PasskeyCreateRequest, PasskeyCreateResult, + PasskeyGetOptions, PasskeyGetRequest, PasskeyGetResult, } from './PasskeyTypes'; @@ -22,18 +24,21 @@ export class Passkey { * @throws */ public static async create( - request: PasskeyCreateRequest + request: PasskeyCreateRequest, + options?: PasskeyCreateOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.create( - JSON.stringify(request), - false, // forcePlatformKey - false // forceSecurityKey - ); + const response = await NativePasskey.create(JSON.stringify(request), { + forcePlatformKey: false, + forceSecurityKey: false, + preferImmediatelyAvailableCredentials: + options?.preferImmediatelyAvailableCredentials ?? false, + isConditional: options?.isConditional ?? false, + }); if (typeof response === 'string') { return JSON.parse(response) as PasskeyCreateResult; @@ -46,7 +51,7 @@ export class Passkey { /** * Creates a new Passkey - * Forces the usage of a platform authenticator on iOS + * Forces the usage of a platform authenticator on iOS and Android * * @param request The FIDO2 Attestation Request in JSON format * @param options An object containing options for the registration process @@ -54,18 +59,21 @@ export class Passkey { * @throws */ public static async createPlatformKey( - request: PasskeyCreateRequest + request: PasskeyCreateRequest, + options?: PasskeyCreateOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.create( - JSON.stringify(request), - true, // forcePlatformKey - false // forceSecurityKey - ); + const response = await NativePasskey.create(JSON.stringify(request), { + forcePlatformKey: true, + forceSecurityKey: false, + preferImmediatelyAvailableCredentials: + options?.preferImmediatelyAvailableCredentials ?? false, + isConditional: options?.isConditional ?? false, + }); if (typeof response === 'string') { return JSON.parse(response) as PasskeyCreateResult; @@ -78,7 +86,7 @@ export class Passkey { /** * Creates a new Passkey - * Forces the usage of a security authenticator on iOS + * Forces the usage of a security authenticator on iOS and Android * * @param request The FIDO2 Attestation Request in JSON format * @param options An object containing options for the registration process @@ -86,18 +94,21 @@ export class Passkey { * @throws */ public static async createSecurityKey( - request: PasskeyCreateRequest + request: PasskeyCreateRequest, + options?: PasskeyCreateOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.create( - JSON.stringify(request), - false, // forcePlatformKey - true // forceSecurityKey - ); + const response = await NativePasskey.create(JSON.stringify(request), { + forcePlatformKey: false, + forceSecurityKey: true, + preferImmediatelyAvailableCredentials: + options?.preferImmediatelyAvailableCredentials ?? false, + isConditional: options?.isConditional ?? false, + }); if (typeof response === 'string') { return JSON.parse(response) as PasskeyCreateResult; @@ -117,18 +128,20 @@ export class Passkey { * @throws */ public static async get( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.get( - JSON.stringify(request), - false, // forcePlatformKey - false // forceSecurityKey - ); + const response = await NativePasskey.get(JSON.stringify(request), { + forcePlatformKey: false, + forceSecurityKey: false, + preferImmediatelyAvailableCredentials: + options?.preferImmediatelyAvailableCredentials ?? false, + }); if (typeof response === 'string') { return JSON.parse(response) as PasskeyGetResult; @@ -141,7 +154,7 @@ export class Passkey { /** * Authenticates using an existing Passkey - * Forces the usage of a platform authenticator on iOS + * Forces the usage of a platform authenticator on iOS and Android * * @param request The FIDO2 Assertion Request in JSON format * @param options An object containing options for the authentication process @@ -149,18 +162,20 @@ export class Passkey { * @throws */ public static async getPlatformKey( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.get( - JSON.stringify(request), - true, // forcePlatformKey - false // forceSecurityKey - ); + const response = await NativePasskey.get(JSON.stringify(request), { + forcePlatformKey: true, + forceSecurityKey: false, + preferImmediatelyAvailableCredentials: + options?.preferImmediatelyAvailableCredentials ?? false, + }); if (typeof response === 'string') { return JSON.parse(response) as PasskeyGetResult; @@ -173,7 +188,7 @@ export class Passkey { /** * Authenticates using an existing Passkey - * Forces the usage of a security authenticator on iOS + * Forces the usage of a security authenticator on iOS and Android * * @param request The FIDO2 Assertion Request in JSON format * @param options An object containing options for the authentication process @@ -181,18 +196,20 @@ export class Passkey { * @throws */ public static async getSecurityKey( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.get( - JSON.stringify(request), - false, // forcePlatformKey - true // forceSecurityKey - ); + const response = await NativePasskey.get(JSON.stringify(request), { + forcePlatformKey: false, + forceSecurityKey: true, + preferImmediatelyAvailableCredentials: + options?.preferImmediatelyAvailableCredentials ?? false, + }); if (typeof response === 'string') { return JSON.parse(response) as PasskeyGetResult; @@ -203,6 +220,20 @@ export class Passkey { } } + /** + * Pre-fetches credential data to reduce latency when subsequently calling get(). + * Should be called early (e.g. on screen load) before the user triggers sign-in. + * Only has effect on Android 14+ (API level 34); resolves immediately on other platforms. + * + * @param request The FIDO2 Assertion Request in JSON format + */ + public static async prepareGet(request: PasskeyGetRequest): Promise { + if (Platform.OS !== 'android' || Platform.Version < 34) { + return; + } + await NativePasskey.prepareGet(JSON.stringify(request)); + } + /** * Checks if Passkeys are supported on the current device * diff --git a/src/PasskeyTypes.ts b/src/PasskeyTypes.ts index f72d77a..5cbbd12 100644 --- a/src/PasskeyTypes.ts +++ b/src/PasskeyTypes.ts @@ -117,6 +117,18 @@ export interface PasskeyGetResult { }; } +export interface PasskeyCreateOptions { + /** Android only: if true, returns immediately without UI when no local credential is available */ + preferImmediatelyAvailableCredentials?: boolean; + /** Android only: creates passkey silently during a password autofill flow, without showing the bottom sheet */ + isConditional?: boolean; +} + +export interface PasskeyGetOptions { + /** Android only: if true, returns immediately without UI when no local credential is available */ + preferImmediatelyAvailableCredentials?: boolean; +} + // https://www.w3.org/TR/webauthn-3/#dictionary-credential-descriptor export interface PublicKeyCredentialDescriptor { type: 'public-key'; diff --git a/src/index.ts b/src/index.ts index df2b895..bc1484c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ import { Passkey } from './Passkey'; import type { PasskeyError } from './PasskeyError'; import type { + PasskeyCreateOptions, PasskeyCreateRequest, PasskeyCreateResult, + PasskeyGetOptions, PasskeyGetRequest, PasskeyGetResult, } from './PasskeyTypes'; @@ -10,8 +12,10 @@ import type { export { Passkey, PasskeyError, + PasskeyCreateOptions, PasskeyCreateRequest, PasskeyCreateResult, + PasskeyGetOptions, PasskeyGetRequest, PasskeyGetResult, }; From 9ce458955ff447c973e39c7f91abaf2f1b9fe2d3 Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Thu, 2 Apr 2026 15:44:00 +0200 Subject: [PATCH 2/7] chore: update README.md --- README.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 36a9735..4716ba9 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,9 @@ try { } ``` -### Force Platform or Security Key (iOS-specific) +### Force Platform or Security Key -You can force users to register and authenticate using either a platform key, a security key (like [Yubikey](https://www.yubico.com/)) or allow both using the following methods. This only works on iOS, Android will ignore these instructions. +You can force users to register and authenticate using either a platform key or a security key (like [Yubikey](https://www.yubico.com/)), or allow both. This works on both iOS and Android. #### Create Passkey @@ -169,6 +169,43 @@ You can force users to register and authenticate using either a platform key, a - `Passkey.getPlatformKey()` - Force the user to authenticate using a platform passkey - `Passkey.getSecurityKey()` - Force the user to authenticate using a security passkey +### Android-specific Options + +The `create()`, `createPlatformKey()`, `createSecurityKey()`, `get()`, `getPlatformKey()` and `getSecurityKey()` methods accept an optional second argument with Android-specific options. + +#### `preferImmediatelyAvailableCredentials` + +When `true`, the call returns immediately without showing any UI if no locally available credential is found, instead of falling back to cross-device flows. Useful for silent sign-in checks at app startup. + +```ts +// Returns NoCredentials error immediately instead of showing the bottom sheet +const result = await Passkey.get(requestJson, { + preferImmediatelyAvailableCredentials: true, +}); +``` + +#### `isConditional` (create only) + +When `true`, creates a passkey silently in the background during a password autofill flow, without showing the bottom sheet UI. + +```ts +const result = await Passkey.create(requestJson, { + isConditional: true, +}); +``` + +#### `prepareGet()` (Android 14+ only) + +Pre-fetches credential data before the user triggers sign-in to reduce UI latency. Call this early (e.g. on screen load), then call `get()` as normal when the user taps the sign-in button. Has no effect on iOS or Android below API level 34. + +```ts +// On screen load +await Passkey.prepareGet(requestJson); + +// On sign-in button press +const result = await Passkey.get(requestJson); +``` + ### Extensions #### largeBlob From f93995a9bf96f23d6ae366867ef815c9efa95f9b Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Thu, 2 Apr 2026 16:11:20 +0200 Subject: [PATCH 3/7] fix: isConditionaissue --- README.md | 10 ---------- .../com/reactnativepasskey/PasskeyModule.kt | 17 +++++++++++------ src/Passkey.ts | 3 --- src/PasskeyTypes.ts | 2 -- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 4716ba9..6020933 100644 --- a/README.md +++ b/README.md @@ -184,16 +184,6 @@ const result = await Passkey.get(requestJson, { }); ``` -#### `isConditional` (create only) - -When `true`, creates a passkey silently in the background during a password autofill flow, without showing the bottom sheet UI. - -```ts -const result = await Passkey.create(requestJson, { - isConditional: true, -}); -``` - #### `prepareGet()` (Android 14+ only) Pre-fetches credential data before the user triggers sign-in to reduce UI latency. Call this early (e.g. on screen load), then call `get()` as normal when the user taps the sign-in button. Has no effect on iOS or Android below API level 34. diff --git a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt index b52a4c7..3635130 100644 --- a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt +++ b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt @@ -33,7 +33,6 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav @ReactMethod fun create(requestJson: String, options: ReadableMap, promise: Promise) { val preferImmediatelyAvailable = options.getBoolean("preferImmediatelyAvailableCredentials") - val isConditional = options.getBoolean("isConditional") val forcePlatformKey = options.getBoolean("forcePlatformKey") val forceSecurityKey = options.getBoolean("forceSecurityKey") @@ -42,8 +41,11 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( requestJson = adjustedJson, + clientDataHash = null, preferImmediatelyAvailableCredentials = preferImmediatelyAvailable, - isConditional = isConditional + isAutoSelectAllowed = false, + origin = null, + preferDefaultProvider = null ) mainScope.launch { @@ -72,11 +74,14 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav mainScope.launch { try { - val storedPreparedResponse = preparedGetResponse - val result = if (storedPreparedResponse != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - preparedGetResponse = null + val pendingHandle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + preparedGetResponse?.pendingGetCredentialHandle.also { preparedGetResponse = null } + } else { + null + } + val result = if (pendingHandle != null) { reactApplicationContext.currentActivity?.let { - credentialManager.getCredential(it, getCredentialRequest, storedPreparedResponse) + credentialManager.getCredential(it, pendingHandle) } } else { reactApplicationContext.currentActivity?.let { diff --git a/src/Passkey.ts b/src/Passkey.ts index 41746f2..b9632eb 100644 --- a/src/Passkey.ts +++ b/src/Passkey.ts @@ -37,7 +37,6 @@ export class Passkey { forceSecurityKey: false, preferImmediatelyAvailableCredentials: options?.preferImmediatelyAvailableCredentials ?? false, - isConditional: options?.isConditional ?? false, }); if (typeof response === 'string') { @@ -72,7 +71,6 @@ export class Passkey { forceSecurityKey: false, preferImmediatelyAvailableCredentials: options?.preferImmediatelyAvailableCredentials ?? false, - isConditional: options?.isConditional ?? false, }); if (typeof response === 'string') { @@ -107,7 +105,6 @@ export class Passkey { forceSecurityKey: true, preferImmediatelyAvailableCredentials: options?.preferImmediatelyAvailableCredentials ?? false, - isConditional: options?.isConditional ?? false, }); if (typeof response === 'string') { diff --git a/src/PasskeyTypes.ts b/src/PasskeyTypes.ts index 5cbbd12..df1b527 100644 --- a/src/PasskeyTypes.ts +++ b/src/PasskeyTypes.ts @@ -120,8 +120,6 @@ export interface PasskeyGetResult { export interface PasskeyCreateOptions { /** Android only: if true, returns immediately without UI when no local credential is available */ preferImmediatelyAvailableCredentials?: boolean; - /** Android only: creates passkey silently during a password autofill flow, without showing the bottom sheet */ - isConditional?: boolean; } export interface PasskeyGetOptions { From 2fe6063a8a5d3df1c0632aa8b959837c9481b52c Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Thu, 2 Apr 2026 16:22:36 +0200 Subject: [PATCH 4/7] chore: undo unneeded changes. Only fix parity --- README.md | 27 ----- .../com/reactnativepasskey/PasskeyModule.kt | 59 ++-------- src/Passkey.ts | 106 ++++++------------ src/PasskeyTypes.ts | 10 -- src/index.ts | 4 - 5 files changed, 43 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 6020933..3a25978 100644 --- a/README.md +++ b/README.md @@ -169,33 +169,6 @@ You can force users to register and authenticate using either a platform key or - `Passkey.getPlatformKey()` - Force the user to authenticate using a platform passkey - `Passkey.getSecurityKey()` - Force the user to authenticate using a security passkey -### Android-specific Options - -The `create()`, `createPlatformKey()`, `createSecurityKey()`, `get()`, `getPlatformKey()` and `getSecurityKey()` methods accept an optional second argument with Android-specific options. - -#### `preferImmediatelyAvailableCredentials` - -When `true`, the call returns immediately without showing any UI if no locally available credential is found, instead of falling back to cross-device flows. Useful for silent sign-in checks at app startup. - -```ts -// Returns NoCredentials error immediately instead of showing the bottom sheet -const result = await Passkey.get(requestJson, { - preferImmediatelyAvailableCredentials: true, -}); -``` - -#### `prepareGet()` (Android 14+ only) - -Pre-fetches credential data before the user triggers sign-in to reduce UI latency. Call this early (e.g. on screen load), then call `get()` as normal when the user taps the sign-in button. Has no effect on iOS or Android below API level 34. - -```ts -// On screen load -await Passkey.prepareGet(requestJson); - -// On sign-in button press -const result = await Passkey.get(requestJson); -``` - ### Extensions #### largeBlob diff --git a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt index 3635130..5ce9d59 100644 --- a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt +++ b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt @@ -4,13 +4,11 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReadableMap import androidx.credentials.CredentialManager import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.PrepareGetCredentialResponse import androidx.credentials.exceptions.* import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException @@ -19,30 +17,24 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import android.os.Build import org.json.JSONObject class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val mainScope = CoroutineScope(Dispatchers.Default) - private var preparedGetResponse: PrepareGetCredentialResponse? = null override fun getName(): String { return "Passkey" } @ReactMethod - fun create(requestJson: String, options: ReadableMap, promise: Promise) { - val preferImmediatelyAvailable = options.getBoolean("preferImmediatelyAvailableCredentials") - val forcePlatformKey = options.getBoolean("forcePlatformKey") - val forceSecurityKey = options.getBoolean("forceSecurityKey") - + fun create(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { val adjustedJson = adjustAuthenticatorAttachment(requestJson, forcePlatformKey, forceSecurityKey) val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( requestJson = adjustedJson, clientDataHash = null, - preferImmediatelyAvailableCredentials = preferImmediatelyAvailable, + preferImmediatelyAvailableCredentials = false, isAutoSelectAllowed = false, origin = null, preferDefaultProvider = null @@ -63,31 +55,15 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } @ReactMethod - fun get(requestJson: String, options: ReadableMap, promise: Promise) { - val preferImmediatelyAvailable = options.getBoolean("preferImmediatelyAvailableCredentials") - + fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val getCredentialRequest = GetCredentialRequest( - credentialOptions = listOf(GetPublicKeyCredentialOption(requestJson)), - preferImmediatelyAvailableCredentials = preferImmediatelyAvailable - ) + val getCredentialRequest = + GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))) mainScope.launch { try { - val pendingHandle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - preparedGetResponse?.pendingGetCredentialHandle.also { preparedGetResponse = null } - } else { - null - } - val result = if (pendingHandle != null) { - reactApplicationContext.currentActivity?.let { - credentialManager.getCredential(it, pendingHandle) - } - } else { - reactApplicationContext.currentActivity?.let { - credentialManager.getCredential(it, getCredentialRequest) - } - } + val result = + reactApplicationContext.currentActivity?.let { credentialManager.getCredential(it, getCredentialRequest) } val response = result?.credential?.data?.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON") @@ -99,27 +75,6 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } } - @ReactMethod - fun prepareGet(requestJson: String, promise: Promise) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - promise.resolve(null) - return - } - - val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val getCredentialRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))) - - mainScope.launch { - try { - preparedGetResponse = credentialManager.prepareGetCredential(getCredentialRequest) - promise.resolve(null) - } catch (e: GetCredentialException) { - val errorCode = handleAuthenticationException(e) - promise.reject(errorCode, errorCode) - } - } - } - private fun adjustAuthenticatorAttachment( requestJson: String, forcePlatformKey: Boolean, diff --git a/src/Passkey.ts b/src/Passkey.ts index b9632eb..a528724 100644 --- a/src/Passkey.ts +++ b/src/Passkey.ts @@ -5,10 +5,8 @@ import { } from './PasskeyError'; import { Platform } from 'react-native'; import type { - PasskeyCreateOptions, PasskeyCreateRequest, PasskeyCreateResult, - PasskeyGetOptions, PasskeyGetRequest, PasskeyGetResult, } from './PasskeyTypes'; @@ -19,25 +17,22 @@ export class Passkey { * Creates a new Passkey * * @param request The FIDO2 Attestation Request in JSON format - * @param options An object containing options for the registration process * @returns The FIDO2 Attestation Result in JSON format * @throws */ public static async create( - request: PasskeyCreateRequest, - options?: PasskeyCreateOptions + request: PasskeyCreateRequest ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.create(JSON.stringify(request), { - forcePlatformKey: false, - forceSecurityKey: false, - preferImmediatelyAvailableCredentials: - options?.preferImmediatelyAvailableCredentials ?? false, - }); + const response = await NativePasskey.create( + JSON.stringify(request), + false, // forcePlatformKey + false // forceSecurityKey + ); if (typeof response === 'string') { return JSON.parse(response) as PasskeyCreateResult; @@ -53,25 +48,22 @@ export class Passkey { * Forces the usage of a platform authenticator on iOS and Android * * @param request The FIDO2 Attestation Request in JSON format - * @param options An object containing options for the registration process * @returns The FIDO2 Attestation Result in JSON format * @throws */ public static async createPlatformKey( - request: PasskeyCreateRequest, - options?: PasskeyCreateOptions + request: PasskeyCreateRequest ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.create(JSON.stringify(request), { - forcePlatformKey: true, - forceSecurityKey: false, - preferImmediatelyAvailableCredentials: - options?.preferImmediatelyAvailableCredentials ?? false, - }); + const response = await NativePasskey.create( + JSON.stringify(request), + true, // forcePlatformKey + false // forceSecurityKey + ); if (typeof response === 'string') { return JSON.parse(response) as PasskeyCreateResult; @@ -87,25 +79,22 @@ export class Passkey { * Forces the usage of a security authenticator on iOS and Android * * @param request The FIDO2 Attestation Request in JSON format - * @param options An object containing options for the registration process * @returns The FIDO2 Attestation Result in JSON format * @throws */ public static async createSecurityKey( - request: PasskeyCreateRequest, - options?: PasskeyCreateOptions + request: PasskeyCreateRequest ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.create(JSON.stringify(request), { - forcePlatformKey: false, - forceSecurityKey: true, - preferImmediatelyAvailableCredentials: - options?.preferImmediatelyAvailableCredentials ?? false, - }); + const response = await NativePasskey.create( + JSON.stringify(request), + false, // forcePlatformKey + true // forceSecurityKey + ); if (typeof response === 'string') { return JSON.parse(response) as PasskeyCreateResult; @@ -120,25 +109,22 @@ export class Passkey { * Authenticates using an existing Passkey * * @param request The FIDO2 Assertion Request in JSON format - * @param options An object containing options for the authentication process * @returns The FIDO2 Assertion Result in JSON format * @throws */ public static async get( - request: PasskeyGetRequest, - options?: PasskeyGetOptions + request: PasskeyGetRequest ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.get(JSON.stringify(request), { - forcePlatformKey: false, - forceSecurityKey: false, - preferImmediatelyAvailableCredentials: - options?.preferImmediatelyAvailableCredentials ?? false, - }); + const response = await NativePasskey.get( + JSON.stringify(request), + false, // forcePlatformKey + false // forceSecurityKey + ); if (typeof response === 'string') { return JSON.parse(response) as PasskeyGetResult; @@ -154,25 +140,22 @@ export class Passkey { * Forces the usage of a platform authenticator on iOS and Android * * @param request The FIDO2 Assertion Request in JSON format - * @param options An object containing options for the authentication process * @returns The FIDO2 Assertion Result in JSON format * @throws */ public static async getPlatformKey( - request: PasskeyGetRequest, - options?: PasskeyGetOptions + request: PasskeyGetRequest ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.get(JSON.stringify(request), { - forcePlatformKey: true, - forceSecurityKey: false, - preferImmediatelyAvailableCredentials: - options?.preferImmediatelyAvailableCredentials ?? false, - }); + const response = await NativePasskey.get( + JSON.stringify(request), + true, // forcePlatformKey + false // forceSecurityKey + ); if (typeof response === 'string') { return JSON.parse(response) as PasskeyGetResult; @@ -188,25 +171,22 @@ export class Passkey { * Forces the usage of a security authenticator on iOS and Android * * @param request The FIDO2 Assertion Request in JSON format - * @param options An object containing options for the authentication process * @returns The FIDO2 Assertion Result in JSON format * @throws */ public static async getSecurityKey( - request: PasskeyGetRequest, - options?: PasskeyGetOptions + request: PasskeyGetRequest ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; } try { - const response = await NativePasskey.get(JSON.stringify(request), { - forcePlatformKey: false, - forceSecurityKey: true, - preferImmediatelyAvailableCredentials: - options?.preferImmediatelyAvailableCredentials ?? false, - }); + const response = await NativePasskey.get( + JSON.stringify(request), + false, // forcePlatformKey + true // forceSecurityKey + ); if (typeof response === 'string') { return JSON.parse(response) as PasskeyGetResult; @@ -217,20 +197,6 @@ export class Passkey { } } - /** - * Pre-fetches credential data to reduce latency when subsequently calling get(). - * Should be called early (e.g. on screen load) before the user triggers sign-in. - * Only has effect on Android 14+ (API level 34); resolves immediately on other platforms. - * - * @param request The FIDO2 Assertion Request in JSON format - */ - public static async prepareGet(request: PasskeyGetRequest): Promise { - if (Platform.OS !== 'android' || Platform.Version < 34) { - return; - } - await NativePasskey.prepareGet(JSON.stringify(request)); - } - /** * Checks if Passkeys are supported on the current device * diff --git a/src/PasskeyTypes.ts b/src/PasskeyTypes.ts index df1b527..f72d77a 100644 --- a/src/PasskeyTypes.ts +++ b/src/PasskeyTypes.ts @@ -117,16 +117,6 @@ export interface PasskeyGetResult { }; } -export interface PasskeyCreateOptions { - /** Android only: if true, returns immediately without UI when no local credential is available */ - preferImmediatelyAvailableCredentials?: boolean; -} - -export interface PasskeyGetOptions { - /** Android only: if true, returns immediately without UI when no local credential is available */ - preferImmediatelyAvailableCredentials?: boolean; -} - // https://www.w3.org/TR/webauthn-3/#dictionary-credential-descriptor export interface PublicKeyCredentialDescriptor { type: 'public-key'; diff --git a/src/index.ts b/src/index.ts index bc1484c..df2b895 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,8 @@ import { Passkey } from './Passkey'; import type { PasskeyError } from './PasskeyError'; import type { - PasskeyCreateOptions, PasskeyCreateRequest, PasskeyCreateResult, - PasskeyGetOptions, PasskeyGetRequest, PasskeyGetResult, } from './PasskeyTypes'; @@ -12,10 +10,8 @@ import type { export { Passkey, PasskeyError, - PasskeyCreateOptions, PasskeyCreateRequest, PasskeyCreateResult, - PasskeyGetOptions, PasskeyGetRequest, PasskeyGetResult, }; From 5d8eb1a9d5311df1b1c1d0eee7ea3448777f1f98 Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Thu, 2 Apr 2026 16:25:24 +0200 Subject: [PATCH 5/7] chore: fn ordering --- .../com/reactnativepasskey/PasskeyModule.kt | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt index 5ce9d59..93840d8 100644 --- a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt +++ b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt @@ -54,6 +54,33 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } } + private fun handleRegistrationException(e: CreateCredentialException): String { + e.printStackTrace() + when (e) { + is CreatePublicKeyCredentialDomException -> { + return e.errorMessage.toString() + } + is CreateCredentialCancellationException -> { + return "UserCancelled" + } + is CreateCredentialInterruptedException -> { + return "Interrupted" + } + is CreateCredentialProviderConfigurationException -> { + return "NotConfigured" + } + is CreateCredentialUnknownException -> { + return "UnknownError" + } + is CreateCredentialUnsupportedException -> { + return "NotSupported" + } + else -> { + return e.errorMessage.toString() + } + } + } + @ReactMethod fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) @@ -88,32 +115,6 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav return json.toString() } - private fun handleRegistrationException(e: CreateCredentialException): String { - e.printStackTrace() - when (e) { - is CreatePublicKeyCredentialDomException -> { - return e.errorMessage.toString() - } - is CreateCredentialCancellationException -> { - return "UserCancelled" - } - is CreateCredentialInterruptedException -> { - return "Interrupted" - } - is CreateCredentialProviderConfigurationException -> { - return "NotConfigured" - } - is CreateCredentialUnknownException -> { - return "UnknownError" - } - is CreateCredentialUnsupportedException -> { - return "NotSupported" - } - else -> { - return e.errorMessage.toString() - } - } - } private fun handleAuthenticationException(e: GetCredentialException): String { e.printStackTrace() From 055473d26d8da306ed25790529395c1d0b0ffa65 Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Thu, 2 Apr 2026 16:32:03 +0200 Subject: [PATCH 6/7] chore: Update README --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a25978..59f1c9f 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,12 @@ try { ### Force Platform or Security Key -You can force users to register and authenticate using either a platform key or a security key (like [Yubikey](https://www.yubico.com/)), or allow both. This works on both iOS and Android. +You can force users to register and authenticate using either a platform key or a security key (like [Yubikey](https://www.yubico.com/)), or allow both. #### Create Passkey +Works on both iOS and Android. + - `Passkey.create()` - Allow the user to choose between platform and security passkey - `Passkey.createPlatformKey()` - Force the user to create a platform passkey - `Passkey.createSecurityKey()` - Force the user to create a security passkey @@ -169,6 +171,8 @@ You can force users to register and authenticate using either a platform key or - `Passkey.getPlatformKey()` - Force the user to authenticate using a platform passkey - `Passkey.getSecurityKey()` - Force the user to authenticate using a security passkey +> **Note:** On Android, forcing platform or security key has no effect during authentication. The WebAuthn spec does not support `authenticatorAttachment` for assertion requests — Credential Manager selects credentials based on what is already registered. iOS handles this at the native request level and is unaffected. + ### Extensions #### largeBlob From fa8175557bb7d293ed2590e0fd83ca275378eea1 Mon Sep 17 00:00:00 2001 From: Jens Verbeken Date: Tue, 14 Apr 2026 08:44:09 +0200 Subject: [PATCH 7/7] undo --- README.md | 8 +-- .../com/reactnativepasskey/PasskeyModule.kt | 59 ++++++------------- src/Passkey.ts | 8 +-- 3 files changed, 23 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 59f1c9f..36a9735 100644 --- a/README.md +++ b/README.md @@ -153,14 +153,12 @@ try { } ``` -### Force Platform or Security Key +### Force Platform or Security Key (iOS-specific) -You can force users to register and authenticate using either a platform key or a security key (like [Yubikey](https://www.yubico.com/)), or allow both. +You can force users to register and authenticate using either a platform key, a security key (like [Yubikey](https://www.yubico.com/)) or allow both using the following methods. This only works on iOS, Android will ignore these instructions. #### Create Passkey -Works on both iOS and Android. - - `Passkey.create()` - Allow the user to choose between platform and security passkey - `Passkey.createPlatformKey()` - Force the user to create a platform passkey - `Passkey.createSecurityKey()` - Force the user to create a security passkey @@ -171,8 +169,6 @@ Works on both iOS and Android. - `Passkey.getPlatformKey()` - Force the user to authenticate using a platform passkey - `Passkey.getSecurityKey()` - Force the user to authenticate using a security passkey -> **Note:** On Android, forcing platform or security key has no effect during authentication. The WebAuthn spec does not support `authenticatorAttachment` for assertion requests — Credential Manager selects credentials based on what is already registered. iOS handles this at the native request level and is unaffected. - ### Extensions #### largeBlob diff --git a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt index 93840d8..9c20377 100644 --- a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt +++ b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt @@ -17,8 +17,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import org.json.JSONObject - class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val mainScope = CoroutineScope(Dispatchers.Default) @@ -28,17 +26,8 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav @ReactMethod fun create(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { - val adjustedJson = adjustAuthenticatorAttachment(requestJson, forcePlatformKey, forceSecurityKey) - val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( - requestJson = adjustedJson, - clientDataHash = null, - preferImmediatelyAvailableCredentials = false, - isAutoSelectAllowed = false, - origin = null, - preferDefaultProvider = null - ) + val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest(requestJson) mainScope.launch { try { @@ -83,39 +72,25 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav @ReactMethod fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { - val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val getCredentialRequest = - GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))) - - mainScope.launch { - try { - val result = - reactApplicationContext.currentActivity?.let { credentialManager.getCredential(it, getCredentialRequest) } - - val response = - result?.credential?.data?.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON") - promise.resolve(response) - } catch (e: GetCredentialException) { - val errorCode = handleAuthenticationException(e) - promise.reject(errorCode, errorCode) + val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) + val getCredentialRequest = + GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))) + + mainScope.launch { + try { + val result = + reactApplicationContext.currentActivity?.let { credentialManager.getCredential(it, getCredentialRequest) } + + val response = + result?.credential?.data?.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON") + promise.resolve(response) + } catch (e: GetCredentialException) { + val errorCode = handleAuthenticationException(e) + promise.reject(errorCode, errorCode) + } } - } } - private fun adjustAuthenticatorAttachment( - requestJson: String, - forcePlatformKey: Boolean, - forceSecurityKey: Boolean - ): String { - if (!forcePlatformKey && !forceSecurityKey) return requestJson - val json = JSONObject(requestJson) - val authSelection = json.optJSONObject("authenticatorSelection") ?: JSONObject() - authSelection.put("authenticatorAttachment", if (forcePlatformKey) "platform" else "cross-platform") - json.put("authenticatorSelection", authSelection) - return json.toString() - } - - private fun handleAuthenticationException(e: GetCredentialException): String { e.printStackTrace() when (e) { diff --git a/src/Passkey.ts b/src/Passkey.ts index a528724..d82f1c6 100644 --- a/src/Passkey.ts +++ b/src/Passkey.ts @@ -45,7 +45,7 @@ export class Passkey { /** * Creates a new Passkey - * Forces the usage of a platform authenticator on iOS and Android + * Forces the usage of a platform authenticator on iOS * * @param request The FIDO2 Attestation Request in JSON format * @returns The FIDO2 Attestation Result in JSON format @@ -76,7 +76,7 @@ export class Passkey { /** * Creates a new Passkey - * Forces the usage of a security authenticator on iOS and Android + * Forces the usage of a security authenticator on iOS * * @param request The FIDO2 Attestation Request in JSON format * @returns The FIDO2 Attestation Result in JSON format @@ -137,7 +137,7 @@ export class Passkey { /** * Authenticates using an existing Passkey - * Forces the usage of a platform authenticator on iOS and Android + * Forces the usage of a platform authenticator on iOS * * @param request The FIDO2 Assertion Request in JSON format * @returns The FIDO2 Assertion Result in JSON format @@ -168,7 +168,7 @@ export class Passkey { /** * Authenticates using an existing Passkey - * Forces the usage of a security authenticator on iOS and Android + * Forces the usage of a security authenticator on iOS * * @param request The FIDO2 Assertion Request in JSON format * @returns The FIDO2 Assertion Result in JSON format