Skip to content

Commit e8eb940

Browse files
committed
feat: add Passkey.getImmediate for silent credential checks
Adds support for silent / opportunistic passkey checks via a new `Passkey.getImmediate(request)` API that returns a `NoCredentials` error without showing the system modal when no credential is available on the device. - iOS 16+: uses `ASAuthorizationController.preferImmediatelyAvailableCredentials` - Android: passes `preferImmediatelyAvailableCredentials = true` on `GetCredentialRequest` - iOS < 16: falls back to a regular `get()` (no native silent support) Also tightens native error surfacing and Android null-safety: - iOS: maps `ASAuthorizationError` code 1005 to a new `NoCredentials` case. - Android: maps `androidx.credentials.exceptions.domerrors.*` to the existing cross-platform error codes via a new `mapDomError` helper, replacing the raw error message that previously leaked through. Adds `InvalidStateError -> CredentialAlreadyExists` and explicit handling of `TimeoutError`, `NotSupportedError`, etc. - Android: returns `RequestFailed` / `UnknownError` instead of silently resolving with `null` when there is no foreground activity or the native bundle response is missing. Updates README with usage docs for `getImmediate` and a normalised error-code reference table. Closes #91
1 parent 347a2cd commit e8eb940

9 files changed

Lines changed: 182 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,58 @@ try {
153153
}
154154
```
155155

156+
#### Silent / Immediate Authentication
157+
158+
Use `Passkey.getImmediate()` to authenticate **only** when a credential is
159+
already available on the device, without surfacing the system modal when
160+
nothing matches. This is useful for opportunistic sign-in checks (e.g. on app
161+
launch or on a sign-in screen) where you do not want to interrupt the user
162+
with a credential picker if no passkey exists.
163+
164+
- iOS 16+: uses `ASAuthorizationController.preferImmediatelyAvailableCredentials`
165+
- Android: uses the `preferImmediatelyAvailableCredentials` flag on
166+
`GetCredentialRequest` (Credential Manager)
167+
- iOS < 16: falls back to a normal `get()` request (no silent behaviour
168+
available on the platform)
169+
170+
When no credential is available the call rejects with a `NoCredentials` error
171+
and no UI is shown. Handle this error to fall back to your usual sign-in flow.
172+
173+
```ts
174+
import { Passkey } from 'react-native-passkey';
175+
176+
try {
177+
const result = await Passkey.getImmediate(requestJson);
178+
// Credential available — pass to your server
179+
} catch (error) {
180+
if (error.error === 'NoCredentials') {
181+
// No passkey on device — fall back to password / OTP / etc.
182+
} else if (error.error === 'UserCancelled') {
183+
// User dismissed the sheet
184+
} else {
185+
// Handle other errors
186+
}
187+
}
188+
```
189+
190+
#### Error codes
191+
192+
The library normalises native error codes into the following set:
193+
194+
| `error` value | Meaning |
195+
| -------------------- | ---------------------------------------------------------------------- |
196+
| `NotSupported` | Passkeys are not supported on this device / OS version |
197+
| `RequestFailed` | Generic request failure (e.g. transport / network / invalid response) |
198+
| `UserCancelled` | User dismissed the system sheet |
199+
| `InvalidChallenge` | The provided challenge could not be decoded |
200+
| `InvalidUserId` | The provided user id could not be decoded (registration only) |
201+
| `BadConfiguration` | App is not configured correctly (associated domain / asset links) |
202+
| `NoCredentials` | No credential is available — also returned for silent `getImmediate()` |
203+
| `CredentialAlreadyExists` | A passkey already exists for this account on this device (registration) |
204+
| `Interrupted` | The operation was interrupted and may be retried |
205+
| `TimedOut` | The operation timed out |
206+
| `UnknownError` | Unknown / unmapped error |
207+
156208
### Force Platform or Security Key (iOS-specific)
157209

158210
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.

‎android/src/main/java/com/reactnativepasskey/PasskeyModule.kt‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import androidx.credentials.CreatePublicKeyCredentialRequest
1010
import androidx.credentials.GetCredentialRequest
1111
import androidx.credentials.GetPublicKeyCredentialOption
1212
import androidx.credentials.exceptions.*
13-
import androidx.credentials.exceptions.domerrors.InvalidStateError
13+
import androidx.credentials.exceptions.domerrors.*
1414
import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
1515
import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException
1616

@@ -32,10 +32,14 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav
3232

3333
mainScope.launch {
3434
try {
35-
val result = reactApplicationContext.currentActivity?.let { credentialManager.createCredential(it, createPublicKeyCredentialRequest) }
35+
val activity = reactApplicationContext.currentActivity
36+
?: run { promise.reject("RequestFailed", "No active Activity"); return@launch }
37+
38+
val result = credentialManager.createCredential(activity, createPublicKeyCredentialRequest)
3639

3740
val response =
38-
result?.data?.getString("androidx.credentials.BUNDLE_KEY_REGISTRATION_RESPONSE_JSON")
41+
result.data.getString("androidx.credentials.BUNDLE_KEY_REGISTRATION_RESPONSE_JSON")
42+
?: run { promise.reject("UnknownError", "Empty credential response"); return@launch }
3943
promise.resolve(response)
4044
} catch (e: CreateCredentialException) {
4145
val errorCode = handleRegistrationException(e)
@@ -48,14 +52,7 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav
4852
e.printStackTrace()
4953
when (e) {
5054
is CreatePublicKeyCredentialDomException -> {
51-
val msg = e.errorMessage?.toString().orEmpty()
52-
val isExcludedMatch = e.domError is InvalidStateError &&
53-
msg.contains("excluded credential", ignoreCase = true)
54-
55-
if (isExcludedMatch) {
56-
return "CredentialAlreadyExists"
57-
}
58-
return msg
55+
return mapDomError(e.domError)
5956
}
6057
is CreateCredentialCancellationException -> {
6158
return "UserCancelled"
@@ -79,18 +76,24 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav
7976
}
8077

8178
@ReactMethod
82-
fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, promise: Promise) {
79+
fun get(requestJson: String, forcePlatformKey: Boolean, forceSecurityKey: Boolean, preferImmediatelyAvailable: Boolean, promise: Promise) {
8380
val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext)
8481
val getCredentialRequest =
85-
GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson)))
82+
GetCredentialRequest(
83+
listOf(GetPublicKeyCredentialOption(requestJson)),
84+
preferImmediatelyAvailableCredentials = preferImmediatelyAvailable
85+
)
8686

8787
mainScope.launch {
8888
try {
89-
val result =
90-
reactApplicationContext.currentActivity?.let { credentialManager.getCredential(it, getCredentialRequest) }
89+
val activity = reactApplicationContext.currentActivity
90+
?: run { promise.reject("RequestFailed", "No active Activity"); return@launch }
91+
92+
val result = credentialManager.getCredential(activity, getCredentialRequest)
9193

9294
val response =
93-
result?.credential?.data?.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON")
95+
result.credential.data.getString("androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON")
96+
?: run { promise.reject("UnknownError", "Empty credential response"); return@launch }
9497
promise.resolve(response)
9598
} catch (e: GetCredentialException) {
9699
val errorCode = handleAuthenticationException(e)
@@ -103,7 +106,7 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav
103106
e.printStackTrace()
104107
when (e) {
105108
is GetPublicKeyCredentialDomException -> {
106-
return e.errorMessage.toString()
109+
return mapDomError(e.domError)
107110
}
108111
is GetCredentialCancellationException -> {
109112
return "UserCancelled"
@@ -128,4 +131,18 @@ class PasskeyModule(reactContext: ReactApplicationContext) : ReactContextBaseJav
128131
}
129132
}
130133
}
134+
135+
private fun mapDomError(domError: DomError): String {
136+
return when (domError) {
137+
is InvalidStateError -> "CredentialAlreadyExists"
138+
is SecurityError -> "RequestFailed"
139+
is ConstraintError -> "BadConfiguration"
140+
is NotAllowedError -> "RequestFailed"
141+
is TimeoutError -> "TimedOut"
142+
is AbortError -> "UserCancelled"
143+
is DataError -> "RequestFailed"
144+
is NotSupportedError -> "NotSupported"
145+
else -> "UnknownError"
146+
}
147+
}
131148
}

‎ios/Passkey.m‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ @interface RCT_EXTERN_MODULE(Passkey, NSObject)
1212
RCT_EXTERN_METHOD(get:(NSString)request
1313
withForcePlatformKey:(BOOL)forcePlatformKey
1414
withForceSecurityKey:(BOOL)forceSecurityKey
15+
withPreferImmediatelyAvailable:(BOOL)preferImmediatelyAvailable
1516
withResolver:(RCTPromiseResolveBlock)resolve
1617
withRejecter:(RCTPromiseRejectBlock)reject);
1718

‎ios/Passkey.swift‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ class Passkey: NSObject, RNPasskeyResultHandler {
6464
/**
6565
Main get entrypoint
6666
*/
67-
@objc(get:withForcePlatformKey:withForceSecurityKey:withResolver:withRejecter:)
68-
func get(_ request: String, forcePlatformKey: Bool, forceSecurityKey: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
67+
@objc(get:withForcePlatformKey:withForceSecurityKey:withPreferImmediatelyAvailable:withResolver:withRejecter:)
68+
func get(_ request: String, forcePlatformKey: Bool, forceSecurityKey: Bool, preferImmediatelyAvailable: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
6969
do {
7070
passkeyHandler = RNPasskeyHandler(resolve, reject);
7171

@@ -91,8 +91,8 @@ class Passkey: NSObject, RNPasskeyResultHandler {
9191
self.passkeyDelegate = passkeyDelegate;
9292

9393
// Perform the authorization
94-
passkeyDelegate.performAuthForController(controller: authController);
95-
94+
passkeyDelegate.performAuthForController(controller: authController, preferImmediatelyAvailable: preferImmediatelyAvailable);
95+
9696
} catch let error as NSError {
9797
reject(error.debugDescription, error.debugDescription, nil);
9898
}
@@ -302,6 +302,10 @@ class Passkey: NSObject, RNPasskeyResultHandler {
302302
return RNPasskeyError(type: .cancelled, message: error.localizedDescription);
303303
case 1004:
304304
return RNPasskeyError(type: .requestFailed, message: error.localizedDescription);
305+
case 1005:
306+
return RNPasskeyError(type: .noCredentials, message: error.localizedDescription);
307+
case 1006:
308+
return RNPasskeyError(type: .credentialAlreadyExists, message: error.localizedDescription);
305309
case 4004:
306310
return RNPasskeyError(type: .badConfiguration, message: error.localizedDescription);
307311
case 31:

‎ios/PasskeyDelegate.swift‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,18 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat
1919
}
2020

2121
// Perform the authorization request for a given ASAuthorizationController instance
22-
func performAuthForController(controller: ASAuthorizationController) {
22+
func performAuthForController(controller: ASAuthorizationController, preferImmediatelyAvailable: Bool = false) {
2323
controller.delegate = self;
2424
controller.presentationContextProvider = self;
25-
controller.performRequests();
25+
if preferImmediatelyAvailable {
26+
if #available(iOS 16.0, *) {
27+
controller.performRequests(options: .preferImmediatelyAvailableCredentials);
28+
} else {
29+
controller.performRequests();
30+
}
31+
} else {
32+
controller.performRequests();
33+
}
2634
}
2735

2836
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {

‎ios/PasskeyErrors.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ enum RNPasskeyErrorType: String {
1919
case badConfiguration = "BadConfiguration"
2020

2121
case timedOut = "TimedOut"
22-
22+
2323
case HandlerUndefined = "HandlerUndefined"
2424

25+
case noCredentials = "NoCredentials"
26+
2527
case credentialAlreadyExists = "CredentialAlreadyExists"
2628

2729
case unknown = "UnknownError"
28-
30+
2931
}

‎src/Passkey.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,45 @@ export class Passkey {
128128
const response = await NativePasskey.get(
129129
stringifyPasskeyRequest(request, Platform.OS),
130130
false, // forcePlatformKey
131-
false // forceSecurityKey
131+
false, // forceSecurityKey
132+
false // preferImmediatelyAvailable
133+
);
134+
135+
if (typeof response === 'string') {
136+
return JSON.parse(response) as PasskeyGetResult;
137+
}
138+
return response as PasskeyGetResult;
139+
} catch (error: unknown) {
140+
throw handleNativeError(error as TNativeError);
141+
}
142+
}
143+
144+
/**
145+
* Authenticates using an existing Passkey, but only if a credential is
146+
* immediately available on the device. On iOS 16+ this uses
147+
* `ASAuthorizationController.preferImmediatelyAvailableCredentials` and on
148+
* Android the `preferImmediatelyAvailableCredentials` flag of
149+
* `GetCredentialRequest`. When no credential is available the request fails
150+
* silently (no modal) with a `NoCredentials` error, making this suitable for
151+
* silent / opportunistic checks.
152+
*
153+
* @param request The FIDO2 Assertion Request in JSON format
154+
* @returns The FIDO2 Assertion Result in JSON format
155+
* @throws
156+
*/
157+
public static async getImmediate(
158+
request: PasskeyGetRequest
159+
): Promise<PasskeyGetResult> {
160+
if (!Passkey.isSupported()) {
161+
throw NotSupportedError;
162+
}
163+
164+
try {
165+
const response = await NativePasskey.get(
166+
stringifyPasskeyRequest(request, Platform.OS),
167+
true, // forcePlatformKey (immediate is platform-only)
168+
false, // forceSecurityKey
169+
true // preferImmediatelyAvailable
132170
);
133171

134172
if (typeof response === 'string') {
@@ -160,7 +198,8 @@ export class Passkey {
160198
const response = await NativePasskey.get(
161199
stringifyPasskeyRequest(request, Platform.OS),
162200
true, // forcePlatformKey
163-
false // forceSecurityKey
201+
false, // forceSecurityKey
202+
false // preferImmediatelyAvailable
164203
);
165204

166205
if (typeof response === 'string') {
@@ -192,7 +231,8 @@ export class Passkey {
192231
const response = await NativePasskey.get(
193232
stringifyPasskeyRequest(request, Platform.OS),
194233
false, // forcePlatformKey
195-
true // forceSecurityKey
234+
true, // forceSecurityKey
235+
false // preferImmediatelyAvailable
196236
);
197237

198238
if (typeof response === 'string') {

‎src/__tests__/PasskeyAndroid.spec.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @ts-ignore
22
import { Platform, NativeModules } from 'react-native';
33
import { Passkey } from '../Passkey';
4+
import { stringifyPasskeyRequest } from '../PasskeyRequest';
45
import type { PasskeyCreateRequest, PasskeyGetRequest } from '../PasskeyTypes';
56

67
import AuthRequestJson from './testData/AuthRequest.json';
@@ -80,4 +81,18 @@ describe('Test Passkey Module', () => {
8081
await Passkey.get(AuthRequest);
8182
expect(authSpy).toHaveBeenCalled();
8283
});
84+
85+
test('should call native auth method with preferImmediatelyAvailable for getImmediate', async () => {
86+
const authSpy = jest
87+
.spyOn(NativeModules.Passkey, 'get')
88+
.mockResolvedValue(JSON.stringify(AuthAndroidResult));
89+
90+
await Passkey.getImmediate(AuthRequest);
91+
expect(authSpy).toHaveBeenCalledWith(
92+
stringifyPasskeyRequest(AuthRequest, 'android'),
93+
true,
94+
false,
95+
true
96+
);
97+
});
8398
});

‎src/__tests__/PasskeyiOS.spec.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @ts-ignore
22
import { Platform, NativeModules } from 'react-native';
33
import { Passkey } from '../Passkey';
4+
import { stringifyPasskeyRequest } from '../PasskeyRequest';
45
import type { PasskeyCreateRequest, PasskeyGetRequest } from '../PasskeyTypes';
56

67
import AuthRequestJson from './testData/AuthRequest.json';
@@ -41,4 +42,18 @@ describe('Test Passkey Module', () => {
4142
await Passkey.get(AuthRequest);
4243
expect(authSpy).toHaveBeenCalled();
4344
});
45+
46+
test('should call native auth method with preferImmediatelyAvailable for getImmediate', async () => {
47+
const authSpy = jest
48+
.spyOn(NativeModules.Passkey, 'get')
49+
.mockResolvedValue(AuthiOSResult);
50+
51+
await Passkey.getImmediate(AuthRequest);
52+
expect(authSpy).toHaveBeenCalledWith(
53+
stringifyPasskeyRequest(AuthRequest, 'ios'),
54+
true,
55+
false,
56+
true
57+
);
58+
});
4459
});

0 commit comments

Comments
 (0)