Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
66 changes: 58 additions & 8 deletions android/src/main/java/com/reactnativepasskey/PasskeyModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
44 changes: 30 additions & 14 deletions src/Passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
PasskeyCreateResult,
PasskeyGetRequest,
PasskeyGetResult,
PasskeyCreateOptions,
PasskeyGetOptions,
} from './PasskeyTypes';
import { stringifyPasskeyRequest } from './PasskeyRequest';
import { NativePasskey } from './NativePasskey';
Expand All @@ -23,7 +25,8 @@ export class Passkey {
* @throws
*/
public static async create(
request: PasskeyCreateRequest
request: PasskeyCreateRequest,
options?: PasskeyCreateOptions
): Promise<PasskeyCreateResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand All @@ -55,7 +59,8 @@ export class Passkey {
* @throws
*/
public static async createPlatformKey(
request: PasskeyCreateRequest
request: PasskeyCreateRequest,
options?: PasskeyCreateOptions
): Promise<PasskeyCreateResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand All @@ -87,7 +93,8 @@ export class Passkey {
* @throws
*/
public static async createSecurityKey(
request: PasskeyCreateRequest
request: PasskeyCreateRequest,
options?: PasskeyCreateOptions
): Promise<PasskeyCreateResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand All @@ -118,7 +126,8 @@ export class Passkey {
* @throws
*/
public static async get(
request: PasskeyGetRequest
request: PasskeyGetRequest,
options?: PasskeyGetOptions
): Promise<PasskeyGetResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand All @@ -155,7 +165,8 @@ export class Passkey {
* @throws
*/
public static async getImmediate(
request: PasskeyGetRequest
request: PasskeyGetRequest,
options?: PasskeyGetOptions
): Promise<PasskeyGetResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand All @@ -188,7 +200,8 @@ export class Passkey {
* @throws
*/
public static async getPlatformKey(
request: PasskeyGetRequest
request: PasskeyGetRequest,
options?: PasskeyGetOptions
): Promise<PasskeyGetResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand All @@ -221,7 +235,8 @@ export class Passkey {
* @throws
*/
public static async getSecurityKey(
request: PasskeyGetRequest
request: PasskeyGetRequest,
options?: PasskeyGetOptions
): Promise<PasskeyGetResult> {
if (!Passkey.isSupported()) {
throw NotSupportedError;
Expand All @@ -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') {
Expand Down
47 changes: 47 additions & 0 deletions src/PasskeyTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
46 changes: 45 additions & 1 deletion src/__tests__/PasskeyAndroid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
});
3 changes: 2 additions & 1 deletion src/__tests__/PasskeyiOS.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe('Test Passkey Module', () => {
stringifyPasskeyRequest(AuthRequest, 'ios'),
true,
false,
true
true,
null
);
});
});
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type {
PasskeyCreateResult,
PasskeyGetRequest,
PasskeyGetResult,
Android15CreateCustomizationOptions,
PasskeyCreateOptions,
Android15GetCustomizationOptions,
PasskeyGetOptions,
BaseAndroid15CustomizationOptions,
} from './PasskeyTypes';

export {
Expand All @@ -14,4 +19,9 @@ export {
PasskeyCreateResult,
PasskeyGetRequest,
PasskeyGetResult,
Android15CreateCustomizationOptions,
PasskeyCreateOptions,
Android15GetCustomizationOptions,
PasskeyGetOptions,
BaseAndroid15CustomizationOptions,
};