react-native-nitro-passkeys 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NitroPasskeys.podspec +37 -0
- package/README.md +314 -0
- package/android/CMakeLists.txt +31 -0
- package/android/build.gradle +155 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/margelo/nitro/nitropasskeys/HybridNitroPasskeys.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/nitropasskeys/NitroPasskeysPackage.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/nitropasskeys/PasskeyErrorToken.kt +64 -0
- package/android/src/test/java/com/margelo/nitro/nitropasskeys/PasskeyErrorTokenTest.kt +128 -0
- package/ios/Base64URL.swift +37 -0
- package/ios/Bridge.h +1 -0
- package/ios/HybridNitroPasskeys.swift +290 -0
- package/ios/PasskeyErrorToken.swift +32 -0
- package/ios/PasskeyJSON.swift +195 -0
- package/lib/commonjs/PasskeyError.js +39 -0
- package/lib/commonjs/PasskeyError.js.map +1 -0
- package/lib/commonjs/index.js +52 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/response.js +30 -0
- package/lib/commonjs/response.js.map +1 -0
- package/lib/commonjs/specs/nitro-passkeys.nitro.js +6 -0
- package/lib/commonjs/specs/nitro-passkeys.nitro.js.map +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/validate.js +118 -0
- package/lib/commonjs/validate.js.map +1 -0
- package/lib/module/PasskeyError.js +34 -0
- package/lib/module/PasskeyError.js.map +1 -0
- package/lib/module/index.js +42 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/response.js +25 -0
- package/lib/module/response.js.map +1 -0
- package/lib/module/specs/nitro-passkeys.nitro.js +4 -0
- package/lib/module/specs/nitro-passkeys.nitro.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/validate.js +109 -0
- package/lib/module/validate.js.map +1 -0
- package/lib/typescript/example/server/index.d.ts +2 -0
- package/lib/typescript/example/server/index.d.ts.map +1 -0
- package/lib/typescript/src/PasskeyError.d.ts +8 -0
- package/lib/typescript/src/PasskeyError.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/response.d.ts +4 -0
- package/lib/typescript/src/response.d.ts.map +1 -0
- package/lib/typescript/src/specs/nitro-passkeys.nitro.d.ts +12 -0
- package/lib/typescript/src/specs/nitro-passkeys.nitro.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +104 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/validate.d.ts +14 -0
- package/lib/typescript/src/validate.d.ts.map +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/NitroPasskeys+autolinking.cmake +81 -0
- package/nitrogen/generated/android/NitroPasskeys+autolinking.gradle +27 -0
- package/nitrogen/generated/android/NitroPasskeysOnLoad.cpp +54 -0
- package/nitrogen/generated/android/NitroPasskeysOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JHybridNitroPasskeysSpec.cpp +86 -0
- package/nitrogen/generated/android/c++/JHybridNitroPasskeysSpec.hpp +64 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitropasskeys/HybridNitroPasskeysSpec.kt +63 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitropasskeys/NitroPasskeysOnLoad.kt +35 -0
- package/nitrogen/generated/ios/NitroPasskeys+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Bridge.cpp +49 -0
- package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Bridge.hpp +111 -0
- package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Umbrella.hpp +45 -0
- package/nitrogen/generated/ios/NitroPasskeysAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroPasskeysAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridNitroPasskeysSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroPasskeysSpecSwift.hpp +93 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridNitroPasskeysSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridNitroPasskeysSpec_cxx.swift +169 -0
- package/nitrogen/generated/shared/c++/HybridNitroPasskeysSpec.cpp +23 -0
- package/nitrogen/generated/shared/c++/HybridNitroPasskeysSpec.hpp +64 -0
- package/package.json +106 -0
- package/src/PasskeyError.ts +58 -0
- package/src/index.ts +66 -0
- package/src/response.ts +49 -0
- package/src/specs/nitro-passkeys.nitro.ts +12 -0
- package/src/types.ts +124 -0
- package/src/validate.ts +132 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alejandro Paredes Alva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
# CocoaPods does not resolve an unpinned dependency on a prerelease version
|
|
6
|
+
# (e.g. `0.0.0-development` or `1.0.0-next.1`), so the pod version drops any
|
|
7
|
+
# prerelease suffix. The git tag keeps the full npm version.
|
|
8
|
+
pod_version = package["version"].sub(/[-+].*\z/, "")
|
|
9
|
+
|
|
10
|
+
Pod::Spec.new do |s|
|
|
11
|
+
s.name = "NitroPasskeys"
|
|
12
|
+
s.version = pod_version
|
|
13
|
+
s.summary = package["description"]
|
|
14
|
+
s.homepage = package["homepage"]
|
|
15
|
+
s.license = package["license"]
|
|
16
|
+
s.authors = package["author"]
|
|
17
|
+
|
|
18
|
+
s.platforms = { :ios => '16.0' }
|
|
19
|
+
s.source = { :git => "https://github.com/aparedes/react-native-nitro-passkeys.git", :tag => "#{package["version"]}" }
|
|
20
|
+
|
|
21
|
+
s.source_files = [
|
|
22
|
+
# Implementation (Swift)
|
|
23
|
+
"ios/**/*.{swift}",
|
|
24
|
+
# Autolinking/Registration (Objective-C++)
|
|
25
|
+
"ios/**/*.{m,mm}",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
s.frameworks = 'AuthenticationServices'
|
|
29
|
+
|
|
30
|
+
load 'nitrogen/generated/ios/NitroPasskeys+autolinking.rb'
|
|
31
|
+
add_nitrogen_files(s)
|
|
32
|
+
|
|
33
|
+
s.dependency 'React-jsi'
|
|
34
|
+
s.dependency 'React-callinvoker'
|
|
35
|
+
|
|
36
|
+
install_modules_dependencies(s)
|
|
37
|
+
end
|
package/README.md
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# react-native-nitro-passkeys
|
|
2
|
+
|
|
3
|
+
WebAuthn passkeys for bare React Native, built on [Nitro Modules](https://nitro.margelo.com).
|
|
4
|
+
JSON in, JSON out: the option and response shapes are the standard WebAuthn JSON types, so they
|
|
5
|
+
plug straight into `@simplewebauthn/server` v14 (or any server that speaks WebAuthn JSON).
|
|
6
|
+
|
|
7
|
+
- iOS 16+ via `AuthenticationServices`
|
|
8
|
+
- Android minSdk 28 via Credential Manager
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
createPasskey,
|
|
13
|
+
getPasskey,
|
|
14
|
+
isPasskeySupported,
|
|
15
|
+
PasskeyError,
|
|
16
|
+
} from 'react-native-nitro-passkeys';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 1. Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun add react-native-nitro-passkeys react-native-nitro-modules
|
|
23
|
+
cd ios && pod install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`react-native-nitro-modules` is a peer dependency (`>=0.37.1 <0.38.0`).
|
|
27
|
+
|
|
28
|
+
Minimums: **iOS 16** and **Android minSdk 28**. The module has no lower-minSdk mode: a host app
|
|
29
|
+
with a `minSdkVersion` below 28 fails the manifest merge by design, so raise it to 28.
|
|
30
|
+
|
|
31
|
+
No R8/ProGuard rule is needed: `androidx.credentials:credentials-play-services-auth` ships its own.
|
|
32
|
+
|
|
33
|
+
## 2. The rpID must equal the domain
|
|
34
|
+
|
|
35
|
+
The `rp.id` in the creation options and the `rpId` in the request options must be **exactly** the
|
|
36
|
+
domain listed in the iOS `webcredentials:` entitlement and serving `assetlinks.json`: the bare
|
|
37
|
+
host, with no scheme and no port.
|
|
38
|
+
|
|
39
|
+
The rpID is **required**. The module rejects options without it (`invalidOptions`), so pass
|
|
40
|
+
`rpID` to `generateAuthenticationOptions` as well as `generateRegistrationOptions`.
|
|
41
|
+
|
|
42
|
+
## 3. iOS Associated Domains
|
|
43
|
+
|
|
44
|
+
1. Add `webcredentials:<rpID>` to the app's Associated Domains entitlement.
|
|
45
|
+
2. Serve `https://<rpID>/.well-known/apple-app-site-association` as `application/json`, over
|
|
46
|
+
HTTPS, with no redirects:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "webcredentials": { "apps": ["<TEAMID>.<bundle id>"] } }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
3. During development, use the `?mode=developer` suffix (`webcredentials:<rpID>?mode=developer`)
|
|
53
|
+
and enable Associated Domains Development on the device (Settings > Developer). This makes
|
|
54
|
+
the device fetch the file directly from your server instead of through Apple's CDN.
|
|
55
|
+
|
|
56
|
+
## 4. Android Digital Asset Links
|
|
57
|
+
|
|
58
|
+
Serve `https://<rpID>/.well-known/assetlinks.json`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
"relation": ["delegate_permission/common.get_login_creds"],
|
|
64
|
+
"target": {
|
|
65
|
+
"namespace": "android_app",
|
|
66
|
+
"package_name": "<applicationId>",
|
|
67
|
+
"sha256_cert_fingerprints": ["<AA:BB:...>"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
List every signing certificate that installs the app: the debug keystore for development and the
|
|
74
|
+
**Play App Signing** key (not the upload key) for Play builds.
|
|
75
|
+
|
|
76
|
+
## 5. The Android origin
|
|
77
|
+
|
|
78
|
+
Android assertions carry the origin `android:apk-key-hash:<base64url(sha256(cert))>`, not
|
|
79
|
+
`https://<rpID>`. iOS uses `https://<rpID>` as its origin. Your server must therefore accept both:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
expectedOrigin: ['https://example.com', 'android:apk-key-hash:...'];
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Compute the hash from the certificate's SHA-256 fingerprint (the colon-separated hex that
|
|
86
|
+
`keytool -list -v` or the Play Console shows): hex, to bytes, to **unpadded** base64url.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const fingerprint = '4A:5B:...'; // SHA-256 cert fingerprint
|
|
90
|
+
const origin = `android:apk-key-hash:${Buffer.from(fingerprint.replaceAll(':', ''), 'hex').toString('base64url')}`;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Node and Bun's `'base64url'` encoding is already unpadded. If you compute it elsewhere, strip
|
|
94
|
+
the trailing `=` and use `-`/`_` instead of `+`/`/`. Add one origin per signing certificate.
|
|
95
|
+
|
|
96
|
+
## 6. Usage
|
|
97
|
+
|
|
98
|
+
Get options from your server, call `createPasskey` / `getPasskey`, then post the result back for
|
|
99
|
+
`verifyRegistrationResponse` / `verifyAuthenticationResponse`.
|
|
100
|
+
|
|
101
|
+
Server (`@simplewebauthn/server` v14), passing `rpID` to **both** option generators:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import {
|
|
105
|
+
generateAuthenticationOptions,
|
|
106
|
+
generateRegistrationOptions,
|
|
107
|
+
verifyAuthenticationResponse,
|
|
108
|
+
verifyRegistrationResponse,
|
|
109
|
+
} from '@simplewebauthn/server';
|
|
110
|
+
|
|
111
|
+
const rpID = 'example.com';
|
|
112
|
+
const expectedOrigin = ['https://example.com', 'android:apk-key-hash:...'];
|
|
113
|
+
|
|
114
|
+
// POST /register/options
|
|
115
|
+
const registrationOptions = await generateRegistrationOptions({
|
|
116
|
+
rpName: 'Example',
|
|
117
|
+
rpID,
|
|
118
|
+
userName: 'alice',
|
|
119
|
+
excludeCredentials, // the user's existing credentials: { id, transports }[]
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// POST /login/options
|
|
123
|
+
const authenticationOptions = await generateAuthenticationOptions({ rpID });
|
|
124
|
+
|
|
125
|
+
// POST /register/verify
|
|
126
|
+
const registration = await verifyRegistrationResponse({
|
|
127
|
+
response, // the RegistrationResponseJSON posted by the app
|
|
128
|
+
expectedChallenge,
|
|
129
|
+
expectedOrigin,
|
|
130
|
+
expectedRPID: rpID,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// POST /login/verify
|
|
134
|
+
const authentication = await verifyAuthenticationResponse({
|
|
135
|
+
response, // the AuthenticationResponseJSON posted by the app
|
|
136
|
+
expectedChallenge,
|
|
137
|
+
expectedOrigin,
|
|
138
|
+
expectedRPID: rpID,
|
|
139
|
+
credential, // the stored credential: { id, publicKey, counter, transports }
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
App:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import {
|
|
147
|
+
createPasskey,
|
|
148
|
+
getPasskey,
|
|
149
|
+
isPasskeySupported,
|
|
150
|
+
} from 'react-native-nitro-passkeys';
|
|
151
|
+
|
|
152
|
+
if (isPasskeySupported()) {
|
|
153
|
+
// Register
|
|
154
|
+
const options = await post('/register/options', { username: 'alice' });
|
|
155
|
+
const registration = await createPasskey(options);
|
|
156
|
+
await post('/register/verify', { username: 'alice', response: registration });
|
|
157
|
+
|
|
158
|
+
// Sign in
|
|
159
|
+
const requestOptions = await post('/login/options');
|
|
160
|
+
const assertion = await getPasskey(requestOptions);
|
|
161
|
+
await post('/login/verify', { response: assertion });
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`isPasskeySupported()` is synchronous. `createPasskey` and `getPasskey` also reject with
|
|
166
|
+
`unsupported` when it is `false`. The option and response types
|
|
167
|
+
(`PublicKeyCredentialCreationOptionsJSON`, `RegistrationResponseJSON`, and so on) are exported
|
|
168
|
+
from the package.
|
|
169
|
+
|
|
170
|
+
## 7. Errors
|
|
171
|
+
|
|
172
|
+
Every rejection from `createPasskey` and `getPasskey` is a `PasskeyError` with a `code`:
|
|
173
|
+
|
|
174
|
+
| `code` | Meaning |
|
|
175
|
+
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
176
|
+
| `canceled` | The user dismissed the sheet (iOS `ASAuthorizationError.canceled`; Android cancellation, or a DOM `NotAllowedError`/`AbortError`/`TimeoutError`). |
|
|
177
|
+
| `noCredentials` | No matching passkey (Android `NoCredentialException`). iOS never returns this. |
|
|
178
|
+
| `alreadyRegistered` | An `excludeCredentials` entry matched (`createPasskey` only). See below. |
|
|
179
|
+
| `domainNotAssociated` | The rpID is not verified for this app (missing or wrong AASA / `assetlinks.json`). |
|
|
180
|
+
| `unsupported` | Passkeys unavailable: `isPasskeySupported()` is `false`, or no credential provider is available (Android). |
|
|
181
|
+
| `invalidOptions` | The options failed validation (JS or native), for example a missing `rp.id` / `rpId` or invalid base64url. |
|
|
182
|
+
| `unknown` | Anything else, including a concurrent request, a malformed native response and unmapped OS errors. |
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { createPasskey, PasskeyError } from 'react-native-nitro-passkeys';
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await createPasskey(options);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (e instanceof PasskeyError) {
|
|
191
|
+
if (e.code === 'canceled') return;
|
|
192
|
+
if (e.code === 'alreadyRegistered') {
|
|
193
|
+
showMessage('This device already has a passkey');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`alreadyRegistered` comes only from `createPasskey`. It means this device or credential provider
|
|
202
|
+
already holds one of the `excludeCredentials`. Show "this device already has a passkey" rather
|
|
203
|
+
than a generic failure.
|
|
204
|
+
|
|
205
|
+
Native mapping details:
|
|
206
|
+
|
|
207
|
+
| Platform | Native error | `code` |
|
|
208
|
+
| -------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
|
209
|
+
| iOS | `ASAuthorizationError.canceled` (1001) | `canceled` |
|
|
210
|
+
| iOS | `.failed` (1004) whose description contains `is not associated with domain` | `domainNotAssociated` |
|
|
211
|
+
| iOS | `.matchedExcludedCredential` (1006, iOS 18+, registration with `excludedCredentials` only) | `alreadyRegistered` |
|
|
212
|
+
| iOS | other `ASAuthorizationError` codes (1000, 1002, 1003, 1004, 1005 and future codes) | `unknown` |
|
|
213
|
+
| iOS | options JSON or base64url decode failure, missing `rp.id` / `rpId` | `invalidOptions` |
|
|
214
|
+
| iOS | missing attestation object, authenticator data or signature; wrong credential class; no window; a request already in progress | `unknown` |
|
|
215
|
+
| Android | `Create/GetCredentialCancellationException`; DOM `NotAllowedError`, `AbortError`, `TimeoutError` | `canceled` |
|
|
216
|
+
| Android | `NoCredentialException` | `noCredentials` |
|
|
217
|
+
| Android | DOM `SecurityError` (rpID not verified against `assetlinks.json`) | `domainNotAssociated` |
|
|
218
|
+
| Android | provider configuration / unsupported / no-create-option exceptions; DOM `NotSupportedError` | `unsupported` |
|
|
219
|
+
| Android | `IllegalArgumentException` or `JSONException` building the request; DOM `DataError`, `EncodingError`, `SyntaxError` | `invalidOptions` |
|
|
220
|
+
| Android | `CreatePublicKeyCredentialDomException` `InvalidStateError` (an `excludeCredentials` match) | `alreadyRegistered` |
|
|
221
|
+
| Android | interrupted / unknown exceptions, other DOM errors, non-passkey result, no Activity, request in progress, any other `Throwable` | `unknown` |
|
|
222
|
+
| JS | `isPasskeySupported()` is `false` | `unsupported` |
|
|
223
|
+
| JS | option validation failure | `invalidOptions` |
|
|
224
|
+
| JS | unparseable or malformed native response JSON | `unknown` |
|
|
225
|
+
|
|
226
|
+
## 8. Platform notes
|
|
227
|
+
|
|
228
|
+
- **`excludeCredentials` and `alreadyRegistered` depend on the platform:**
|
|
229
|
+
- iOS 18+ and Android (when the credential provider honours the list) report a match as
|
|
230
|
+
`alreadyRegistered`.
|
|
231
|
+
- iOS 17.4 to 17.x honours the list but never reports a match as `alreadyRegistered`
|
|
232
|
+
(typically `unknown`), because error 1006 does not exist before iOS 18.
|
|
233
|
+
- iOS 16.0 to 17.3 ignores the list and registers anyway.
|
|
234
|
+
|
|
235
|
+
Either way, the server must refuse duplicate registrations itself.
|
|
236
|
+
|
|
237
|
+
- iOS ignores `timeout`, `hints`, `attestationFormats`, `extensions`, `residentKey`,
|
|
238
|
+
`requireResidentKey` and `authenticatorAttachment`, and always returns
|
|
239
|
+
`clientExtensionResults: {}`.
|
|
240
|
+
- On iOS, `domainNotAssociated` detection is best-effort (a string match on the OS error).
|
|
241
|
+
- iOS never returns `noCredentials`; it shows the hybrid (QR code) sheet instead.
|
|
242
|
+
- Only one request can be in flight at a time. A second concurrent call rejects with `unknown`
|
|
243
|
+
("already in progress").
|
|
244
|
+
- Android passes the JSON through untouched, so its responses may carry extra fields
|
|
245
|
+
(`publicKey`, `publicKeyAlgorithm`, `authenticatorData`). `@simplewebauthn/server` ignores them.
|
|
246
|
+
- On Android, `isPasskeySupported()` is `true` for SDK 28 and above.
|
|
247
|
+
|
|
248
|
+
## Example app & test server
|
|
249
|
+
|
|
250
|
+
`example/` is a bare React Native app that runs the full register and sign-in flows against
|
|
251
|
+
`example/server`, a small in-memory `@simplewebauthn/server` server that also serves the
|
|
252
|
+
`apple-app-site-association` and `assetlinks.json` files. See
|
|
253
|
+
[`example/server/README.md`](example/server/README.md) for the full details.
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
env RP_ID=passkeys.example.com APPLE_APP_ID=TEAMID.com.nitropasskeysexample \
|
|
257
|
+
bun run example:server --record
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Passkeys need HTTPS on the real `RP_ID`, so expose port `8787` (the default `PORT`) through a
|
|
261
|
+
tunnel whose host is `RP_ID`. Then:
|
|
262
|
+
|
|
263
|
+
- Set `SERVER_URL` in `example/src/config.ts` to `https://<RP_ID>`.
|
|
264
|
+
- iOS: set the `PASSKEYS_RP_ID` build setting on the app target in Xcode to `<RP_ID>`. The
|
|
265
|
+
entitlement is `webcredentials:$(PASSKEYS_RP_ID)?mode=developer`, so enable Associated Domains
|
|
266
|
+
Development on the device, and sign with your team.
|
|
267
|
+
- Android: the server reads the debug keystore fingerprint automatically (override with
|
|
268
|
+
`ANDROID_CERT_SHA256`) and prints the computed `android:apk-key-hash:` origin on startup.
|
|
269
|
+
|
|
270
|
+
Server environment variables: `RP_ID` (required), `PORT` (`8787`), `EXPECTED_ORIGINS`,
|
|
271
|
+
`APPLE_APP_ID`, `ANDROID_PACKAGE`, `ANDROID_CERT_SHA256`.
|
|
272
|
+
|
|
273
|
+
`--record` writes real device output to `src/__tests__/fixtures/`; once committed,
|
|
274
|
+
`bun test src` runs `roundtrip.test.ts` against it (the test is skipped until the fixtures exist).
|
|
275
|
+
|
|
276
|
+
## Development
|
|
277
|
+
|
|
278
|
+
```sh
|
|
279
|
+
bun test src # unit tests (validation, PasskeyError, response parsing)
|
|
280
|
+
bun run typecheck
|
|
281
|
+
bun run build
|
|
282
|
+
bun run codegen # nitrogen + build
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Native unit tests:
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
# iOS XCTest
|
|
289
|
+
xcodebuild test -workspace example/ios/NitroPasskeysExample.xcworkspace \
|
|
290
|
+
-scheme NitroPasskeysExample -destination 'platform=iOS Simulator,name=iPhone 17'
|
|
291
|
+
|
|
292
|
+
# Android JUnit (run from example/android)
|
|
293
|
+
./gradlew :react-native-nitro-passkeys:testDebugUnitTest
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
[React Native Harness](https://www.react-native-harness.dev) integration tests exercise the
|
|
297
|
+
JS to Nitro to native path without system sheets: JS validation through the real entry point,
|
|
298
|
+
`PasskeyError` across the bridge, and (iOS only) an unassociated domain returning
|
|
299
|
+
`domainNotAssociated`:
|
|
300
|
+
|
|
301
|
+
```sh
|
|
302
|
+
env HARNESS_IOS_VERSION=27.0 bun run --cwd example test:harness:ios
|
|
303
|
+
bun run --cwd example test:harness:android
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`HARNESS_IOS_VERSION` selects the simulator runtime (CI uses `26.4`).
|
|
307
|
+
|
|
308
|
+
Successful create/get ceremonies, `canceled`, `alreadyRegistered` and the Android
|
|
309
|
+
`domainNotAssociated` case cannot be driven without system sheets and are verified manually
|
|
310
|
+
with the example app and server.
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
project(NitroPasskeys)
|
|
2
|
+
cmake_minimum_required(VERSION 3.9.0)
|
|
3
|
+
|
|
4
|
+
set (PACKAGE_NAME NitroPasskeys)
|
|
5
|
+
set (CMAKE_VERBOSE_MAKEFILE ON)
|
|
6
|
+
set (CMAKE_CXX_STANDARD 20)
|
|
7
|
+
|
|
8
|
+
# Enable Raw Props parsing in react-native (for Nitro Views)
|
|
9
|
+
add_compile_options(-DRN_SERIALIZABLE_STATE=1)
|
|
10
|
+
|
|
11
|
+
# Define C++ library and add all sources
|
|
12
|
+
add_library(${PACKAGE_NAME} SHARED
|
|
13
|
+
src/main/cpp/cpp-adapter.cpp
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Add Nitrogen specs :)
|
|
17
|
+
include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/NitroPasskeys+autolinking.cmake)
|
|
18
|
+
|
|
19
|
+
# Set up local includes
|
|
20
|
+
include_directories(
|
|
21
|
+
"src/main/cpp"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
find_library(LOG_LIB log)
|
|
25
|
+
|
|
26
|
+
# Link all libraries together
|
|
27
|
+
target_link_libraries(
|
|
28
|
+
${PACKAGE_NAME}
|
|
29
|
+
${LOG_LIB}
|
|
30
|
+
android # <-- Android core
|
|
31
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
repositories {
|
|
3
|
+
google()
|
|
4
|
+
mavenCentral()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
dependencies {
|
|
8
|
+
classpath "com.android.tools.build:gradle:8.8.0"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
def reactNativeArchitectures() {
|
|
13
|
+
def value = rootProject.getProperties().get("reactNativeArchitectures")
|
|
14
|
+
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def isNewArchitectureEnabled() {
|
|
18
|
+
return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
apply plugin: "com.android.library"
|
|
22
|
+
apply plugin: 'org.jetbrains.kotlin.android'
|
|
23
|
+
apply from: '../nitrogen/generated/android/NitroPasskeys+autolinking.gradle'
|
|
24
|
+
apply from: "./fix-prefab.gradle"
|
|
25
|
+
|
|
26
|
+
if (isNewArchitectureEnabled()) {
|
|
27
|
+
apply plugin: "com.facebook.react"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def getExtOrDefault(name) {
|
|
31
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["NitroPasskeys_" + name]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def getExtOrIntegerDefault(name) {
|
|
35
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["NitroPasskeys_" + name]).toInteger()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
android {
|
|
39
|
+
namespace "com.margelo.nitro.nitropasskeys"
|
|
40
|
+
|
|
41
|
+
ndkVersion getExtOrDefault("ndkVersion")
|
|
42
|
+
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
|
|
43
|
+
|
|
44
|
+
defaultConfig {
|
|
45
|
+
// Hard-coded: hosts below 28 must fail the manifest merge (no lower-minSdk mode).
|
|
46
|
+
minSdkVersion 28
|
|
47
|
+
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
48
|
+
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
|
49
|
+
|
|
50
|
+
externalNativeBuild {
|
|
51
|
+
cmake {
|
|
52
|
+
cppFlags "-frtti -fexceptions -Wall -Wextra -fstack-protector-all"
|
|
53
|
+
arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
|
54
|
+
abiFilters (*reactNativeArchitectures())
|
|
55
|
+
|
|
56
|
+
buildTypes {
|
|
57
|
+
debug {
|
|
58
|
+
cppFlags "-O1 -g"
|
|
59
|
+
}
|
|
60
|
+
release {
|
|
61
|
+
cppFlags "-O2"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
externalNativeBuild {
|
|
69
|
+
cmake {
|
|
70
|
+
path "CMakeLists.txt"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
packagingOptions {
|
|
75
|
+
excludes = [
|
|
76
|
+
"META-INF",
|
|
77
|
+
"META-INF/**",
|
|
78
|
+
"**/libc++_shared.so",
|
|
79
|
+
"**/libfbjni.so",
|
|
80
|
+
"**/libjsi.so",
|
|
81
|
+
"**/libfolly_json.so",
|
|
82
|
+
"**/libfolly_runtime.so",
|
|
83
|
+
"**/libglog.so",
|
|
84
|
+
"**/libhermes.so",
|
|
85
|
+
"**/libhermes-executor-debug.so",
|
|
86
|
+
"**/libhermes_executor.so",
|
|
87
|
+
"**/libreactnative.so",
|
|
88
|
+
"**/libreactnativejni.so",
|
|
89
|
+
"**/libturbomodulejsijni.so",
|
|
90
|
+
"**/libreact_nativemodule_core.so",
|
|
91
|
+
"**/libjscexecutor.so"
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
testOptions {
|
|
96
|
+
unitTests.returnDefaultValues = true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
buildFeatures {
|
|
100
|
+
buildConfig true
|
|
101
|
+
prefab true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
buildTypes {
|
|
105
|
+
release {
|
|
106
|
+
minifyEnabled false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lintOptions {
|
|
111
|
+
disable "GradleCompatible"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
compileOptions {
|
|
115
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
116
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sourceSets {
|
|
120
|
+
main {
|
|
121
|
+
if (isNewArchitectureEnabled()) {
|
|
122
|
+
java.srcDirs += [
|
|
123
|
+
// React Codegen files
|
|
124
|
+
"${project.buildDir}/generated/source/codegen/java"
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
repositories {
|
|
132
|
+
mavenCentral()
|
|
133
|
+
google()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
dependencies {
|
|
138
|
+
//noinspection GradleDynamicVersion
|
|
139
|
+
implementation "com.facebook.react:react-native:+"
|
|
140
|
+
implementation project(":react-native-nitro-modules")
|
|
141
|
+
implementation "androidx.credentials:credentials:1.6.0"
|
|
142
|
+
implementation "androidx.credentials:credentials-play-services-auth:1.6.0"
|
|
143
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" // CredentialManager APIs are suspend funs
|
|
144
|
+
|
|
145
|
+
testImplementation "junit:junit:4.13.2"
|
|
146
|
+
testImplementation "org.json:json:20240303" // Android's org.json is a stub on the JVM
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isNewArchitectureEnabled()) {
|
|
150
|
+
react {
|
|
151
|
+
jsRootDir = file("../src/")
|
|
152
|
+
libraryName = "NitroPasskeys"
|
|
153
|
+
codegenJavaPackageName = "com.margelo.nitro.nitropasskeys"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
tasks.configureEach { task ->
|
|
2
|
+
// Make sure that we generate our prefab publication file only after having built the native library
|
|
3
|
+
// so that not a header publication file, but a full configuration publication will be generated, which
|
|
4
|
+
// will include the .so file
|
|
5
|
+
|
|
6
|
+
def prefabConfigurePattern = ~/^prefab(.+)ConfigurePackage$/
|
|
7
|
+
def matcher = task.name =~ prefabConfigurePattern
|
|
8
|
+
if (matcher.matches()) {
|
|
9
|
+
def variantName = matcher[0][1]
|
|
10
|
+
task.outputs.upToDateWhen { false }
|
|
11
|
+
task.dependsOn("externalNativeBuild${variantName}")
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
afterEvaluate {
|
|
16
|
+
def abis = reactNativeArchitectures()
|
|
17
|
+
rootProject.allprojects.each { proj ->
|
|
18
|
+
if (proj === rootProject) return
|
|
19
|
+
|
|
20
|
+
def dependsOnThisLib = proj.configurations.findAll { it.canBeResolved }.any { config ->
|
|
21
|
+
config.dependencies.any { dep ->
|
|
22
|
+
dep.group == project.group && dep.name == project.name
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!dependsOnThisLib && proj != project) return
|
|
26
|
+
|
|
27
|
+
if (!proj.plugins.hasPlugin('com.android.application') && !proj.plugins.hasPlugin('com.android.library')) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def variants = proj.android.hasProperty('applicationVariants') ? proj.android.applicationVariants : proj.android.libraryVariants
|
|
32
|
+
// Touch the prefab_config.json files to ensure that in ExternalNativeJsonGenerator.kt we will re-trigger the prefab CLI to
|
|
33
|
+
// generate a libnameConfig.cmake file that will contain our native library (.so).
|
|
34
|
+
// See this condition: https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/ExternalNativeJsonGenerator.kt;l=207-219?q=createPrefabBuildSystemGlue
|
|
35
|
+
variants.all { variant ->
|
|
36
|
+
def variantName = variant.name
|
|
37
|
+
abis.each { abi ->
|
|
38
|
+
def searchDir = new File(proj.projectDir, ".cxx/${variantName}")
|
|
39
|
+
if (!searchDir.exists()) return
|
|
40
|
+
def matches = []
|
|
41
|
+
searchDir.eachDir { randomDir ->
|
|
42
|
+
def prefabFile = new File(randomDir, "${abi}/prefab_config.json")
|
|
43
|
+
if (prefabFile.exists()) matches << prefabFile
|
|
44
|
+
}
|
|
45
|
+
matches.each { prefabConfig ->
|
|
46
|
+
prefabConfig.setLastModified(System.currentTimeMillis())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|