@rakomi/react-native 0.0.0 → 0.2.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/COMPLIANCE.md +90 -0
- package/LICENSE +21 -0
- package/README.md +327 -2
- package/SECURITY.md +206 -0
- package/THIRD-PARTY-NOTICES +11 -0
- package/dist/index.cjs +2788 -0
- package/dist/index.d.cts +698 -0
- package/dist/index.d.ts +698 -0
- package/dist/index.js +2681 -0
- package/dist/native/index.cjs +646 -0
- package/dist/native/index.d.cts +143 -0
- package/dist/native/index.d.ts +143 -0
- package/dist/native/index.js +637 -0
- package/dist/passkey-adapter-D_Z3Z7lV.d.cts +469 -0
- package/dist/passkey-adapter-D_Z3Z7lV.d.ts +469 -0
- package/package.json +96 -5
- package/sbom.cdx.json +72 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sdkCore = require('@rakomi/sdk-core');
|
|
4
|
+
|
|
5
|
+
// src/native/bare-rn-example.ts
|
|
6
|
+
function createBareRnAdapter(options) {
|
|
7
|
+
const servicePrefix = options.keychainServicePrefix ?? "rakomi";
|
|
8
|
+
const crypto = {
|
|
9
|
+
getRandomBytes: async (length) => new Uint8Array(options.quickCrypto.randomBytes(length)),
|
|
10
|
+
digestSha256: async (input) => new Uint8Array(options.quickCrypto.createHash("sha256").update(input).digest())
|
|
11
|
+
};
|
|
12
|
+
const storage = {
|
|
13
|
+
getItem: async (key, opts) => {
|
|
14
|
+
const result = await options.keychain.getGenericPassword({
|
|
15
|
+
service: `${servicePrefix}.${key}`,
|
|
16
|
+
...opts?.requireAuthentication ? { authenticationPrompt: { title: opts.authenticationPrompt ?? "Authenticate" } } : {}
|
|
17
|
+
});
|
|
18
|
+
return result ? result.password : null;
|
|
19
|
+
},
|
|
20
|
+
setItem: async (key, value, opts) => {
|
|
21
|
+
await options.keychain.setGenericPassword("rakomi", value, {
|
|
22
|
+
service: `${servicePrefix}.${key}`,
|
|
23
|
+
accessible: opts?.keychainAccessible ?? "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY"
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
removeItem: async (key) => {
|
|
27
|
+
await options.keychain.resetGenericPassword({ service: `${servicePrefix}.${key}` });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const browser = {
|
|
31
|
+
openAuthSession: async (authUrl, redirectUri, opts) => {
|
|
32
|
+
const available = await options.inAppBrowser.isAvailable();
|
|
33
|
+
if (!available) return { type: "locked" };
|
|
34
|
+
const result = await options.inAppBrowser.openAuth(authUrl, redirectUri, {
|
|
35
|
+
ephemeralWebSession: opts?.preferEphemeralSession ?? true
|
|
36
|
+
});
|
|
37
|
+
if (result.type === "success" && result.url) return { type: "success", url: result.url };
|
|
38
|
+
if (result.type === "cancel") return { type: "cancel" };
|
|
39
|
+
return { type: "dismiss" };
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const deepLink = {
|
|
43
|
+
getInitialUrl: () => options.linking.getInitialURL(),
|
|
44
|
+
addListener: (listener) => options.linking.addEventListener("url", (e) => listener(e.url))
|
|
45
|
+
};
|
|
46
|
+
const biometric = {
|
|
47
|
+
isAvailable: async () => {
|
|
48
|
+
const sensor = await options.biometrics.isSensorAvailable();
|
|
49
|
+
return sensor.available;
|
|
50
|
+
},
|
|
51
|
+
authenticate: async ({ promptMessage }) => {
|
|
52
|
+
const result = await options.biometrics.simplePrompt({ promptMessage });
|
|
53
|
+
if (result.success) return { success: true };
|
|
54
|
+
return { success: false, reason: "cancelled" };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const appLifecycle = {
|
|
58
|
+
addStateChangeListener: (listener) => options.appState.addEventListener("change", listener),
|
|
59
|
+
getCurrent: () => options.appState.currentState
|
|
60
|
+
};
|
|
61
|
+
const connectivity = {
|
|
62
|
+
isConnected: async () => {
|
|
63
|
+
const state = await options.netInfo.fetch();
|
|
64
|
+
return state.isConnected === true;
|
|
65
|
+
},
|
|
66
|
+
addListener: (listener) => {
|
|
67
|
+
const unsub = options.netInfo.addEventListener((state) => listener(state.isConnected === true));
|
|
68
|
+
return { remove: unsub };
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
crypto,
|
|
73
|
+
storage,
|
|
74
|
+
browser,
|
|
75
|
+
deepLink,
|
|
76
|
+
biometric,
|
|
77
|
+
appLifecycle,
|
|
78
|
+
connectivity
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/native/dpop-prover.ts
|
|
83
|
+
function createNativeDpopProver(options) {
|
|
84
|
+
const mod = options.module;
|
|
85
|
+
return {
|
|
86
|
+
async createProof(input) {
|
|
87
|
+
const proof = await mod.createDpopProof(input.htm, input.htu, input.nonce ?? null);
|
|
88
|
+
if (typeof proof !== "string" || proof.length === 0) {
|
|
89
|
+
throw new Error("native DPoP signer returned an empty proof");
|
|
90
|
+
}
|
|
91
|
+
return proof;
|
|
92
|
+
},
|
|
93
|
+
async jktHint() {
|
|
94
|
+
return mod.dpopJktThumbprint();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/native/expo-adapter.ts
|
|
100
|
+
function createDefaultExpoAdapter(options = {}) {
|
|
101
|
+
const crypto = createExpoCrypto();
|
|
102
|
+
const storage = createExpoSecureStore({ tokenCache: options.tokenCache });
|
|
103
|
+
const browser = createExpoBrowser();
|
|
104
|
+
const deepLink = createExpoDeepLink();
|
|
105
|
+
const biometric = createExpoBiometricGate();
|
|
106
|
+
const appLifecycle = createRnAppLifecycle();
|
|
107
|
+
const connectivity = createNetInfoConnectivity();
|
|
108
|
+
return {
|
|
109
|
+
crypto,
|
|
110
|
+
storage,
|
|
111
|
+
browser,
|
|
112
|
+
deepLink,
|
|
113
|
+
biometric,
|
|
114
|
+
appLifecycle,
|
|
115
|
+
connectivity
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function createExpoCrypto() {
|
|
119
|
+
let mod = null;
|
|
120
|
+
async function load() {
|
|
121
|
+
if (!mod) {
|
|
122
|
+
mod = await import('expo-crypto').catch(() => null);
|
|
123
|
+
if (!mod) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
"@rakomi/react-native: expo-crypto is required for the default adapter. Install it (Expo-managed) or pass a custom nativeAdapter (bare RN)."
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return mod;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
getRandomBytes: async (n) => {
|
|
133
|
+
const m = await load();
|
|
134
|
+
const result = await m.getRandomBytesAsync(n);
|
|
135
|
+
return new Uint8Array(result);
|
|
136
|
+
},
|
|
137
|
+
digestSha256: async (input) => {
|
|
138
|
+
const m = await load();
|
|
139
|
+
let str = "";
|
|
140
|
+
for (let i = 0; i < input.length; i++) str += String.fromCharCode(input[i]);
|
|
141
|
+
const hex = await m.digestStringAsync(
|
|
142
|
+
m.CryptoDigestAlgorithm.SHA256,
|
|
143
|
+
str,
|
|
144
|
+
{ encoding: m.CryptoEncoding.HEX }
|
|
145
|
+
);
|
|
146
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
147
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
148
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
149
|
+
}
|
|
150
|
+
return bytes;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function createExpoSecureStore(opts) {
|
|
155
|
+
if (opts.tokenCache) {
|
|
156
|
+
const tc = opts.tokenCache;
|
|
157
|
+
return {
|
|
158
|
+
getItem: (key) => tc.getToken(key),
|
|
159
|
+
setItem: async (key, value) => {
|
|
160
|
+
await tc.saveToken(key, value);
|
|
161
|
+
},
|
|
162
|
+
removeItem: (key) => tc.clearToken(key)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
let mod = null;
|
|
166
|
+
async function load() {
|
|
167
|
+
if (!mod) {
|
|
168
|
+
mod = await import('expo-secure-store').catch(() => null);
|
|
169
|
+
if (!mod) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"@rakomi/react-native: expo-secure-store is required for the default adapter. Install it (Expo-managed) or pass `tokenCache` / a custom nativeAdapter."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return mod;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
getItem: async (key, options) => {
|
|
179
|
+
const m = await load();
|
|
180
|
+
return m.getItemAsync(key, {
|
|
181
|
+
requireAuthentication: options?.requireAuthentication,
|
|
182
|
+
authenticationPrompt: options?.authenticationPrompt
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
setItem: async (key, value, options) => {
|
|
186
|
+
const m = await load();
|
|
187
|
+
const accessibility = mapKeychainAccessible(
|
|
188
|
+
m,
|
|
189
|
+
options?.keychainAccessible ?? "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY"
|
|
190
|
+
);
|
|
191
|
+
await m.setItemAsync(key, value, {
|
|
192
|
+
keychainAccessible: accessibility,
|
|
193
|
+
requireAuthentication: options?.requireAuthentication,
|
|
194
|
+
authenticationPrompt: options?.authenticationPrompt
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
removeItem: async (key) => {
|
|
198
|
+
const m = await load();
|
|
199
|
+
await m.deleteItemAsync(key);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function mapKeychainAccessible(m, level) {
|
|
204
|
+
switch (level) {
|
|
205
|
+
case "WHEN_UNLOCKED":
|
|
206
|
+
return m.WHEN_UNLOCKED;
|
|
207
|
+
case "AFTER_FIRST_UNLOCK":
|
|
208
|
+
return m.AFTER_FIRST_UNLOCK;
|
|
209
|
+
case "WHEN_UNLOCKED_THIS_DEVICE_ONLY":
|
|
210
|
+
return m.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
|
211
|
+
case "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY":
|
|
212
|
+
return m.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
|
|
213
|
+
case "WHEN_PASSCODE_SET_THIS_DEVICE_ONLY":
|
|
214
|
+
return m.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function createExpoBrowser() {
|
|
218
|
+
let mod = null;
|
|
219
|
+
async function load() {
|
|
220
|
+
if (!mod) {
|
|
221
|
+
mod = await import('expo-web-browser').catch(() => null);
|
|
222
|
+
if (!mod) {
|
|
223
|
+
throw new Error("@rakomi/react-native: expo-web-browser is required for OAuth flows.");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return mod;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
openAuthSession: async (authUrl, redirectUri, options) => {
|
|
230
|
+
const m = await load();
|
|
231
|
+
const result = await m.openAuthSessionAsync(authUrl, redirectUri, {
|
|
232
|
+
preferEphemeralSession: options?.preferEphemeralSession ?? true
|
|
233
|
+
});
|
|
234
|
+
if (result.type === "success" && result.url) {
|
|
235
|
+
return { type: "success", url: result.url };
|
|
236
|
+
}
|
|
237
|
+
if (result.type === "cancel") return { type: "cancel" };
|
|
238
|
+
if (result.type === "dismiss") return { type: "dismiss" };
|
|
239
|
+
if (result.type === "locked") return { type: "locked" };
|
|
240
|
+
return { type: "cancel" };
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function createExpoDeepLink() {
|
|
245
|
+
let mod = null;
|
|
246
|
+
async function load() {
|
|
247
|
+
if (!mod) {
|
|
248
|
+
mod = await import('expo-linking').catch(() => null);
|
|
249
|
+
if (!mod) {
|
|
250
|
+
throw new Error("@rakomi/react-native: expo-linking is required for OAuth deep-link callback.");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return mod;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
getInitialUrl: async () => {
|
|
257
|
+
const m = await load();
|
|
258
|
+
return m.getInitialURL();
|
|
259
|
+
},
|
|
260
|
+
addListener: (listener) => {
|
|
261
|
+
let sub = null;
|
|
262
|
+
void load().then((m) => {
|
|
263
|
+
sub = m.addEventListener("url", (event) => listener(event.url));
|
|
264
|
+
});
|
|
265
|
+
return {
|
|
266
|
+
remove: () => {
|
|
267
|
+
sub?.remove();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function createExpoBiometricGate() {
|
|
274
|
+
let mod = null;
|
|
275
|
+
async function load() {
|
|
276
|
+
if (mod !== null) return mod;
|
|
277
|
+
try {
|
|
278
|
+
mod = await import('expo-local-authentication');
|
|
279
|
+
} catch {
|
|
280
|
+
mod = null;
|
|
281
|
+
}
|
|
282
|
+
return mod;
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
isAvailable: async () => {
|
|
286
|
+
const m = await load();
|
|
287
|
+
if (!m) return false;
|
|
288
|
+
const has = await m.hasHardwareAsync();
|
|
289
|
+
if (!has) return false;
|
|
290
|
+
const enrolled = await m.isEnrolledAsync();
|
|
291
|
+
return enrolled;
|
|
292
|
+
},
|
|
293
|
+
authenticate: async ({ promptMessage, strict = false }) => {
|
|
294
|
+
const m = await load();
|
|
295
|
+
if (!m) return { success: false, reason: "unavailable" };
|
|
296
|
+
const result = await m.authenticateAsync({
|
|
297
|
+
promptMessage,
|
|
298
|
+
disableDeviceFallback: strict,
|
|
299
|
+
fallbackLabel: "Use passcode"
|
|
300
|
+
});
|
|
301
|
+
if (result.success) return { success: true };
|
|
302
|
+
switch (result.error) {
|
|
303
|
+
case "user_cancel":
|
|
304
|
+
case "system_cancel":
|
|
305
|
+
case "app_cancel":
|
|
306
|
+
return { success: false, reason: "cancelled" };
|
|
307
|
+
case "lockout":
|
|
308
|
+
case "lockout_permanent":
|
|
309
|
+
return { success: false, reason: "lockout" };
|
|
310
|
+
case "not_enrolled":
|
|
311
|
+
return { success: false, reason: "not_enrolled" };
|
|
312
|
+
case "not_available":
|
|
313
|
+
case "no_space":
|
|
314
|
+
return { success: false, reason: "unavailable" };
|
|
315
|
+
default:
|
|
316
|
+
return { success: false, reason: "unknown" };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function createRnAppLifecycle() {
|
|
322
|
+
let rn = null;
|
|
323
|
+
let pending = null;
|
|
324
|
+
async function ensure() {
|
|
325
|
+
if (rn) return rn;
|
|
326
|
+
if (!pending) {
|
|
327
|
+
pending = import('react-native').then((m) => {
|
|
328
|
+
rn = m.default ?? m;
|
|
329
|
+
return rn;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return pending;
|
|
333
|
+
}
|
|
334
|
+
void ensure().catch(() => void 0);
|
|
335
|
+
return {
|
|
336
|
+
addStateChangeListener: (listener) => {
|
|
337
|
+
let sub = null;
|
|
338
|
+
void ensure().then((r) => {
|
|
339
|
+
sub = r.AppState.addEventListener("change", (next) => {
|
|
340
|
+
listener(coerceAppState(next));
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
return { remove: () => sub?.remove() };
|
|
344
|
+
},
|
|
345
|
+
getCurrent: () => rn ? coerceAppState(rn.AppState.currentState) : "unknown"
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function coerceAppState(value) {
|
|
349
|
+
if (value === "active" || value === "background" || value === "inactive") return value;
|
|
350
|
+
return "unknown";
|
|
351
|
+
}
|
|
352
|
+
function createNetInfoConnectivity() {
|
|
353
|
+
let mod = null;
|
|
354
|
+
async function load() {
|
|
355
|
+
if (mod !== null) return mod;
|
|
356
|
+
try {
|
|
357
|
+
mod = (await import('@react-native-community/netinfo')).default ?? null;
|
|
358
|
+
} catch {
|
|
359
|
+
mod = null;
|
|
360
|
+
}
|
|
361
|
+
return mod;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
isConnected: async () => {
|
|
365
|
+
const m = await load();
|
|
366
|
+
if (!m) return true;
|
|
367
|
+
const state = await m.fetch();
|
|
368
|
+
return state.isConnected ?? true;
|
|
369
|
+
},
|
|
370
|
+
addListener: (listener) => {
|
|
371
|
+
let unsubscribe = null;
|
|
372
|
+
void load().then((m) => {
|
|
373
|
+
if (!m) return;
|
|
374
|
+
unsubscribe = m.addEventListener((state) => listener(state.isConnected ?? true));
|
|
375
|
+
});
|
|
376
|
+
return { remove: () => unsubscribe?.() };
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/native/passkey-envelope.ts
|
|
382
|
+
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
383
|
+
var RULES = {
|
|
384
|
+
registration: {
|
|
385
|
+
required: ["id", "rawId", "response.clientDataJSON", "response.attestationObject"],
|
|
386
|
+
base64url: ["id", "rawId", "response.clientDataJSON", "response.attestationObject"],
|
|
387
|
+
requiredStringArray: ["response.transports"],
|
|
388
|
+
requiredObject: ["clientExtensionResults"],
|
|
389
|
+
keep: ["id", "rawId", "type", "authenticatorAttachment", "clientExtensionResults"],
|
|
390
|
+
keepInResponse: [
|
|
391
|
+
"clientDataJSON",
|
|
392
|
+
"attestationObject",
|
|
393
|
+
"authenticatorData",
|
|
394
|
+
"publicKey",
|
|
395
|
+
"publicKeyAlgorithm"
|
|
396
|
+
],
|
|
397
|
+
keepStringArrayInResponse: ["transports"]
|
|
398
|
+
},
|
|
399
|
+
assertion: {
|
|
400
|
+
required: [
|
|
401
|
+
"id",
|
|
402
|
+
"rawId",
|
|
403
|
+
"response.clientDataJSON",
|
|
404
|
+
"response.authenticatorData",
|
|
405
|
+
"response.signature"
|
|
406
|
+
],
|
|
407
|
+
base64url: [
|
|
408
|
+
"id",
|
|
409
|
+
"rawId",
|
|
410
|
+
"response.clientDataJSON",
|
|
411
|
+
"response.authenticatorData",
|
|
412
|
+
"response.signature",
|
|
413
|
+
"response.userHandle"
|
|
414
|
+
],
|
|
415
|
+
requiredStringArray: [],
|
|
416
|
+
requiredObject: ["clientExtensionResults"],
|
|
417
|
+
keep: ["id", "rawId", "type", "authenticatorAttachment", "clientExtensionResults"],
|
|
418
|
+
keepInResponse: ["clientDataJSON", "authenticatorData", "signature", "userHandle"],
|
|
419
|
+
keepStringArrayInResponse: []
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
var CredentialEnvelopeError = class extends Error {
|
|
423
|
+
constructor(message) {
|
|
424
|
+
super(message);
|
|
425
|
+
this.name = "CredentialEnvelopeError";
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
function isRecord(value) {
|
|
429
|
+
return typeof value === "object" && value !== null;
|
|
430
|
+
}
|
|
431
|
+
function readPath(envelope, path) {
|
|
432
|
+
const dot = path.indexOf(".");
|
|
433
|
+
if (dot === -1) return envelope[path];
|
|
434
|
+
const parent = envelope[path.slice(0, dot)];
|
|
435
|
+
return isRecord(parent) ? parent[path.slice(dot + 1)] : void 0;
|
|
436
|
+
}
|
|
437
|
+
function assertW3cCredentialEnvelope(kind, parsed) {
|
|
438
|
+
if (!isRecord(parsed)) {
|
|
439
|
+
throw new CredentialEnvelopeError(
|
|
440
|
+
`the native module returned a ${parsed === null ? "null" : typeof parsed}; the contract is a JSON object`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (parsed["type"] !== "public-key") {
|
|
444
|
+
throw new CredentialEnvelopeError(`'type' must be the string "public-key" (W3C WebAuthn L3)`);
|
|
445
|
+
}
|
|
446
|
+
if (!isRecord(parsed["response"])) {
|
|
447
|
+
throw new CredentialEnvelopeError(`'response' is missing or is not an object`);
|
|
448
|
+
}
|
|
449
|
+
const rule = RULES[kind];
|
|
450
|
+
for (const path of rule.required) {
|
|
451
|
+
const value = readPath(parsed, path);
|
|
452
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
453
|
+
throw new CredentialEnvelopeError(
|
|
454
|
+
`'${path}' is required on a ${kind} response and must be a non-empty string (W3C WebAuthn L3 JSON serialization)`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const path of rule.requiredStringArray) {
|
|
459
|
+
const value = readPath(parsed, path);
|
|
460
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
461
|
+
throw new CredentialEnvelopeError(
|
|
462
|
+
`'${path}' is required on a ${kind} response and must be an array of strings (W3C WebAuthn L3). The server stores it and replays it into allowCredentials \u2014 omitting it silently breaks cross-device sign-in later, on another device.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const path of rule.requiredObject) {
|
|
467
|
+
const value = readPath(parsed, path);
|
|
468
|
+
if (!isRecord(value)) {
|
|
469
|
+
throw new CredentialEnvelopeError(
|
|
470
|
+
`'${path}' is required on a ${kind} response and must be an object (an empty object is fine)`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const path of rule.base64url) {
|
|
475
|
+
const value = readPath(parsed, path);
|
|
476
|
+
if (value === void 0 || value === null) continue;
|
|
477
|
+
if (typeof value !== "string" || !BASE64URL.test(value)) {
|
|
478
|
+
throw new CredentialEnvelopeError(
|
|
479
|
+
`'${path}' is not unpadded base64url (RFC 4648 \xA75). On iOS, Data.base64EncodedString() emits standard base64 (RFC 4648 \xA74, with '+', '/' and '=' padding) \u2014 use the URL-safe, unpadded variant.`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function sanitizeCredentialEnvelope(kind, parsed) {
|
|
485
|
+
assertW3cCredentialEnvelope(kind, parsed);
|
|
486
|
+
const envelope = parsed;
|
|
487
|
+
const source = envelope["response"];
|
|
488
|
+
const rule = RULES[kind];
|
|
489
|
+
const response = /* @__PURE__ */ Object.create(null);
|
|
490
|
+
for (const key of rule.keepInResponse) {
|
|
491
|
+
const value = source[key];
|
|
492
|
+
if (typeof value === "string" || typeof value === "number") response[key] = value;
|
|
493
|
+
}
|
|
494
|
+
for (const key of rule.keepStringArrayInResponse) {
|
|
495
|
+
const value = source[key];
|
|
496
|
+
if (Array.isArray(value)) response[key] = value.filter((entry) => typeof entry === "string");
|
|
497
|
+
}
|
|
498
|
+
const projected = /* @__PURE__ */ Object.create(null);
|
|
499
|
+
for (const key of rule.keep) {
|
|
500
|
+
const value = envelope[key];
|
|
501
|
+
if (value !== void 0) projected[key] = value;
|
|
502
|
+
}
|
|
503
|
+
projected["response"] = response;
|
|
504
|
+
return projected;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/native/passkey-adapter.ts
|
|
508
|
+
var NATIVE_CODE_TO_KIND = Object.assign(/* @__PURE__ */ Object.create(null), {
|
|
509
|
+
PASSKEY_CANCELLED: "cancelled",
|
|
510
|
+
PASSKEY_UNSUPPORTED: "unsupported",
|
|
511
|
+
PASSKEY_NO_CREDENTIAL: "cancelled"
|
|
512
|
+
});
|
|
513
|
+
function isRecord2(value) {
|
|
514
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
515
|
+
}
|
|
516
|
+
function normalizeNativeRejection(err) {
|
|
517
|
+
const code = isRecord2(err) ? err["code"] : void 0;
|
|
518
|
+
if (typeof code === "string") {
|
|
519
|
+
const kind = NATIVE_CODE_TO_KIND[code];
|
|
520
|
+
if (kind !== void 0) {
|
|
521
|
+
throw new sdkCore.PasskeyCeremonyError(kind);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
function assertNever(value) {
|
|
527
|
+
throw new sdkCore.PasskeyCeremonyError("failed", `unhandled ceremony-error kind: ${String(value)}`);
|
|
528
|
+
}
|
|
529
|
+
function describeKind(kind) {
|
|
530
|
+
switch (kind) {
|
|
531
|
+
case "cancelled":
|
|
532
|
+
return "the user dismissed the passkey sheet";
|
|
533
|
+
case "unsupported":
|
|
534
|
+
return "this platform cannot run a passkey ceremony";
|
|
535
|
+
case "failed":
|
|
536
|
+
return "the passkey ceremony failed";
|
|
537
|
+
default:
|
|
538
|
+
return assertNever(kind);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function createNativePasskeyAdapter(options) {
|
|
542
|
+
const mod = options.module;
|
|
543
|
+
let inFlight = null;
|
|
544
|
+
const candidate = mod;
|
|
545
|
+
const missing = ["isPasskeySupported", "createPasskey", "getPasskey"].filter(
|
|
546
|
+
(method) => typeof candidate?.[method] !== "function"
|
|
547
|
+
);
|
|
548
|
+
if (missing.length > 0) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`native passkey module is missing: ${missing.join(", ")} \u2014 it does not satisfy NativePasskeyModuleSpec`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
async function runCeremony(kind, requestJson, ceremonyOptions) {
|
|
554
|
+
const signal = ceremonyOptions?.signal;
|
|
555
|
+
if (signal?.aborted === true) {
|
|
556
|
+
throw new sdkCore.PasskeyCeremonyError("cancelled", describeKind("cancelled"));
|
|
557
|
+
}
|
|
558
|
+
const ticket = /* @__PURE__ */ Symbol("passkey-ceremony");
|
|
559
|
+
inFlight = ticket;
|
|
560
|
+
const onAbort = () => {
|
|
561
|
+
if (inFlight !== ticket) return;
|
|
562
|
+
try {
|
|
563
|
+
void Promise.resolve(mod.cancelPasskeyRequest?.()).catch(() => void 0);
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
568
|
+
let raw;
|
|
569
|
+
try {
|
|
570
|
+
raw = await (kind === "registration" ? mod.createPasskey(requestJson) : mod.getPasskey(requestJson));
|
|
571
|
+
} catch (err) {
|
|
572
|
+
normalizeNativeRejection(err);
|
|
573
|
+
} finally {
|
|
574
|
+
signal?.removeEventListener("abort", onAbort);
|
|
575
|
+
if (inFlight === ticket) inFlight = null;
|
|
576
|
+
}
|
|
577
|
+
if (typeof raw !== "string") {
|
|
578
|
+
throw new CredentialEnvelopeError(
|
|
579
|
+
`the native module returned ${raw === null ? "null" : `a ${typeof raw}`}; the contract is a JSON string \u2014 wrap a module with an object API so it stringifies its argument and its result`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
let parsed;
|
|
583
|
+
try {
|
|
584
|
+
parsed = JSON.parse(raw);
|
|
585
|
+
} catch {
|
|
586
|
+
throw new CredentialEnvelopeError(
|
|
587
|
+
"the native module returned a string that is not valid JSON (a double-encoded response does this)"
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
return sanitizeCredentialEnvelope(kind, parsed);
|
|
591
|
+
}
|
|
592
|
+
async function ceremonyOrMalformed(kind, requestJson, ceremonyOptions) {
|
|
593
|
+
try {
|
|
594
|
+
return await runCeremony(kind, requestJson, ceremonyOptions);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (err instanceof CredentialEnvelopeError) {
|
|
597
|
+
return { rakomiEnvelopeViolation: err.message };
|
|
598
|
+
}
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
async isSupported() {
|
|
604
|
+
try {
|
|
605
|
+
return await mod.isPasskeySupported() === true;
|
|
606
|
+
} catch {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
hasPlatformAuthenticator() {
|
|
611
|
+
return probePlatformAuthenticator(mod);
|
|
612
|
+
},
|
|
613
|
+
createCredential(creationOptions, ceremonyOptions) {
|
|
614
|
+
return ceremonyOrMalformed(
|
|
615
|
+
"registration",
|
|
616
|
+
JSON.stringify(creationOptions),
|
|
617
|
+
ceremonyOptions
|
|
618
|
+
);
|
|
619
|
+
},
|
|
620
|
+
getCredential(requestOptions, ceremonyOptions) {
|
|
621
|
+
return ceremonyOrMalformed(
|
|
622
|
+
"assertion",
|
|
623
|
+
JSON.stringify(requestOptions),
|
|
624
|
+
ceremonyOptions
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
async function probePlatformAuthenticator(mod) {
|
|
630
|
+
if (typeof mod.isPlatformAuthenticatorAvailable !== "function") return null;
|
|
631
|
+
try {
|
|
632
|
+
const available = await mod.isPlatformAuthenticatorAvailable();
|
|
633
|
+
return typeof available === "boolean" ? available : null;
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
exports.CredentialEnvelopeError = CredentialEnvelopeError;
|
|
640
|
+
exports.assertW3cCredentialEnvelope = assertW3cCredentialEnvelope;
|
|
641
|
+
exports.createBareRnAdapter = createBareRnAdapter;
|
|
642
|
+
exports.createDefaultExpoAdapter = createDefaultExpoAdapter;
|
|
643
|
+
exports.createNativeDpopProver = createNativeDpopProver;
|
|
644
|
+
exports.createNativePasskeyAdapter = createNativePasskeyAdapter;
|
|
645
|
+
exports.probePlatformAuthenticator = probePlatformAuthenticator;
|
|
646
|
+
exports.sanitizeCredentialEnvelope = sanitizeCredentialEnvelope;
|