diff --git a/android/build.gradle b/android/build.gradle index 28701c6..a0aeb87 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -132,8 +132,8 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" // Android Credentials - implementation "androidx.credentials:credentials-play-services-auth:1.5.0" - implementation "androidx.credentials:credentials:1.5.0" + implementation "androidx.credentials:credentials-play-services-auth:1.6.0" + implementation "androidx.credentials:credentials:1.6.0" // Kotlin Coroutines implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2' diff --git a/android/gradle.properties b/android/gradle.properties index f4255dd..32ae029 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ Passkey_kotlinVersion=1.8.0 Passkey_minSdkVersion=21 -Passkey_targetSdkVersion=31 -Passkey_compileSdkVersion=31 +Passkey_targetSdkVersion=35 +Passkey_compileSdkVersion=35 Passkey_ndkversion=21.4.7075529 diff --git a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt index d1f2adb..4710ce0 100644 --- a/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt +++ b/android/src/main/java/com/reactnativepasskey/PasskeyModule.kt @@ -4,6 +4,8 @@ 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 android.os.Build import androidx.credentials.CredentialManager import androidx.credentials.CreatePublicKeyCredentialRequest @@ -26,9 +28,23 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } @ReactMethod - fun create(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) { + fun create(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, options: ReadableMap?, promise: Promise) { val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest(requestJson) + + val parsedOptions = parseCustomizationOptions(options) + + val createPublicKeyCredentialRequest = if (Build.VERSION.SDK_INT >= 35) { + CreatePublicKeyCredentialRequest( + requestJson = requestJson, + clientDataHash = null, + preferImmediatelyAvailableCredentials = parsedOptions.preferImmediatelyAvailable, + origin = null, + isAutoSelectAllowed = parsedOptions.autoSelectAllowed, + isConditional = parsedOptions.isConditional + ) + } else { + CreatePublicKeyCredentialRequest(requestJson) + } mainScope.launch { try { @@ -76,13 +92,25 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav } @ReactMethod - fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, preferImmediatelyAvailable: Boolean, promise: Promise) { + fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, preferImmediatelyAvailable: Boolean, options: ReadableMap?, promise: Promise) { val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) - val getCredentialRequest = - GetCredentialRequest( - listOf(GetPublicKeyCredentialOption(requestJson)), - preferImmediatelyAvailableCredentials = preferImmediatelyAvailable - ) + + val parsedOptions = parseCustomizationOptions(options) + val finalPreferImmediatelyAvailable = preferImmediatelyAvailable || parsedOptions.preferImmediatelyAvailable + + val getPublicKeyCredentialOption = GetPublicKeyCredentialOption(requestJson) + if (parsedOptions.autoSelectAllowed) { + getPublicKeyCredentialOption.requestData.putBoolean("androidx.credentials.BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED", true) + getPublicKeyCredentialOption.candidateQueryData.putBoolean("androidx.credentials.BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED", true) + } + + val getCredentialRequestBuilder = GetCredentialRequest.Builder() + .addCredentialOption(getPublicKeyCredentialOption) + .setPreferImmediatelyAvailableCredentials(finalPreferImmediatelyAvailable) + + + + val getCredentialRequest = getCredentialRequestBuilder.build() mainScope.launch { try { @@ -145,4 +173,26 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav else -> "UnknownError" } } + + private fun parseCustomizationOptions(options: ReadableMap?): CustomizationOptions { + val result = CustomizationOptions() + if (options == null) return result + + if (options.hasKey("autoSelectAllowed")) { + result.autoSelectAllowed = options.getBoolean("autoSelectAllowed") + } + if (options.hasKey("preferImmediatelyAvailable")) { + result.preferImmediatelyAvailable = options.getBoolean("preferImmediatelyAvailable") + } + if (options.hasKey("isConditional")) { + result.isConditional = options.getBoolean("isConditional") + } + return result + } + + class CustomizationOptions { + var autoSelectAllowed: Boolean = false + var preferImmediatelyAvailable: Boolean = false + var isConditional: Boolean = false + } } diff --git a/src/Passkey.ts b/src/Passkey.ts index bce4d24..6538d34 100644 --- a/src/Passkey.ts +++ b/src/Passkey.ts @@ -9,6 +9,8 @@ import type { PasskeyCreateResult, PasskeyGetRequest, PasskeyGetResult, + PasskeyCreateOptions, + PasskeyGetOptions, } from './PasskeyTypes'; import { stringifyPasskeyRequest } from './PasskeyRequest'; import { NativePasskey } from './NativePasskey'; @@ -23,7 +25,8 @@ export class Passkey { * @throws */ public static async create( - request: PasskeyCreateRequest + request: PasskeyCreateRequest, + options?: PasskeyCreateOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -33,7 +36,8 @@ export class Passkey { const response = await NativePasskey.create( stringifyPasskeyRequest(request, Platform.OS), false, // forcePlatformKey - false // forceSecurityKey + false, // forceSecurityKey + options?.androidOptions ?? null ); if (typeof response === 'string') { @@ -55,7 +59,8 @@ export class Passkey { * @throws */ public static async createPlatformKey( - request: PasskeyCreateRequest + request: PasskeyCreateRequest, + options?: PasskeyCreateOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -65,7 +70,8 @@ export class Passkey { const response = await NativePasskey.create( stringifyPasskeyRequest(request, Platform.OS), true, // forcePlatformKey - false // forceSecurityKey + false, // forceSecurityKey + options?.androidOptions ?? null ); if (typeof response === 'string') { @@ -87,7 +93,8 @@ export class Passkey { * @throws */ public static async createSecurityKey( - request: PasskeyCreateRequest + request: PasskeyCreateRequest, + options?: PasskeyCreateOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -97,7 +104,8 @@ export class Passkey { const response = await NativePasskey.create( stringifyPasskeyRequest(request, Platform.OS), false, // forcePlatformKey - true // forceSecurityKey + true, // forceSecurityKey + options?.androidOptions ?? null ); if (typeof response === 'string') { @@ -118,7 +126,8 @@ export class Passkey { * @throws */ public static async get( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -129,7 +138,8 @@ export class Passkey { stringifyPasskeyRequest(request, Platform.OS), false, // forcePlatformKey false, // forceSecurityKey - false // preferImmediatelyAvailable + false, // preferImmediatelyAvailable + options?.androidOptions ?? null ); if (typeof response === 'string') { @@ -155,7 +165,8 @@ export class Passkey { * @throws */ public static async getImmediate( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -166,7 +177,8 @@ export class Passkey { stringifyPasskeyRequest(request, Platform.OS), true, // forcePlatformKey (immediate is platform-only) false, // forceSecurityKey - true // preferImmediatelyAvailable + true, // preferImmediatelyAvailable + options?.androidOptions ?? null ); if (typeof response === 'string') { @@ -188,7 +200,8 @@ export class Passkey { * @throws */ public static async getPlatformKey( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -199,7 +212,8 @@ export class Passkey { stringifyPasskeyRequest(request, Platform.OS), true, // forcePlatformKey false, // forceSecurityKey - false // preferImmediatelyAvailable + false, // preferImmediatelyAvailable + options?.androidOptions ?? null ); if (typeof response === 'string') { @@ -221,7 +235,8 @@ export class Passkey { * @throws */ public static async getSecurityKey( - request: PasskeyGetRequest + request: PasskeyGetRequest, + options?: PasskeyGetOptions ): Promise { if (!Passkey.isSupported()) { throw NotSupportedError; @@ -232,7 +247,8 @@ export class Passkey { stringifyPasskeyRequest(request, Platform.OS), false, // forcePlatformKey true, // forceSecurityKey - false // preferImmediatelyAvailable + false, // preferImmediatelyAvailable + options?.androidOptions ?? null ); if (typeof response === 'string') { diff --git a/src/PasskeyTypes.ts b/src/PasskeyTypes.ts index a5f4e00..03e5f3c 100644 --- a/src/PasskeyTypes.ts +++ b/src/PasskeyTypes.ts @@ -146,3 +146,50 @@ export interface AuthenticationExtensionsPRFValues { first: PasskeyBinaryValue; second?: PasskeyBinaryValue; } + +export interface BaseAndroid15CustomizationOptions { + /** + * If true, allows the system to automatically select a credential without + * prompting the user with a dialog, provided there is exactly one matching credential. + */ + autoSelectAllowed?: boolean; + /** + * If true, specifies a preference for credentials that are immediately available on the device + * (e.g. local biometrics) rather than initiating a flow that requires external devices + * (like security keys or another phone via QR code). + * + * If no local credentials are immediately available, the operation will fail silently + * with a 'NoCredentials' error. + */ + preferImmediatelyAvailable?: boolean; + themeVariant?: 'system' | 'light' | 'dark'; + displayHint?: { + title?: string; + subtitle?: string; + }; +} + +export interface Android15CreateCustomizationOptions + extends BaseAndroid15CustomizationOptions { + /** + * If true, enables silent passkey creation (conditional registration). The system + * attempts to create the passkey in the background without immediately showing a popup dialog. + * + * NOTE: This requires that the user already has a saved password credential for the same + * account in their password manager (e.g., Google Password Manager). If this condition is not met, + * the call will fail with a 'NoCreateOption' error, and the app should fall back to calling + * `Passkey.create` with `isConditional: false` (an interactive prompt). + */ + isConditional?: boolean; +} + +export interface PasskeyCreateOptions { + androidOptions?: Android15CreateCustomizationOptions; +} + +export interface Android15GetCustomizationOptions + extends BaseAndroid15CustomizationOptions {} + +export interface PasskeyGetOptions { + androidOptions?: Android15GetCustomizationOptions; +} diff --git a/src/__tests__/PasskeyAndroid.spec.ts b/src/__tests__/PasskeyAndroid.spec.ts index 9c2d6ba..52eac27 100644 --- a/src/__tests__/PasskeyAndroid.spec.ts +++ b/src/__tests__/PasskeyAndroid.spec.ts @@ -92,7 +92,51 @@ describe('Test Passkey Module', () => { stringifyPasskeyRequest(AuthRequest, 'android'), true, false, - true + true, + null + ); + }); + + test('should call native register method with options', async () => { + const registerSpy = jest + .spyOn(NativeModules.Passkey, 'create') + .mockResolvedValue(JSON.stringify(RegAndroidResult)); + + const options = { + androidOptions: { + autoSelectAllowed: true, + preferImmediatelyAvailable: true, + }, + }; + + await Passkey.create(RegRequest, options); + expect(registerSpy).toHaveBeenCalledWith( + stringifyPasskeyRequest(RegRequest, 'android'), + false, + false, + options.androidOptions + ); + }); + + test('should call native auth method with options', async () => { + const authSpy = jest + .spyOn(NativeModules.Passkey, 'get') + .mockResolvedValue(JSON.stringify(AuthAndroidResult)); + + const options = { + androidOptions: { + autoSelectAllowed: true, + preferImmediatelyAvailable: true, + }, + }; + + await Passkey.get(AuthRequest, options); + expect(authSpy).toHaveBeenCalledWith( + stringifyPasskeyRequest(AuthRequest, 'android'), + false, + false, + false, + options.androidOptions ); }); }); diff --git a/src/__tests__/PasskeyiOS.spec.ts b/src/__tests__/PasskeyiOS.spec.ts index 2c74a4f..c006865 100644 --- a/src/__tests__/PasskeyiOS.spec.ts +++ b/src/__tests__/PasskeyiOS.spec.ts @@ -53,7 +53,8 @@ describe('Test Passkey Module', () => { stringifyPasskeyRequest(AuthRequest, 'ios'), true, false, - true + true, + null ); }); }); diff --git a/src/index.ts b/src/index.ts index df2b895..41e5e79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,11 @@ import type { PasskeyCreateResult, PasskeyGetRequest, PasskeyGetResult, + Android15CreateCustomizationOptions, + PasskeyCreateOptions, + Android15GetCustomizationOptions, + PasskeyGetOptions, + BaseAndroid15CustomizationOptions, } from './PasskeyTypes'; export { @@ -14,4 +19,9 @@ export { PasskeyCreateResult, PasskeyGetRequest, PasskeyGetResult, + Android15CreateCustomizationOptions, + PasskeyCreateOptions, + Android15GetCustomizationOptions, + PasskeyGetOptions, + BaseAndroid15CustomizationOptions, };