@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
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2788 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var sdkCore = require('@rakomi/sdk-core');
|
|
5
|
+
var jose = require('jose');
|
|
6
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
7
|
+
|
|
8
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
9
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
10
|
+
}) : x)(function(x) {
|
|
11
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
12
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// src/internal/ceremony-lock.ts
|
|
16
|
+
function createCeremonyLock() {
|
|
17
|
+
let held = false;
|
|
18
|
+
return {
|
|
19
|
+
tryAcquire() {
|
|
20
|
+
if (held) return false;
|
|
21
|
+
held = true;
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
release() {
|
|
25
|
+
held = false;
|
|
26
|
+
},
|
|
27
|
+
get held() {
|
|
28
|
+
return held;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/internal/canonical-url.ts
|
|
34
|
+
var CanonicalizeUrlError = class extends Error {
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "CanonicalizeUrlError";
|
|
39
|
+
}
|
|
40
|
+
code;
|
|
41
|
+
};
|
|
42
|
+
function collapsePath(path) {
|
|
43
|
+
const collapsed = path.replace(/\/{2,}/g, "/");
|
|
44
|
+
if (collapsed.length > 1 && collapsed.endsWith("/")) {
|
|
45
|
+
return collapsed.slice(0, -1);
|
|
46
|
+
}
|
|
47
|
+
return collapsed;
|
|
48
|
+
}
|
|
49
|
+
function lowercasePercentHex(s) {
|
|
50
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (_m, h) => `%${h.toLowerCase()}`);
|
|
51
|
+
}
|
|
52
|
+
function canonicalizeUrl(input, options = {}) {
|
|
53
|
+
const version = options.version ?? "rfc9449-2023";
|
|
54
|
+
if (version !== "rfc9449-2023") {
|
|
55
|
+
throw new CanonicalizeUrlError("unsupported_version", `unsupported canonicalize-url version: ${version}`);
|
|
56
|
+
}
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(input);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new CanonicalizeUrlError("invalid_url", "invalid url");
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol === "https:" && url.port === "443" || url.protocol === "http:" && url.port === "80") {
|
|
64
|
+
url.port = "";
|
|
65
|
+
}
|
|
66
|
+
url.search = "";
|
|
67
|
+
url.hash = "";
|
|
68
|
+
const normalizedPath = collapsePath(lowercasePercentHex(url.pathname));
|
|
69
|
+
const host = url.host;
|
|
70
|
+
return `${url.protocol}//${host}${normalizedPath}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/internal/dpop-session.ts
|
|
74
|
+
var DpopSession = class {
|
|
75
|
+
prover;
|
|
76
|
+
baseUrl;
|
|
77
|
+
onDowngrade;
|
|
78
|
+
_bound = false;
|
|
79
|
+
_downgraded = false;
|
|
80
|
+
_downgradeNotified = false;
|
|
81
|
+
_boundJkt;
|
|
82
|
+
constructor(options) {
|
|
83
|
+
this.prover = options.prover;
|
|
84
|
+
this.baseUrl = options.baseUrl.endsWith("/") ? options.baseUrl.slice(0, -1) : options.baseUrl;
|
|
85
|
+
this.onDowngrade = options.onDowngrade;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `true` once the server has confirmed (`token_type === "DPoP"`) that this
|
|
89
|
+
* session's key is sender-bound. The SDK attaches a refresh proof IFF this is
|
|
90
|
+
* `true`. Starts `false`; set by observing issuance/refresh responses.
|
|
91
|
+
*/
|
|
92
|
+
get isBound() {
|
|
93
|
+
return this._bound;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* `true` if a session that presented a DPoP proof was returned as `Bearer` by
|
|
97
|
+
* the server (a security downgrade — flag off / server-side downgrade).
|
|
98
|
+
*/
|
|
99
|
+
get isDowngraded() {
|
|
100
|
+
return this._downgraded;
|
|
101
|
+
}
|
|
102
|
+
/** The committed RFC 7638 thumbprint (`jkt`) of the session keypair, once bound. */
|
|
103
|
+
get boundJkt() {
|
|
104
|
+
return this._boundJkt;
|
|
105
|
+
}
|
|
106
|
+
async resolveProof(htm, target, opts) {
|
|
107
|
+
const absolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : `${this.baseUrl}${target}`;
|
|
108
|
+
const htu = canonicalizeUrl(absolute);
|
|
109
|
+
const input = { htm, htu, ...opts?.nonce !== void 0 ? { nonce: opts.nonce } : {} };
|
|
110
|
+
return this.prover.createProof(input);
|
|
111
|
+
}
|
|
112
|
+
async jktHint() {
|
|
113
|
+
return this.prover.jktHint();
|
|
114
|
+
}
|
|
115
|
+
async observeTokenType(tokenType, attachedProof) {
|
|
116
|
+
if (tokenType === "DPoP") {
|
|
117
|
+
this._bound = true;
|
|
118
|
+
this._downgraded = false;
|
|
119
|
+
if (this._boundJkt === void 0) {
|
|
120
|
+
this._boundJkt = await this.prover.jktHint();
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this._bound = false;
|
|
125
|
+
if (attachedProof) {
|
|
126
|
+
this._downgraded = true;
|
|
127
|
+
if (!this._downgradeNotified) {
|
|
128
|
+
this._downgradeNotified = true;
|
|
129
|
+
this.onDowngrade?.({ reason: "server_returned_bearer" });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
function createDpopSession(options) {
|
|
135
|
+
return new DpopSession(options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/internal/compose-signals.ts
|
|
139
|
+
function composeSignals(caller, internal) {
|
|
140
|
+
if (caller === void 0) {
|
|
141
|
+
return { signal: internal, dispose: () => void 0 };
|
|
142
|
+
}
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
const abort = (reason) => {
|
|
145
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
146
|
+
};
|
|
147
|
+
const onCaller = () => abort(caller.reason);
|
|
148
|
+
const onInternal = () => abort(internal.reason);
|
|
149
|
+
if (caller.aborted) {
|
|
150
|
+
abort(caller.reason);
|
|
151
|
+
} else if (internal.aborted) {
|
|
152
|
+
abort(internal.reason);
|
|
153
|
+
} else {
|
|
154
|
+
caller.addEventListener("abort", onCaller, { once: true });
|
|
155
|
+
internal.addEventListener("abort", onInternal, { once: true });
|
|
156
|
+
}
|
|
157
|
+
let disposed = false;
|
|
158
|
+
return {
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
dispose: () => {
|
|
161
|
+
if (disposed) return;
|
|
162
|
+
disposed = true;
|
|
163
|
+
caller.removeEventListener("abort", onCaller);
|
|
164
|
+
internal.removeEventListener("abort", onInternal);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/internal/http-client.ts
|
|
170
|
+
function createRnHttpClient(options = {}) {
|
|
171
|
+
const baseUrl = options.baseUrl?.replace(/\/$/, "") ?? "";
|
|
172
|
+
const timeout = options.defaultTimeoutMs ?? 3e4;
|
|
173
|
+
return {
|
|
174
|
+
fetch: async (url, init = {}) => {
|
|
175
|
+
const fullUrl = url.startsWith("http://") || url.startsWith("https://") ? url : `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const timedOut = Object.assign(new Error("request timed out"), { name: "TimeoutError" });
|
|
178
|
+
const timer = setTimeout(() => controller.abort(timedOut), timeout);
|
|
179
|
+
const composed = composeSignals(init.signal, controller.signal);
|
|
180
|
+
let response;
|
|
181
|
+
try {
|
|
182
|
+
response = await fetch(fullUrl, {
|
|
183
|
+
method: init.method ?? "GET",
|
|
184
|
+
headers: init.headers,
|
|
185
|
+
body: init.body,
|
|
186
|
+
redirect: "error",
|
|
187
|
+
signal: composed.signal
|
|
188
|
+
});
|
|
189
|
+
} catch (err2) {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
composed.dispose();
|
|
192
|
+
throw err2;
|
|
193
|
+
}
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
const body = response.body;
|
|
196
|
+
const streaming = body !== null && body !== void 0 && typeof body.pipeThrough === "function" && typeof TransformStream !== "undefined";
|
|
197
|
+
if (!streaming) {
|
|
198
|
+
composed.dispose();
|
|
199
|
+
return response;
|
|
200
|
+
}
|
|
201
|
+
const release = new TransformStream({
|
|
202
|
+
flush: () => composed.dispose()
|
|
203
|
+
});
|
|
204
|
+
const piped = body.pipeThrough(release);
|
|
205
|
+
const wrapped = new Response(piped, {
|
|
206
|
+
status: response.status,
|
|
207
|
+
statusText: response.statusText,
|
|
208
|
+
headers: response.headers
|
|
209
|
+
});
|
|
210
|
+
composed.signal.addEventListener("abort", () => composed.dispose(), { once: true });
|
|
211
|
+
return wrapped;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function err(cls, message) {
|
|
216
|
+
return { ok: false, error: { class: cls, code: `auth/${cls}`, message } };
|
|
217
|
+
}
|
|
218
|
+
function proverUnavailable(message = "DPoP prover unavailable") {
|
|
219
|
+
return err("dpop_prover_unavailable", message);
|
|
220
|
+
}
|
|
221
|
+
async function refreshWithDpop(input) {
|
|
222
|
+
const session = input.dpopSession;
|
|
223
|
+
const bound = session?.isBound === true;
|
|
224
|
+
if (!bound || !session) {
|
|
225
|
+
const result2 = await sdkCore.refreshAccessToken({
|
|
226
|
+
http: input.http,
|
|
227
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
228
|
+
refreshToken: input.refreshToken,
|
|
229
|
+
clientId: input.clientId
|
|
230
|
+
});
|
|
231
|
+
if (result2.ok) {
|
|
232
|
+
await session?.observeTokenType(result2.tokens.token_type, false);
|
|
233
|
+
return { ok: true, tokens: result2.tokens, attachedProof: false };
|
|
234
|
+
}
|
|
235
|
+
return mapFailure(result2, false);
|
|
236
|
+
}
|
|
237
|
+
let proof;
|
|
238
|
+
try {
|
|
239
|
+
proof = await session.resolveProof("POST", input.tokenEndpoint);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return proverUnavailable(e instanceof Error ? e.message : "DPoP prover threw");
|
|
242
|
+
}
|
|
243
|
+
if (!proof) return proverUnavailable("DPoP prover returned an empty proof");
|
|
244
|
+
let result = await sdkCore.refreshAccessToken({
|
|
245
|
+
http: input.http,
|
|
246
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
247
|
+
refreshToken: input.refreshToken,
|
|
248
|
+
clientId: input.clientId,
|
|
249
|
+
dpopProof: proof
|
|
250
|
+
});
|
|
251
|
+
if (!result.ok && result.dpopNonce !== void 0) {
|
|
252
|
+
let retryProof;
|
|
253
|
+
try {
|
|
254
|
+
retryProof = await session.resolveProof("POST", input.tokenEndpoint, { nonce: result.dpopNonce });
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return proverUnavailable(e instanceof Error ? e.message : "DPoP prover threw on nonce retry");
|
|
257
|
+
}
|
|
258
|
+
if (!retryProof) return proverUnavailable("DPoP prover returned an empty proof on nonce retry");
|
|
259
|
+
result = await sdkCore.refreshAccessToken({
|
|
260
|
+
http: input.http,
|
|
261
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
262
|
+
refreshToken: input.refreshToken,
|
|
263
|
+
clientId: input.clientId,
|
|
264
|
+
dpopProof: retryProof
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (result.ok) {
|
|
268
|
+
await session.observeTokenType(result.tokens.token_type, true);
|
|
269
|
+
return { ok: true, tokens: result.tokens, attachedProof: true };
|
|
270
|
+
}
|
|
271
|
+
return mapFailure(result, true);
|
|
272
|
+
}
|
|
273
|
+
function mapFailure(result, attachedProof) {
|
|
274
|
+
const message = result.error.message ?? "refresh failed";
|
|
275
|
+
if (attachedProof && (result.dpopProofRejected || result.dpopNonce !== void 0)) {
|
|
276
|
+
return err("invalid_dpop_proof", message);
|
|
277
|
+
}
|
|
278
|
+
if (result.error.code === "REFRESH_FAILED" && result.error.reason === "network") {
|
|
279
|
+
return err("network", message);
|
|
280
|
+
}
|
|
281
|
+
return err("invalid_refresh_token", message);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/internal/token-runtime.ts
|
|
285
|
+
var RETRY_DELAYS_MS = [2e3, 8e3, 3e4];
|
|
286
|
+
function bytesToBase64Url(bytes) {
|
|
287
|
+
let binary = "";
|
|
288
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
289
|
+
const std = btoa(binary);
|
|
290
|
+
return std.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
291
|
+
}
|
|
292
|
+
function deriveDefaultJwksUri(tokenEndpoint) {
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL(tokenEndpoint);
|
|
295
|
+
return `${url.origin}/.well-known/jwks.json`;
|
|
296
|
+
} catch {
|
|
297
|
+
return tokenEndpoint.replace(/\/[^/]*$/, "") + "/.well-known/jwks.json";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
var RAKOMI_PLATFORM_ISSUER_DEFAULT = "https://api.rakomi.com";
|
|
301
|
+
var RAKOMI_PLATFORM_AUDIENCE_DEFAULT = "https://api.rakomi.com";
|
|
302
|
+
function deriveDefaultIssuer(_tokenEndpoint) {
|
|
303
|
+
return RAKOMI_PLATFORM_ISSUER_DEFAULT;
|
|
304
|
+
}
|
|
305
|
+
var REFRESH_TOKEN_PREFIX = "v1:";
|
|
306
|
+
var REFRESH_BUFFER_SECONDS = 60;
|
|
307
|
+
var TokenRuntime = class _TokenRuntime {
|
|
308
|
+
clientId;
|
|
309
|
+
tenantId;
|
|
310
|
+
tokenEndpoint;
|
|
311
|
+
storage;
|
|
312
|
+
http;
|
|
313
|
+
crypto;
|
|
314
|
+
biometric;
|
|
315
|
+
biometricEnabled;
|
|
316
|
+
biometricStrict;
|
|
317
|
+
biometricPrompt;
|
|
318
|
+
dispatch;
|
|
319
|
+
dpopSession;
|
|
320
|
+
emitEvent;
|
|
321
|
+
now;
|
|
322
|
+
setTimeoutFn;
|
|
323
|
+
clearTimeoutFn;
|
|
324
|
+
storageKeyResolved = null;
|
|
325
|
+
jwksKeyResolved = null;
|
|
326
|
+
jwksCache = null;
|
|
327
|
+
jwksUri;
|
|
328
|
+
jwksTtlMs;
|
|
329
|
+
expectedIssuer;
|
|
330
|
+
expectedAudience;
|
|
331
|
+
accessToken = null;
|
|
332
|
+
expiresAtMs = null;
|
|
333
|
+
currentUser = null;
|
|
334
|
+
currentSession = null;
|
|
335
|
+
refreshInFlight = null;
|
|
336
|
+
refreshAttempts = 0;
|
|
337
|
+
retryTimer = null;
|
|
338
|
+
destroyed = false;
|
|
339
|
+
offlineStaleCycles = 0;
|
|
340
|
+
static MAX_OFFLINE_STALE_CYCLES = 3;
|
|
341
|
+
submitNonces = /* @__PURE__ */ new Map();
|
|
342
|
+
static NONCE_TTL_MS = 5 * 60 * 1e3;
|
|
343
|
+
static MAX_PENDING_NONCES = 100;
|
|
344
|
+
constructor(options) {
|
|
345
|
+
this.clientId = options.clientId;
|
|
346
|
+
this.tenantId = options.tenantId;
|
|
347
|
+
this.tokenEndpoint = options.tokenEndpoint;
|
|
348
|
+
this.storage = options.storage;
|
|
349
|
+
this.http = options.http;
|
|
350
|
+
this.crypto = options.crypto;
|
|
351
|
+
this.biometric = options.biometric;
|
|
352
|
+
this.biometricEnabled = options.biometricEnabled ?? false;
|
|
353
|
+
this.biometricStrict = options.biometricStrict ?? false;
|
|
354
|
+
this.biometricPrompt = options.biometricPrompt ?? "Authenticate to continue";
|
|
355
|
+
this.dispatch = options.dispatch;
|
|
356
|
+
this.dpopSession = options.dpopSession;
|
|
357
|
+
this.emitEvent = options.onEvent;
|
|
358
|
+
this.now = options.now ?? Date.now;
|
|
359
|
+
this.setTimeoutFn = options.setTimeout ?? globalThis.setTimeout.bind(globalThis);
|
|
360
|
+
this.clearTimeoutFn = options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
|
|
361
|
+
this.jwksUri = options.jwksUri ?? deriveDefaultJwksUri(options.tokenEndpoint);
|
|
362
|
+
this.jwksTtlMs = options.jwksTtlMs ?? 24 * 60 * 60 * 1e3;
|
|
363
|
+
this.expectedIssuer = options.expectedIssuer ?? deriveDefaultIssuer(options.tokenEndpoint);
|
|
364
|
+
this.expectedAudience = options.expectedAudience ?? RAKOMI_PLATFORM_AUDIENCE_DEFAULT;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Issue a one-time submit nonce for an authentication ceremony.
|
|
368
|
+
*
|
|
369
|
+
* every public-surface `submitOAuthTokens` call MUST present a nonce
|
|
370
|
+
* issued by an in-flight flow. Nonces are 32 bytes, base64url, single-use, 5-minute TTL.
|
|
371
|
+
*/
|
|
372
|
+
async beginAuthFlow() {
|
|
373
|
+
const bytes = await this.crypto.getRandomBytes(32);
|
|
374
|
+
const nonce = bytesToBase64Url(bytes);
|
|
375
|
+
this.submitNonces.set(nonce, this.now() + _TokenRuntime.NONCE_TTL_MS);
|
|
376
|
+
this.gcExpiredNonces();
|
|
377
|
+
while (this.submitNonces.size > _TokenRuntime.MAX_PENDING_NONCES) {
|
|
378
|
+
const oldest = this.submitNonces.keys().next().value;
|
|
379
|
+
if (oldest === void 0) break;
|
|
380
|
+
this.submitNonces.delete(oldest);
|
|
381
|
+
}
|
|
382
|
+
return nonce;
|
|
383
|
+
}
|
|
384
|
+
gcExpiredNonces() {
|
|
385
|
+
const cutoff = this.now();
|
|
386
|
+
for (const [nonce, expiresAt] of this.submitNonces) {
|
|
387
|
+
if (expiresAt < cutoff) this.submitNonces.delete(nonce);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
consumeNonce(nonce) {
|
|
391
|
+
this.gcExpiredNonces();
|
|
392
|
+
const entry = this.submitNonces.get(nonce);
|
|
393
|
+
if (entry === void 0) return false;
|
|
394
|
+
this.submitNonces.delete(nonce);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Validate a submit nonce — public for Provider-layer nonce gate.
|
|
399
|
+
* Internal class paths (refresh, restore) bypass this; external consumer-facing
|
|
400
|
+
* surfaces (`Provider.submitOAuthTokens`) MUST call this BEFORE invoking `setTokens`.
|
|
401
|
+
*/
|
|
402
|
+
validateSubmitNonce(nonce) {
|
|
403
|
+
return this.consumeNonce(nonce);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Persist tokens after a successful sign-in or refresh. Updates in-memory state,
|
|
407
|
+
* writes refresh token to secure storage, and dispatches `SIGN_IN_SUCCESS` /
|
|
408
|
+
* `REFRESH_SUCCESS` to the FSM.
|
|
409
|
+
*
|
|
410
|
+
* Internal API — the consumer-facing tokens-write surface is `Provider.submitOAuthTokens`,
|
|
411
|
+
* which gates this with `validateSubmitNonce`.
|
|
412
|
+
*/
|
|
413
|
+
async setTokens(response, source = "sign_in") {
|
|
414
|
+
const user = sdkCore.decodeUser(response.access_token);
|
|
415
|
+
if (!user) {
|
|
416
|
+
this.dispatch({ type: "SIGN_IN_FAILED", error: { code: "SIGN_IN_FAILED", message: "Invalid or missing JWT claims" } });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (this.currentUser && this.currentUser.id !== user.id) {
|
|
420
|
+
await this.clear({ code: "SIGN_IN_FAILED", message: "session_mismatch on token set" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const sessionDecoded = sdkCore.decodeSession(response.access_token, response.expires_in);
|
|
424
|
+
if (!sessionDecoded) {
|
|
425
|
+
this.dispatch({ type: "SIGN_IN_FAILED", error: { code: "SIGN_IN_FAILED", message: "Invalid session claims" } });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const clampedExpiresIn = Math.max(10, Math.min(86400, Math.floor(response.expires_in)));
|
|
429
|
+
this.accessToken = response.access_token;
|
|
430
|
+
this.expiresAtMs = this.now() + clampedExpiresIn * 1e3;
|
|
431
|
+
this.refreshAttempts = 0;
|
|
432
|
+
this.currentUser = user;
|
|
433
|
+
this.currentSession = { ...sessionDecoded, isExpiringSoon: false };
|
|
434
|
+
if (response.refresh_token) {
|
|
435
|
+
const key = await this.resolveStorageKey();
|
|
436
|
+
await this.storage.setItem(key, REFRESH_TOKEN_PREFIX + response.refresh_token, {
|
|
437
|
+
keychainAccessible: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY",
|
|
438
|
+
requireAuthentication: this.biometricEnabled,
|
|
439
|
+
authenticationPrompt: this.biometricEnabled ? this.biometricPrompt : void 0
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (source === "refresh") {
|
|
443
|
+
this.dispatch({ type: "REFRESH_SUCCESS", session: this.currentSession });
|
|
444
|
+
} else if (source === "sign_in") {
|
|
445
|
+
this.dispatch({ type: "SIGN_IN_SUCCESS", user, session: this.currentSession });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Public token getter — returns a `TokenResult` shape compatible with `useAuth().getToken`.
|
|
450
|
+
* Refreshes proactively if within the refresh buffer; deduplicates concurrent callers.
|
|
451
|
+
*/
|
|
452
|
+
getToken = async () => {
|
|
453
|
+
const valid = this.getValidAccessToken();
|
|
454
|
+
if (valid) {
|
|
455
|
+
const expiresIn2 = Math.max(0, Math.floor(((this.expiresAtMs ?? 0) - this.now()) / 1e3));
|
|
456
|
+
return { ok: true, token: valid, tokenType: "Bearer", headers: {}, expiresIn: expiresIn2 };
|
|
457
|
+
}
|
|
458
|
+
const success = await this.performRefresh();
|
|
459
|
+
if (!success || !this.accessToken || this.expiresAtMs === null) {
|
|
460
|
+
return { ok: false, error: { code: "REFRESH_FAILED", reason: "expired", message: "No valid token available" } };
|
|
461
|
+
}
|
|
462
|
+
const expiresIn = Math.max(0, Math.floor((this.expiresAtMs - this.now()) / 1e3));
|
|
463
|
+
return { ok: true, token: this.accessToken, tokenType: "Bearer", headers: {}, expiresIn };
|
|
464
|
+
};
|
|
465
|
+
/**
|
|
466
|
+
* Try to restore session from persisted refresh token. Called once on Provider mount.
|
|
467
|
+
* Triggers a refresh — on success dispatches RESTORE_SUCCESS; on failure RESTORE_FAILED.
|
|
468
|
+
*/
|
|
469
|
+
async restore() {
|
|
470
|
+
const success = await this.performRefresh({ duringRestore: true });
|
|
471
|
+
if (success && this.currentUser && this.currentSession) {
|
|
472
|
+
this.dispatch({ type: "RESTORE_SUCCESS", user: this.currentUser, session: this.currentSession });
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
this.dispatch({ type: "RESTORE_FAILED" });
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Foreground refresh — called by Provider AppState handler. Dispatches REFRESH_START
|
|
480
|
+
* and kicks a single in-flight refresh. No-op if no refresh token / already in flight.
|
|
481
|
+
*/
|
|
482
|
+
async refreshOnForeground() {
|
|
483
|
+
if (this.refreshInFlight) return;
|
|
484
|
+
if (!this.currentUser) return;
|
|
485
|
+
this.dispatch({ type: "REFRESH_START" });
|
|
486
|
+
await this.performRefresh();
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Clear all auth state. GDPR Art. 17 erasure.
|
|
490
|
+
*/
|
|
491
|
+
async clear(error) {
|
|
492
|
+
this.accessToken = null;
|
|
493
|
+
this.expiresAtMs = null;
|
|
494
|
+
this.currentUser = null;
|
|
495
|
+
this.currentSession = null;
|
|
496
|
+
this.refreshInFlight = null;
|
|
497
|
+
this.refreshAttempts = 0;
|
|
498
|
+
if (this.retryTimer) {
|
|
499
|
+
this.clearTimeoutFn(this.retryTimer);
|
|
500
|
+
this.retryTimer = null;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const key = await this.resolveStorageKey();
|
|
504
|
+
await this.storage.removeItem(key);
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
if (error) {
|
|
508
|
+
this.dispatch({ type: "REFRESH_REVOKED", error });
|
|
509
|
+
} else {
|
|
510
|
+
this.dispatch({ type: "SIGN_OUT" });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Verify a JWT against the cached JWKS (offline-capable).
|
|
515
|
+
*
|
|
516
|
+
* uses `jose.createLocalJWKSet` so verification is purely local once
|
|
517
|
+
* the JWKS document has been fetched and cached. The cache is preloaded from `KeyValueStore`
|
|
518
|
+
* on cold-start (slot `jwks_cache`) and refreshed on TTL miss; on persistent network failure
|
|
519
|
+
* the stale-while-error fallback keeps verification working until the cached doc itself expires.
|
|
520
|
+
*/
|
|
521
|
+
async verifyAccessToken(token) {
|
|
522
|
+
try {
|
|
523
|
+
const cache = await this.getOrCreateJwksCache();
|
|
524
|
+
const keySet = await cache.getKeySet();
|
|
525
|
+
const result = await jose.jwtVerify(token, keySet, {
|
|
526
|
+
issuer: this.expectedIssuer,
|
|
527
|
+
audience: this.expectedAudience,
|
|
528
|
+
algorithms: ["RS256"]
|
|
529
|
+
});
|
|
530
|
+
return { ok: true, payload: result.payload };
|
|
531
|
+
} catch (err2) {
|
|
532
|
+
return { ok: false, reason: err2 instanceof Error ? err2.message : "verify failed" };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
destroy() {
|
|
536
|
+
this.destroyed = true;
|
|
537
|
+
if (this.retryTimer) {
|
|
538
|
+
this.clearTimeoutFn(this.retryTimer);
|
|
539
|
+
this.retryTimer = null;
|
|
540
|
+
}
|
|
541
|
+
this.refreshInFlight = null;
|
|
542
|
+
}
|
|
543
|
+
async resolveStorageKey() {
|
|
544
|
+
if (this.storageKeyResolved) return this.storageKeyResolved;
|
|
545
|
+
this.storageKeyResolved = await sdkCore.deriveTenantStorageKey(this.crypto, this.tenantId, "refresh_token");
|
|
546
|
+
return this.storageKeyResolved;
|
|
547
|
+
}
|
|
548
|
+
async resolveJwksKey() {
|
|
549
|
+
if (this.jwksKeyResolved) return this.jwksKeyResolved;
|
|
550
|
+
this.jwksKeyResolved = await sdkCore.deriveTenantStorageKey(this.crypto, this.tenantId, "jwks_cache");
|
|
551
|
+
return this.jwksKeyResolved;
|
|
552
|
+
}
|
|
553
|
+
async getOrCreateJwksCache() {
|
|
554
|
+
if (this.jwksCache) return this.jwksCache;
|
|
555
|
+
const key = await this.resolveJwksKey();
|
|
556
|
+
let initial;
|
|
557
|
+
try {
|
|
558
|
+
const raw = await this.storage.getItem(key);
|
|
559
|
+
if (raw) {
|
|
560
|
+
const parsed = JSON.parse(raw);
|
|
561
|
+
if (parsed.document && typeof parsed.fetchedAt === "number") {
|
|
562
|
+
initial = { document: parsed.document, fetchedAt: parsed.fetchedAt };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
this.jwksCache = sdkCore.createJwksCache({
|
|
568
|
+
ttlMs: this.jwksTtlMs,
|
|
569
|
+
fetchJwks: async () => {
|
|
570
|
+
const response = await this.http.fetch(this.jwksUri, { method: "GET", headers: { Accept: "application/json" } });
|
|
571
|
+
if (!response.ok) {
|
|
572
|
+
throw new Error(`JWKS fetch failed: HTTP ${response.status}`);
|
|
573
|
+
}
|
|
574
|
+
return await response.json();
|
|
575
|
+
},
|
|
576
|
+
initial,
|
|
577
|
+
onFetched: (document, fetchedAt) => {
|
|
578
|
+
void this.storage.setItem(key, JSON.stringify({ document, fetchedAt })).catch(() => void 0);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
return this.jwksCache;
|
|
582
|
+
}
|
|
583
|
+
getValidAccessToken() {
|
|
584
|
+
if (!this.accessToken || this.expiresAtMs === null) return null;
|
|
585
|
+
if (this.now() >= this.expiresAtMs - REFRESH_BUFFER_SECONDS * 1e3) return null;
|
|
586
|
+
return this.accessToken;
|
|
587
|
+
}
|
|
588
|
+
async performRefresh(opts = {}) {
|
|
589
|
+
if (this.refreshInFlight) return this.refreshInFlight;
|
|
590
|
+
this.refreshInFlight = this.doRefresh(opts).finally(() => {
|
|
591
|
+
this.refreshInFlight = null;
|
|
592
|
+
});
|
|
593
|
+
return this.refreshInFlight;
|
|
594
|
+
}
|
|
595
|
+
async doRefresh(opts) {
|
|
596
|
+
if (this.destroyed) return false;
|
|
597
|
+
this.emitEvent?.({ type: "refresh_started" });
|
|
598
|
+
if (this.biometricEnabled && this.biometric) {
|
|
599
|
+
const available = await this.biometric.isAvailable();
|
|
600
|
+
if (!available) {
|
|
601
|
+
this.emitEvent?.({ type: "biometric_failed", metadata: { reason: "unavailable" } });
|
|
602
|
+
if (!opts.duringRestore) {
|
|
603
|
+
await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Biometric unavailable" });
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
const result2 = await this.biometric.authenticate({ promptMessage: this.biometricPrompt, strict: this.biometricStrict });
|
|
608
|
+
if (!result2.success) {
|
|
609
|
+
this.emitEvent?.({ type: "biometric_failed", metadata: { reason: result2.reason } });
|
|
610
|
+
if (!opts.duringRestore) {
|
|
611
|
+
if (result2.reason === "lockout" || result2.reason === "not_enrolled") {
|
|
612
|
+
await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: `Biometric ${result2.reason}` });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const key = await this.resolveStorageKey();
|
|
619
|
+
let raw;
|
|
620
|
+
try {
|
|
621
|
+
raw = await this.storage.getItem(key, {
|
|
622
|
+
requireAuthentication: this.biometricEnabled,
|
|
623
|
+
authenticationPrompt: this.biometricEnabled ? this.biometricPrompt : void 0
|
|
624
|
+
});
|
|
625
|
+
} catch {
|
|
626
|
+
raw = null;
|
|
627
|
+
}
|
|
628
|
+
if (!raw) {
|
|
629
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "no_refresh_token" } });
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
if (!raw.startsWith(REFRESH_TOKEN_PREFIX)) {
|
|
633
|
+
await this.storage.removeItem(key).catch(() => void 0);
|
|
634
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "invalid_storage_prefix" } });
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
const refreshToken = raw.slice(REFRESH_TOKEN_PREFIX.length);
|
|
638
|
+
if (this.dpopSession?.isBound) {
|
|
639
|
+
const dResult = await refreshWithDpop({
|
|
640
|
+
http: this.http,
|
|
641
|
+
tokenEndpoint: this.tokenEndpoint,
|
|
642
|
+
refreshToken,
|
|
643
|
+
clientId: this.clientId,
|
|
644
|
+
dpopSession: this.dpopSession
|
|
645
|
+
});
|
|
646
|
+
if (!dResult.ok) {
|
|
647
|
+
return this.handleDpopRefreshFailure(dResult.error, opts.duringRestore ?? false);
|
|
648
|
+
}
|
|
649
|
+
this.emitEvent?.({ type: "refresh_succeeded" });
|
|
650
|
+
this.refreshAttempts = 0;
|
|
651
|
+
this.offlineStaleCycles = 0;
|
|
652
|
+
await this.setTokens(dResult.tokens, opts.duringRestore ? "restore" : "refresh");
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
const result = await sdkCore.refreshAccessToken({
|
|
656
|
+
http: this.http,
|
|
657
|
+
tokenEndpoint: this.tokenEndpoint,
|
|
658
|
+
refreshToken,
|
|
659
|
+
clientId: this.clientId
|
|
660
|
+
});
|
|
661
|
+
if (!result.ok) {
|
|
662
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { code: result.error.code } });
|
|
663
|
+
if (this.isAuthError(result.error)) {
|
|
664
|
+
await this.clear(result.error);
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
return this.scheduleRetry(result.error, opts.duringRestore ?? false);
|
|
668
|
+
}
|
|
669
|
+
this.emitEvent?.({ type: "refresh_succeeded" });
|
|
670
|
+
this.refreshAttempts = 0;
|
|
671
|
+
this.offlineStaleCycles = 0;
|
|
672
|
+
await this.setTokens(result.tokens, opts.duringRestore ? "restore" : "refresh");
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
isAuthError(error) {
|
|
676
|
+
return error.code === "REFRESH_FAILED" && (error.reason === "expired" || error.reason === "revoked");
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Map a DPoP-refresh failure (three-class taxonomy) onto the FSM. The
|
|
680
|
+
* distinct `code` is emitted as telemetry, so consumers can tell an
|
|
681
|
+
* `auth/invalid_dpop_proof` spike apart from `auth/invalid_refresh_token`
|
|
682
|
+
* (the classes are kept distinct deliberately).
|
|
683
|
+
*/
|
|
684
|
+
async handleDpopRefreshFailure(error, duringRestore) {
|
|
685
|
+
this.emitEvent?.({ type: "dpop_refresh_failed", metadata: { code: error.code } });
|
|
686
|
+
if (error.class === "network") {
|
|
687
|
+
return this.scheduleRetry({ code: "REFRESH_FAILED", reason: "network", message: error.message }, duringRestore);
|
|
688
|
+
}
|
|
689
|
+
if (error.class === "dpop_prover_unavailable") {
|
|
690
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "dpop_prover_unavailable" } });
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: error.message });
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
scheduleRetry(error, duringRestore) {
|
|
697
|
+
if (this.refreshAttempts >= RETRY_DELAYS_MS.length) {
|
|
698
|
+
if (!duringRestore && this.accessToken && this.expiresAtMs !== null && this.now() < this.expiresAtMs) {
|
|
699
|
+
this.refreshAttempts = 0;
|
|
700
|
+
this.offlineStaleCycles += 1;
|
|
701
|
+
if (this.offlineStaleCycles >= _TokenRuntime.MAX_OFFLINE_STALE_CYCLES) {
|
|
702
|
+
this.offlineStaleCycles = 0;
|
|
703
|
+
void this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Refresh chronically failing \u2014 session lost" });
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
this.dispatch({ type: "OFFLINE_STALE" });
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
void this.clear({ code: "REFRESH_FAILED", reason: "network", message: "Refresh exhausted retries" });
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
const delay = RETRY_DELAYS_MS[this.refreshAttempts];
|
|
713
|
+
this.refreshAttempts++;
|
|
714
|
+
this.emitEvent?.({ type: "network_retry", metadata: { delayMs: delay, attempt: this.refreshAttempts } });
|
|
715
|
+
if (!duringRestore) {
|
|
716
|
+
this.dispatch({ type: "REFRESH_NETWORK_ERROR", error });
|
|
717
|
+
}
|
|
718
|
+
if (this.retryTimer) this.clearTimeoutFn(this.retryTimer);
|
|
719
|
+
this.retryTimer = this.setTimeoutFn(() => {
|
|
720
|
+
this.retryTimer = null;
|
|
721
|
+
void this.performRefresh();
|
|
722
|
+
}, delay);
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// src/native/expo-adapter.ts
|
|
728
|
+
function createDefaultExpoAdapter(options = {}) {
|
|
729
|
+
const crypto = createExpoCrypto();
|
|
730
|
+
const storage = createExpoSecureStore({ tokenCache: options.tokenCache });
|
|
731
|
+
const browser = createExpoBrowser();
|
|
732
|
+
const deepLink = createExpoDeepLink();
|
|
733
|
+
const biometric = createExpoBiometricGate();
|
|
734
|
+
const appLifecycle = createRnAppLifecycle();
|
|
735
|
+
const connectivity = createNetInfoConnectivity();
|
|
736
|
+
return {
|
|
737
|
+
crypto,
|
|
738
|
+
storage,
|
|
739
|
+
browser,
|
|
740
|
+
deepLink,
|
|
741
|
+
biometric,
|
|
742
|
+
appLifecycle,
|
|
743
|
+
connectivity
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function createExpoCrypto() {
|
|
747
|
+
let mod = null;
|
|
748
|
+
async function load() {
|
|
749
|
+
if (!mod) {
|
|
750
|
+
mod = await import('expo-crypto').catch(() => null);
|
|
751
|
+
if (!mod) {
|
|
752
|
+
throw new Error(
|
|
753
|
+
"@rakomi/react-native: expo-crypto is required for the default adapter. Install it (Expo-managed) or pass a custom nativeAdapter (bare RN)."
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return mod;
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
getRandomBytes: async (n) => {
|
|
761
|
+
const m = await load();
|
|
762
|
+
const result = await m.getRandomBytesAsync(n);
|
|
763
|
+
return new Uint8Array(result);
|
|
764
|
+
},
|
|
765
|
+
digestSha256: async (input) => {
|
|
766
|
+
const m = await load();
|
|
767
|
+
let str = "";
|
|
768
|
+
for (let i = 0; i < input.length; i++) str += String.fromCharCode(input[i]);
|
|
769
|
+
const hex = await m.digestStringAsync(
|
|
770
|
+
m.CryptoDigestAlgorithm.SHA256,
|
|
771
|
+
str,
|
|
772
|
+
{ encoding: m.CryptoEncoding.HEX }
|
|
773
|
+
);
|
|
774
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
775
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
776
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
777
|
+
}
|
|
778
|
+
return bytes;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function createExpoSecureStore(opts) {
|
|
783
|
+
if (opts.tokenCache) {
|
|
784
|
+
const tc = opts.tokenCache;
|
|
785
|
+
return {
|
|
786
|
+
getItem: (key) => tc.getToken(key),
|
|
787
|
+
setItem: async (key, value) => {
|
|
788
|
+
await tc.saveToken(key, value);
|
|
789
|
+
},
|
|
790
|
+
removeItem: (key) => tc.clearToken(key)
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
let mod = null;
|
|
794
|
+
async function load() {
|
|
795
|
+
if (!mod) {
|
|
796
|
+
mod = await import('expo-secure-store').catch(() => null);
|
|
797
|
+
if (!mod) {
|
|
798
|
+
throw new Error(
|
|
799
|
+
"@rakomi/react-native: expo-secure-store is required for the default adapter. Install it (Expo-managed) or pass `tokenCache` / a custom nativeAdapter."
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return mod;
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
getItem: async (key, options) => {
|
|
807
|
+
const m = await load();
|
|
808
|
+
return m.getItemAsync(key, {
|
|
809
|
+
requireAuthentication: options?.requireAuthentication,
|
|
810
|
+
authenticationPrompt: options?.authenticationPrompt
|
|
811
|
+
});
|
|
812
|
+
},
|
|
813
|
+
setItem: async (key, value, options) => {
|
|
814
|
+
const m = await load();
|
|
815
|
+
const accessibility = mapKeychainAccessible(
|
|
816
|
+
m,
|
|
817
|
+
options?.keychainAccessible ?? "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY"
|
|
818
|
+
);
|
|
819
|
+
await m.setItemAsync(key, value, {
|
|
820
|
+
keychainAccessible: accessibility,
|
|
821
|
+
requireAuthentication: options?.requireAuthentication,
|
|
822
|
+
authenticationPrompt: options?.authenticationPrompt
|
|
823
|
+
});
|
|
824
|
+
},
|
|
825
|
+
removeItem: async (key) => {
|
|
826
|
+
const m = await load();
|
|
827
|
+
await m.deleteItemAsync(key);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
function mapKeychainAccessible(m, level) {
|
|
832
|
+
switch (level) {
|
|
833
|
+
case "WHEN_UNLOCKED":
|
|
834
|
+
return m.WHEN_UNLOCKED;
|
|
835
|
+
case "AFTER_FIRST_UNLOCK":
|
|
836
|
+
return m.AFTER_FIRST_UNLOCK;
|
|
837
|
+
case "WHEN_UNLOCKED_THIS_DEVICE_ONLY":
|
|
838
|
+
return m.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
|
839
|
+
case "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY":
|
|
840
|
+
return m.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
|
|
841
|
+
case "WHEN_PASSCODE_SET_THIS_DEVICE_ONLY":
|
|
842
|
+
return m.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function createExpoBrowser() {
|
|
846
|
+
let mod = null;
|
|
847
|
+
async function load() {
|
|
848
|
+
if (!mod) {
|
|
849
|
+
mod = await import('expo-web-browser').catch(() => null);
|
|
850
|
+
if (!mod) {
|
|
851
|
+
throw new Error("@rakomi/react-native: expo-web-browser is required for OAuth flows.");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return mod;
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
openAuthSession: async (authUrl, redirectUri, options) => {
|
|
858
|
+
const m = await load();
|
|
859
|
+
const result = await m.openAuthSessionAsync(authUrl, redirectUri, {
|
|
860
|
+
preferEphemeralSession: options?.preferEphemeralSession ?? true
|
|
861
|
+
});
|
|
862
|
+
if (result.type === "success" && result.url) {
|
|
863
|
+
return { type: "success", url: result.url };
|
|
864
|
+
}
|
|
865
|
+
if (result.type === "cancel") return { type: "cancel" };
|
|
866
|
+
if (result.type === "dismiss") return { type: "dismiss" };
|
|
867
|
+
if (result.type === "locked") return { type: "locked" };
|
|
868
|
+
return { type: "cancel" };
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function createExpoDeepLink() {
|
|
873
|
+
let mod = null;
|
|
874
|
+
async function load() {
|
|
875
|
+
if (!mod) {
|
|
876
|
+
mod = await import('expo-linking').catch(() => null);
|
|
877
|
+
if (!mod) {
|
|
878
|
+
throw new Error("@rakomi/react-native: expo-linking is required for OAuth deep-link callback.");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return mod;
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
getInitialUrl: async () => {
|
|
885
|
+
const m = await load();
|
|
886
|
+
return m.getInitialURL();
|
|
887
|
+
},
|
|
888
|
+
addListener: (listener) => {
|
|
889
|
+
let sub = null;
|
|
890
|
+
void load().then((m) => {
|
|
891
|
+
sub = m.addEventListener("url", (event) => listener(event.url));
|
|
892
|
+
});
|
|
893
|
+
return {
|
|
894
|
+
remove: () => {
|
|
895
|
+
sub?.remove();
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function createExpoBiometricGate() {
|
|
902
|
+
let mod = null;
|
|
903
|
+
async function load() {
|
|
904
|
+
if (mod !== null) return mod;
|
|
905
|
+
try {
|
|
906
|
+
mod = await import('expo-local-authentication');
|
|
907
|
+
} catch {
|
|
908
|
+
mod = null;
|
|
909
|
+
}
|
|
910
|
+
return mod;
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
isAvailable: async () => {
|
|
914
|
+
const m = await load();
|
|
915
|
+
if (!m) return false;
|
|
916
|
+
const has = await m.hasHardwareAsync();
|
|
917
|
+
if (!has) return false;
|
|
918
|
+
const enrolled = await m.isEnrolledAsync();
|
|
919
|
+
return enrolled;
|
|
920
|
+
},
|
|
921
|
+
authenticate: async ({ promptMessage, strict = false }) => {
|
|
922
|
+
const m = await load();
|
|
923
|
+
if (!m) return { success: false, reason: "unavailable" };
|
|
924
|
+
const result = await m.authenticateAsync({
|
|
925
|
+
promptMessage,
|
|
926
|
+
disableDeviceFallback: strict,
|
|
927
|
+
fallbackLabel: "Use passcode"
|
|
928
|
+
});
|
|
929
|
+
if (result.success) return { success: true };
|
|
930
|
+
switch (result.error) {
|
|
931
|
+
case "user_cancel":
|
|
932
|
+
case "system_cancel":
|
|
933
|
+
case "app_cancel":
|
|
934
|
+
return { success: false, reason: "cancelled" };
|
|
935
|
+
case "lockout":
|
|
936
|
+
case "lockout_permanent":
|
|
937
|
+
return { success: false, reason: "lockout" };
|
|
938
|
+
case "not_enrolled":
|
|
939
|
+
return { success: false, reason: "not_enrolled" };
|
|
940
|
+
case "not_available":
|
|
941
|
+
case "no_space":
|
|
942
|
+
return { success: false, reason: "unavailable" };
|
|
943
|
+
default:
|
|
944
|
+
return { success: false, reason: "unknown" };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function createRnAppLifecycle() {
|
|
950
|
+
let rn = null;
|
|
951
|
+
let pending = null;
|
|
952
|
+
async function ensure() {
|
|
953
|
+
if (rn) return rn;
|
|
954
|
+
if (!pending) {
|
|
955
|
+
pending = import('react-native').then((m) => {
|
|
956
|
+
rn = m.default ?? m;
|
|
957
|
+
return rn;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
return pending;
|
|
961
|
+
}
|
|
962
|
+
void ensure().catch(() => void 0);
|
|
963
|
+
return {
|
|
964
|
+
addStateChangeListener: (listener) => {
|
|
965
|
+
let sub = null;
|
|
966
|
+
void ensure().then((r) => {
|
|
967
|
+
sub = r.AppState.addEventListener("change", (next) => {
|
|
968
|
+
listener(coerceAppState(next));
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
return { remove: () => sub?.remove() };
|
|
972
|
+
},
|
|
973
|
+
getCurrent: () => rn ? coerceAppState(rn.AppState.currentState) : "unknown"
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
function coerceAppState(value) {
|
|
977
|
+
if (value === "active" || value === "background" || value === "inactive") return value;
|
|
978
|
+
return "unknown";
|
|
979
|
+
}
|
|
980
|
+
function createNetInfoConnectivity() {
|
|
981
|
+
let mod = null;
|
|
982
|
+
async function load() {
|
|
983
|
+
if (mod !== null) return mod;
|
|
984
|
+
try {
|
|
985
|
+
mod = (await import('@react-native-community/netinfo')).default ?? null;
|
|
986
|
+
} catch {
|
|
987
|
+
mod = null;
|
|
988
|
+
}
|
|
989
|
+
return mod;
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
isConnected: async () => {
|
|
993
|
+
const m = await load();
|
|
994
|
+
if (!m) return true;
|
|
995
|
+
const state = await m.fetch();
|
|
996
|
+
return state.isConnected ?? true;
|
|
997
|
+
},
|
|
998
|
+
addListener: (listener) => {
|
|
999
|
+
let unsubscribe = null;
|
|
1000
|
+
void load().then((m) => {
|
|
1001
|
+
if (!m) return;
|
|
1002
|
+
unsubscribe = m.addEventListener((state) => listener(state.isConnected ?? true));
|
|
1003
|
+
});
|
|
1004
|
+
return { remove: () => unsubscribe?.() };
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
var RakomiContext = react.createContext(null);
|
|
1009
|
+
var APP_STATE_DEBOUNCE_MS = 300;
|
|
1010
|
+
function RakomiProvider(props) {
|
|
1011
|
+
const adapter = react.useMemo(() => {
|
|
1012
|
+
const built = props.nativeAdapter ?? createDefaultExpoAdapter({ tokenCache: props.tokenCache });
|
|
1013
|
+
return Object.freeze(built);
|
|
1014
|
+
}, [props.nativeAdapter, props.tokenCache]);
|
|
1015
|
+
const http = react.useMemo(() => createRnHttpClient({ baseUrl: props.baseUrl }), [props.baseUrl]);
|
|
1016
|
+
const events = react.useMemo(() => new sdkCore.EventLog(props.onEvent), [props.onEvent]);
|
|
1017
|
+
const [snapshot, dispatch] = react.useReducer(sdkCore.reduce, sdkCore.INITIAL_SNAPSHOT);
|
|
1018
|
+
const passkeyCeremonyLock = react.useRef(createCeremonyLock()).current;
|
|
1019
|
+
const translate = react.useMemo(
|
|
1020
|
+
() => sdkCore.createTranslator(props.locale ?? "en", props.translations),
|
|
1021
|
+
[props.locale, props.translations]
|
|
1022
|
+
);
|
|
1023
|
+
const dpopSession = react.useMemo(() => {
|
|
1024
|
+
if (!adapter.dpopProver) return void 0;
|
|
1025
|
+
return createDpopSession({
|
|
1026
|
+
prover: adapter.dpopProver,
|
|
1027
|
+
baseUrl: props.baseUrl ?? "",
|
|
1028
|
+
onDowngrade: () => {
|
|
1029
|
+
events.push({ type: "sign_in_failed", severity: "security", metadata: { reason: "dpop_downgrade" } });
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}, [adapter, props.baseUrl, events]);
|
|
1033
|
+
const runtime = react.useMemo(() => {
|
|
1034
|
+
const tokenEndpoint = props.tokenEndpoint ?? `${(props.baseUrl ?? "").replace(/\/$/, "")}/oauth/token`;
|
|
1035
|
+
return new TokenRuntime({
|
|
1036
|
+
clientId: props.publishableKey,
|
|
1037
|
+
tenantId: props.tenantId ?? props.publishableKey,
|
|
1038
|
+
tokenEndpoint,
|
|
1039
|
+
storage: adapter.storage,
|
|
1040
|
+
http,
|
|
1041
|
+
crypto: adapter.crypto,
|
|
1042
|
+
biometric: adapter.biometric,
|
|
1043
|
+
biometricEnabled: props.biometric ?? false,
|
|
1044
|
+
biometricStrict: props.biometricStrict ?? false,
|
|
1045
|
+
biometricPrompt: props.biometricPrompt ?? "Authenticate to continue",
|
|
1046
|
+
dispatch,
|
|
1047
|
+
dpopSession,
|
|
1048
|
+
onEvent: ({ type, metadata }) => {
|
|
1049
|
+
if (type === "biometric_failed") {
|
|
1050
|
+
events.push({ type: "biometric_failed", severity: "warning", metadata });
|
|
1051
|
+
} else if (type === "network_retry") {
|
|
1052
|
+
events.push({ type: "network_retry", severity: "warning", metadata });
|
|
1053
|
+
} else if (type === "refresh_started") {
|
|
1054
|
+
events.push({ type: "refresh_started", severity: "info", metadata });
|
|
1055
|
+
} else if (type === "refresh_succeeded") {
|
|
1056
|
+
events.push({ type: "refresh_succeeded", severity: "info", metadata });
|
|
1057
|
+
} else if (type === "refresh_failed") {
|
|
1058
|
+
events.push({ type: "refresh_failed", severity: "warning", metadata });
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
}, [adapter, http, props.publishableKey, props.tenantId, props.baseUrl, props.tokenEndpoint, props.biometric, props.biometricStrict, props.biometricPrompt, events, dpopSession]);
|
|
1063
|
+
react.useEffect(() => {
|
|
1064
|
+
void runtime.restore();
|
|
1065
|
+
return () => runtime.destroy();
|
|
1066
|
+
}, [runtime]);
|
|
1067
|
+
const debounceTimer = react.useRef(null);
|
|
1068
|
+
react.useEffect(() => {
|
|
1069
|
+
const sub = adapter.appLifecycle.addStateChangeListener((next) => {
|
|
1070
|
+
if (next !== "active") return;
|
|
1071
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
1072
|
+
debounceTimer.current = setTimeout(() => {
|
|
1073
|
+
events.push({ type: "app_state_foreground", severity: "info" });
|
|
1074
|
+
void runtime.refreshOnForeground();
|
|
1075
|
+
}, APP_STATE_DEBOUNCE_MS);
|
|
1076
|
+
});
|
|
1077
|
+
return () => {
|
|
1078
|
+
sub.remove();
|
|
1079
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
1080
|
+
};
|
|
1081
|
+
}, [adapter, events, runtime]);
|
|
1082
|
+
react.useEffect(() => {
|
|
1083
|
+
const bg = adapter.backgroundTask;
|
|
1084
|
+
if (!bg) return void 0;
|
|
1085
|
+
let unregister = null;
|
|
1086
|
+
void (async () => {
|
|
1087
|
+
try {
|
|
1088
|
+
const ok = await bg.isAvailable();
|
|
1089
|
+
if (!ok) return;
|
|
1090
|
+
unregister = await bg.register(async () => {
|
|
1091
|
+
await runtime.refreshOnForeground();
|
|
1092
|
+
});
|
|
1093
|
+
} catch {
|
|
1094
|
+
}
|
|
1095
|
+
})();
|
|
1096
|
+
return () => {
|
|
1097
|
+
if (unregister) void unregister().catch(() => void 0);
|
|
1098
|
+
};
|
|
1099
|
+
}, [adapter, runtime]);
|
|
1100
|
+
react.useEffect(() => {
|
|
1101
|
+
const sub = adapter.connectivity.addListener((isConnected) => {
|
|
1102
|
+
if (isConnected) {
|
|
1103
|
+
events.push({ type: "offline_queue_drained", severity: "info" });
|
|
1104
|
+
dispatch({ type: "BACK_ONLINE" });
|
|
1105
|
+
void runtime.refreshOnForeground();
|
|
1106
|
+
} else {
|
|
1107
|
+
dispatch({ type: "OFFLINE_STALE" });
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
return () => sub.remove();
|
|
1111
|
+
}, [adapter, events, runtime]);
|
|
1112
|
+
react.useEffect(() => {
|
|
1113
|
+
if (props.deepLinkAutoHandle === false) return void 0;
|
|
1114
|
+
const sub = adapter.deepLink.addListener(() => {
|
|
1115
|
+
events.push({ type: "deep_link_received", severity: "info" });
|
|
1116
|
+
});
|
|
1117
|
+
void adapter.deepLink.getInitialUrl().then((url) => {
|
|
1118
|
+
if (url) {
|
|
1119
|
+
events.push({ type: "deep_link_received", severity: "info", metadata: { coldStart: true } });
|
|
1120
|
+
}
|
|
1121
|
+
}).catch(() => void 0);
|
|
1122
|
+
return () => sub.remove();
|
|
1123
|
+
}, [adapter, events, props.deepLinkAutoHandle]);
|
|
1124
|
+
const signOut = react.useMemo(
|
|
1125
|
+
() => async () => {
|
|
1126
|
+
events.push({ type: "signed_out", severity: "info" });
|
|
1127
|
+
await runtime.clear();
|
|
1128
|
+
},
|
|
1129
|
+
[events, runtime]
|
|
1130
|
+
);
|
|
1131
|
+
const getToken = react.useMemo(() => () => runtime.getToken(), [runtime]);
|
|
1132
|
+
const submitOAuthTokens = react.useMemo(
|
|
1133
|
+
() => async (tokens, nonce) => {
|
|
1134
|
+
if (!runtime.validateSubmitNonce(nonce)) {
|
|
1135
|
+
events.push({ type: "sign_in_failed", severity: "security", error: { code: "CSRF_MISMATCH", message: "Submit nonce missing or expired" } });
|
|
1136
|
+
dispatch({ type: "SIGN_IN_FAILED", error: { code: "CSRF_MISMATCH", message: "Submit nonce missing or expired" } });
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
await runtime.setTokens(tokens, "sign_in");
|
|
1140
|
+
},
|
|
1141
|
+
[runtime, events]
|
|
1142
|
+
);
|
|
1143
|
+
const beginAuthFlow = react.useMemo(() => () => runtime.beginAuthFlow(), [runtime]);
|
|
1144
|
+
const value = react.useMemo(
|
|
1145
|
+
() => ({
|
|
1146
|
+
publishableKey: props.publishableKey,
|
|
1147
|
+
baseUrl: props.baseUrl ?? "",
|
|
1148
|
+
redirectUri: props.redirectUri,
|
|
1149
|
+
adapter,
|
|
1150
|
+
http,
|
|
1151
|
+
snapshot,
|
|
1152
|
+
state: snapshot.state,
|
|
1153
|
+
isSignedIn: sdkCore.isSignedIn(snapshot),
|
|
1154
|
+
user: snapshot.user,
|
|
1155
|
+
events,
|
|
1156
|
+
locale: props.locale ?? "en",
|
|
1157
|
+
translate,
|
|
1158
|
+
dispatch,
|
|
1159
|
+
signOut,
|
|
1160
|
+
getToken,
|
|
1161
|
+
submitOAuthTokens,
|
|
1162
|
+
passkeyCeremonyLock,
|
|
1163
|
+
beginAuthFlow,
|
|
1164
|
+
dpopSession
|
|
1165
|
+
}),
|
|
1166
|
+
[adapter, http, snapshot, events, props.publishableKey, props.baseUrl, props.redirectUri, props.locale, translate, signOut, getToken, submitOAuthTokens, beginAuthFlow, dpopSession, passkeyCeremonyLock]
|
|
1167
|
+
);
|
|
1168
|
+
return /* @__PURE__ */ jsxRuntime.jsx(RakomiContext.Provider, { value, children: props.children });
|
|
1169
|
+
}
|
|
1170
|
+
function useRakomiContext() {
|
|
1171
|
+
const ctx = react.useContext(RakomiContext);
|
|
1172
|
+
if (!ctx) {
|
|
1173
|
+
throw new Error("useRakomi*() must be called inside a <RakomiProvider>.");
|
|
1174
|
+
}
|
|
1175
|
+
return ctx;
|
|
1176
|
+
}
|
|
1177
|
+
function useAuth() {
|
|
1178
|
+
const ctx = useRakomiContext();
|
|
1179
|
+
const { snapshot, signOut, state, getToken } = ctx;
|
|
1180
|
+
const signIn = async (_options) => {
|
|
1181
|
+
return { status: "error", error: { code: "INVALID_CONFIG", message: "sign-in flow not yet wired in 0.1.0" } };
|
|
1182
|
+
};
|
|
1183
|
+
const switchOrganization = async (_orgId) => ({ status: "error", error: { code: "INVALID_CONFIG", message: "switchOrganization not yet wired" } });
|
|
1184
|
+
if (state === "idle") {
|
|
1185
|
+
return {
|
|
1186
|
+
isLoaded: true,
|
|
1187
|
+
isSignedIn: false,
|
|
1188
|
+
userId: null,
|
|
1189
|
+
user: null,
|
|
1190
|
+
sessionId: null,
|
|
1191
|
+
error: snapshot.error,
|
|
1192
|
+
state,
|
|
1193
|
+
signIn,
|
|
1194
|
+
signOut,
|
|
1195
|
+
getToken
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
if (state === "authenticating") {
|
|
1199
|
+
return {
|
|
1200
|
+
isLoaded: false,
|
|
1201
|
+
isSignedIn: void 0,
|
|
1202
|
+
userId: void 0,
|
|
1203
|
+
user: void 0,
|
|
1204
|
+
sessionId: void 0,
|
|
1205
|
+
error: null,
|
|
1206
|
+
state,
|
|
1207
|
+
signIn,
|
|
1208
|
+
signOut,
|
|
1209
|
+
getToken
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
if (snapshot.user && snapshot.session) {
|
|
1213
|
+
return {
|
|
1214
|
+
isLoaded: true,
|
|
1215
|
+
isSignedIn: true,
|
|
1216
|
+
userId: snapshot.user.id,
|
|
1217
|
+
user: snapshot.user,
|
|
1218
|
+
sessionId: snapshot.session.id,
|
|
1219
|
+
error: snapshot.error,
|
|
1220
|
+
state,
|
|
1221
|
+
isExpiringSoon: snapshot.session.isExpiringSoon ?? false,
|
|
1222
|
+
has: (params) => {
|
|
1223
|
+
if (params.permission && sdkCore.hasPermission(snapshot.user, params.permission)) return true;
|
|
1224
|
+
if (params.role && sdkCore.hasRole(snapshot.user, params.role)) return true;
|
|
1225
|
+
return false;
|
|
1226
|
+
},
|
|
1227
|
+
signIn,
|
|
1228
|
+
signOut,
|
|
1229
|
+
getToken,
|
|
1230
|
+
switchOrganization
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
isLoaded: true,
|
|
1235
|
+
isSignedIn: false,
|
|
1236
|
+
userId: null,
|
|
1237
|
+
user: null,
|
|
1238
|
+
sessionId: null,
|
|
1239
|
+
error: snapshot.error,
|
|
1240
|
+
state,
|
|
1241
|
+
signIn,
|
|
1242
|
+
signOut,
|
|
1243
|
+
getToken
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/hooks/use-session.ts
|
|
1248
|
+
function useSession() {
|
|
1249
|
+
const { snapshot } = useRakomiContext();
|
|
1250
|
+
return {
|
|
1251
|
+
isLoaded: snapshot.state !== "authenticating",
|
|
1252
|
+
isSignedIn: snapshot.state === "authenticated" || snapshot.state === "refreshing" || snapshot.state === "offline_stale",
|
|
1253
|
+
session: snapshot.session
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// src/hooks/use-user.ts
|
|
1258
|
+
function useUser() {
|
|
1259
|
+
const { snapshot } = useRakomiContext();
|
|
1260
|
+
return {
|
|
1261
|
+
isLoaded: snapshot.state !== "authenticating",
|
|
1262
|
+
isSignedIn: snapshot.state === "authenticated" || snapshot.state === "refreshing" || snapshot.state === "offline_stale",
|
|
1263
|
+
user: snapshot.user
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// src/hooks/index.ts
|
|
1268
|
+
function useFlag(flag, options) {
|
|
1269
|
+
const { user } = useRakomiContext();
|
|
1270
|
+
const flags = user?.rawClaims["feature_flags"] ?? {};
|
|
1271
|
+
const value = flags[flag] ?? options?.defaultValue;
|
|
1272
|
+
return {
|
|
1273
|
+
isLoaded: !!user,
|
|
1274
|
+
value,
|
|
1275
|
+
isEnabled: value === true
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function useOrganization() {
|
|
1279
|
+
const { user } = useRakomiContext();
|
|
1280
|
+
const { org_id: orgId, org_role: orgRole, org_memberships: orgMemberships } = sdkCore.extractOrgClaims(
|
|
1281
|
+
user?.rawClaims ?? {}
|
|
1282
|
+
);
|
|
1283
|
+
return {
|
|
1284
|
+
isLoaded: !!user,
|
|
1285
|
+
organization: orgId && orgRole ? { orgId, orgRole, orgMemberships: orgMemberships ?? [] } : null
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function useOrganizationList() {
|
|
1289
|
+
const { user } = useRakomiContext();
|
|
1290
|
+
const { org_memberships: organizations } = sdkCore.extractOrgClaims(user?.rawClaims ?? {});
|
|
1291
|
+
return {
|
|
1292
|
+
isLoaded: !!user,
|
|
1293
|
+
organizations: organizations ?? []
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function useLinkedAccounts() {
|
|
1297
|
+
const { user } = useRakomiContext();
|
|
1298
|
+
const methods = user?.rawClaims["linked_methods"] ?? [];
|
|
1299
|
+
return {
|
|
1300
|
+
isLoaded: !!user,
|
|
1301
|
+
methods,
|
|
1302
|
+
link: async () => ({ status: "error" }),
|
|
1303
|
+
unlink: async () => ({ status: "error" })
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function useTranslation() {
|
|
1307
|
+
const { translate, locale } = useRakomiContext();
|
|
1308
|
+
return react.useMemo(() => ({ t: translate, locale }), [translate, locale]);
|
|
1309
|
+
}
|
|
1310
|
+
var authConfigCache = /* @__PURE__ */ new Map();
|
|
1311
|
+
function useAuthConfig() {
|
|
1312
|
+
const { baseUrl, http, publishableKey } = useRakomiContext();
|
|
1313
|
+
const [config, setConfig] = react.useState(null);
|
|
1314
|
+
const [isLoaded, setIsLoaded] = react.useState(false);
|
|
1315
|
+
react.useEffect(() => {
|
|
1316
|
+
if (!baseUrl) return;
|
|
1317
|
+
const cacheKey = `${baseUrl}|${publishableKey}`;
|
|
1318
|
+
let cached2 = authConfigCache.get(cacheKey);
|
|
1319
|
+
if (!cached2) {
|
|
1320
|
+
cached2 = (async () => {
|
|
1321
|
+
try {
|
|
1322
|
+
const response = await http.fetch(`${baseUrl}/v1/auth/config`, {
|
|
1323
|
+
method: "GET",
|
|
1324
|
+
headers: { Accept: "application/json", "X-API-Key": publishableKey }
|
|
1325
|
+
});
|
|
1326
|
+
if (!response.ok) return null;
|
|
1327
|
+
const raw = await response.json();
|
|
1328
|
+
return sdkCore.parseAuthConfigResponse(raw, baseUrl);
|
|
1329
|
+
} catch {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
})();
|
|
1333
|
+
authConfigCache.set(cacheKey, cached2);
|
|
1334
|
+
}
|
|
1335
|
+
let cancelled = false;
|
|
1336
|
+
void cached2.then((value) => {
|
|
1337
|
+
if (!cancelled) {
|
|
1338
|
+
setConfig(value);
|
|
1339
|
+
setIsLoaded(true);
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
return () => {
|
|
1343
|
+
cancelled = true;
|
|
1344
|
+
};
|
|
1345
|
+
}, [baseUrl, http, publishableKey]);
|
|
1346
|
+
return { isLoaded, config };
|
|
1347
|
+
}
|
|
1348
|
+
function useBranding() {
|
|
1349
|
+
const { config, isLoaded } = useAuthConfig();
|
|
1350
|
+
return {
|
|
1351
|
+
isLoaded,
|
|
1352
|
+
branding: config?.branding ?? null
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function useAnonymousSignin() {
|
|
1356
|
+
return {
|
|
1357
|
+
isLoaded: true,
|
|
1358
|
+
signInAnonymously: async () => ({ status: "error" })
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
var baasPlansCache = /* @__PURE__ */ new Map();
|
|
1362
|
+
function useBaasPlans({ tenantSlug }) {
|
|
1363
|
+
const { baseUrl, http, publishableKey } = useRakomiContext();
|
|
1364
|
+
const [plans, setPlans] = react.useState([]);
|
|
1365
|
+
const [isLoaded, setIsLoaded] = react.useState(false);
|
|
1366
|
+
const [error, setError] = react.useState(null);
|
|
1367
|
+
react.useEffect(() => {
|
|
1368
|
+
if (!baseUrl || !tenantSlug) return;
|
|
1369
|
+
const cacheKey = `${baseUrl}|${tenantSlug}`;
|
|
1370
|
+
let cached2 = baasPlansCache.get(cacheKey);
|
|
1371
|
+
if (!cached2) {
|
|
1372
|
+
cached2 = (async () => {
|
|
1373
|
+
try {
|
|
1374
|
+
const response = await http.fetch(`${baseUrl}/v1/billing/baas/${encodeURIComponent(tenantSlug)}/public-plans`, {
|
|
1375
|
+
method: "GET",
|
|
1376
|
+
headers: { Accept: "application/json", "X-API-Key": publishableKey }
|
|
1377
|
+
});
|
|
1378
|
+
if (response.status === 404) return [];
|
|
1379
|
+
if (!response.ok) return { error: `HTTP ${response.status}` };
|
|
1380
|
+
const json = await response.json();
|
|
1381
|
+
return json.data ?? [];
|
|
1382
|
+
} catch (err2) {
|
|
1383
|
+
return { error: err2 instanceof Error ? err2.message : "Network request failed" };
|
|
1384
|
+
}
|
|
1385
|
+
})();
|
|
1386
|
+
baasPlansCache.set(cacheKey, cached2);
|
|
1387
|
+
}
|
|
1388
|
+
let cancelled = false;
|
|
1389
|
+
void cached2.then((value) => {
|
|
1390
|
+
if (cancelled) return;
|
|
1391
|
+
if (Array.isArray(value)) {
|
|
1392
|
+
setPlans(value);
|
|
1393
|
+
setError(null);
|
|
1394
|
+
} else {
|
|
1395
|
+
setError(value.error);
|
|
1396
|
+
}
|
|
1397
|
+
setIsLoaded(true);
|
|
1398
|
+
});
|
|
1399
|
+
return () => {
|
|
1400
|
+
cancelled = true;
|
|
1401
|
+
};
|
|
1402
|
+
}, [baseUrl, http, publishableKey, tenantSlug]);
|
|
1403
|
+
return { isLoaded, plans, error };
|
|
1404
|
+
}
|
|
1405
|
+
function useBaasSubscription({ tenantSlug }) {
|
|
1406
|
+
const { baseUrl, http, publishableKey, getToken } = useRakomiContext();
|
|
1407
|
+
const [subscription, setSubscription] = react.useState(null);
|
|
1408
|
+
const [isLoaded, setIsLoaded] = react.useState(false);
|
|
1409
|
+
const [error, setError] = react.useState(null);
|
|
1410
|
+
react.useEffect(() => {
|
|
1411
|
+
if (!baseUrl || !tenantSlug) return;
|
|
1412
|
+
let cancelled = false;
|
|
1413
|
+
void (async () => {
|
|
1414
|
+
try {
|
|
1415
|
+
const tokenResult = await getToken();
|
|
1416
|
+
const headers = { Accept: "application/json", "X-API-Key": publishableKey };
|
|
1417
|
+
if (tokenResult.ok) headers["Authorization"] = `Bearer ${tokenResult.token}`;
|
|
1418
|
+
const response = await http.fetch(`${baseUrl}/v1/billing/baas/${encodeURIComponent(tenantSlug)}/user/subscription`, {
|
|
1419
|
+
method: "GET",
|
|
1420
|
+
headers
|
|
1421
|
+
});
|
|
1422
|
+
if (cancelled) return;
|
|
1423
|
+
if (response.status === 404) {
|
|
1424
|
+
setSubscription(null);
|
|
1425
|
+
setError(null);
|
|
1426
|
+
} else if (response.ok) {
|
|
1427
|
+
const json = await response.json();
|
|
1428
|
+
setSubscription(json);
|
|
1429
|
+
setError(null);
|
|
1430
|
+
} else {
|
|
1431
|
+
setError(`HTTP ${response.status}`);
|
|
1432
|
+
}
|
|
1433
|
+
} catch (err2) {
|
|
1434
|
+
if (!cancelled) setError(err2 instanceof Error ? err2.message : "Network request failed");
|
|
1435
|
+
} finally {
|
|
1436
|
+
if (!cancelled) setIsLoaded(true);
|
|
1437
|
+
}
|
|
1438
|
+
})();
|
|
1439
|
+
return () => {
|
|
1440
|
+
cancelled = true;
|
|
1441
|
+
};
|
|
1442
|
+
}, [baseUrl, http, publishableKey, tenantSlug, getToken]);
|
|
1443
|
+
return { isLoaded, subscription, error };
|
|
1444
|
+
}
|
|
1445
|
+
function SignedIn({ children }) {
|
|
1446
|
+
const { isSignedIn } = useRakomiContext();
|
|
1447
|
+
return isSignedIn ? /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children }) : null;
|
|
1448
|
+
}
|
|
1449
|
+
function SignedOut({ children }) {
|
|
1450
|
+
const { isSignedIn } = useRakomiContext();
|
|
1451
|
+
return isSignedIn ? null : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
|
|
1452
|
+
}
|
|
1453
|
+
function Protect({ children, fallback = null, permission, role }) {
|
|
1454
|
+
const { user, isSignedIn } = useRakomiContext();
|
|
1455
|
+
if (!isSignedIn || !user) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: fallback });
|
|
1456
|
+
if (permission && !sdkCore.hasPermission(user, permission)) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: fallback });
|
|
1457
|
+
if (role && !sdkCore.hasRole(user, role)) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: fallback });
|
|
1458
|
+
return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
|
|
1459
|
+
}
|
|
1460
|
+
function Feature({ flag, children, fallback = null }) {
|
|
1461
|
+
const { user } = useRakomiContext();
|
|
1462
|
+
const flags = user?.rawClaims["feature_flags"] ?? {};
|
|
1463
|
+
return flags[flag] === true ? /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: fallback });
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/internal/rn-primitives.ts
|
|
1467
|
+
var cached = null;
|
|
1468
|
+
function loadRnPrimitives() {
|
|
1469
|
+
if (cached) return cached;
|
|
1470
|
+
cached = __require("react-native");
|
|
1471
|
+
return cached;
|
|
1472
|
+
}
|
|
1473
|
+
var PKCE_STORAGE_KEY = "rakomi.oauth.pkce.in-flight";
|
|
1474
|
+
async function startSocialSignIn(input) {
|
|
1475
|
+
const { adapter, http } = input;
|
|
1476
|
+
const pkce = await sdkCore.generatePkce(adapter.crypto);
|
|
1477
|
+
const state = await sdkCore.issueState(adapter.crypto, adapter.storage);
|
|
1478
|
+
const pkceRecord = JSON.stringify({ codeVerifier: pkce.codeVerifier });
|
|
1479
|
+
await adapter.storage.setItem(PKCE_STORAGE_KEY, pkceRecord, { keychainAccessible: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY" });
|
|
1480
|
+
const authUrl = sdkCore.buildAuthorizationUrl({
|
|
1481
|
+
authorizationEndpoint: input.authorizationEndpoint,
|
|
1482
|
+
clientId: input.clientId,
|
|
1483
|
+
redirectUri: input.redirectUri,
|
|
1484
|
+
state: state.state,
|
|
1485
|
+
codeChallenge: pkce.codeChallenge,
|
|
1486
|
+
codeChallengeMethod: "S256",
|
|
1487
|
+
scope: input.scope,
|
|
1488
|
+
provider: input.provider,
|
|
1489
|
+
acrValues: input.acrValues
|
|
1490
|
+
});
|
|
1491
|
+
const result = await adapter.browser.openAuthSession(authUrl, input.redirectUri, {
|
|
1492
|
+
preferEphemeralSession: input.preferEphemeralSession ?? true
|
|
1493
|
+
});
|
|
1494
|
+
if (result.type !== "success") {
|
|
1495
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1496
|
+
if (result.type === "cancel") {
|
|
1497
|
+
return { ok: false, error: { code: "OAUTH_CALLBACK_ERROR", oauthError: "oauth_user_cancelled", description: "User cancelled the sign-in flow" } };
|
|
1498
|
+
}
|
|
1499
|
+
if (result.type === "dismiss") {
|
|
1500
|
+
return { ok: false, error: { code: "OAUTH_CALLBACK_ERROR", oauthError: "oauth_user_cancelled", description: "Sign-in dismissed" } };
|
|
1501
|
+
}
|
|
1502
|
+
if (result.type === "locked") {
|
|
1503
|
+
return { ok: false, error: { code: "OAUTH_CALLBACK_ERROR", oauthError: "oauth_locked", description: "iOS auto-fill locked the auth session" } };
|
|
1504
|
+
}
|
|
1505
|
+
return { ok: false, error: { code: "SIGN_IN_FAILED", message: "unknown browser auth result" } };
|
|
1506
|
+
}
|
|
1507
|
+
const url = result.url;
|
|
1508
|
+
const parsed = parseCallback(url, input.redirectUri);
|
|
1509
|
+
if (!parsed.ok) {
|
|
1510
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1511
|
+
return { ok: false, error: parsed.error };
|
|
1512
|
+
}
|
|
1513
|
+
const stateCheck = await sdkCore.consumeState(adapter.storage, state.storageKey, parsed.state);
|
|
1514
|
+
if (!stateCheck.ok) {
|
|
1515
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1516
|
+
return {
|
|
1517
|
+
ok: false,
|
|
1518
|
+
error: {
|
|
1519
|
+
code: "OAUTH_CALLBACK_ERROR",
|
|
1520
|
+
oauthError: "oauth_state_mismatch",
|
|
1521
|
+
description: `State validation failed (${stateCheck.reason})`
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
const pkceRecovered = await adapter.storage.getItem(PKCE_STORAGE_KEY);
|
|
1526
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1527
|
+
if (!pkceRecovered) {
|
|
1528
|
+
return { ok: false, error: { code: "CODE_EXCHANGE_FAILED", message: "PKCE verifier missing" } };
|
|
1529
|
+
}
|
|
1530
|
+
let codeVerifier;
|
|
1531
|
+
try {
|
|
1532
|
+
const parsedPkce = JSON.parse(pkceRecovered);
|
|
1533
|
+
if (typeof parsedPkce.codeVerifier !== "string") throw new TypeError("bad PKCE record");
|
|
1534
|
+
codeVerifier = parsedPkce.codeVerifier;
|
|
1535
|
+
} catch {
|
|
1536
|
+
return { ok: false, error: { code: "CODE_EXCHANGE_FAILED", message: "PKCE record corrupted" } };
|
|
1537
|
+
}
|
|
1538
|
+
let dpopProof;
|
|
1539
|
+
if (input.dpopSession) {
|
|
1540
|
+
try {
|
|
1541
|
+
dpopProof = await input.dpopSession.resolveProof("POST", input.tokenEndpoint);
|
|
1542
|
+
} catch (e) {
|
|
1543
|
+
return {
|
|
1544
|
+
ok: false,
|
|
1545
|
+
error: {
|
|
1546
|
+
code: "CODE_EXCHANGE_FAILED",
|
|
1547
|
+
message: `DPoP prover unavailable at issuance: ${e instanceof Error ? e.message : "signer threw"}`
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const exchange = await sdkCore.exchangeAuthCode({
|
|
1553
|
+
http,
|
|
1554
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
1555
|
+
code: parsed.code,
|
|
1556
|
+
codeVerifier,
|
|
1557
|
+
redirectUri: input.redirectUri,
|
|
1558
|
+
clientId: input.clientId,
|
|
1559
|
+
...dpopProof !== void 0 ? { dpopProof } : {}
|
|
1560
|
+
});
|
|
1561
|
+
if (!exchange.ok) return exchange;
|
|
1562
|
+
if (input.dpopSession) {
|
|
1563
|
+
await input.dpopSession.observeTokenType(exchange.tokens.token_type, dpopProof !== void 0);
|
|
1564
|
+
}
|
|
1565
|
+
return {
|
|
1566
|
+
ok: true,
|
|
1567
|
+
tokens: {
|
|
1568
|
+
access_token: exchange.tokens.access_token,
|
|
1569
|
+
refresh_token: exchange.tokens.refresh_token,
|
|
1570
|
+
expires_in: exchange.tokens.expires_in,
|
|
1571
|
+
token_type: exchange.tokens.token_type
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
function parseCallback(callbackUrl, expectedRedirectUri) {
|
|
1576
|
+
const expected = matchSchemeAndHost(expectedRedirectUri);
|
|
1577
|
+
const actual = matchSchemeAndHost(callbackUrl);
|
|
1578
|
+
if (!expected || !actual || expected.scheme !== actual.scheme || expected.host !== actual.host) {
|
|
1579
|
+
return {
|
|
1580
|
+
ok: false,
|
|
1581
|
+
error: {
|
|
1582
|
+
code: "OAUTH_CALLBACK_ERROR",
|
|
1583
|
+
oauthError: "oauth_redirect_mismatch",
|
|
1584
|
+
description: "Callback redirect URI scheme/host changed mid-flow (confused-deputy guard)"
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
const queryStart = callbackUrl.indexOf("?");
|
|
1589
|
+
const query = queryStart >= 0 ? callbackUrl.slice(queryStart + 1) : "";
|
|
1590
|
+
const params = new URLSearchParams(query);
|
|
1591
|
+
const error = params.get("error");
|
|
1592
|
+
if (error) {
|
|
1593
|
+
return {
|
|
1594
|
+
ok: false,
|
|
1595
|
+
error: sdkCore.parseOAuthCallbackError(error, params.get("error_description") ?? void 0)
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
const code = params.get("code");
|
|
1599
|
+
const state = params.get("state");
|
|
1600
|
+
if (!code || !state) {
|
|
1601
|
+
return {
|
|
1602
|
+
ok: false,
|
|
1603
|
+
error: {
|
|
1604
|
+
code: "OAUTH_CALLBACK_ERROR",
|
|
1605
|
+
oauthError: "oauth_missing_params",
|
|
1606
|
+
description: "Callback URL missing code or state"
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
return { ok: true, code, state };
|
|
1611
|
+
}
|
|
1612
|
+
function matchSchemeAndHost(url) {
|
|
1613
|
+
const m = url.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i);
|
|
1614
|
+
if (!m) return null;
|
|
1615
|
+
return { scheme: m[1].toLowerCase(), host: m[2].toLowerCase() };
|
|
1616
|
+
}
|
|
1617
|
+
function SignIn(props) {
|
|
1618
|
+
const ctx = useRakomiContext();
|
|
1619
|
+
const { View, Text, TextInput, Pressable } = loadRnPrimitives();
|
|
1620
|
+
const [step, setStep] = react.useState({ kind: "methods" });
|
|
1621
|
+
const [code, setCode] = react.useState("");
|
|
1622
|
+
const [busy, setBusy] = react.useState(false);
|
|
1623
|
+
const tokenEndpoint = props.tokenEndpoint ?? "/v1/auth/oauth/callback";
|
|
1624
|
+
const authorizationEndpoint = props.authorizationEndpoint ?? "/oauth/authorize";
|
|
1625
|
+
const totpVerifyEndpoint = props.totpVerifyEndpoint ?? "/v1/auth/mfa/totp/verify";
|
|
1626
|
+
const providers = props.providers ?? ["google"];
|
|
1627
|
+
async function onProviderPress(provider) {
|
|
1628
|
+
if (busy) return;
|
|
1629
|
+
setBusy(true);
|
|
1630
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { provider } });
|
|
1631
|
+
const outcome = await startSocialSignIn({
|
|
1632
|
+
adapter: ctx.adapter,
|
|
1633
|
+
http: ctx.http,
|
|
1634
|
+
authorizationEndpoint,
|
|
1635
|
+
tokenEndpoint,
|
|
1636
|
+
clientId: ctx.publishableKey,
|
|
1637
|
+
redirectUri: ctx.redirectUri,
|
|
1638
|
+
provider,
|
|
1639
|
+
...ctx.dpopSession ? { dpopSession: ctx.dpopSession } : {}
|
|
1640
|
+
});
|
|
1641
|
+
if (outcome.ok) {
|
|
1642
|
+
ctx.events.push({ type: "signed_in", severity: "info" });
|
|
1643
|
+
setStep({ kind: "success" });
|
|
1644
|
+
} else {
|
|
1645
|
+
ctx.events.push({ type: "sign_in_failed", severity: "warning", error: outcome.error });
|
|
1646
|
+
setStep({ kind: "error", error: outcome.error });
|
|
1647
|
+
}
|
|
1648
|
+
setBusy(false);
|
|
1649
|
+
}
|
|
1650
|
+
async function onTotpSubmit() {
|
|
1651
|
+
if (busy || step.kind !== "awaiting_mfa") return;
|
|
1652
|
+
setBusy(true);
|
|
1653
|
+
const result = await sdkCore.verifyTotp({
|
|
1654
|
+
http: ctx.http,
|
|
1655
|
+
endpoint: totpVerifyEndpoint,
|
|
1656
|
+
challengeToken: step.challengeToken,
|
|
1657
|
+
code
|
|
1658
|
+
});
|
|
1659
|
+
if (result.ok) {
|
|
1660
|
+
ctx.events.push({ type: "signed_in", severity: "info", metadata: { method: "mfa_totp" } });
|
|
1661
|
+
setStep({ kind: "success" });
|
|
1662
|
+
} else {
|
|
1663
|
+
if (result.error.code === "SIGN_IN_FAILED" && /unavailable/i.test(result.error.message)) {
|
|
1664
|
+
const e = new sdkCore.MfaStepUpUnavailableError(result.error.message);
|
|
1665
|
+
ctx.events.push({ type: "sign_in_failed", severity: "warning", metadata: { reason: "mfa_step_up_unavailable", guidance: e.guidance } });
|
|
1666
|
+
}
|
|
1667
|
+
setStep({ kind: "error", error: result.error });
|
|
1668
|
+
}
|
|
1669
|
+
setBusy(false);
|
|
1670
|
+
}
|
|
1671
|
+
if (step.kind === "success") {
|
|
1672
|
+
return /* @__PURE__ */ jsxRuntime.jsx(View, { style: props.style, accessibilityRole: "alert", children: /* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityLabel: ctx.translate("common.continue"), children: ctx.translate("common.continue") }) });
|
|
1673
|
+
}
|
|
1674
|
+
if (step.kind === "awaiting_mfa") {
|
|
1675
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(View, { style: props.style, children: [
|
|
1676
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityRole: "header", children: ctx.translate("signin.mfa.title") }),
|
|
1677
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1678
|
+
TextInput,
|
|
1679
|
+
{
|
|
1680
|
+
accessibilityLabel: ctx.translate("signin.mfa.code"),
|
|
1681
|
+
accessibilityHint: ctx.translate("signin.mfa.code"),
|
|
1682
|
+
keyboardType: "number-pad",
|
|
1683
|
+
textContentType: "oneTimeCode",
|
|
1684
|
+
autoComplete: "sms-otp",
|
|
1685
|
+
maxLength: 6,
|
|
1686
|
+
value: code,
|
|
1687
|
+
onChangeText: (t) => setCode(t.replace(/\D/g, "").slice(0, 6))
|
|
1688
|
+
}
|
|
1689
|
+
),
|
|
1690
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1691
|
+
Pressable,
|
|
1692
|
+
{
|
|
1693
|
+
accessibilityRole: "button",
|
|
1694
|
+
accessibilityLabel: ctx.translate("signin.mfa.submit"),
|
|
1695
|
+
onPress: onTotpSubmit,
|
|
1696
|
+
disabled: busy || code.length !== 6,
|
|
1697
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(Text, { children: ctx.translate("signin.mfa.submit") })
|
|
1698
|
+
}
|
|
1699
|
+
)
|
|
1700
|
+
] });
|
|
1701
|
+
}
|
|
1702
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(View, { style: props.style, children: [
|
|
1703
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityRole: "header", children: props.title ?? ctx.translate("signin.title") }),
|
|
1704
|
+
providers.map((provider) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
1705
|
+
Pressable,
|
|
1706
|
+
{
|
|
1707
|
+
accessibilityRole: "button",
|
|
1708
|
+
accessibilityLabel: `Sign in with ${provider}`,
|
|
1709
|
+
onPress: () => onProviderPress(provider),
|
|
1710
|
+
disabled: busy,
|
|
1711
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs(Text, { children: [
|
|
1712
|
+
"Sign in with ",
|
|
1713
|
+
provider
|
|
1714
|
+
] })
|
|
1715
|
+
},
|
|
1716
|
+
provider
|
|
1717
|
+
)),
|
|
1718
|
+
step.kind === "error" ? /* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityRole: "alert", accessibilityLabel: ctx.translate("common.error.unknown"), children: "description" in step.error ? step.error.description : "message" in step.error ? step.error.message : ctx.translate("common.error.unknown") }) : null
|
|
1719
|
+
] });
|
|
1720
|
+
}
|
|
1721
|
+
function SignUp(props) {
|
|
1722
|
+
const ctx = useRakomiContext();
|
|
1723
|
+
const { View, Text, TextInput, Pressable } = loadRnPrimitives();
|
|
1724
|
+
const [email, setEmail] = react.useState("");
|
|
1725
|
+
const [password, setPassword] = react.useState("");
|
|
1726
|
+
const [busy, setBusy] = react.useState(false);
|
|
1727
|
+
const [step, setStep] = react.useState({ kind: "form" });
|
|
1728
|
+
const strength = password ? sdkCore.scorePassword(password) : null;
|
|
1729
|
+
const returnTo = props.returnTo && sdkCore.isSafeUrl(props.returnTo, ["http", "https", "rakomi"]) ? props.returnTo : void 0;
|
|
1730
|
+
async function onSubmit() {
|
|
1731
|
+
if (busy) return;
|
|
1732
|
+
setBusy(true);
|
|
1733
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { mode: "register" } });
|
|
1734
|
+
try {
|
|
1735
|
+
const response = await ctx.http.fetch(props.registerEndpoint ?? "/v1/auth/register", {
|
|
1736
|
+
method: "POST",
|
|
1737
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
1738
|
+
body: JSON.stringify({ email, password, returnTo })
|
|
1739
|
+
});
|
|
1740
|
+
if (!response.ok) {
|
|
1741
|
+
setStep({ kind: "error", error: { code: "SIGN_IN_FAILED", message: `Register failed (${response.status})` } });
|
|
1742
|
+
} else {
|
|
1743
|
+
setStep({ kind: "verification_required" });
|
|
1744
|
+
}
|
|
1745
|
+
} catch (err2) {
|
|
1746
|
+
setStep({ kind: "error", error: { code: "NETWORK_ERROR", message: err2 instanceof Error ? err2.message : "network" } });
|
|
1747
|
+
} finally {
|
|
1748
|
+
setBusy(false);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (step.kind === "verification_required") {
|
|
1752
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(View, { style: props.style, accessibilityRole: "alert", children: [
|
|
1753
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { children: ctx.translate("signup.title") }),
|
|
1754
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { children: ctx.translate("common.continue") })
|
|
1755
|
+
] });
|
|
1756
|
+
}
|
|
1757
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(View, { style: props.style, children: [
|
|
1758
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityRole: "header", children: ctx.translate("signup.title") }),
|
|
1759
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1760
|
+
TextInput,
|
|
1761
|
+
{
|
|
1762
|
+
accessibilityLabel: ctx.translate("signup.email"),
|
|
1763
|
+
keyboardType: "email-address",
|
|
1764
|
+
textContentType: "emailAddress",
|
|
1765
|
+
autoComplete: "email",
|
|
1766
|
+
autoCapitalize: "none",
|
|
1767
|
+
value: email,
|
|
1768
|
+
onChangeText: setEmail
|
|
1769
|
+
}
|
|
1770
|
+
),
|
|
1771
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1772
|
+
TextInput,
|
|
1773
|
+
{
|
|
1774
|
+
accessibilityLabel: ctx.translate("signup.password"),
|
|
1775
|
+
secureTextEntry: true,
|
|
1776
|
+
textContentType: "newPassword",
|
|
1777
|
+
autoComplete: "password-new",
|
|
1778
|
+
value: password,
|
|
1779
|
+
onChangeText: setPassword
|
|
1780
|
+
}
|
|
1781
|
+
),
|
|
1782
|
+
strength ? /* @__PURE__ */ jsxRuntime.jsxs(Text, { accessibilityLabel: `Password strength: ${strength}`, children: [
|
|
1783
|
+
"Strength: ",
|
|
1784
|
+
strength
|
|
1785
|
+
] }) : null,
|
|
1786
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1787
|
+
Pressable,
|
|
1788
|
+
{
|
|
1789
|
+
accessibilityRole: "button",
|
|
1790
|
+
accessibilityLabel: ctx.translate("signup.submit"),
|
|
1791
|
+
onPress: onSubmit,
|
|
1792
|
+
disabled: busy || !email || !password,
|
|
1793
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(Text, { children: ctx.translate("signup.submit") })
|
|
1794
|
+
}
|
|
1795
|
+
),
|
|
1796
|
+
step.kind === "error" ? /* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityRole: "alert", children: "message" in step.error ? step.error.message : ctx.translate("common.error.unknown") }) : null
|
|
1797
|
+
] });
|
|
1798
|
+
}
|
|
1799
|
+
function UserButton(props) {
|
|
1800
|
+
const ctx = useRakomiContext();
|
|
1801
|
+
const { View, Text, Pressable } = loadRnPrimitives();
|
|
1802
|
+
const [expanded, setExpanded] = react.useState(false);
|
|
1803
|
+
const user = ctx.user;
|
|
1804
|
+
if (!user) return null;
|
|
1805
|
+
const initials = (user.email || "?").slice(0, 2).toUpperCase();
|
|
1806
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(View, { style: props.style, children: [
|
|
1807
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1808
|
+
Pressable,
|
|
1809
|
+
{
|
|
1810
|
+
accessibilityRole: "button",
|
|
1811
|
+
accessibilityLabel: `Account menu for ${user.email}`,
|
|
1812
|
+
onPress: () => setExpanded((v) => !v),
|
|
1813
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs(View, { children: [
|
|
1814
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityLabel: `Avatar ${initials}`, children: initials }),
|
|
1815
|
+
props.showName !== false ? /* @__PURE__ */ jsxRuntime.jsx(Text, { children: user.email }) : null
|
|
1816
|
+
] })
|
|
1817
|
+
}
|
|
1818
|
+
),
|
|
1819
|
+
expanded ? /* @__PURE__ */ jsxRuntime.jsx(View, { accessibilityRole: "menu", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1820
|
+
Pressable,
|
|
1821
|
+
{
|
|
1822
|
+
accessibilityRole: "menuitem",
|
|
1823
|
+
accessibilityLabel: "Sign out",
|
|
1824
|
+
onPress: () => {
|
|
1825
|
+
setExpanded(false);
|
|
1826
|
+
void ctx.signOut();
|
|
1827
|
+
},
|
|
1828
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(Text, { children: "Sign out" })
|
|
1829
|
+
}
|
|
1830
|
+
) }) : null
|
|
1831
|
+
] });
|
|
1832
|
+
}
|
|
1833
|
+
function UserProfile(props) {
|
|
1834
|
+
react.useEffect(() => {
|
|
1835
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
1836
|
+
console.warn("[@rakomi/react-native] <UserProfile /> is in preview until 0.2.0 \u2014 feedback welcome.");
|
|
1837
|
+
}
|
|
1838
|
+
}, []);
|
|
1839
|
+
const ctx = useRakomiContext();
|
|
1840
|
+
const { View, Text } = loadRnPrimitives();
|
|
1841
|
+
const user = ctx.user;
|
|
1842
|
+
if (!user) {
|
|
1843
|
+
return /* @__PURE__ */ jsxRuntime.jsx(View, { style: props.style, children: /* @__PURE__ */ jsxRuntime.jsx(Text, { children: "Not signed in" }) });
|
|
1844
|
+
}
|
|
1845
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(View, { style: props.style, accessibilityRole: "summary", children: [
|
|
1846
|
+
/* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityRole: "header", children: user.email || "Anonymous user" }),
|
|
1847
|
+
/* @__PURE__ */ jsxRuntime.jsxs(Text, { children: [
|
|
1848
|
+
"ID: ",
|
|
1849
|
+
user.id
|
|
1850
|
+
] }),
|
|
1851
|
+
/* @__PURE__ */ jsxRuntime.jsxs(Text, { children: [
|
|
1852
|
+
"Tenant: ",
|
|
1853
|
+
user.tenantId
|
|
1854
|
+
] }),
|
|
1855
|
+
user.roles.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(Text, { children: [
|
|
1856
|
+
"Roles: ",
|
|
1857
|
+
user.roles.join(", ")
|
|
1858
|
+
] }) : null,
|
|
1859
|
+
user.permissions.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(Text, { children: [
|
|
1860
|
+
"Permissions: ",
|
|
1861
|
+
user.permissions.length
|
|
1862
|
+
] }) : null,
|
|
1863
|
+
user.mfaVerified ? /* @__PURE__ */ jsxRuntime.jsx(Text, { accessibilityLabel: "MFA verified", children: "MFA: verified" }) : null
|
|
1864
|
+
] });
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// src/native/dpop-prover.ts
|
|
1868
|
+
function createNativeDpopProver(options) {
|
|
1869
|
+
const mod = options.module;
|
|
1870
|
+
return {
|
|
1871
|
+
async createProof(input) {
|
|
1872
|
+
const proof = await mod.createDpopProof(input.htm, input.htu, input.nonce ?? null);
|
|
1873
|
+
if (typeof proof !== "string" || proof.length === 0) {
|
|
1874
|
+
throw new Error("native DPoP signer returned an empty proof");
|
|
1875
|
+
}
|
|
1876
|
+
return proof;
|
|
1877
|
+
},
|
|
1878
|
+
async jktHint() {
|
|
1879
|
+
return mod.dpopJktThumbprint();
|
|
1880
|
+
}
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// src/native/passkey-envelope.ts
|
|
1885
|
+
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
1886
|
+
var RULES = {
|
|
1887
|
+
registration: {
|
|
1888
|
+
required: ["id", "rawId", "response.clientDataJSON", "response.attestationObject"],
|
|
1889
|
+
base64url: ["id", "rawId", "response.clientDataJSON", "response.attestationObject"],
|
|
1890
|
+
requiredStringArray: ["response.transports"],
|
|
1891
|
+
requiredObject: ["clientExtensionResults"],
|
|
1892
|
+
keep: ["id", "rawId", "type", "authenticatorAttachment", "clientExtensionResults"],
|
|
1893
|
+
keepInResponse: [
|
|
1894
|
+
"clientDataJSON",
|
|
1895
|
+
"attestationObject",
|
|
1896
|
+
"authenticatorData",
|
|
1897
|
+
"publicKey",
|
|
1898
|
+
"publicKeyAlgorithm"
|
|
1899
|
+
],
|
|
1900
|
+
keepStringArrayInResponse: ["transports"]
|
|
1901
|
+
},
|
|
1902
|
+
assertion: {
|
|
1903
|
+
required: [
|
|
1904
|
+
"id",
|
|
1905
|
+
"rawId",
|
|
1906
|
+
"response.clientDataJSON",
|
|
1907
|
+
"response.authenticatorData",
|
|
1908
|
+
"response.signature"
|
|
1909
|
+
],
|
|
1910
|
+
base64url: [
|
|
1911
|
+
"id",
|
|
1912
|
+
"rawId",
|
|
1913
|
+
"response.clientDataJSON",
|
|
1914
|
+
"response.authenticatorData",
|
|
1915
|
+
"response.signature",
|
|
1916
|
+
"response.userHandle"
|
|
1917
|
+
],
|
|
1918
|
+
requiredStringArray: [],
|
|
1919
|
+
requiredObject: ["clientExtensionResults"],
|
|
1920
|
+
keep: ["id", "rawId", "type", "authenticatorAttachment", "clientExtensionResults"],
|
|
1921
|
+
keepInResponse: ["clientDataJSON", "authenticatorData", "signature", "userHandle"],
|
|
1922
|
+
keepStringArrayInResponse: []
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
var CredentialEnvelopeError = class extends Error {
|
|
1926
|
+
constructor(message) {
|
|
1927
|
+
super(message);
|
|
1928
|
+
this.name = "CredentialEnvelopeError";
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
function isRecord(value) {
|
|
1932
|
+
return typeof value === "object" && value !== null;
|
|
1933
|
+
}
|
|
1934
|
+
function readPath(envelope, path) {
|
|
1935
|
+
const dot = path.indexOf(".");
|
|
1936
|
+
if (dot === -1) return envelope[path];
|
|
1937
|
+
const parent = envelope[path.slice(0, dot)];
|
|
1938
|
+
return isRecord(parent) ? parent[path.slice(dot + 1)] : void 0;
|
|
1939
|
+
}
|
|
1940
|
+
function assertW3cCredentialEnvelope(kind, parsed) {
|
|
1941
|
+
if (!isRecord(parsed)) {
|
|
1942
|
+
throw new CredentialEnvelopeError(
|
|
1943
|
+
`the native module returned a ${parsed === null ? "null" : typeof parsed}; the contract is a JSON object`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
if (parsed["type"] !== "public-key") {
|
|
1947
|
+
throw new CredentialEnvelopeError(`'type' must be the string "public-key" (W3C WebAuthn L3)`);
|
|
1948
|
+
}
|
|
1949
|
+
if (!isRecord(parsed["response"])) {
|
|
1950
|
+
throw new CredentialEnvelopeError(`'response' is missing or is not an object`);
|
|
1951
|
+
}
|
|
1952
|
+
const rule = RULES[kind];
|
|
1953
|
+
for (const path of rule.required) {
|
|
1954
|
+
const value = readPath(parsed, path);
|
|
1955
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1956
|
+
throw new CredentialEnvelopeError(
|
|
1957
|
+
`'${path}' is required on a ${kind} response and must be a non-empty string (W3C WebAuthn L3 JSON serialization)`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
for (const path of rule.requiredStringArray) {
|
|
1962
|
+
const value = readPath(parsed, path);
|
|
1963
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
1964
|
+
throw new CredentialEnvelopeError(
|
|
1965
|
+
`'${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.`
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
for (const path of rule.requiredObject) {
|
|
1970
|
+
const value = readPath(parsed, path);
|
|
1971
|
+
if (!isRecord(value)) {
|
|
1972
|
+
throw new CredentialEnvelopeError(
|
|
1973
|
+
`'${path}' is required on a ${kind} response and must be an object (an empty object is fine)`
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
for (const path of rule.base64url) {
|
|
1978
|
+
const value = readPath(parsed, path);
|
|
1979
|
+
if (value === void 0 || value === null) continue;
|
|
1980
|
+
if (typeof value !== "string" || !BASE64URL.test(value)) {
|
|
1981
|
+
throw new CredentialEnvelopeError(
|
|
1982
|
+
`'${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.`
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
function sanitizeCredentialEnvelope(kind, parsed) {
|
|
1988
|
+
assertW3cCredentialEnvelope(kind, parsed);
|
|
1989
|
+
const envelope = parsed;
|
|
1990
|
+
const source = envelope["response"];
|
|
1991
|
+
const rule = RULES[kind];
|
|
1992
|
+
const response = /* @__PURE__ */ Object.create(null);
|
|
1993
|
+
for (const key of rule.keepInResponse) {
|
|
1994
|
+
const value = source[key];
|
|
1995
|
+
if (typeof value === "string" || typeof value === "number") response[key] = value;
|
|
1996
|
+
}
|
|
1997
|
+
for (const key of rule.keepStringArrayInResponse) {
|
|
1998
|
+
const value = source[key];
|
|
1999
|
+
if (Array.isArray(value)) response[key] = value.filter((entry) => typeof entry === "string");
|
|
2000
|
+
}
|
|
2001
|
+
const projected = /* @__PURE__ */ Object.create(null);
|
|
2002
|
+
for (const key of rule.keep) {
|
|
2003
|
+
const value = envelope[key];
|
|
2004
|
+
if (value !== void 0) projected[key] = value;
|
|
2005
|
+
}
|
|
2006
|
+
projected["response"] = response;
|
|
2007
|
+
return projected;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// src/native/passkey-adapter.ts
|
|
2011
|
+
var NATIVE_CODE_TO_KIND = Object.assign(/* @__PURE__ */ Object.create(null), {
|
|
2012
|
+
PASSKEY_CANCELLED: "cancelled",
|
|
2013
|
+
PASSKEY_UNSUPPORTED: "unsupported",
|
|
2014
|
+
PASSKEY_NO_CREDENTIAL: "cancelled"
|
|
2015
|
+
});
|
|
2016
|
+
function isRecord2(value) {
|
|
2017
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2018
|
+
}
|
|
2019
|
+
function normalizeNativeRejection(err2) {
|
|
2020
|
+
const code = isRecord2(err2) ? err2["code"] : void 0;
|
|
2021
|
+
if (typeof code === "string") {
|
|
2022
|
+
const kind = NATIVE_CODE_TO_KIND[code];
|
|
2023
|
+
if (kind !== void 0) {
|
|
2024
|
+
throw new sdkCore.PasskeyCeremonyError(kind);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
throw err2;
|
|
2028
|
+
}
|
|
2029
|
+
function assertNever(value) {
|
|
2030
|
+
throw new sdkCore.PasskeyCeremonyError("failed", `unhandled ceremony-error kind: ${String(value)}`);
|
|
2031
|
+
}
|
|
2032
|
+
function describeKind(kind) {
|
|
2033
|
+
switch (kind) {
|
|
2034
|
+
case "cancelled":
|
|
2035
|
+
return "the user dismissed the passkey sheet";
|
|
2036
|
+
case "unsupported":
|
|
2037
|
+
return "this platform cannot run a passkey ceremony";
|
|
2038
|
+
case "failed":
|
|
2039
|
+
return "the passkey ceremony failed";
|
|
2040
|
+
default:
|
|
2041
|
+
return assertNever(kind);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
function createNativePasskeyAdapter(options) {
|
|
2045
|
+
const mod = options.module;
|
|
2046
|
+
let inFlight = null;
|
|
2047
|
+
const candidate = mod;
|
|
2048
|
+
const missing = ["isPasskeySupported", "createPasskey", "getPasskey"].filter(
|
|
2049
|
+
(method) => typeof candidate?.[method] !== "function"
|
|
2050
|
+
);
|
|
2051
|
+
if (missing.length > 0) {
|
|
2052
|
+
throw new Error(
|
|
2053
|
+
`native passkey module is missing: ${missing.join(", ")} \u2014 it does not satisfy NativePasskeyModuleSpec`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
async function runCeremony(kind, requestJson, ceremonyOptions) {
|
|
2057
|
+
const signal = ceremonyOptions?.signal;
|
|
2058
|
+
if (signal?.aborted === true) {
|
|
2059
|
+
throw new sdkCore.PasskeyCeremonyError("cancelled", describeKind("cancelled"));
|
|
2060
|
+
}
|
|
2061
|
+
const ticket = /* @__PURE__ */ Symbol("passkey-ceremony");
|
|
2062
|
+
inFlight = ticket;
|
|
2063
|
+
const onAbort = () => {
|
|
2064
|
+
if (inFlight !== ticket) return;
|
|
2065
|
+
try {
|
|
2066
|
+
void Promise.resolve(mod.cancelPasskeyRequest?.()).catch(() => void 0);
|
|
2067
|
+
} catch {
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2071
|
+
let raw;
|
|
2072
|
+
try {
|
|
2073
|
+
raw = await (kind === "registration" ? mod.createPasskey(requestJson) : mod.getPasskey(requestJson));
|
|
2074
|
+
} catch (err2) {
|
|
2075
|
+
normalizeNativeRejection(err2);
|
|
2076
|
+
} finally {
|
|
2077
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2078
|
+
if (inFlight === ticket) inFlight = null;
|
|
2079
|
+
}
|
|
2080
|
+
if (typeof raw !== "string") {
|
|
2081
|
+
throw new CredentialEnvelopeError(
|
|
2082
|
+
`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`
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
let parsed;
|
|
2086
|
+
try {
|
|
2087
|
+
parsed = JSON.parse(raw);
|
|
2088
|
+
} catch {
|
|
2089
|
+
throw new CredentialEnvelopeError(
|
|
2090
|
+
"the native module returned a string that is not valid JSON (a double-encoded response does this)"
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
return sanitizeCredentialEnvelope(kind, parsed);
|
|
2094
|
+
}
|
|
2095
|
+
async function ceremonyOrMalformed(kind, requestJson, ceremonyOptions) {
|
|
2096
|
+
try {
|
|
2097
|
+
return await runCeremony(kind, requestJson, ceremonyOptions);
|
|
2098
|
+
} catch (err2) {
|
|
2099
|
+
if (err2 instanceof CredentialEnvelopeError) {
|
|
2100
|
+
return { rakomiEnvelopeViolation: err2.message };
|
|
2101
|
+
}
|
|
2102
|
+
throw err2;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
return {
|
|
2106
|
+
async isSupported() {
|
|
2107
|
+
try {
|
|
2108
|
+
return await mod.isPasskeySupported() === true;
|
|
2109
|
+
} catch {
|
|
2110
|
+
return false;
|
|
2111
|
+
}
|
|
2112
|
+
},
|
|
2113
|
+
hasPlatformAuthenticator() {
|
|
2114
|
+
return probePlatformAuthenticator(mod);
|
|
2115
|
+
},
|
|
2116
|
+
createCredential(creationOptions, ceremonyOptions) {
|
|
2117
|
+
return ceremonyOrMalformed(
|
|
2118
|
+
"registration",
|
|
2119
|
+
JSON.stringify(creationOptions),
|
|
2120
|
+
ceremonyOptions
|
|
2121
|
+
);
|
|
2122
|
+
},
|
|
2123
|
+
getCredential(requestOptions, ceremonyOptions) {
|
|
2124
|
+
return ceremonyOrMalformed(
|
|
2125
|
+
"assertion",
|
|
2126
|
+
JSON.stringify(requestOptions),
|
|
2127
|
+
ceremonyOptions
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
async function probePlatformAuthenticator(mod) {
|
|
2133
|
+
if (typeof mod.isPlatformAuthenticatorAvailable !== "function") return null;
|
|
2134
|
+
try {
|
|
2135
|
+
const available = await mod.isPlatformAuthenticatorAvailable();
|
|
2136
|
+
return typeof available === "boolean" ? available : null;
|
|
2137
|
+
} catch {
|
|
2138
|
+
return null;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// src/internal/webhook-verify.ts
|
|
2143
|
+
var RKSEC_PREFIX = "rksec_";
|
|
2144
|
+
var EXPECTED_KEY_LENGTH = 32;
|
|
2145
|
+
var DEFAULT_WEBHOOK_TOLERANCE = 300;
|
|
2146
|
+
var MAX_WEBHOOK_TOLERANCE = 600;
|
|
2147
|
+
function getHeader(headers, name) {
|
|
2148
|
+
const lower = name.toLowerCase();
|
|
2149
|
+
for (const key of Object.keys(headers)) {
|
|
2150
|
+
if (key.toLowerCase() === lower) {
|
|
2151
|
+
const value = headers[key];
|
|
2152
|
+
const v = Array.isArray(value) ? value[0] : value;
|
|
2153
|
+
return v?.trim();
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return void 0;
|
|
2157
|
+
}
|
|
2158
|
+
function base64ToBytes(input, urlSafe) {
|
|
2159
|
+
try {
|
|
2160
|
+
let s = urlSafe ? input.replace(/-/g, "+").replace(/_/g, "/") : input;
|
|
2161
|
+
const pad = s.length % 4;
|
|
2162
|
+
if (pad) s += "=".repeat(4 - pad);
|
|
2163
|
+
const bin = atob(s);
|
|
2164
|
+
const out = new Uint8Array(bin.length);
|
|
2165
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
2166
|
+
return out;
|
|
2167
|
+
} catch {
|
|
2168
|
+
return null;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
function deriveKey(secret) {
|
|
2172
|
+
const encoded = secret.startsWith(RKSEC_PREFIX) ? secret.slice(RKSEC_PREFIX.length) : secret;
|
|
2173
|
+
const key = base64ToBytes(encoded, true);
|
|
2174
|
+
if (!key || key.length !== EXPECTED_KEY_LENGTH) return null;
|
|
2175
|
+
return key;
|
|
2176
|
+
}
|
|
2177
|
+
function timingSafeEqual(a, b) {
|
|
2178
|
+
let diff = a.length ^ b.length;
|
|
2179
|
+
const len = Math.min(a.length, b.length);
|
|
2180
|
+
for (let i = 0; i < len; i++) diff |= a[i] ^ b[i];
|
|
2181
|
+
return diff === 0;
|
|
2182
|
+
}
|
|
2183
|
+
function toArrayBuffer(u) {
|
|
2184
|
+
const out = new ArrayBuffer(u.byteLength);
|
|
2185
|
+
new Uint8Array(out).set(u);
|
|
2186
|
+
return out;
|
|
2187
|
+
}
|
|
2188
|
+
async function hmacSha256(key, data) {
|
|
2189
|
+
const subtle = globalThis.crypto?.subtle;
|
|
2190
|
+
if (!subtle) {
|
|
2191
|
+
throw new Error("WebCrypto subtle is unavailable \u2014 a crypto polyfill (e.g. expo-crypto) is required");
|
|
2192
|
+
}
|
|
2193
|
+
const cryptoKey = await subtle.importKey(
|
|
2194
|
+
"raw",
|
|
2195
|
+
toArrayBuffer(key),
|
|
2196
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
2197
|
+
false,
|
|
2198
|
+
["sign"]
|
|
2199
|
+
);
|
|
2200
|
+
const sig = await subtle.sign("HMAC", cryptoKey, toArrayBuffer(data));
|
|
2201
|
+
return new Uint8Array(sig);
|
|
2202
|
+
}
|
|
2203
|
+
function toBytes(body) {
|
|
2204
|
+
return typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
2205
|
+
}
|
|
2206
|
+
function bytesToUtf8(body) {
|
|
2207
|
+
return typeof body === "string" ? body : new TextDecoder("utf-8", { fatal: false }).decode(body);
|
|
2208
|
+
}
|
|
2209
|
+
async function verifyWebhook(body, headers, secret, tolerance = DEFAULT_WEBHOOK_TOLERANCE) {
|
|
2210
|
+
const signature = getHeader(headers, "webhook-signature");
|
|
2211
|
+
const timestampStr = getHeader(headers, "webhook-timestamp");
|
|
2212
|
+
const webhookId = getHeader(headers, "webhook-id");
|
|
2213
|
+
if (!signature || !timestampStr || !webhookId) {
|
|
2214
|
+
return { ok: false, error: "missing_header" };
|
|
2215
|
+
}
|
|
2216
|
+
if (!/^-?\d+$/.test(timestampStr)) {
|
|
2217
|
+
return { ok: false, error: "missing_header" };
|
|
2218
|
+
}
|
|
2219
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
2220
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2221
|
+
const diff = now - timestamp;
|
|
2222
|
+
if (diff > tolerance) return { ok: false, error: "timestamp_too_old" };
|
|
2223
|
+
if (diff < -tolerance) return { ok: false, error: "timestamp_too_new" };
|
|
2224
|
+
const key = deriveKey(secret);
|
|
2225
|
+
if (!key) return { ok: false, error: "invalid_secret" };
|
|
2226
|
+
const bodyString = bytesToUtf8(body);
|
|
2227
|
+
const signedContent = `${webhookId}.${timestampStr}.${bodyString}`;
|
|
2228
|
+
const expected = await hmacSha256(key, toBytes(signedContent));
|
|
2229
|
+
let matched = false;
|
|
2230
|
+
for (const raw of signature.split(" ")) {
|
|
2231
|
+
const entry = raw.trim();
|
|
2232
|
+
if (!entry.startsWith("v1,")) continue;
|
|
2233
|
+
const candidate = base64ToBytes(entry.slice(3), false);
|
|
2234
|
+
if (!candidate || candidate.length !== EXPECTED_KEY_LENGTH) continue;
|
|
2235
|
+
if (timingSafeEqual(expected, candidate)) {
|
|
2236
|
+
matched = true;
|
|
2237
|
+
break;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
if (!matched) return { ok: false, error: "invalid_signature" };
|
|
2241
|
+
let payload;
|
|
2242
|
+
try {
|
|
2243
|
+
payload = JSON.parse(bodyString);
|
|
2244
|
+
if (typeof payload !== "object" || payload === null) return { ok: false, error: "invalid_body" };
|
|
2245
|
+
} catch {
|
|
2246
|
+
return { ok: false, error: "invalid_body" };
|
|
2247
|
+
}
|
|
2248
|
+
const deliveryId = getHeader(headers, "x-rakomi-delivery-id") ?? webhookId;
|
|
2249
|
+
const eventType = getHeader(headers, "x-rakomi-event") ?? "";
|
|
2250
|
+
return { ok: true, data: { webhookId, deliveryId, eventType, timestamp, payload } };
|
|
2251
|
+
}
|
|
2252
|
+
async function verifyPublisherWebhook(body, headers, secret, tolerance = DEFAULT_WEBHOOK_TOLERANCE) {
|
|
2253
|
+
if (!secret.startsWith(RKSEC_PREFIX)) return { ok: false, error: "invalid_secret" };
|
|
2254
|
+
const clamped = Math.min(Math.max(0, tolerance), MAX_WEBHOOK_TOLERANCE);
|
|
2255
|
+
return verifyWebhook(body, headers, secret, clamped);
|
|
2256
|
+
}
|
|
2257
|
+
var CEREMONY_ACTIONS = /* @__PURE__ */ new Set(["signIn", "register", "stepUp"]);
|
|
2258
|
+
function platformProbe(adapter) {
|
|
2259
|
+
const candidate = adapter.hasPlatformAuthenticator;
|
|
2260
|
+
return typeof candidate === "function" ? candidate.bind(adapter) : void 0;
|
|
2261
|
+
}
|
|
2262
|
+
function sessionIdentity(token) {
|
|
2263
|
+
const payload = sdkCore.decodeJwtPayload(token);
|
|
2264
|
+
if (payload === null) return null;
|
|
2265
|
+
const subject = payload["sub"];
|
|
2266
|
+
const session = payload["sid"];
|
|
2267
|
+
if (typeof subject !== "string" || !subject) return null;
|
|
2268
|
+
if (typeof session !== "string" || !session) return null;
|
|
2269
|
+
return { subject, session };
|
|
2270
|
+
}
|
|
2271
|
+
var notSupported = () => sdkCore.passkeyError(
|
|
2272
|
+
"PASSKEY_NOT_SUPPORTED",
|
|
2273
|
+
"no passkey ceremony adapter is wired: pass one on `nativeAdapter.passkeys` (see createNativePasskeyAdapter)"
|
|
2274
|
+
);
|
|
2275
|
+
function usePasskeys(options = {}) {
|
|
2276
|
+
const ctx = useRakomiContext();
|
|
2277
|
+
const {
|
|
2278
|
+
adapter: nativeAdapter,
|
|
2279
|
+
http,
|
|
2280
|
+
baseUrl,
|
|
2281
|
+
publishableKey,
|
|
2282
|
+
getToken,
|
|
2283
|
+
beginAuthFlow,
|
|
2284
|
+
submitOAuthTokens,
|
|
2285
|
+
passkeyCeremonyLock
|
|
2286
|
+
} = ctx;
|
|
2287
|
+
const [isSupported, setIsSupported] = react.useState(null);
|
|
2288
|
+
const [hasPlatformAuthenticator, setHasPlatformAuthenticator] = react.useState(null);
|
|
2289
|
+
const [passkeys, setPasskeys] = react.useState(void 0);
|
|
2290
|
+
const [error, setError] = react.useState(null);
|
|
2291
|
+
const [isSigningIn, setIsSigningIn] = react.useState(false);
|
|
2292
|
+
const [isRegistering, setIsRegistering] = react.useState(false);
|
|
2293
|
+
const [isSteppingUp, setIsSteppingUp] = react.useState(false);
|
|
2294
|
+
const [isLoading, setIsLoading] = react.useState(false);
|
|
2295
|
+
const [isMutating, setIsMutating] = react.useState(false);
|
|
2296
|
+
const adapter = options.adapter ?? nativeAdapter.passkeys;
|
|
2297
|
+
const mountedRef = react.useRef(true);
|
|
2298
|
+
const busyRef = react.useRef({
|
|
2299
|
+
signIn: false,
|
|
2300
|
+
register: false,
|
|
2301
|
+
stepUp: false,
|
|
2302
|
+
list: false,
|
|
2303
|
+
mutate: false
|
|
2304
|
+
});
|
|
2305
|
+
const inFlightRef = react.useRef(/* @__PURE__ */ new Set());
|
|
2306
|
+
react.useEffect(() => {
|
|
2307
|
+
mountedRef.current = true;
|
|
2308
|
+
const inFlight = inFlightRef.current;
|
|
2309
|
+
return () => {
|
|
2310
|
+
mountedRef.current = false;
|
|
2311
|
+
for (const controller of inFlight) controller.abort();
|
|
2312
|
+
inFlight.clear();
|
|
2313
|
+
};
|
|
2314
|
+
}, []);
|
|
2315
|
+
react.useEffect(() => {
|
|
2316
|
+
if (adapter === void 0) {
|
|
2317
|
+
setIsSupported(false);
|
|
2318
|
+
setHasPlatformAuthenticator(null);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
let cancelled = false;
|
|
2322
|
+
void (async () => {
|
|
2323
|
+
const [supported, platform] = await Promise.all([
|
|
2324
|
+
Promise.resolve(adapter.isSupported()).catch(() => false),
|
|
2325
|
+
Promise.resolve(platformProbe(adapter)?.() ?? null).catch(() => null)
|
|
2326
|
+
]);
|
|
2327
|
+
if (cancelled || !mountedRef.current) return;
|
|
2328
|
+
setIsSupported(supported === true);
|
|
2329
|
+
setHasPlatformAuthenticator(typeof platform === "boolean" ? platform : null);
|
|
2330
|
+
})();
|
|
2331
|
+
return () => {
|
|
2332
|
+
cancelled = true;
|
|
2333
|
+
};
|
|
2334
|
+
}, [adapter]);
|
|
2335
|
+
const setBusy = react.useCallback((key, value) => {
|
|
2336
|
+
busyRef.current[key] = value;
|
|
2337
|
+
if (key === "signIn") setIsSigningIn(value);
|
|
2338
|
+
else if (key === "register") setIsRegistering(value);
|
|
2339
|
+
else if (key === "stepUp") setIsSteppingUp(value);
|
|
2340
|
+
else if (key === "list") setIsLoading(value);
|
|
2341
|
+
else setIsMutating(value);
|
|
2342
|
+
}, []);
|
|
2343
|
+
const requireSession = react.useCallback(async () => {
|
|
2344
|
+
const expired = () => sdkCore.passkeyError("PASSKEY_SESSION_EXPIRED", "you must be signed in to manage passkeys");
|
|
2345
|
+
const token = await getToken();
|
|
2346
|
+
if (!token.ok) {
|
|
2347
|
+
const err2 = token.error;
|
|
2348
|
+
if (err2.code === "NETWORK_ERROR" || err2.code === "REFRESH_FAILED" && err2.reason === "network") {
|
|
2349
|
+
return sdkCore.passkeyError("PASSKEY_NETWORK_ERROR", err2.message);
|
|
2350
|
+
}
|
|
2351
|
+
return expired();
|
|
2352
|
+
}
|
|
2353
|
+
const user = sdkCore.decodeUser(token.token);
|
|
2354
|
+
if (user === null) return expired();
|
|
2355
|
+
return { accessToken: token.token, userId: user.id };
|
|
2356
|
+
}, [getToken]);
|
|
2357
|
+
const runAction = react.useCallback(
|
|
2358
|
+
async (key, guarded, run, onSuccess) => {
|
|
2359
|
+
const refuse = (result) => {
|
|
2360
|
+
if (!result.ok && mountedRef.current) setError(result.error);
|
|
2361
|
+
return result;
|
|
2362
|
+
};
|
|
2363
|
+
if (busyRef.current[key]) return refuse(guarded);
|
|
2364
|
+
const isCeremony = CEREMONY_ACTIONS.has(key);
|
|
2365
|
+
if (isCeremony && !passkeyCeremonyLock.tryAcquire()) return refuse(guarded);
|
|
2366
|
+
setBusy(key, true);
|
|
2367
|
+
setError(null);
|
|
2368
|
+
const controller = new AbortController();
|
|
2369
|
+
inFlightRef.current.add(controller);
|
|
2370
|
+
try {
|
|
2371
|
+
const result = await run(controller.signal);
|
|
2372
|
+
if (!result.ok && mountedRef.current) setError(result.error);
|
|
2373
|
+
if (result.ok && mountedRef.current) onSuccess?.(result);
|
|
2374
|
+
return result;
|
|
2375
|
+
} finally {
|
|
2376
|
+
inFlightRef.current.delete(controller);
|
|
2377
|
+
busyRef.current[key] = false;
|
|
2378
|
+
if (isCeremony) passkeyCeremonyLock.release();
|
|
2379
|
+
if (mountedRef.current) setBusy(key, false);
|
|
2380
|
+
}
|
|
2381
|
+
},
|
|
2382
|
+
[passkeyCeremonyLock, setBusy]
|
|
2383
|
+
);
|
|
2384
|
+
const busyResult = react.useCallback(
|
|
2385
|
+
(action) => ({
|
|
2386
|
+
ok: false,
|
|
2387
|
+
error: sdkCore.passkeyError("PASSKEY_INVALID_INPUT", `a passkey ${action} is already in progress`)
|
|
2388
|
+
}),
|
|
2389
|
+
[]
|
|
2390
|
+
);
|
|
2391
|
+
const unsupported = react.useCallback(() => {
|
|
2392
|
+
const result = { ok: false, error: notSupported() };
|
|
2393
|
+
if (mountedRef.current) setError(result.error);
|
|
2394
|
+
return Promise.resolve(result);
|
|
2395
|
+
}, []);
|
|
2396
|
+
const sessionFailure = (session) => !("accessToken" in session);
|
|
2397
|
+
const signInWithPasskey = react.useCallback(
|
|
2398
|
+
(input = {}) => {
|
|
2399
|
+
if (adapter === void 0) return unsupported();
|
|
2400
|
+
return runAction("signIn", busyResult("sign-in"), async (signal) => {
|
|
2401
|
+
const result = await sdkCore.assertPasskey({
|
|
2402
|
+
http,
|
|
2403
|
+
baseUrl,
|
|
2404
|
+
clientId: publishableKey,
|
|
2405
|
+
adapter,
|
|
2406
|
+
...input.userHandle === void 0 ? {} : { userHandle: input.userHandle },
|
|
2407
|
+
signal
|
|
2408
|
+
});
|
|
2409
|
+
if (!result.ok) return result;
|
|
2410
|
+
const failed = (message) => ({
|
|
2411
|
+
ok: false,
|
|
2412
|
+
error: sdkCore.passkeyError("PASSKEY_REQUEST_FAILED", message)
|
|
2413
|
+
});
|
|
2414
|
+
let nonce;
|
|
2415
|
+
try {
|
|
2416
|
+
nonce = await beginAuthFlow();
|
|
2417
|
+
} catch (err2) {
|
|
2418
|
+
return failed(
|
|
2419
|
+
`the passkey was accepted but the session could not be started: ${err2 instanceof Error ? err2.message : "unknown error"}`
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
const tokens = result.tokens;
|
|
2423
|
+
if (nativeAdapter.dpopProver !== void 0 && tokens.token_type !== "DPoP") {
|
|
2424
|
+
return failed(
|
|
2425
|
+
"the passkey was accepted but the session was refused: this app is configured for sender-constrained (DPoP) sessions, and the passkey sign-in returned an unbound bearer session. Storing it would silently downgrade the app\u2019s security posture."
|
|
2426
|
+
);
|
|
2427
|
+
}
|
|
2428
|
+
try {
|
|
2429
|
+
await submitOAuthTokens(tokens, nonce);
|
|
2430
|
+
} catch (err2) {
|
|
2431
|
+
return failed(
|
|
2432
|
+
`the passkey was accepted but the session could not be stored: ${err2 instanceof Error ? err2.message : "unknown error"}`
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
const confirmed = await getToken();
|
|
2436
|
+
if (!confirmed.ok) {
|
|
2437
|
+
return failed(
|
|
2438
|
+
"the passkey was accepted but the session was not established (the token submission was rejected)"
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
const submitted = sessionIdentity(tokens.access_token);
|
|
2442
|
+
const live = sessionIdentity(confirmed.token);
|
|
2443
|
+
if (submitted === null || live === null) {
|
|
2444
|
+
if (confirmed.token !== tokens.access_token) {
|
|
2445
|
+
return failed("the passkey was accepted but the resulting session token could not be read");
|
|
2446
|
+
}
|
|
2447
|
+
return result;
|
|
2448
|
+
}
|
|
2449
|
+
if (live.subject !== submitted.subject || live.session !== submitted.session) {
|
|
2450
|
+
return failed(
|
|
2451
|
+
"the passkey was accepted but the session was not established (the token submission was rejected)"
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
return result;
|
|
2455
|
+
});
|
|
2456
|
+
},
|
|
2457
|
+
[
|
|
2458
|
+
adapter,
|
|
2459
|
+
baseUrl,
|
|
2460
|
+
beginAuthFlow,
|
|
2461
|
+
busyResult,
|
|
2462
|
+
getToken,
|
|
2463
|
+
http,
|
|
2464
|
+
publishableKey,
|
|
2465
|
+
runAction,
|
|
2466
|
+
submitOAuthTokens,
|
|
2467
|
+
unsupported
|
|
2468
|
+
]
|
|
2469
|
+
);
|
|
2470
|
+
const stepUpWithPasskey = react.useCallback(() => {
|
|
2471
|
+
if (adapter === void 0) return unsupported();
|
|
2472
|
+
return runAction("stepUp", busyResult("step-up"), async (signal) => {
|
|
2473
|
+
const session = await requireSession();
|
|
2474
|
+
if (sessionFailure(session)) return { ok: false, error: session };
|
|
2475
|
+
return sdkCore.stepUpWithPasskey({
|
|
2476
|
+
http,
|
|
2477
|
+
baseUrl,
|
|
2478
|
+
clientId: publishableKey,
|
|
2479
|
+
accessToken: session.accessToken,
|
|
2480
|
+
adapter,
|
|
2481
|
+
signal
|
|
2482
|
+
});
|
|
2483
|
+
});
|
|
2484
|
+
}, [adapter, baseUrl, busyResult, http, publishableKey, requireSession, runAction, unsupported]);
|
|
2485
|
+
const registerPasskey = react.useCallback(
|
|
2486
|
+
(input) => {
|
|
2487
|
+
if (adapter === void 0) return unsupported();
|
|
2488
|
+
return runAction("register", busyResult("registration"), async (signal) => {
|
|
2489
|
+
const session = await requireSession();
|
|
2490
|
+
if (sessionFailure(session)) return { ok: false, error: session };
|
|
2491
|
+
return sdkCore.registerPasskey({
|
|
2492
|
+
http,
|
|
2493
|
+
baseUrl,
|
|
2494
|
+
clientId: publishableKey,
|
|
2495
|
+
accessToken: session.accessToken,
|
|
2496
|
+
stepUpToken: input.stepUpToken,
|
|
2497
|
+
adapter,
|
|
2498
|
+
...input.nickname === void 0 ? {} : { nickname: input.nickname },
|
|
2499
|
+
signal
|
|
2500
|
+
});
|
|
2501
|
+
});
|
|
2502
|
+
},
|
|
2503
|
+
[adapter, baseUrl, busyResult, http, publishableKey, requireSession, runAction, unsupported]
|
|
2504
|
+
);
|
|
2505
|
+
const listPasskeys = react.useCallback(
|
|
2506
|
+
(input) => runAction(
|
|
2507
|
+
"list",
|
|
2508
|
+
busyResult("list"),
|
|
2509
|
+
async (signal) => {
|
|
2510
|
+
const session = await requireSession();
|
|
2511
|
+
if (sessionFailure(session)) return { ok: false, error: session };
|
|
2512
|
+
return sdkCore.listPasskeys({
|
|
2513
|
+
http,
|
|
2514
|
+
baseUrl,
|
|
2515
|
+
clientId: publishableKey,
|
|
2516
|
+
accessToken: session.accessToken,
|
|
2517
|
+
stepUpToken: input.stepUpToken,
|
|
2518
|
+
userId: session.userId,
|
|
2519
|
+
signal
|
|
2520
|
+
});
|
|
2521
|
+
},
|
|
2522
|
+
(result) => {
|
|
2523
|
+
if (result.ok) setPasskeys(result.data);
|
|
2524
|
+
}
|
|
2525
|
+
),
|
|
2526
|
+
[baseUrl, busyResult, http, publishableKey, requireSession, runAction]
|
|
2527
|
+
);
|
|
2528
|
+
const renamePasskey = react.useCallback(
|
|
2529
|
+
(input) => runAction(
|
|
2530
|
+
"mutate",
|
|
2531
|
+
busyResult("mutation"),
|
|
2532
|
+
async (signal) => {
|
|
2533
|
+
const session = await requireSession();
|
|
2534
|
+
if (sessionFailure(session)) return { ok: false, error: session };
|
|
2535
|
+
return sdkCore.renamePasskey({
|
|
2536
|
+
http,
|
|
2537
|
+
baseUrl,
|
|
2538
|
+
clientId: publishableKey,
|
|
2539
|
+
accessToken: session.accessToken,
|
|
2540
|
+
stepUpToken: input.stepUpToken,
|
|
2541
|
+
userId: session.userId,
|
|
2542
|
+
passkeyId: input.passkeyId,
|
|
2543
|
+
nickname: input.nickname,
|
|
2544
|
+
signal
|
|
2545
|
+
});
|
|
2546
|
+
},
|
|
2547
|
+
(result) => {
|
|
2548
|
+
if (!result.ok) return;
|
|
2549
|
+
setPasskeys(
|
|
2550
|
+
(current) => current?.map((passkey) => passkey.id === result.passkey.id ? result.passkey : passkey)
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
),
|
|
2554
|
+
[baseUrl, busyResult, http, publishableKey, requireSession, runAction]
|
|
2555
|
+
);
|
|
2556
|
+
const deletePasskey = react.useCallback(
|
|
2557
|
+
(input) => runAction(
|
|
2558
|
+
"mutate",
|
|
2559
|
+
busyResult("mutation"),
|
|
2560
|
+
async (signal) => {
|
|
2561
|
+
const session = await requireSession();
|
|
2562
|
+
if (sessionFailure(session)) return { ok: false, error: session };
|
|
2563
|
+
return sdkCore.deletePasskey({
|
|
2564
|
+
http,
|
|
2565
|
+
baseUrl,
|
|
2566
|
+
clientId: publishableKey,
|
|
2567
|
+
accessToken: session.accessToken,
|
|
2568
|
+
stepUpToken: input.stepUpToken,
|
|
2569
|
+
userId: session.userId,
|
|
2570
|
+
passkeyId: input.passkeyId,
|
|
2571
|
+
signal
|
|
2572
|
+
});
|
|
2573
|
+
},
|
|
2574
|
+
(result) => {
|
|
2575
|
+
if (result.ok) {
|
|
2576
|
+
setPasskeys((current) => current?.filter((passkey) => passkey.id !== input.passkeyId));
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
),
|
|
2580
|
+
[baseUrl, busyResult, http, publishableKey, requireSession, runAction]
|
|
2581
|
+
);
|
|
2582
|
+
return {
|
|
2583
|
+
isSupported,
|
|
2584
|
+
hasPlatformAuthenticator,
|
|
2585
|
+
passkeys,
|
|
2586
|
+
error,
|
|
2587
|
+
isSigningIn,
|
|
2588
|
+
isRegistering,
|
|
2589
|
+
isSteppingUp,
|
|
2590
|
+
isLoading,
|
|
2591
|
+
isMutating,
|
|
2592
|
+
signInWithPasskey,
|
|
2593
|
+
stepUpWithPasskey,
|
|
2594
|
+
registerPasskey,
|
|
2595
|
+
listPasskeys,
|
|
2596
|
+
renamePasskey,
|
|
2597
|
+
deletePasskey
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// src/hooks/use-submit-oauth-tokens.ts
|
|
2602
|
+
function useSubmitOAuthTokens() {
|
|
2603
|
+
const ctx = useRakomiContext();
|
|
2604
|
+
return async (tokens) => {
|
|
2605
|
+
const nonce = await ctx.beginAuthFlow();
|
|
2606
|
+
await ctx.submitOAuthTokens(tokens, nonce);
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
function useMagicLink() {
|
|
2610
|
+
const ctx = useRakomiContext();
|
|
2611
|
+
return {
|
|
2612
|
+
request: async (email) => {
|
|
2613
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { method: "magic_link" } });
|
|
2614
|
+
return sdkCore.requestMagicLink({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email });
|
|
2615
|
+
},
|
|
2616
|
+
verify: async (token) => {
|
|
2617
|
+
const result = await sdkCore.verifyMagicLink({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, token });
|
|
2618
|
+
if (result.ok && "tokens" in result) {
|
|
2619
|
+
const nonce = await ctx.beginAuthFlow();
|
|
2620
|
+
await ctx.submitOAuthTokens(result.tokens, nonce);
|
|
2621
|
+
} else if (!result.ok && "error" in result) {
|
|
2622
|
+
emitError(ctx.events, result.error);
|
|
2623
|
+
}
|
|
2624
|
+
return result;
|
|
2625
|
+
}
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
function useEmailOtp() {
|
|
2629
|
+
const ctx = useRakomiContext();
|
|
2630
|
+
return {
|
|
2631
|
+
request: async (email, mode) => {
|
|
2632
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { method: "email_otp", mode } });
|
|
2633
|
+
return sdkCore.requestEmailOtp({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email, mode });
|
|
2634
|
+
},
|
|
2635
|
+
verify: async (email, code) => {
|
|
2636
|
+
const result = await sdkCore.verifyEmailOtp({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email, code });
|
|
2637
|
+
if (result.ok && "tokens" in result) {
|
|
2638
|
+
const nonce = await ctx.beginAuthFlow();
|
|
2639
|
+
await ctx.submitOAuthTokens(result.tokens, nonce);
|
|
2640
|
+
} else if (!result.ok && "error" in result) {
|
|
2641
|
+
emitError(ctx.events, result.error);
|
|
2642
|
+
}
|
|
2643
|
+
return result;
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
function useRegister() {
|
|
2648
|
+
const ctx = useRakomiContext();
|
|
2649
|
+
return {
|
|
2650
|
+
register: async (email, password) => {
|
|
2651
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { method: "register" } });
|
|
2652
|
+
return sdkCore.register({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email, password });
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
function emitError(events, error) {
|
|
2657
|
+
events.push({ type: "sign_in_failed", severity: "warning", error });
|
|
2658
|
+
}
|
|
2659
|
+
function useMfa() {
|
|
2660
|
+
const ctx = useRakomiContext();
|
|
2661
|
+
return {
|
|
2662
|
+
verifyTotp: async (challengeToken, code, endpoint) => {
|
|
2663
|
+
const url = endpoint ?? `${ctx.baseUrl}/v1/auth/mfa/totp/verify`;
|
|
2664
|
+
const result = await sdkCore.verifyTotp({ http: ctx.http, endpoint: url, challengeToken, code });
|
|
2665
|
+
if (result.ok) {
|
|
2666
|
+
const nonce = await ctx.beginAuthFlow();
|
|
2667
|
+
await ctx.submitOAuthTokens({
|
|
2668
|
+
access_token: result.tokens.access_token,
|
|
2669
|
+
token_type: result.tokens.token_type,
|
|
2670
|
+
expires_in: result.tokens.expires_in,
|
|
2671
|
+
refresh_token: result.tokens.refresh_token
|
|
2672
|
+
}, nonce);
|
|
2673
|
+
ctx.events.push({ type: "signed_in", severity: "info", metadata: { method: "mfa_totp" } });
|
|
2674
|
+
return { ok: true };
|
|
2675
|
+
}
|
|
2676
|
+
emitError(ctx.events, result.error);
|
|
2677
|
+
return { ok: false, error: result.error };
|
|
2678
|
+
}
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
Object.defineProperty(exports, "MfaStepUpRequiredError", {
|
|
2683
|
+
enumerable: true,
|
|
2684
|
+
get: function () { return sdkCore.MfaStepUpRequiredError; }
|
|
2685
|
+
});
|
|
2686
|
+
Object.defineProperty(exports, "MfaStepUpUnavailableError", {
|
|
2687
|
+
enumerable: true,
|
|
2688
|
+
get: function () { return sdkCore.MfaStepUpUnavailableError; }
|
|
2689
|
+
});
|
|
2690
|
+
Object.defineProperty(exports, "base64url", {
|
|
2691
|
+
enumerable: true,
|
|
2692
|
+
get: function () { return sdkCore.base64url; }
|
|
2693
|
+
});
|
|
2694
|
+
Object.defineProperty(exports, "createTranslator", {
|
|
2695
|
+
enumerable: true,
|
|
2696
|
+
get: function () { return sdkCore.createTranslator; }
|
|
2697
|
+
});
|
|
2698
|
+
Object.defineProperty(exports, "decodeJwtPayload", {
|
|
2699
|
+
enumerable: true,
|
|
2700
|
+
get: function () { return sdkCore.decodeJwtPayload; }
|
|
2701
|
+
});
|
|
2702
|
+
Object.defineProperty(exports, "decodeSession", {
|
|
2703
|
+
enumerable: true,
|
|
2704
|
+
get: function () { return sdkCore.decodeSession; }
|
|
2705
|
+
});
|
|
2706
|
+
Object.defineProperty(exports, "decodeUser", {
|
|
2707
|
+
enumerable: true,
|
|
2708
|
+
get: function () { return sdkCore.decodeUser; }
|
|
2709
|
+
});
|
|
2710
|
+
Object.defineProperty(exports, "generatePkce", {
|
|
2711
|
+
enumerable: true,
|
|
2712
|
+
get: function () { return sdkCore.generatePkce; }
|
|
2713
|
+
});
|
|
2714
|
+
Object.defineProperty(exports, "getErrorMessage", {
|
|
2715
|
+
enumerable: true,
|
|
2716
|
+
get: function () { return sdkCore.getErrorMessage; }
|
|
2717
|
+
});
|
|
2718
|
+
Object.defineProperty(exports, "hasPermission", {
|
|
2719
|
+
enumerable: true,
|
|
2720
|
+
get: function () { return sdkCore.hasPermission; }
|
|
2721
|
+
});
|
|
2722
|
+
Object.defineProperty(exports, "hasRole", {
|
|
2723
|
+
enumerable: true,
|
|
2724
|
+
get: function () { return sdkCore.hasRole; }
|
|
2725
|
+
});
|
|
2726
|
+
Object.defineProperty(exports, "isSafeUrl", {
|
|
2727
|
+
enumerable: true,
|
|
2728
|
+
get: function () { return sdkCore.isSafeUrl; }
|
|
2729
|
+
});
|
|
2730
|
+
Object.defineProperty(exports, "networkError", {
|
|
2731
|
+
enumerable: true,
|
|
2732
|
+
get: function () { return sdkCore.networkError; }
|
|
2733
|
+
});
|
|
2734
|
+
Object.defineProperty(exports, "parseOAuthCallbackError", {
|
|
2735
|
+
enumerable: true,
|
|
2736
|
+
get: function () { return sdkCore.parseOAuthCallbackError; }
|
|
2737
|
+
});
|
|
2738
|
+
Object.defineProperty(exports, "parseTokenEndpointError", {
|
|
2739
|
+
enumerable: true,
|
|
2740
|
+
get: function () { return sdkCore.parseTokenEndpointError; }
|
|
2741
|
+
});
|
|
2742
|
+
Object.defineProperty(exports, "scorePassword", {
|
|
2743
|
+
enumerable: true,
|
|
2744
|
+
get: function () { return sdkCore.scorePassword; }
|
|
2745
|
+
});
|
|
2746
|
+
Object.defineProperty(exports, "selectPluralForm", {
|
|
2747
|
+
enumerable: true,
|
|
2748
|
+
get: function () { return sdkCore.selectPluralForm; }
|
|
2749
|
+
});
|
|
2750
|
+
exports.DEFAULT_WEBHOOK_TOLERANCE = DEFAULT_WEBHOOK_TOLERANCE;
|
|
2751
|
+
exports.DpopSession = DpopSession;
|
|
2752
|
+
exports.Feature = Feature;
|
|
2753
|
+
exports.MAX_WEBHOOK_TOLERANCE = MAX_WEBHOOK_TOLERANCE;
|
|
2754
|
+
exports.Protect = Protect;
|
|
2755
|
+
exports.RakomiProvider = RakomiProvider;
|
|
2756
|
+
exports.SignIn = SignIn;
|
|
2757
|
+
exports.SignUp = SignUp;
|
|
2758
|
+
exports.SignedIn = SignedIn;
|
|
2759
|
+
exports.SignedOut = SignedOut;
|
|
2760
|
+
exports.UserButton = UserButton;
|
|
2761
|
+
exports.UserProfile = UserProfile;
|
|
2762
|
+
exports.createDefaultExpoAdapter = createDefaultExpoAdapter;
|
|
2763
|
+
exports.createDpopSession = createDpopSession;
|
|
2764
|
+
exports.createNativeDpopProver = createNativeDpopProver;
|
|
2765
|
+
exports.createNativePasskeyAdapter = createNativePasskeyAdapter;
|
|
2766
|
+
exports.createRnHttpClient = createRnHttpClient;
|
|
2767
|
+
exports.startSocialSignIn = startSocialSignIn;
|
|
2768
|
+
exports.useAnonymousSignin = useAnonymousSignin;
|
|
2769
|
+
exports.useAuth = useAuth;
|
|
2770
|
+
exports.useAuthConfig = useAuthConfig;
|
|
2771
|
+
exports.useBaasPlans = useBaasPlans;
|
|
2772
|
+
exports.useBaasSubscription = useBaasSubscription;
|
|
2773
|
+
exports.useBranding = useBranding;
|
|
2774
|
+
exports.useEmailOtp = useEmailOtp;
|
|
2775
|
+
exports.useFlag = useFlag;
|
|
2776
|
+
exports.useLinkedAccounts = useLinkedAccounts;
|
|
2777
|
+
exports.useMagicLink = useMagicLink;
|
|
2778
|
+
exports.useMfa = useMfa;
|
|
2779
|
+
exports.useOrganization = useOrganization;
|
|
2780
|
+
exports.useOrganizationList = useOrganizationList;
|
|
2781
|
+
exports.usePasskeys = usePasskeys;
|
|
2782
|
+
exports.useRegister = useRegister;
|
|
2783
|
+
exports.useSession = useSession;
|
|
2784
|
+
exports.useSubmitOAuthTokens = useSubmitOAuthTokens;
|
|
2785
|
+
exports.useTranslation = useTranslation;
|
|
2786
|
+
exports.useUser = useUser;
|
|
2787
|
+
exports.verifyPublisherWebhook = verifyPublisherWebhook;
|
|
2788
|
+
exports.verifyWebhook = verifyWebhook;
|