@rakomi/react-native 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPLIANCE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/SECURITY.md +206 -0
- package/THIRD-PARTY-NOTICES +11 -0
- package/dist/expo-adapter-CtPCEgue.d.cts +299 -0
- package/dist/expo-adapter-CtPCEgue.d.ts +299 -0
- package/dist/index.cjs +2109 -0
- package/dist/index.d.cts +634 -0
- package/dist/index.d.ts +634 -0
- package/dist/index.js +2004 -0
- package/dist/native/index.cjs +381 -0
- package/dist/native/index.d.cts +113 -0
- package/dist/native/index.d.ts +113 -0
- package/dist/native/index.js +377 -0
- package/package.json +96 -5
- package/sbom.cdx.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2004 @@
|
|
|
1
|
+
import { createContext, useMemo, useReducer, useEffect, useRef, useState, useContext } from 'react';
|
|
2
|
+
import { EventLog, reduce, INITIAL_SNAPSHOT, createTranslator, isSignedIn, hasPermission, hasRole, generatePkce, issueState, buildAuthorizationUrl, consumeState, exchangeAuthCode, parseOAuthCallbackError, scorePassword, isSafeUrl, verifyMagicLink, requestMagicLink, verifyEmailOtp, requestEmailOtp, register, verifyTotp, MfaStepUpUnavailableError, decodeUser, decodeSession, deriveTenantStorageKey, createJwksCache, refreshAccessToken } from '@rakomi/sdk-core';
|
|
3
|
+
export { MfaStepUpRequiredError, MfaStepUpUnavailableError, base64url, createTranslator, decodeJwtPayload, decodeSession, decodeUser, generatePkce, getErrorMessage, hasPermission, hasRole, isSafeUrl, networkError, parseOAuthCallbackError, parseTokenEndpointError, scorePassword, selectPluralForm } from '@rakomi/sdk-core';
|
|
4
|
+
import { jwtVerify } from 'jose';
|
|
5
|
+
import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
8
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
9
|
+
}) : x)(function(x) {
|
|
10
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
11
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// src/internal/canonical-url.ts
|
|
15
|
+
var CanonicalizeUrlError = class extends Error {
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.name = "CanonicalizeUrlError";
|
|
20
|
+
}
|
|
21
|
+
code;
|
|
22
|
+
};
|
|
23
|
+
function collapsePath(path) {
|
|
24
|
+
const collapsed = path.replace(/\/{2,}/g, "/");
|
|
25
|
+
if (collapsed.length > 1 && collapsed.endsWith("/")) {
|
|
26
|
+
return collapsed.slice(0, -1);
|
|
27
|
+
}
|
|
28
|
+
return collapsed;
|
|
29
|
+
}
|
|
30
|
+
function lowercasePercentHex(s) {
|
|
31
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (_m, h) => `%${h.toLowerCase()}`);
|
|
32
|
+
}
|
|
33
|
+
function canonicalizeUrl(input, options = {}) {
|
|
34
|
+
const version = options.version ?? "rfc9449-2023";
|
|
35
|
+
if (version !== "rfc9449-2023") {
|
|
36
|
+
throw new CanonicalizeUrlError("unsupported_version", `unsupported canonicalize-url version: ${version}`);
|
|
37
|
+
}
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(input);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new CanonicalizeUrlError("invalid_url", "invalid url");
|
|
43
|
+
}
|
|
44
|
+
if (url.protocol === "https:" && url.port === "443" || url.protocol === "http:" && url.port === "80") {
|
|
45
|
+
url.port = "";
|
|
46
|
+
}
|
|
47
|
+
url.search = "";
|
|
48
|
+
url.hash = "";
|
|
49
|
+
const normalizedPath = collapsePath(lowercasePercentHex(url.pathname));
|
|
50
|
+
const host = url.host;
|
|
51
|
+
return `${url.protocol}//${host}${normalizedPath}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/internal/dpop-session.ts
|
|
55
|
+
var DpopSession = class {
|
|
56
|
+
prover;
|
|
57
|
+
baseUrl;
|
|
58
|
+
onDowngrade;
|
|
59
|
+
_bound = false;
|
|
60
|
+
_downgraded = false;
|
|
61
|
+
_downgradeNotified = false;
|
|
62
|
+
_boundJkt;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.prover = options.prover;
|
|
65
|
+
this.baseUrl = options.baseUrl.endsWith("/") ? options.baseUrl.slice(0, -1) : options.baseUrl;
|
|
66
|
+
this.onDowngrade = options.onDowngrade;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `true` once the server has confirmed (`token_type === "DPoP"`) that this
|
|
70
|
+
* session's key is sender-bound. The SDK attaches a refresh proof IFF this is
|
|
71
|
+
* `true`. Starts `false`; set by observing issuance/refresh responses.
|
|
72
|
+
*/
|
|
73
|
+
get isBound() {
|
|
74
|
+
return this._bound;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `true` if a session that presented a DPoP proof was returned as `Bearer` by
|
|
78
|
+
* the server (a security downgrade — flag off / server-side downgrade).
|
|
79
|
+
*/
|
|
80
|
+
get isDowngraded() {
|
|
81
|
+
return this._downgraded;
|
|
82
|
+
}
|
|
83
|
+
/** The committed RFC 7638 thumbprint (`jkt`) of the session keypair, once bound. */
|
|
84
|
+
get boundJkt() {
|
|
85
|
+
return this._boundJkt;
|
|
86
|
+
}
|
|
87
|
+
async resolveProof(htm, target, opts) {
|
|
88
|
+
const absolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : `${this.baseUrl}${target}`;
|
|
89
|
+
const htu = canonicalizeUrl(absolute);
|
|
90
|
+
const input = { htm, htu, ...opts?.nonce !== void 0 ? { nonce: opts.nonce } : {} };
|
|
91
|
+
return this.prover.createProof(input);
|
|
92
|
+
}
|
|
93
|
+
async jktHint() {
|
|
94
|
+
return this.prover.jktHint();
|
|
95
|
+
}
|
|
96
|
+
async observeTokenType(tokenType, attachedProof) {
|
|
97
|
+
if (tokenType === "DPoP") {
|
|
98
|
+
this._bound = true;
|
|
99
|
+
this._downgraded = false;
|
|
100
|
+
if (this._boundJkt === void 0) {
|
|
101
|
+
this._boundJkt = await this.prover.jktHint();
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this._bound = false;
|
|
106
|
+
if (attachedProof) {
|
|
107
|
+
this._downgraded = true;
|
|
108
|
+
if (!this._downgradeNotified) {
|
|
109
|
+
this._downgradeNotified = true;
|
|
110
|
+
this.onDowngrade?.({ reason: "server_returned_bearer" });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
function createDpopSession(options) {
|
|
116
|
+
return new DpopSession(options);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/internal/http-client.ts
|
|
120
|
+
function createRnHttpClient(options = {}) {
|
|
121
|
+
const baseUrl = options.baseUrl?.replace(/\/$/, "") ?? "";
|
|
122
|
+
const timeout = options.defaultTimeoutMs ?? 3e4;
|
|
123
|
+
return {
|
|
124
|
+
fetch: async (url, init = {}) => {
|
|
125
|
+
const fullUrl = url.startsWith("http://") || url.startsWith("https://") ? url : `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
128
|
+
try {
|
|
129
|
+
return await fetch(fullUrl, {
|
|
130
|
+
method: init.method ?? "GET",
|
|
131
|
+
headers: init.headers,
|
|
132
|
+
body: init.body,
|
|
133
|
+
redirect: "error",
|
|
134
|
+
signal: init.signal ?? controller.signal
|
|
135
|
+
});
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function err(cls, message) {
|
|
143
|
+
return { ok: false, error: { class: cls, code: `auth/${cls}`, message } };
|
|
144
|
+
}
|
|
145
|
+
function proverUnavailable(message = "DPoP prover unavailable") {
|
|
146
|
+
return err("dpop_prover_unavailable", message);
|
|
147
|
+
}
|
|
148
|
+
async function refreshWithDpop(input) {
|
|
149
|
+
const session = input.dpopSession;
|
|
150
|
+
const bound = session?.isBound === true;
|
|
151
|
+
if (!bound || !session) {
|
|
152
|
+
const result2 = await refreshAccessToken({
|
|
153
|
+
http: input.http,
|
|
154
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
155
|
+
refreshToken: input.refreshToken,
|
|
156
|
+
clientId: input.clientId
|
|
157
|
+
});
|
|
158
|
+
if (result2.ok) {
|
|
159
|
+
await session?.observeTokenType(result2.tokens.token_type, false);
|
|
160
|
+
return { ok: true, tokens: result2.tokens, attachedProof: false };
|
|
161
|
+
}
|
|
162
|
+
return mapFailure(result2, false);
|
|
163
|
+
}
|
|
164
|
+
let proof;
|
|
165
|
+
try {
|
|
166
|
+
proof = await session.resolveProof("POST", input.tokenEndpoint);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return proverUnavailable(e instanceof Error ? e.message : "DPoP prover threw");
|
|
169
|
+
}
|
|
170
|
+
if (!proof) return proverUnavailable("DPoP prover returned an empty proof");
|
|
171
|
+
let result = await refreshAccessToken({
|
|
172
|
+
http: input.http,
|
|
173
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
174
|
+
refreshToken: input.refreshToken,
|
|
175
|
+
clientId: input.clientId,
|
|
176
|
+
dpopProof: proof
|
|
177
|
+
});
|
|
178
|
+
if (!result.ok && result.dpopNonce !== void 0) {
|
|
179
|
+
let retryProof;
|
|
180
|
+
try {
|
|
181
|
+
retryProof = await session.resolveProof("POST", input.tokenEndpoint, { nonce: result.dpopNonce });
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return proverUnavailable(e instanceof Error ? e.message : "DPoP prover threw on nonce retry");
|
|
184
|
+
}
|
|
185
|
+
if (!retryProof) return proverUnavailable("DPoP prover returned an empty proof on nonce retry");
|
|
186
|
+
result = await refreshAccessToken({
|
|
187
|
+
http: input.http,
|
|
188
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
189
|
+
refreshToken: input.refreshToken,
|
|
190
|
+
clientId: input.clientId,
|
|
191
|
+
dpopProof: retryProof
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (result.ok) {
|
|
195
|
+
await session.observeTokenType(result.tokens.token_type, true);
|
|
196
|
+
return { ok: true, tokens: result.tokens, attachedProof: true };
|
|
197
|
+
}
|
|
198
|
+
return mapFailure(result, true);
|
|
199
|
+
}
|
|
200
|
+
function mapFailure(result, attachedProof) {
|
|
201
|
+
const message = result.error.message ?? "refresh failed";
|
|
202
|
+
if (attachedProof && (result.dpopProofRejected || result.dpopNonce !== void 0)) {
|
|
203
|
+
return err("invalid_dpop_proof", message);
|
|
204
|
+
}
|
|
205
|
+
if (result.error.code === "REFRESH_FAILED" && result.error.reason === "network") {
|
|
206
|
+
return err("network", message);
|
|
207
|
+
}
|
|
208
|
+
return err("invalid_refresh_token", message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/internal/token-runtime.ts
|
|
212
|
+
var RETRY_DELAYS_MS = [2e3, 8e3, 3e4];
|
|
213
|
+
function bytesToBase64Url(bytes) {
|
|
214
|
+
let binary = "";
|
|
215
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
216
|
+
const std = btoa(binary);
|
|
217
|
+
return std.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
218
|
+
}
|
|
219
|
+
function deriveDefaultJwksUri(tokenEndpoint) {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(tokenEndpoint);
|
|
222
|
+
return `${url.origin}/.well-known/jwks.json`;
|
|
223
|
+
} catch {
|
|
224
|
+
return tokenEndpoint.replace(/\/[^/]*$/, "") + "/.well-known/jwks.json";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
var RAKOMI_PLATFORM_ISSUER_DEFAULT = "https://rakomi.com";
|
|
228
|
+
var RAKOMI_PLATFORM_AUDIENCE_DEFAULT = "https://rakomi.com";
|
|
229
|
+
function deriveDefaultIssuer(_tokenEndpoint) {
|
|
230
|
+
return RAKOMI_PLATFORM_ISSUER_DEFAULT;
|
|
231
|
+
}
|
|
232
|
+
var REFRESH_TOKEN_PREFIX = "v1:";
|
|
233
|
+
var REFRESH_BUFFER_SECONDS = 60;
|
|
234
|
+
var TokenRuntime = class _TokenRuntime {
|
|
235
|
+
clientId;
|
|
236
|
+
tenantId;
|
|
237
|
+
tokenEndpoint;
|
|
238
|
+
storage;
|
|
239
|
+
http;
|
|
240
|
+
crypto;
|
|
241
|
+
biometric;
|
|
242
|
+
biometricEnabled;
|
|
243
|
+
biometricStrict;
|
|
244
|
+
biometricPrompt;
|
|
245
|
+
dispatch;
|
|
246
|
+
dpopSession;
|
|
247
|
+
emitEvent;
|
|
248
|
+
now;
|
|
249
|
+
setTimeoutFn;
|
|
250
|
+
clearTimeoutFn;
|
|
251
|
+
storageKeyResolved = null;
|
|
252
|
+
jwksKeyResolved = null;
|
|
253
|
+
jwksCache = null;
|
|
254
|
+
jwksUri;
|
|
255
|
+
jwksTtlMs;
|
|
256
|
+
expectedIssuer;
|
|
257
|
+
expectedAudience;
|
|
258
|
+
accessToken = null;
|
|
259
|
+
expiresAtMs = null;
|
|
260
|
+
currentUser = null;
|
|
261
|
+
currentSession = null;
|
|
262
|
+
refreshInFlight = null;
|
|
263
|
+
refreshAttempts = 0;
|
|
264
|
+
retryTimer = null;
|
|
265
|
+
destroyed = false;
|
|
266
|
+
offlineStaleCycles = 0;
|
|
267
|
+
static MAX_OFFLINE_STALE_CYCLES = 3;
|
|
268
|
+
submitNonces = /* @__PURE__ */ new Map();
|
|
269
|
+
static NONCE_TTL_MS = 5 * 60 * 1e3;
|
|
270
|
+
static MAX_PENDING_NONCES = 100;
|
|
271
|
+
constructor(options) {
|
|
272
|
+
this.clientId = options.clientId;
|
|
273
|
+
this.tenantId = options.tenantId;
|
|
274
|
+
this.tokenEndpoint = options.tokenEndpoint;
|
|
275
|
+
this.storage = options.storage;
|
|
276
|
+
this.http = options.http;
|
|
277
|
+
this.crypto = options.crypto;
|
|
278
|
+
this.biometric = options.biometric;
|
|
279
|
+
this.biometricEnabled = options.biometricEnabled ?? false;
|
|
280
|
+
this.biometricStrict = options.biometricStrict ?? false;
|
|
281
|
+
this.biometricPrompt = options.biometricPrompt ?? "Authenticate to continue";
|
|
282
|
+
this.dispatch = options.dispatch;
|
|
283
|
+
this.dpopSession = options.dpopSession;
|
|
284
|
+
this.emitEvent = options.onEvent;
|
|
285
|
+
this.now = options.now ?? Date.now;
|
|
286
|
+
this.setTimeoutFn = options.setTimeout ?? globalThis.setTimeout.bind(globalThis);
|
|
287
|
+
this.clearTimeoutFn = options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
|
|
288
|
+
this.jwksUri = options.jwksUri ?? deriveDefaultJwksUri(options.tokenEndpoint);
|
|
289
|
+
this.jwksTtlMs = options.jwksTtlMs ?? 24 * 60 * 60 * 1e3;
|
|
290
|
+
this.expectedIssuer = options.expectedIssuer ?? deriveDefaultIssuer(options.tokenEndpoint);
|
|
291
|
+
this.expectedAudience = options.expectedAudience ?? RAKOMI_PLATFORM_AUDIENCE_DEFAULT;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Issue a one-time submit nonce for an authentication ceremony.
|
|
295
|
+
*
|
|
296
|
+
* every public-surface `submitOAuthTokens` call MUST present a nonce
|
|
297
|
+
* issued by an in-flight flow. Nonces are 32 bytes, base64url, single-use, 5-minute TTL.
|
|
298
|
+
*/
|
|
299
|
+
async beginAuthFlow() {
|
|
300
|
+
const bytes = await this.crypto.getRandomBytes(32);
|
|
301
|
+
const nonce = bytesToBase64Url(bytes);
|
|
302
|
+
this.submitNonces.set(nonce, this.now() + _TokenRuntime.NONCE_TTL_MS);
|
|
303
|
+
this.gcExpiredNonces();
|
|
304
|
+
while (this.submitNonces.size > _TokenRuntime.MAX_PENDING_NONCES) {
|
|
305
|
+
const oldest = this.submitNonces.keys().next().value;
|
|
306
|
+
if (oldest === void 0) break;
|
|
307
|
+
this.submitNonces.delete(oldest);
|
|
308
|
+
}
|
|
309
|
+
return nonce;
|
|
310
|
+
}
|
|
311
|
+
gcExpiredNonces() {
|
|
312
|
+
const cutoff = this.now();
|
|
313
|
+
for (const [nonce, expiresAt] of this.submitNonces) {
|
|
314
|
+
if (expiresAt < cutoff) this.submitNonces.delete(nonce);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
consumeNonce(nonce) {
|
|
318
|
+
this.gcExpiredNonces();
|
|
319
|
+
const entry = this.submitNonces.get(nonce);
|
|
320
|
+
if (entry === void 0) return false;
|
|
321
|
+
this.submitNonces.delete(nonce);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Validate a submit nonce — public for Provider-layer nonce gate.
|
|
326
|
+
* Internal class paths (refresh, restore) bypass this; external consumer-facing
|
|
327
|
+
* surfaces (`Provider.submitOAuthTokens`) MUST call this BEFORE invoking `setTokens`.
|
|
328
|
+
*/
|
|
329
|
+
validateSubmitNonce(nonce) {
|
|
330
|
+
return this.consumeNonce(nonce);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Persist tokens after a successful sign-in or refresh. Updates in-memory state,
|
|
334
|
+
* writes refresh token to secure storage, and dispatches `SIGN_IN_SUCCESS` /
|
|
335
|
+
* `REFRESH_SUCCESS` to the FSM.
|
|
336
|
+
*
|
|
337
|
+
* Internal API — the consumer-facing tokens-write surface is `Provider.submitOAuthTokens`,
|
|
338
|
+
* which gates this with `validateSubmitNonce`.
|
|
339
|
+
*/
|
|
340
|
+
async setTokens(response, source = "sign_in") {
|
|
341
|
+
const user = decodeUser(response.access_token);
|
|
342
|
+
if (!user) {
|
|
343
|
+
this.dispatch({ type: "SIGN_IN_FAILED", error: { code: "SIGN_IN_FAILED", message: "Invalid or missing JWT claims" } });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (this.currentUser && this.currentUser.id !== user.id) {
|
|
347
|
+
await this.clear({ code: "SIGN_IN_FAILED", message: "session_mismatch on token set" });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const sessionDecoded = decodeSession(response.access_token, response.expires_in);
|
|
351
|
+
if (!sessionDecoded) {
|
|
352
|
+
this.dispatch({ type: "SIGN_IN_FAILED", error: { code: "SIGN_IN_FAILED", message: "Invalid session claims" } });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const clampedExpiresIn = Math.max(10, Math.min(86400, Math.floor(response.expires_in)));
|
|
356
|
+
this.accessToken = response.access_token;
|
|
357
|
+
this.expiresAtMs = this.now() + clampedExpiresIn * 1e3;
|
|
358
|
+
this.refreshAttempts = 0;
|
|
359
|
+
this.currentUser = user;
|
|
360
|
+
this.currentSession = { ...sessionDecoded, isExpiringSoon: false };
|
|
361
|
+
if (response.refresh_token) {
|
|
362
|
+
const key = await this.resolveStorageKey();
|
|
363
|
+
await this.storage.setItem(key, REFRESH_TOKEN_PREFIX + response.refresh_token, {
|
|
364
|
+
keychainAccessible: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY",
|
|
365
|
+
requireAuthentication: this.biometricEnabled,
|
|
366
|
+
authenticationPrompt: this.biometricEnabled ? this.biometricPrompt : void 0
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
if (source === "refresh") {
|
|
370
|
+
this.dispatch({ type: "REFRESH_SUCCESS", session: this.currentSession });
|
|
371
|
+
} else if (source === "sign_in") {
|
|
372
|
+
this.dispatch({ type: "SIGN_IN_SUCCESS", user, session: this.currentSession });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Public token getter — returns a `TokenResult` shape compatible with `useAuth().getToken`.
|
|
377
|
+
* Refreshes proactively if within the refresh buffer; deduplicates concurrent callers.
|
|
378
|
+
*/
|
|
379
|
+
getToken = async () => {
|
|
380
|
+
const valid = this.getValidAccessToken();
|
|
381
|
+
if (valid) {
|
|
382
|
+
const expiresIn2 = Math.max(0, Math.floor(((this.expiresAtMs ?? 0) - this.now()) / 1e3));
|
|
383
|
+
return { ok: true, token: valid, tokenType: "Bearer", headers: {}, expiresIn: expiresIn2 };
|
|
384
|
+
}
|
|
385
|
+
const success = await this.performRefresh();
|
|
386
|
+
if (!success || !this.accessToken || this.expiresAtMs === null) {
|
|
387
|
+
return { ok: false, error: { code: "REFRESH_FAILED", reason: "expired", message: "No valid token available" } };
|
|
388
|
+
}
|
|
389
|
+
const expiresIn = Math.max(0, Math.floor((this.expiresAtMs - this.now()) / 1e3));
|
|
390
|
+
return { ok: true, token: this.accessToken, tokenType: "Bearer", headers: {}, expiresIn };
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Try to restore session from persisted refresh token. Called once on Provider mount.
|
|
394
|
+
* Triggers a refresh — on success dispatches RESTORE_SUCCESS; on failure RESTORE_FAILED.
|
|
395
|
+
*/
|
|
396
|
+
async restore() {
|
|
397
|
+
const success = await this.performRefresh({ duringRestore: true });
|
|
398
|
+
if (success && this.currentUser && this.currentSession) {
|
|
399
|
+
this.dispatch({ type: "RESTORE_SUCCESS", user: this.currentUser, session: this.currentSession });
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
this.dispatch({ type: "RESTORE_FAILED" });
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Foreground refresh — called by Provider AppState handler. Dispatches REFRESH_START
|
|
407
|
+
* and kicks a single in-flight refresh. No-op if no refresh token / already in flight.
|
|
408
|
+
*/
|
|
409
|
+
async refreshOnForeground() {
|
|
410
|
+
if (this.refreshInFlight) return;
|
|
411
|
+
if (!this.currentUser) return;
|
|
412
|
+
this.dispatch({ type: "REFRESH_START" });
|
|
413
|
+
await this.performRefresh();
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Clear all auth state. GDPR Art. 17 erasure.
|
|
417
|
+
*/
|
|
418
|
+
async clear(error) {
|
|
419
|
+
this.accessToken = null;
|
|
420
|
+
this.expiresAtMs = null;
|
|
421
|
+
this.currentUser = null;
|
|
422
|
+
this.currentSession = null;
|
|
423
|
+
this.refreshInFlight = null;
|
|
424
|
+
this.refreshAttempts = 0;
|
|
425
|
+
if (this.retryTimer) {
|
|
426
|
+
this.clearTimeoutFn(this.retryTimer);
|
|
427
|
+
this.retryTimer = null;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const key = await this.resolveStorageKey();
|
|
431
|
+
await this.storage.removeItem(key);
|
|
432
|
+
} catch {
|
|
433
|
+
}
|
|
434
|
+
if (error) {
|
|
435
|
+
this.dispatch({ type: "REFRESH_REVOKED", error });
|
|
436
|
+
} else {
|
|
437
|
+
this.dispatch({ type: "SIGN_OUT" });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Verify a JWT against the cached JWKS (offline-capable).
|
|
442
|
+
*
|
|
443
|
+
* uses `jose.createLocalJWKSet` so verification is purely local once
|
|
444
|
+
* the JWKS document has been fetched and cached. The cache is preloaded from `KeyValueStore`
|
|
445
|
+
* on cold-start (slot `jwks_cache`) and refreshed on TTL miss; on persistent network failure
|
|
446
|
+
* the stale-while-error fallback keeps verification working until the cached doc itself expires.
|
|
447
|
+
*/
|
|
448
|
+
async verifyAccessToken(token) {
|
|
449
|
+
try {
|
|
450
|
+
const cache = await this.getOrCreateJwksCache();
|
|
451
|
+
const keySet = await cache.getKeySet();
|
|
452
|
+
const result = await jwtVerify(token, keySet, {
|
|
453
|
+
issuer: this.expectedIssuer,
|
|
454
|
+
audience: this.expectedAudience,
|
|
455
|
+
algorithms: ["RS256"]
|
|
456
|
+
});
|
|
457
|
+
return { ok: true, payload: result.payload };
|
|
458
|
+
} catch (err2) {
|
|
459
|
+
return { ok: false, reason: err2 instanceof Error ? err2.message : "verify failed" };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
destroy() {
|
|
463
|
+
this.destroyed = true;
|
|
464
|
+
if (this.retryTimer) {
|
|
465
|
+
this.clearTimeoutFn(this.retryTimer);
|
|
466
|
+
this.retryTimer = null;
|
|
467
|
+
}
|
|
468
|
+
this.refreshInFlight = null;
|
|
469
|
+
}
|
|
470
|
+
async resolveStorageKey() {
|
|
471
|
+
if (this.storageKeyResolved) return this.storageKeyResolved;
|
|
472
|
+
this.storageKeyResolved = await deriveTenantStorageKey(this.crypto, this.tenantId, "refresh_token");
|
|
473
|
+
return this.storageKeyResolved;
|
|
474
|
+
}
|
|
475
|
+
async resolveJwksKey() {
|
|
476
|
+
if (this.jwksKeyResolved) return this.jwksKeyResolved;
|
|
477
|
+
this.jwksKeyResolved = await deriveTenantStorageKey(this.crypto, this.tenantId, "jwks_cache");
|
|
478
|
+
return this.jwksKeyResolved;
|
|
479
|
+
}
|
|
480
|
+
async getOrCreateJwksCache() {
|
|
481
|
+
if (this.jwksCache) return this.jwksCache;
|
|
482
|
+
const key = await this.resolveJwksKey();
|
|
483
|
+
let initial;
|
|
484
|
+
try {
|
|
485
|
+
const raw = await this.storage.getItem(key);
|
|
486
|
+
if (raw) {
|
|
487
|
+
const parsed = JSON.parse(raw);
|
|
488
|
+
if (parsed.document && typeof parsed.fetchedAt === "number") {
|
|
489
|
+
initial = { document: parsed.document, fetchedAt: parsed.fetchedAt };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
this.jwksCache = createJwksCache({
|
|
495
|
+
ttlMs: this.jwksTtlMs,
|
|
496
|
+
fetchJwks: async () => {
|
|
497
|
+
const response = await this.http.fetch(this.jwksUri, { method: "GET", headers: { Accept: "application/json" } });
|
|
498
|
+
if (!response.ok) {
|
|
499
|
+
throw new Error(`JWKS fetch failed: HTTP ${response.status}`);
|
|
500
|
+
}
|
|
501
|
+
return await response.json();
|
|
502
|
+
},
|
|
503
|
+
initial,
|
|
504
|
+
onFetched: (document, fetchedAt) => {
|
|
505
|
+
void this.storage.setItem(key, JSON.stringify({ document, fetchedAt })).catch(() => void 0);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
return this.jwksCache;
|
|
509
|
+
}
|
|
510
|
+
getValidAccessToken() {
|
|
511
|
+
if (!this.accessToken || this.expiresAtMs === null) return null;
|
|
512
|
+
if (this.now() >= this.expiresAtMs - REFRESH_BUFFER_SECONDS * 1e3) return null;
|
|
513
|
+
return this.accessToken;
|
|
514
|
+
}
|
|
515
|
+
async performRefresh(opts = {}) {
|
|
516
|
+
if (this.refreshInFlight) return this.refreshInFlight;
|
|
517
|
+
this.refreshInFlight = this.doRefresh(opts).finally(() => {
|
|
518
|
+
this.refreshInFlight = null;
|
|
519
|
+
});
|
|
520
|
+
return this.refreshInFlight;
|
|
521
|
+
}
|
|
522
|
+
async doRefresh(opts) {
|
|
523
|
+
if (this.destroyed) return false;
|
|
524
|
+
this.emitEvent?.({ type: "refresh_started" });
|
|
525
|
+
if (this.biometricEnabled && this.biometric) {
|
|
526
|
+
const available = await this.biometric.isAvailable();
|
|
527
|
+
if (!available) {
|
|
528
|
+
this.emitEvent?.({ type: "biometric_failed", metadata: { reason: "unavailable" } });
|
|
529
|
+
if (!opts.duringRestore) {
|
|
530
|
+
await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Biometric unavailable" });
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
const result2 = await this.biometric.authenticate({ promptMessage: this.biometricPrompt, strict: this.biometricStrict });
|
|
535
|
+
if (!result2.success) {
|
|
536
|
+
this.emitEvent?.({ type: "biometric_failed", metadata: { reason: result2.reason } });
|
|
537
|
+
if (!opts.duringRestore) {
|
|
538
|
+
if (result2.reason === "lockout" || result2.reason === "not_enrolled") {
|
|
539
|
+
await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: `Biometric ${result2.reason}` });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const key = await this.resolveStorageKey();
|
|
546
|
+
let raw;
|
|
547
|
+
try {
|
|
548
|
+
raw = await this.storage.getItem(key, {
|
|
549
|
+
requireAuthentication: this.biometricEnabled,
|
|
550
|
+
authenticationPrompt: this.biometricEnabled ? this.biometricPrompt : void 0
|
|
551
|
+
});
|
|
552
|
+
} catch {
|
|
553
|
+
raw = null;
|
|
554
|
+
}
|
|
555
|
+
if (!raw) {
|
|
556
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "no_refresh_token" } });
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
if (!raw.startsWith(REFRESH_TOKEN_PREFIX)) {
|
|
560
|
+
await this.storage.removeItem(key).catch(() => void 0);
|
|
561
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "invalid_storage_prefix" } });
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
const refreshToken = raw.slice(REFRESH_TOKEN_PREFIX.length);
|
|
565
|
+
if (this.dpopSession?.isBound) {
|
|
566
|
+
const dResult = await refreshWithDpop({
|
|
567
|
+
http: this.http,
|
|
568
|
+
tokenEndpoint: this.tokenEndpoint,
|
|
569
|
+
refreshToken,
|
|
570
|
+
clientId: this.clientId,
|
|
571
|
+
dpopSession: this.dpopSession
|
|
572
|
+
});
|
|
573
|
+
if (!dResult.ok) {
|
|
574
|
+
return this.handleDpopRefreshFailure(dResult.error, opts.duringRestore ?? false);
|
|
575
|
+
}
|
|
576
|
+
this.emitEvent?.({ type: "refresh_succeeded" });
|
|
577
|
+
this.refreshAttempts = 0;
|
|
578
|
+
this.offlineStaleCycles = 0;
|
|
579
|
+
await this.setTokens(dResult.tokens, opts.duringRestore ? "restore" : "refresh");
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
const result = await refreshAccessToken({
|
|
583
|
+
http: this.http,
|
|
584
|
+
tokenEndpoint: this.tokenEndpoint,
|
|
585
|
+
refreshToken,
|
|
586
|
+
clientId: this.clientId
|
|
587
|
+
});
|
|
588
|
+
if (!result.ok) {
|
|
589
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { code: result.error.code } });
|
|
590
|
+
if (this.isAuthError(result.error)) {
|
|
591
|
+
await this.clear(result.error);
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
return this.scheduleRetry(result.error, opts.duringRestore ?? false);
|
|
595
|
+
}
|
|
596
|
+
this.emitEvent?.({ type: "refresh_succeeded" });
|
|
597
|
+
this.refreshAttempts = 0;
|
|
598
|
+
this.offlineStaleCycles = 0;
|
|
599
|
+
await this.setTokens(result.tokens, opts.duringRestore ? "restore" : "refresh");
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
isAuthError(error) {
|
|
603
|
+
return error.code === "REFRESH_FAILED" && (error.reason === "expired" || error.reason === "revoked");
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Map a DPoP-refresh failure (three-class taxonomy) onto the FSM. The
|
|
607
|
+
* distinct `code` is emitted as telemetry, so consumers can tell an
|
|
608
|
+
* `auth/invalid_dpop_proof` spike apart from `auth/invalid_refresh_token`
|
|
609
|
+
* (the classes are kept distinct deliberately).
|
|
610
|
+
*/
|
|
611
|
+
async handleDpopRefreshFailure(error, duringRestore) {
|
|
612
|
+
this.emitEvent?.({ type: "dpop_refresh_failed", metadata: { code: error.code } });
|
|
613
|
+
if (error.class === "network") {
|
|
614
|
+
return this.scheduleRetry({ code: "REFRESH_FAILED", reason: "network", message: error.message }, duringRestore);
|
|
615
|
+
}
|
|
616
|
+
if (error.class === "dpop_prover_unavailable") {
|
|
617
|
+
this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "dpop_prover_unavailable" } });
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: error.message });
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
scheduleRetry(error, duringRestore) {
|
|
624
|
+
if (this.refreshAttempts >= RETRY_DELAYS_MS.length) {
|
|
625
|
+
if (!duringRestore && this.accessToken && this.expiresAtMs !== null && this.now() < this.expiresAtMs) {
|
|
626
|
+
this.refreshAttempts = 0;
|
|
627
|
+
this.offlineStaleCycles += 1;
|
|
628
|
+
if (this.offlineStaleCycles >= _TokenRuntime.MAX_OFFLINE_STALE_CYCLES) {
|
|
629
|
+
this.offlineStaleCycles = 0;
|
|
630
|
+
void this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Refresh chronically failing \u2014 session lost" });
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
this.dispatch({ type: "OFFLINE_STALE" });
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
void this.clear({ code: "REFRESH_FAILED", reason: "network", message: "Refresh exhausted retries" });
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
const delay = RETRY_DELAYS_MS[this.refreshAttempts];
|
|
640
|
+
this.refreshAttempts++;
|
|
641
|
+
this.emitEvent?.({ type: "network_retry", metadata: { delayMs: delay, attempt: this.refreshAttempts } });
|
|
642
|
+
if (!duringRestore) {
|
|
643
|
+
this.dispatch({ type: "REFRESH_NETWORK_ERROR", error });
|
|
644
|
+
}
|
|
645
|
+
if (this.retryTimer) this.clearTimeoutFn(this.retryTimer);
|
|
646
|
+
this.retryTimer = this.setTimeoutFn(() => {
|
|
647
|
+
this.retryTimer = null;
|
|
648
|
+
void this.performRefresh();
|
|
649
|
+
}, delay);
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// src/native/expo-adapter.ts
|
|
655
|
+
function createDefaultExpoAdapter(options = {}) {
|
|
656
|
+
const crypto = createExpoCrypto();
|
|
657
|
+
const storage = createExpoSecureStore({ tokenCache: options.tokenCache });
|
|
658
|
+
const browser = createExpoBrowser();
|
|
659
|
+
const deepLink = createExpoDeepLink();
|
|
660
|
+
const biometric = createExpoBiometricGate();
|
|
661
|
+
const appLifecycle = createRnAppLifecycle();
|
|
662
|
+
const connectivity = createNetInfoConnectivity();
|
|
663
|
+
return {
|
|
664
|
+
crypto,
|
|
665
|
+
storage,
|
|
666
|
+
browser,
|
|
667
|
+
deepLink,
|
|
668
|
+
biometric,
|
|
669
|
+
appLifecycle,
|
|
670
|
+
connectivity
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function createExpoCrypto() {
|
|
674
|
+
let mod = null;
|
|
675
|
+
async function load() {
|
|
676
|
+
if (!mod) {
|
|
677
|
+
mod = await import('expo-crypto').catch(() => null);
|
|
678
|
+
if (!mod) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
"@rakomi/react-native: expo-crypto is required for the default adapter. Install it (Expo-managed) or pass a custom nativeAdapter (bare RN)."
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return mod;
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
getRandomBytes: async (n) => {
|
|
688
|
+
const m = await load();
|
|
689
|
+
const result = await m.getRandomBytesAsync(n);
|
|
690
|
+
return new Uint8Array(result);
|
|
691
|
+
},
|
|
692
|
+
digestSha256: async (input) => {
|
|
693
|
+
const m = await load();
|
|
694
|
+
let str = "";
|
|
695
|
+
for (let i = 0; i < input.length; i++) str += String.fromCharCode(input[i]);
|
|
696
|
+
const hex = await m.digestStringAsync(
|
|
697
|
+
m.CryptoDigestAlgorithm.SHA256,
|
|
698
|
+
str,
|
|
699
|
+
{ encoding: m.CryptoEncoding.HEX }
|
|
700
|
+
);
|
|
701
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
702
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
703
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
704
|
+
}
|
|
705
|
+
return bytes;
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function createExpoSecureStore(opts) {
|
|
710
|
+
if (opts.tokenCache) {
|
|
711
|
+
const tc = opts.tokenCache;
|
|
712
|
+
return {
|
|
713
|
+
getItem: (key) => tc.getToken(key),
|
|
714
|
+
setItem: async (key, value) => {
|
|
715
|
+
await tc.saveToken(key, value);
|
|
716
|
+
},
|
|
717
|
+
removeItem: (key) => tc.clearToken(key)
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
let mod = null;
|
|
721
|
+
async function load() {
|
|
722
|
+
if (!mod) {
|
|
723
|
+
mod = await import('expo-secure-store').catch(() => null);
|
|
724
|
+
if (!mod) {
|
|
725
|
+
throw new Error(
|
|
726
|
+
"@rakomi/react-native: expo-secure-store is required for the default adapter. Install it (Expo-managed) or pass `tokenCache` / a custom nativeAdapter."
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return mod;
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
getItem: async (key, options) => {
|
|
734
|
+
const m = await load();
|
|
735
|
+
return m.getItemAsync(key, {
|
|
736
|
+
requireAuthentication: options?.requireAuthentication,
|
|
737
|
+
authenticationPrompt: options?.authenticationPrompt
|
|
738
|
+
});
|
|
739
|
+
},
|
|
740
|
+
setItem: async (key, value, options) => {
|
|
741
|
+
const m = await load();
|
|
742
|
+
const accessibility = mapKeychainAccessible(
|
|
743
|
+
m,
|
|
744
|
+
options?.keychainAccessible ?? "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY"
|
|
745
|
+
);
|
|
746
|
+
await m.setItemAsync(key, value, {
|
|
747
|
+
keychainAccessible: accessibility,
|
|
748
|
+
requireAuthentication: options?.requireAuthentication,
|
|
749
|
+
authenticationPrompt: options?.authenticationPrompt
|
|
750
|
+
});
|
|
751
|
+
},
|
|
752
|
+
removeItem: async (key) => {
|
|
753
|
+
const m = await load();
|
|
754
|
+
await m.deleteItemAsync(key);
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function mapKeychainAccessible(m, level) {
|
|
759
|
+
switch (level) {
|
|
760
|
+
case "WHEN_UNLOCKED":
|
|
761
|
+
return m.WHEN_UNLOCKED;
|
|
762
|
+
case "AFTER_FIRST_UNLOCK":
|
|
763
|
+
return m.AFTER_FIRST_UNLOCK;
|
|
764
|
+
case "WHEN_UNLOCKED_THIS_DEVICE_ONLY":
|
|
765
|
+
return m.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
|
766
|
+
case "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY":
|
|
767
|
+
return m.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
|
|
768
|
+
case "WHEN_PASSCODE_SET_THIS_DEVICE_ONLY":
|
|
769
|
+
return m.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function createExpoBrowser() {
|
|
773
|
+
let mod = null;
|
|
774
|
+
async function load() {
|
|
775
|
+
if (!mod) {
|
|
776
|
+
mod = await import('expo-web-browser').catch(() => null);
|
|
777
|
+
if (!mod) {
|
|
778
|
+
throw new Error("@rakomi/react-native: expo-web-browser is required for OAuth flows.");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return mod;
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
openAuthSession: async (authUrl, redirectUri, options) => {
|
|
785
|
+
const m = await load();
|
|
786
|
+
const result = await m.openAuthSessionAsync(authUrl, redirectUri, {
|
|
787
|
+
preferEphemeralSession: options?.preferEphemeralSession ?? true
|
|
788
|
+
});
|
|
789
|
+
if (result.type === "success" && result.url) {
|
|
790
|
+
return { type: "success", url: result.url };
|
|
791
|
+
}
|
|
792
|
+
if (result.type === "cancel") return { type: "cancel" };
|
|
793
|
+
if (result.type === "dismiss") return { type: "dismiss" };
|
|
794
|
+
if (result.type === "locked") return { type: "locked" };
|
|
795
|
+
return { type: "cancel" };
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function createExpoDeepLink() {
|
|
800
|
+
let mod = null;
|
|
801
|
+
async function load() {
|
|
802
|
+
if (!mod) {
|
|
803
|
+
mod = await import('expo-linking').catch(() => null);
|
|
804
|
+
if (!mod) {
|
|
805
|
+
throw new Error("@rakomi/react-native: expo-linking is required for OAuth deep-link callback.");
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return mod;
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
getInitialUrl: async () => {
|
|
812
|
+
const m = await load();
|
|
813
|
+
return m.getInitialURL();
|
|
814
|
+
},
|
|
815
|
+
addListener: (listener) => {
|
|
816
|
+
let sub = null;
|
|
817
|
+
void load().then((m) => {
|
|
818
|
+
sub = m.addEventListener("url", (event) => listener(event.url));
|
|
819
|
+
});
|
|
820
|
+
return {
|
|
821
|
+
remove: () => {
|
|
822
|
+
sub?.remove();
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function createExpoBiometricGate() {
|
|
829
|
+
let mod = null;
|
|
830
|
+
async function load() {
|
|
831
|
+
if (mod !== null) return mod;
|
|
832
|
+
try {
|
|
833
|
+
mod = await import('expo-local-authentication');
|
|
834
|
+
} catch {
|
|
835
|
+
mod = null;
|
|
836
|
+
}
|
|
837
|
+
return mod;
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
isAvailable: async () => {
|
|
841
|
+
const m = await load();
|
|
842
|
+
if (!m) return false;
|
|
843
|
+
const has = await m.hasHardwareAsync();
|
|
844
|
+
if (!has) return false;
|
|
845
|
+
const enrolled = await m.isEnrolledAsync();
|
|
846
|
+
return enrolled;
|
|
847
|
+
},
|
|
848
|
+
authenticate: async ({ promptMessage, strict = false }) => {
|
|
849
|
+
const m = await load();
|
|
850
|
+
if (!m) return { success: false, reason: "unavailable" };
|
|
851
|
+
const result = await m.authenticateAsync({
|
|
852
|
+
promptMessage,
|
|
853
|
+
disableDeviceFallback: strict,
|
|
854
|
+
fallbackLabel: "Use passcode"
|
|
855
|
+
});
|
|
856
|
+
if (result.success) return { success: true };
|
|
857
|
+
switch (result.error) {
|
|
858
|
+
case "user_cancel":
|
|
859
|
+
case "system_cancel":
|
|
860
|
+
case "app_cancel":
|
|
861
|
+
return { success: false, reason: "cancelled" };
|
|
862
|
+
case "lockout":
|
|
863
|
+
case "lockout_permanent":
|
|
864
|
+
return { success: false, reason: "lockout" };
|
|
865
|
+
case "not_enrolled":
|
|
866
|
+
return { success: false, reason: "not_enrolled" };
|
|
867
|
+
case "not_available":
|
|
868
|
+
case "no_space":
|
|
869
|
+
return { success: false, reason: "unavailable" };
|
|
870
|
+
default:
|
|
871
|
+
return { success: false, reason: "unknown" };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function createRnAppLifecycle() {
|
|
877
|
+
let rn = null;
|
|
878
|
+
let pending = null;
|
|
879
|
+
async function ensure() {
|
|
880
|
+
if (rn) return rn;
|
|
881
|
+
if (!pending) {
|
|
882
|
+
pending = import('react-native').then((m) => {
|
|
883
|
+
rn = m.default ?? m;
|
|
884
|
+
return rn;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
return pending;
|
|
888
|
+
}
|
|
889
|
+
void ensure().catch(() => void 0);
|
|
890
|
+
return {
|
|
891
|
+
addStateChangeListener: (listener) => {
|
|
892
|
+
let sub = null;
|
|
893
|
+
void ensure().then((r) => {
|
|
894
|
+
sub = r.AppState.addEventListener("change", (next) => {
|
|
895
|
+
listener(coerceAppState(next));
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
return { remove: () => sub?.remove() };
|
|
899
|
+
},
|
|
900
|
+
getCurrent: () => rn ? coerceAppState(rn.AppState.currentState) : "unknown"
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function coerceAppState(value) {
|
|
904
|
+
if (value === "active" || value === "background" || value === "inactive") return value;
|
|
905
|
+
return "unknown";
|
|
906
|
+
}
|
|
907
|
+
function createNetInfoConnectivity() {
|
|
908
|
+
let mod = null;
|
|
909
|
+
async function load() {
|
|
910
|
+
if (mod !== null) return mod;
|
|
911
|
+
try {
|
|
912
|
+
mod = (await import('@react-native-community/netinfo')).default ?? null;
|
|
913
|
+
} catch {
|
|
914
|
+
mod = null;
|
|
915
|
+
}
|
|
916
|
+
return mod;
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
isConnected: async () => {
|
|
920
|
+
const m = await load();
|
|
921
|
+
if (!m) return true;
|
|
922
|
+
const state = await m.fetch();
|
|
923
|
+
return state.isConnected ?? true;
|
|
924
|
+
},
|
|
925
|
+
addListener: (listener) => {
|
|
926
|
+
let unsubscribe = null;
|
|
927
|
+
void load().then((m) => {
|
|
928
|
+
if (!m) return;
|
|
929
|
+
unsubscribe = m.addEventListener((state) => listener(state.isConnected ?? true));
|
|
930
|
+
});
|
|
931
|
+
return { remove: () => unsubscribe?.() };
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
var RakomiContext = createContext(null);
|
|
936
|
+
var APP_STATE_DEBOUNCE_MS = 300;
|
|
937
|
+
function RakomiProvider(props) {
|
|
938
|
+
const adapter = useMemo(() => {
|
|
939
|
+
const built = props.nativeAdapter ?? createDefaultExpoAdapter({ tokenCache: props.tokenCache });
|
|
940
|
+
return Object.freeze(built);
|
|
941
|
+
}, [props.nativeAdapter, props.tokenCache]);
|
|
942
|
+
const http = useMemo(() => createRnHttpClient({ baseUrl: props.baseUrl }), [props.baseUrl]);
|
|
943
|
+
const events = useMemo(() => new EventLog(props.onEvent), [props.onEvent]);
|
|
944
|
+
const [snapshot, dispatch] = useReducer(reduce, INITIAL_SNAPSHOT);
|
|
945
|
+
const translate = useMemo(
|
|
946
|
+
() => createTranslator(props.locale ?? "en", props.translations),
|
|
947
|
+
[props.locale, props.translations]
|
|
948
|
+
);
|
|
949
|
+
const dpopSession = useMemo(() => {
|
|
950
|
+
if (!adapter.dpopProver) return void 0;
|
|
951
|
+
return createDpopSession({
|
|
952
|
+
prover: adapter.dpopProver,
|
|
953
|
+
baseUrl: props.baseUrl ?? "",
|
|
954
|
+
onDowngrade: () => {
|
|
955
|
+
events.push({ type: "sign_in_failed", severity: "security", metadata: { reason: "dpop_downgrade" } });
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
}, [adapter, props.baseUrl, events]);
|
|
959
|
+
const runtime = useMemo(() => {
|
|
960
|
+
const tokenEndpoint = props.tokenEndpoint ?? `${(props.baseUrl ?? "").replace(/\/$/, "")}/oauth/token`;
|
|
961
|
+
return new TokenRuntime({
|
|
962
|
+
clientId: props.publishableKey,
|
|
963
|
+
tenantId: props.tenantId ?? props.publishableKey,
|
|
964
|
+
tokenEndpoint,
|
|
965
|
+
storage: adapter.storage,
|
|
966
|
+
http,
|
|
967
|
+
crypto: adapter.crypto,
|
|
968
|
+
biometric: adapter.biometric,
|
|
969
|
+
biometricEnabled: props.biometric ?? false,
|
|
970
|
+
biometricStrict: props.biometricStrict ?? false,
|
|
971
|
+
biometricPrompt: props.biometricPrompt ?? "Authenticate to continue",
|
|
972
|
+
dispatch,
|
|
973
|
+
dpopSession,
|
|
974
|
+
onEvent: ({ type, metadata }) => {
|
|
975
|
+
if (type === "biometric_failed") {
|
|
976
|
+
events.push({ type: "biometric_failed", severity: "warning", metadata });
|
|
977
|
+
} else if (type === "network_retry") {
|
|
978
|
+
events.push({ type: "network_retry", severity: "warning", metadata });
|
|
979
|
+
} else if (type === "refresh_started") {
|
|
980
|
+
events.push({ type: "refresh_started", severity: "info", metadata });
|
|
981
|
+
} else if (type === "refresh_succeeded") {
|
|
982
|
+
events.push({ type: "refresh_succeeded", severity: "info", metadata });
|
|
983
|
+
} else if (type === "refresh_failed") {
|
|
984
|
+
events.push({ type: "refresh_failed", severity: "warning", metadata });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
}, [adapter, http, props.publishableKey, props.tenantId, props.baseUrl, props.tokenEndpoint, props.biometric, props.biometricStrict, props.biometricPrompt, events, dpopSession]);
|
|
989
|
+
useEffect(() => {
|
|
990
|
+
void runtime.restore();
|
|
991
|
+
return () => runtime.destroy();
|
|
992
|
+
}, [runtime]);
|
|
993
|
+
const debounceTimer = useRef(null);
|
|
994
|
+
useEffect(() => {
|
|
995
|
+
const sub = adapter.appLifecycle.addStateChangeListener((next) => {
|
|
996
|
+
if (next !== "active") return;
|
|
997
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
998
|
+
debounceTimer.current = setTimeout(() => {
|
|
999
|
+
events.push({ type: "app_state_foreground", severity: "info" });
|
|
1000
|
+
void runtime.refreshOnForeground();
|
|
1001
|
+
}, APP_STATE_DEBOUNCE_MS);
|
|
1002
|
+
});
|
|
1003
|
+
return () => {
|
|
1004
|
+
sub.remove();
|
|
1005
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
1006
|
+
};
|
|
1007
|
+
}, [adapter, events, runtime]);
|
|
1008
|
+
useEffect(() => {
|
|
1009
|
+
const bg = adapter.backgroundTask;
|
|
1010
|
+
if (!bg) return void 0;
|
|
1011
|
+
let unregister = null;
|
|
1012
|
+
void (async () => {
|
|
1013
|
+
try {
|
|
1014
|
+
const ok = await bg.isAvailable();
|
|
1015
|
+
if (!ok) return;
|
|
1016
|
+
unregister = await bg.register(async () => {
|
|
1017
|
+
await runtime.refreshOnForeground();
|
|
1018
|
+
});
|
|
1019
|
+
} catch {
|
|
1020
|
+
}
|
|
1021
|
+
})();
|
|
1022
|
+
return () => {
|
|
1023
|
+
if (unregister) void unregister().catch(() => void 0);
|
|
1024
|
+
};
|
|
1025
|
+
}, [adapter, runtime]);
|
|
1026
|
+
useEffect(() => {
|
|
1027
|
+
const sub = adapter.connectivity.addListener((isConnected) => {
|
|
1028
|
+
if (isConnected) {
|
|
1029
|
+
events.push({ type: "offline_queue_drained", severity: "info" });
|
|
1030
|
+
dispatch({ type: "BACK_ONLINE" });
|
|
1031
|
+
void runtime.refreshOnForeground();
|
|
1032
|
+
} else {
|
|
1033
|
+
dispatch({ type: "OFFLINE_STALE" });
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
return () => sub.remove();
|
|
1037
|
+
}, [adapter, events, runtime]);
|
|
1038
|
+
useEffect(() => {
|
|
1039
|
+
if (props.deepLinkAutoHandle === false) return void 0;
|
|
1040
|
+
const sub = adapter.deepLink.addListener(() => {
|
|
1041
|
+
events.push({ type: "deep_link_received", severity: "info" });
|
|
1042
|
+
});
|
|
1043
|
+
void adapter.deepLink.getInitialUrl().then((url) => {
|
|
1044
|
+
if (url) {
|
|
1045
|
+
events.push({ type: "deep_link_received", severity: "info", metadata: { coldStart: true } });
|
|
1046
|
+
}
|
|
1047
|
+
}).catch(() => void 0);
|
|
1048
|
+
return () => sub.remove();
|
|
1049
|
+
}, [adapter, events, props.deepLinkAutoHandle]);
|
|
1050
|
+
const signOut = useMemo(
|
|
1051
|
+
() => async () => {
|
|
1052
|
+
events.push({ type: "signed_out", severity: "info" });
|
|
1053
|
+
await runtime.clear();
|
|
1054
|
+
},
|
|
1055
|
+
[events, runtime]
|
|
1056
|
+
);
|
|
1057
|
+
const getToken = useMemo(() => () => runtime.getToken(), [runtime]);
|
|
1058
|
+
const submitOAuthTokens = useMemo(
|
|
1059
|
+
() => async (tokens, nonce) => {
|
|
1060
|
+
if (!runtime.validateSubmitNonce(nonce)) {
|
|
1061
|
+
events.push({ type: "sign_in_failed", severity: "security", error: { code: "CSRF_MISMATCH", message: "Submit nonce missing or expired" } });
|
|
1062
|
+
dispatch({ type: "SIGN_IN_FAILED", error: { code: "CSRF_MISMATCH", message: "Submit nonce missing or expired" } });
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
await runtime.setTokens(tokens, "sign_in");
|
|
1066
|
+
},
|
|
1067
|
+
[runtime, events]
|
|
1068
|
+
);
|
|
1069
|
+
const beginAuthFlow = useMemo(() => () => runtime.beginAuthFlow(), [runtime]);
|
|
1070
|
+
const value = useMemo(
|
|
1071
|
+
() => ({
|
|
1072
|
+
publishableKey: props.publishableKey,
|
|
1073
|
+
baseUrl: props.baseUrl ?? "",
|
|
1074
|
+
redirectUri: props.redirectUri,
|
|
1075
|
+
adapter,
|
|
1076
|
+
http,
|
|
1077
|
+
snapshot,
|
|
1078
|
+
state: snapshot.state,
|
|
1079
|
+
isSignedIn: isSignedIn(snapshot),
|
|
1080
|
+
user: snapshot.user,
|
|
1081
|
+
events,
|
|
1082
|
+
locale: props.locale ?? "en",
|
|
1083
|
+
translate,
|
|
1084
|
+
dispatch,
|
|
1085
|
+
signOut,
|
|
1086
|
+
getToken,
|
|
1087
|
+
submitOAuthTokens,
|
|
1088
|
+
beginAuthFlow,
|
|
1089
|
+
dpopSession
|
|
1090
|
+
}),
|
|
1091
|
+
[adapter, http, snapshot, events, props.publishableKey, props.baseUrl, props.redirectUri, props.locale, translate, signOut, getToken, submitOAuthTokens, beginAuthFlow, dpopSession]
|
|
1092
|
+
);
|
|
1093
|
+
return /* @__PURE__ */ jsx(RakomiContext.Provider, { value, children: props.children });
|
|
1094
|
+
}
|
|
1095
|
+
function useRakomiContext() {
|
|
1096
|
+
const ctx = useContext(RakomiContext);
|
|
1097
|
+
if (!ctx) {
|
|
1098
|
+
throw new Error("useRakomi*() must be called inside a <RakomiProvider>.");
|
|
1099
|
+
}
|
|
1100
|
+
return ctx;
|
|
1101
|
+
}
|
|
1102
|
+
function useAuth() {
|
|
1103
|
+
const ctx = useRakomiContext();
|
|
1104
|
+
const { snapshot, signOut, state, getToken } = ctx;
|
|
1105
|
+
const signIn = async (_options) => {
|
|
1106
|
+
return { status: "error", error: { code: "INVALID_CONFIG", message: "sign-in flow not yet wired in 0.1.0" } };
|
|
1107
|
+
};
|
|
1108
|
+
const switchOrganization = async (_orgId) => ({ status: "error", error: { code: "INVALID_CONFIG", message: "switchOrganization not yet wired" } });
|
|
1109
|
+
if (state === "idle") {
|
|
1110
|
+
return {
|
|
1111
|
+
isLoaded: true,
|
|
1112
|
+
isSignedIn: false,
|
|
1113
|
+
userId: null,
|
|
1114
|
+
user: null,
|
|
1115
|
+
sessionId: null,
|
|
1116
|
+
error: snapshot.error,
|
|
1117
|
+
state,
|
|
1118
|
+
signIn,
|
|
1119
|
+
signOut,
|
|
1120
|
+
getToken
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (state === "authenticating") {
|
|
1124
|
+
return {
|
|
1125
|
+
isLoaded: false,
|
|
1126
|
+
isSignedIn: void 0,
|
|
1127
|
+
userId: void 0,
|
|
1128
|
+
user: void 0,
|
|
1129
|
+
sessionId: void 0,
|
|
1130
|
+
error: null,
|
|
1131
|
+
state,
|
|
1132
|
+
signIn,
|
|
1133
|
+
signOut,
|
|
1134
|
+
getToken
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
if (snapshot.user && snapshot.session) {
|
|
1138
|
+
return {
|
|
1139
|
+
isLoaded: true,
|
|
1140
|
+
isSignedIn: true,
|
|
1141
|
+
userId: snapshot.user.id,
|
|
1142
|
+
user: snapshot.user,
|
|
1143
|
+
sessionId: snapshot.session.id,
|
|
1144
|
+
error: snapshot.error,
|
|
1145
|
+
state,
|
|
1146
|
+
isExpiringSoon: snapshot.session.isExpiringSoon ?? false,
|
|
1147
|
+
has: (params) => {
|
|
1148
|
+
if (params.permission && hasPermission(snapshot.user, params.permission)) return true;
|
|
1149
|
+
if (params.role && hasRole(snapshot.user, params.role)) return true;
|
|
1150
|
+
return false;
|
|
1151
|
+
},
|
|
1152
|
+
signIn,
|
|
1153
|
+
signOut,
|
|
1154
|
+
getToken,
|
|
1155
|
+
switchOrganization
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
isLoaded: true,
|
|
1160
|
+
isSignedIn: false,
|
|
1161
|
+
userId: null,
|
|
1162
|
+
user: null,
|
|
1163
|
+
sessionId: null,
|
|
1164
|
+
error: snapshot.error,
|
|
1165
|
+
state,
|
|
1166
|
+
signIn,
|
|
1167
|
+
signOut,
|
|
1168
|
+
getToken
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/hooks/use-session.ts
|
|
1173
|
+
function useSession() {
|
|
1174
|
+
const { snapshot } = useRakomiContext();
|
|
1175
|
+
return {
|
|
1176
|
+
isLoaded: snapshot.state !== "authenticating",
|
|
1177
|
+
isSignedIn: snapshot.state === "authenticated" || snapshot.state === "refreshing" || snapshot.state === "offline_stale",
|
|
1178
|
+
session: snapshot.session
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/hooks/use-user.ts
|
|
1183
|
+
function useUser() {
|
|
1184
|
+
const { snapshot } = useRakomiContext();
|
|
1185
|
+
return {
|
|
1186
|
+
isLoaded: snapshot.state !== "authenticating",
|
|
1187
|
+
isSignedIn: snapshot.state === "authenticated" || snapshot.state === "refreshing" || snapshot.state === "offline_stale",
|
|
1188
|
+
user: snapshot.user
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// src/hooks/index.ts
|
|
1193
|
+
function useFlag(flag, options) {
|
|
1194
|
+
const { user } = useRakomiContext();
|
|
1195
|
+
const flags = user?.rawClaims["feature_flags"] ?? {};
|
|
1196
|
+
const value = flags[flag] ?? options?.defaultValue;
|
|
1197
|
+
return {
|
|
1198
|
+
isLoaded: !!user,
|
|
1199
|
+
value,
|
|
1200
|
+
isEnabled: value === true
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
function useOrganization() {
|
|
1204
|
+
const { user } = useRakomiContext();
|
|
1205
|
+
const orgId = user?.rawClaims["org_id"];
|
|
1206
|
+
const orgRole = user?.rawClaims["org_role"];
|
|
1207
|
+
const orgMemberships = user?.rawClaims["org_memberships"] ?? [];
|
|
1208
|
+
return {
|
|
1209
|
+
isLoaded: !!user,
|
|
1210
|
+
organization: orgId && orgRole ? { orgId, orgRole, orgMemberships } : null
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function useOrganizationList() {
|
|
1214
|
+
const { user } = useRakomiContext();
|
|
1215
|
+
return {
|
|
1216
|
+
isLoaded: !!user,
|
|
1217
|
+
organizations: user?.rawClaims["org_memberships"] ?? []
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function useLinkedAccounts() {
|
|
1221
|
+
const { user } = useRakomiContext();
|
|
1222
|
+
const methods = user?.rawClaims["linked_methods"] ?? [];
|
|
1223
|
+
return {
|
|
1224
|
+
isLoaded: !!user,
|
|
1225
|
+
methods,
|
|
1226
|
+
link: async () => ({ status: "error" }),
|
|
1227
|
+
unlink: async () => ({ status: "error" })
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function useTranslation() {
|
|
1231
|
+
const { translate, locale } = useRakomiContext();
|
|
1232
|
+
return useMemo(() => ({ t: translate, locale }), [translate, locale]);
|
|
1233
|
+
}
|
|
1234
|
+
var authConfigCache = /* @__PURE__ */ new Map();
|
|
1235
|
+
function useAuthConfig() {
|
|
1236
|
+
const { baseUrl, http, publishableKey } = useRakomiContext();
|
|
1237
|
+
const [config, setConfig] = useState(null);
|
|
1238
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
1239
|
+
useEffect(() => {
|
|
1240
|
+
if (!baseUrl) return;
|
|
1241
|
+
const cacheKey = `${baseUrl}|${publishableKey}`;
|
|
1242
|
+
let cached2 = authConfigCache.get(cacheKey);
|
|
1243
|
+
if (!cached2) {
|
|
1244
|
+
cached2 = (async () => {
|
|
1245
|
+
try {
|
|
1246
|
+
const response = await http.fetch(`${baseUrl}/v1/auth/config`, {
|
|
1247
|
+
method: "GET",
|
|
1248
|
+
headers: { Accept: "application/json", "X-API-Key": publishableKey }
|
|
1249
|
+
});
|
|
1250
|
+
if (!response.ok) return null;
|
|
1251
|
+
return await response.json();
|
|
1252
|
+
} catch {
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
})();
|
|
1256
|
+
authConfigCache.set(cacheKey, cached2);
|
|
1257
|
+
}
|
|
1258
|
+
let cancelled = false;
|
|
1259
|
+
void cached2.then((value) => {
|
|
1260
|
+
if (!cancelled) {
|
|
1261
|
+
setConfig(value);
|
|
1262
|
+
setIsLoaded(true);
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
return () => {
|
|
1266
|
+
cancelled = true;
|
|
1267
|
+
};
|
|
1268
|
+
}, [baseUrl, http, publishableKey]);
|
|
1269
|
+
return { isLoaded, config };
|
|
1270
|
+
}
|
|
1271
|
+
function useBranding() {
|
|
1272
|
+
const { config, isLoaded } = useAuthConfig();
|
|
1273
|
+
return {
|
|
1274
|
+
isLoaded,
|
|
1275
|
+
branding: config?.branding ?? null
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function useAnonymousSignin() {
|
|
1279
|
+
return {
|
|
1280
|
+
isLoaded: true,
|
|
1281
|
+
signInAnonymously: async () => ({ status: "error" })
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
var baasPlansCache = /* @__PURE__ */ new Map();
|
|
1285
|
+
function useBaasPlans({ tenantSlug }) {
|
|
1286
|
+
const { baseUrl, http, publishableKey } = useRakomiContext();
|
|
1287
|
+
const [plans, setPlans] = useState([]);
|
|
1288
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
1289
|
+
const [error, setError] = useState(null);
|
|
1290
|
+
useEffect(() => {
|
|
1291
|
+
if (!baseUrl || !tenantSlug) return;
|
|
1292
|
+
const cacheKey = `${baseUrl}|${tenantSlug}`;
|
|
1293
|
+
let cached2 = baasPlansCache.get(cacheKey);
|
|
1294
|
+
if (!cached2) {
|
|
1295
|
+
cached2 = (async () => {
|
|
1296
|
+
try {
|
|
1297
|
+
const response = await http.fetch(`${baseUrl}/v1/billing/baas/${encodeURIComponent(tenantSlug)}/public-plans`, {
|
|
1298
|
+
method: "GET",
|
|
1299
|
+
headers: { Accept: "application/json", "X-API-Key": publishableKey }
|
|
1300
|
+
});
|
|
1301
|
+
if (response.status === 404) return [];
|
|
1302
|
+
if (!response.ok) return { error: `HTTP ${response.status}` };
|
|
1303
|
+
const json = await response.json();
|
|
1304
|
+
return json.data ?? [];
|
|
1305
|
+
} catch (err2) {
|
|
1306
|
+
return { error: err2 instanceof Error ? err2.message : "Network request failed" };
|
|
1307
|
+
}
|
|
1308
|
+
})();
|
|
1309
|
+
baasPlansCache.set(cacheKey, cached2);
|
|
1310
|
+
}
|
|
1311
|
+
let cancelled = false;
|
|
1312
|
+
void cached2.then((value) => {
|
|
1313
|
+
if (cancelled) return;
|
|
1314
|
+
if (Array.isArray(value)) {
|
|
1315
|
+
setPlans(value);
|
|
1316
|
+
setError(null);
|
|
1317
|
+
} else {
|
|
1318
|
+
setError(value.error);
|
|
1319
|
+
}
|
|
1320
|
+
setIsLoaded(true);
|
|
1321
|
+
});
|
|
1322
|
+
return () => {
|
|
1323
|
+
cancelled = true;
|
|
1324
|
+
};
|
|
1325
|
+
}, [baseUrl, http, publishableKey, tenantSlug]);
|
|
1326
|
+
return { isLoaded, plans, error };
|
|
1327
|
+
}
|
|
1328
|
+
function useBaasSubscription({ tenantSlug }) {
|
|
1329
|
+
const { baseUrl, http, publishableKey, getToken } = useRakomiContext();
|
|
1330
|
+
const [subscription, setSubscription] = useState(null);
|
|
1331
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
1332
|
+
const [error, setError] = useState(null);
|
|
1333
|
+
useEffect(() => {
|
|
1334
|
+
if (!baseUrl || !tenantSlug) return;
|
|
1335
|
+
let cancelled = false;
|
|
1336
|
+
void (async () => {
|
|
1337
|
+
try {
|
|
1338
|
+
const tokenResult = await getToken();
|
|
1339
|
+
const headers = { Accept: "application/json", "X-API-Key": publishableKey };
|
|
1340
|
+
if (tokenResult.ok) headers["Authorization"] = `Bearer ${tokenResult.token}`;
|
|
1341
|
+
const response = await http.fetch(`${baseUrl}/v1/billing/baas/${encodeURIComponent(tenantSlug)}/user/subscription`, {
|
|
1342
|
+
method: "GET",
|
|
1343
|
+
headers
|
|
1344
|
+
});
|
|
1345
|
+
if (cancelled) return;
|
|
1346
|
+
if (response.status === 404) {
|
|
1347
|
+
setSubscription(null);
|
|
1348
|
+
setError(null);
|
|
1349
|
+
} else if (response.ok) {
|
|
1350
|
+
const json = await response.json();
|
|
1351
|
+
setSubscription(json);
|
|
1352
|
+
setError(null);
|
|
1353
|
+
} else {
|
|
1354
|
+
setError(`HTTP ${response.status}`);
|
|
1355
|
+
}
|
|
1356
|
+
} catch (err2) {
|
|
1357
|
+
if (!cancelled) setError(err2 instanceof Error ? err2.message : "Network request failed");
|
|
1358
|
+
} finally {
|
|
1359
|
+
if (!cancelled) setIsLoaded(true);
|
|
1360
|
+
}
|
|
1361
|
+
})();
|
|
1362
|
+
return () => {
|
|
1363
|
+
cancelled = true;
|
|
1364
|
+
};
|
|
1365
|
+
}, [baseUrl, http, publishableKey, tenantSlug, getToken]);
|
|
1366
|
+
return { isLoaded, subscription, error };
|
|
1367
|
+
}
|
|
1368
|
+
function SignedIn({ children }) {
|
|
1369
|
+
const { isSignedIn } = useRakomiContext();
|
|
1370
|
+
return isSignedIn ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
|
|
1371
|
+
}
|
|
1372
|
+
function SignedOut({ children }) {
|
|
1373
|
+
const { isSignedIn } = useRakomiContext();
|
|
1374
|
+
return isSignedIn ? null : /* @__PURE__ */ jsx(Fragment, { children });
|
|
1375
|
+
}
|
|
1376
|
+
function Protect({ children, fallback = null, permission, role }) {
|
|
1377
|
+
const { user, isSignedIn } = useRakomiContext();
|
|
1378
|
+
if (!isSignedIn || !user) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
|
|
1379
|
+
if (permission && !hasPermission(user, permission)) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
|
|
1380
|
+
if (role && !hasRole(user, role)) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
|
|
1381
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
1382
|
+
}
|
|
1383
|
+
function Feature({ flag, children, fallback = null }) {
|
|
1384
|
+
const { user } = useRakomiContext();
|
|
1385
|
+
const flags = user?.rawClaims["feature_flags"] ?? {};
|
|
1386
|
+
return flags[flag] === true ? /* @__PURE__ */ jsx(Fragment, { children }) : /* @__PURE__ */ jsx(Fragment, { children: fallback });
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/internal/rn-primitives.ts
|
|
1390
|
+
var cached = null;
|
|
1391
|
+
function loadRnPrimitives() {
|
|
1392
|
+
if (cached) return cached;
|
|
1393
|
+
cached = __require("react-native");
|
|
1394
|
+
return cached;
|
|
1395
|
+
}
|
|
1396
|
+
var PKCE_STORAGE_KEY = "rakomi.oauth.pkce.in-flight";
|
|
1397
|
+
async function startSocialSignIn(input) {
|
|
1398
|
+
const { adapter, http } = input;
|
|
1399
|
+
const pkce = await generatePkce(adapter.crypto);
|
|
1400
|
+
const state = await issueState(adapter.crypto, adapter.storage);
|
|
1401
|
+
const pkceRecord = JSON.stringify({ codeVerifier: pkce.codeVerifier });
|
|
1402
|
+
await adapter.storage.setItem(PKCE_STORAGE_KEY, pkceRecord, { keychainAccessible: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY" });
|
|
1403
|
+
const authUrl = buildAuthorizationUrl({
|
|
1404
|
+
authorizationEndpoint: input.authorizationEndpoint,
|
|
1405
|
+
clientId: input.clientId,
|
|
1406
|
+
redirectUri: input.redirectUri,
|
|
1407
|
+
state: state.state,
|
|
1408
|
+
codeChallenge: pkce.codeChallenge,
|
|
1409
|
+
codeChallengeMethod: "S256",
|
|
1410
|
+
scope: input.scope,
|
|
1411
|
+
provider: input.provider,
|
|
1412
|
+
acrValues: input.acrValues
|
|
1413
|
+
});
|
|
1414
|
+
const result = await adapter.browser.openAuthSession(authUrl, input.redirectUri, {
|
|
1415
|
+
preferEphemeralSession: input.preferEphemeralSession ?? true
|
|
1416
|
+
});
|
|
1417
|
+
if (result.type !== "success") {
|
|
1418
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1419
|
+
if (result.type === "cancel") {
|
|
1420
|
+
return { ok: false, error: { code: "OAUTH_CALLBACK_ERROR", oauthError: "oauth_user_cancelled", description: "User cancelled the sign-in flow" } };
|
|
1421
|
+
}
|
|
1422
|
+
if (result.type === "dismiss") {
|
|
1423
|
+
return { ok: false, error: { code: "OAUTH_CALLBACK_ERROR", oauthError: "oauth_user_cancelled", description: "Sign-in dismissed" } };
|
|
1424
|
+
}
|
|
1425
|
+
if (result.type === "locked") {
|
|
1426
|
+
return { ok: false, error: { code: "OAUTH_CALLBACK_ERROR", oauthError: "oauth_locked", description: "iOS auto-fill locked the auth session" } };
|
|
1427
|
+
}
|
|
1428
|
+
return { ok: false, error: { code: "SIGN_IN_FAILED", message: "unknown browser auth result" } };
|
|
1429
|
+
}
|
|
1430
|
+
const url = result.url;
|
|
1431
|
+
const parsed = parseCallback(url, input.redirectUri);
|
|
1432
|
+
if (!parsed.ok) {
|
|
1433
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1434
|
+
return { ok: false, error: parsed.error };
|
|
1435
|
+
}
|
|
1436
|
+
const stateCheck = await consumeState(adapter.storage, state.storageKey, parsed.state);
|
|
1437
|
+
if (!stateCheck.ok) {
|
|
1438
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1439
|
+
return {
|
|
1440
|
+
ok: false,
|
|
1441
|
+
error: {
|
|
1442
|
+
code: "OAUTH_CALLBACK_ERROR",
|
|
1443
|
+
oauthError: "oauth_state_mismatch",
|
|
1444
|
+
description: `State validation failed (${stateCheck.reason})`
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
const pkceRecovered = await adapter.storage.getItem(PKCE_STORAGE_KEY);
|
|
1449
|
+
await adapter.storage.removeItem(PKCE_STORAGE_KEY).catch(() => void 0);
|
|
1450
|
+
if (!pkceRecovered) {
|
|
1451
|
+
return { ok: false, error: { code: "CODE_EXCHANGE_FAILED", message: "PKCE verifier missing" } };
|
|
1452
|
+
}
|
|
1453
|
+
let codeVerifier;
|
|
1454
|
+
try {
|
|
1455
|
+
const parsedPkce = JSON.parse(pkceRecovered);
|
|
1456
|
+
if (typeof parsedPkce.codeVerifier !== "string") throw new TypeError("bad PKCE record");
|
|
1457
|
+
codeVerifier = parsedPkce.codeVerifier;
|
|
1458
|
+
} catch {
|
|
1459
|
+
return { ok: false, error: { code: "CODE_EXCHANGE_FAILED", message: "PKCE record corrupted" } };
|
|
1460
|
+
}
|
|
1461
|
+
let dpopProof;
|
|
1462
|
+
if (input.dpopSession) {
|
|
1463
|
+
try {
|
|
1464
|
+
dpopProof = await input.dpopSession.resolveProof("POST", input.tokenEndpoint);
|
|
1465
|
+
} catch (e) {
|
|
1466
|
+
return {
|
|
1467
|
+
ok: false,
|
|
1468
|
+
error: {
|
|
1469
|
+
code: "CODE_EXCHANGE_FAILED",
|
|
1470
|
+
message: `DPoP prover unavailable at issuance: ${e instanceof Error ? e.message : "signer threw"}`
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
const exchange = await exchangeAuthCode({
|
|
1476
|
+
http,
|
|
1477
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
1478
|
+
code: parsed.code,
|
|
1479
|
+
codeVerifier,
|
|
1480
|
+
redirectUri: input.redirectUri,
|
|
1481
|
+
clientId: input.clientId,
|
|
1482
|
+
...dpopProof !== void 0 ? { dpopProof } : {}
|
|
1483
|
+
});
|
|
1484
|
+
if (!exchange.ok) return exchange;
|
|
1485
|
+
if (input.dpopSession) {
|
|
1486
|
+
await input.dpopSession.observeTokenType(exchange.tokens.token_type, dpopProof !== void 0);
|
|
1487
|
+
}
|
|
1488
|
+
return {
|
|
1489
|
+
ok: true,
|
|
1490
|
+
tokens: {
|
|
1491
|
+
access_token: exchange.tokens.access_token,
|
|
1492
|
+
refresh_token: exchange.tokens.refresh_token,
|
|
1493
|
+
expires_in: exchange.tokens.expires_in,
|
|
1494
|
+
token_type: exchange.tokens.token_type
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
function parseCallback(callbackUrl, expectedRedirectUri) {
|
|
1499
|
+
const expected = matchSchemeAndHost(expectedRedirectUri);
|
|
1500
|
+
const actual = matchSchemeAndHost(callbackUrl);
|
|
1501
|
+
if (!expected || !actual || expected.scheme !== actual.scheme || expected.host !== actual.host) {
|
|
1502
|
+
return {
|
|
1503
|
+
ok: false,
|
|
1504
|
+
error: {
|
|
1505
|
+
code: "OAUTH_CALLBACK_ERROR",
|
|
1506
|
+
oauthError: "oauth_redirect_mismatch",
|
|
1507
|
+
description: "Callback redirect URI scheme/host changed mid-flow (confused-deputy guard)"
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
const queryStart = callbackUrl.indexOf("?");
|
|
1512
|
+
const query = queryStart >= 0 ? callbackUrl.slice(queryStart + 1) : "";
|
|
1513
|
+
const params = new URLSearchParams(query);
|
|
1514
|
+
const error = params.get("error");
|
|
1515
|
+
if (error) {
|
|
1516
|
+
return {
|
|
1517
|
+
ok: false,
|
|
1518
|
+
error: parseOAuthCallbackError(error, params.get("error_description") ?? void 0)
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
const code = params.get("code");
|
|
1522
|
+
const state = params.get("state");
|
|
1523
|
+
if (!code || !state) {
|
|
1524
|
+
return {
|
|
1525
|
+
ok: false,
|
|
1526
|
+
error: {
|
|
1527
|
+
code: "OAUTH_CALLBACK_ERROR",
|
|
1528
|
+
oauthError: "oauth_missing_params",
|
|
1529
|
+
description: "Callback URL missing code or state"
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
return { ok: true, code, state };
|
|
1534
|
+
}
|
|
1535
|
+
function matchSchemeAndHost(url) {
|
|
1536
|
+
const m = url.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i);
|
|
1537
|
+
if (!m) return null;
|
|
1538
|
+
return { scheme: m[1].toLowerCase(), host: m[2].toLowerCase() };
|
|
1539
|
+
}
|
|
1540
|
+
function SignIn(props) {
|
|
1541
|
+
const ctx = useRakomiContext();
|
|
1542
|
+
const { View, Text, TextInput, Pressable } = loadRnPrimitives();
|
|
1543
|
+
const [step, setStep] = useState({ kind: "methods" });
|
|
1544
|
+
const [code, setCode] = useState("");
|
|
1545
|
+
const [busy, setBusy] = useState(false);
|
|
1546
|
+
const tokenEndpoint = props.tokenEndpoint ?? "/v1/auth/oauth/callback";
|
|
1547
|
+
const authorizationEndpoint = props.authorizationEndpoint ?? "/oauth/authorize";
|
|
1548
|
+
const totpVerifyEndpoint = props.totpVerifyEndpoint ?? "/v1/auth/mfa/totp/verify";
|
|
1549
|
+
const providers = props.providers ?? ["google"];
|
|
1550
|
+
async function onProviderPress(provider) {
|
|
1551
|
+
if (busy) return;
|
|
1552
|
+
setBusy(true);
|
|
1553
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { provider } });
|
|
1554
|
+
const outcome = await startSocialSignIn({
|
|
1555
|
+
adapter: ctx.adapter,
|
|
1556
|
+
http: ctx.http,
|
|
1557
|
+
authorizationEndpoint,
|
|
1558
|
+
tokenEndpoint,
|
|
1559
|
+
clientId: ctx.publishableKey,
|
|
1560
|
+
redirectUri: ctx.redirectUri,
|
|
1561
|
+
provider,
|
|
1562
|
+
...ctx.dpopSession ? { dpopSession: ctx.dpopSession } : {}
|
|
1563
|
+
});
|
|
1564
|
+
if (outcome.ok) {
|
|
1565
|
+
ctx.events.push({ type: "signed_in", severity: "info" });
|
|
1566
|
+
setStep({ kind: "success" });
|
|
1567
|
+
} else {
|
|
1568
|
+
ctx.events.push({ type: "sign_in_failed", severity: "warning", error: outcome.error });
|
|
1569
|
+
setStep({ kind: "error", error: outcome.error });
|
|
1570
|
+
}
|
|
1571
|
+
setBusy(false);
|
|
1572
|
+
}
|
|
1573
|
+
async function onTotpSubmit() {
|
|
1574
|
+
if (busy || step.kind !== "awaiting_mfa") return;
|
|
1575
|
+
setBusy(true);
|
|
1576
|
+
const result = await verifyTotp({
|
|
1577
|
+
http: ctx.http,
|
|
1578
|
+
endpoint: totpVerifyEndpoint,
|
|
1579
|
+
challengeToken: step.challengeToken,
|
|
1580
|
+
code
|
|
1581
|
+
});
|
|
1582
|
+
if (result.ok) {
|
|
1583
|
+
ctx.events.push({ type: "signed_in", severity: "info", metadata: { method: "mfa_totp" } });
|
|
1584
|
+
setStep({ kind: "success" });
|
|
1585
|
+
} else {
|
|
1586
|
+
if (result.error.code === "SIGN_IN_FAILED" && /unavailable/i.test(result.error.message)) {
|
|
1587
|
+
const e = new MfaStepUpUnavailableError(result.error.message);
|
|
1588
|
+
ctx.events.push({ type: "sign_in_failed", severity: "warning", metadata: { reason: "mfa_step_up_unavailable", guidance: e.guidance } });
|
|
1589
|
+
}
|
|
1590
|
+
setStep({ kind: "error", error: result.error });
|
|
1591
|
+
}
|
|
1592
|
+
setBusy(false);
|
|
1593
|
+
}
|
|
1594
|
+
if (step.kind === "success") {
|
|
1595
|
+
return /* @__PURE__ */ jsx(View, { style: props.style, accessibilityRole: "alert", children: /* @__PURE__ */ jsx(Text, { accessibilityLabel: ctx.translate("common.continue"), children: ctx.translate("common.continue") }) });
|
|
1596
|
+
}
|
|
1597
|
+
if (step.kind === "awaiting_mfa") {
|
|
1598
|
+
return /* @__PURE__ */ jsxs(View, { style: props.style, children: [
|
|
1599
|
+
/* @__PURE__ */ jsx(Text, { accessibilityRole: "header", children: ctx.translate("signin.mfa.title") }),
|
|
1600
|
+
/* @__PURE__ */ jsx(
|
|
1601
|
+
TextInput,
|
|
1602
|
+
{
|
|
1603
|
+
accessibilityLabel: ctx.translate("signin.mfa.code"),
|
|
1604
|
+
accessibilityHint: ctx.translate("signin.mfa.code"),
|
|
1605
|
+
keyboardType: "number-pad",
|
|
1606
|
+
textContentType: "oneTimeCode",
|
|
1607
|
+
autoComplete: "sms-otp",
|
|
1608
|
+
maxLength: 6,
|
|
1609
|
+
value: code,
|
|
1610
|
+
onChangeText: (t) => setCode(t.replace(/\D/g, "").slice(0, 6))
|
|
1611
|
+
}
|
|
1612
|
+
),
|
|
1613
|
+
/* @__PURE__ */ jsx(
|
|
1614
|
+
Pressable,
|
|
1615
|
+
{
|
|
1616
|
+
accessibilityRole: "button",
|
|
1617
|
+
accessibilityLabel: ctx.translate("signin.mfa.submit"),
|
|
1618
|
+
onPress: onTotpSubmit,
|
|
1619
|
+
disabled: busy || code.length !== 6,
|
|
1620
|
+
children: /* @__PURE__ */ jsx(Text, { children: ctx.translate("signin.mfa.submit") })
|
|
1621
|
+
}
|
|
1622
|
+
)
|
|
1623
|
+
] });
|
|
1624
|
+
}
|
|
1625
|
+
return /* @__PURE__ */ jsxs(View, { style: props.style, children: [
|
|
1626
|
+
/* @__PURE__ */ jsx(Text, { accessibilityRole: "header", children: props.title ?? ctx.translate("signin.title") }),
|
|
1627
|
+
providers.map((provider) => /* @__PURE__ */ jsx(
|
|
1628
|
+
Pressable,
|
|
1629
|
+
{
|
|
1630
|
+
accessibilityRole: "button",
|
|
1631
|
+
accessibilityLabel: `Sign in with ${provider}`,
|
|
1632
|
+
onPress: () => onProviderPress(provider),
|
|
1633
|
+
disabled: busy,
|
|
1634
|
+
children: /* @__PURE__ */ jsxs(Text, { children: [
|
|
1635
|
+
"Sign in with ",
|
|
1636
|
+
provider
|
|
1637
|
+
] })
|
|
1638
|
+
},
|
|
1639
|
+
provider
|
|
1640
|
+
)),
|
|
1641
|
+
step.kind === "error" ? /* @__PURE__ */ 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
|
|
1642
|
+
] });
|
|
1643
|
+
}
|
|
1644
|
+
function SignUp(props) {
|
|
1645
|
+
const ctx = useRakomiContext();
|
|
1646
|
+
const { View, Text, TextInput, Pressable } = loadRnPrimitives();
|
|
1647
|
+
const [email, setEmail] = useState("");
|
|
1648
|
+
const [password, setPassword] = useState("");
|
|
1649
|
+
const [busy, setBusy] = useState(false);
|
|
1650
|
+
const [step, setStep] = useState({ kind: "form" });
|
|
1651
|
+
const strength = password ? scorePassword(password) : null;
|
|
1652
|
+
const returnTo = props.returnTo && isSafeUrl(props.returnTo, ["http", "https", "rakomi"]) ? props.returnTo : void 0;
|
|
1653
|
+
async function onSubmit() {
|
|
1654
|
+
if (busy) return;
|
|
1655
|
+
setBusy(true);
|
|
1656
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { mode: "register" } });
|
|
1657
|
+
try {
|
|
1658
|
+
const response = await ctx.http.fetch(props.registerEndpoint ?? "/v1/auth/register", {
|
|
1659
|
+
method: "POST",
|
|
1660
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
1661
|
+
body: JSON.stringify({ email, password, returnTo })
|
|
1662
|
+
});
|
|
1663
|
+
if (!response.ok) {
|
|
1664
|
+
setStep({ kind: "error", error: { code: "SIGN_IN_FAILED", message: `Register failed (${response.status})` } });
|
|
1665
|
+
} else {
|
|
1666
|
+
setStep({ kind: "verification_required" });
|
|
1667
|
+
}
|
|
1668
|
+
} catch (err2) {
|
|
1669
|
+
setStep({ kind: "error", error: { code: "NETWORK_ERROR", message: err2 instanceof Error ? err2.message : "network" } });
|
|
1670
|
+
} finally {
|
|
1671
|
+
setBusy(false);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (step.kind === "verification_required") {
|
|
1675
|
+
return /* @__PURE__ */ jsxs(View, { style: props.style, accessibilityRole: "alert", children: [
|
|
1676
|
+
/* @__PURE__ */ jsx(Text, { children: ctx.translate("signup.title") }),
|
|
1677
|
+
/* @__PURE__ */ jsx(Text, { children: ctx.translate("common.continue") })
|
|
1678
|
+
] });
|
|
1679
|
+
}
|
|
1680
|
+
return /* @__PURE__ */ jsxs(View, { style: props.style, children: [
|
|
1681
|
+
/* @__PURE__ */ jsx(Text, { accessibilityRole: "header", children: ctx.translate("signup.title") }),
|
|
1682
|
+
/* @__PURE__ */ jsx(
|
|
1683
|
+
TextInput,
|
|
1684
|
+
{
|
|
1685
|
+
accessibilityLabel: ctx.translate("signup.email"),
|
|
1686
|
+
keyboardType: "email-address",
|
|
1687
|
+
textContentType: "emailAddress",
|
|
1688
|
+
autoComplete: "email",
|
|
1689
|
+
autoCapitalize: "none",
|
|
1690
|
+
value: email,
|
|
1691
|
+
onChangeText: setEmail
|
|
1692
|
+
}
|
|
1693
|
+
),
|
|
1694
|
+
/* @__PURE__ */ jsx(
|
|
1695
|
+
TextInput,
|
|
1696
|
+
{
|
|
1697
|
+
accessibilityLabel: ctx.translate("signup.password"),
|
|
1698
|
+
secureTextEntry: true,
|
|
1699
|
+
textContentType: "newPassword",
|
|
1700
|
+
autoComplete: "password-new",
|
|
1701
|
+
value: password,
|
|
1702
|
+
onChangeText: setPassword
|
|
1703
|
+
}
|
|
1704
|
+
),
|
|
1705
|
+
strength ? /* @__PURE__ */ jsxs(Text, { accessibilityLabel: `Password strength: ${strength}`, children: [
|
|
1706
|
+
"Strength: ",
|
|
1707
|
+
strength
|
|
1708
|
+
] }) : null,
|
|
1709
|
+
/* @__PURE__ */ jsx(
|
|
1710
|
+
Pressable,
|
|
1711
|
+
{
|
|
1712
|
+
accessibilityRole: "button",
|
|
1713
|
+
accessibilityLabel: ctx.translate("signup.submit"),
|
|
1714
|
+
onPress: onSubmit,
|
|
1715
|
+
disabled: busy || !email || !password,
|
|
1716
|
+
children: /* @__PURE__ */ jsx(Text, { children: ctx.translate("signup.submit") })
|
|
1717
|
+
}
|
|
1718
|
+
),
|
|
1719
|
+
step.kind === "error" ? /* @__PURE__ */ jsx(Text, { accessibilityRole: "alert", children: "message" in step.error ? step.error.message : ctx.translate("common.error.unknown") }) : null
|
|
1720
|
+
] });
|
|
1721
|
+
}
|
|
1722
|
+
function UserButton(props) {
|
|
1723
|
+
const ctx = useRakomiContext();
|
|
1724
|
+
const { View, Text, Pressable } = loadRnPrimitives();
|
|
1725
|
+
const [expanded, setExpanded] = useState(false);
|
|
1726
|
+
const user = ctx.user;
|
|
1727
|
+
if (!user) return null;
|
|
1728
|
+
const initials = (user.email || "?").slice(0, 2).toUpperCase();
|
|
1729
|
+
return /* @__PURE__ */ jsxs(View, { style: props.style, children: [
|
|
1730
|
+
/* @__PURE__ */ jsx(
|
|
1731
|
+
Pressable,
|
|
1732
|
+
{
|
|
1733
|
+
accessibilityRole: "button",
|
|
1734
|
+
accessibilityLabel: `Account menu for ${user.email}`,
|
|
1735
|
+
onPress: () => setExpanded((v) => !v),
|
|
1736
|
+
children: /* @__PURE__ */ jsxs(View, { children: [
|
|
1737
|
+
/* @__PURE__ */ jsx(Text, { accessibilityLabel: `Avatar ${initials}`, children: initials }),
|
|
1738
|
+
props.showName !== false ? /* @__PURE__ */ jsx(Text, { children: user.email }) : null
|
|
1739
|
+
] })
|
|
1740
|
+
}
|
|
1741
|
+
),
|
|
1742
|
+
expanded ? /* @__PURE__ */ jsx(View, { accessibilityRole: "menu", children: /* @__PURE__ */ jsx(
|
|
1743
|
+
Pressable,
|
|
1744
|
+
{
|
|
1745
|
+
accessibilityRole: "menuitem",
|
|
1746
|
+
accessibilityLabel: "Sign out",
|
|
1747
|
+
onPress: () => {
|
|
1748
|
+
setExpanded(false);
|
|
1749
|
+
void ctx.signOut();
|
|
1750
|
+
},
|
|
1751
|
+
children: /* @__PURE__ */ jsx(Text, { children: "Sign out" })
|
|
1752
|
+
}
|
|
1753
|
+
) }) : null
|
|
1754
|
+
] });
|
|
1755
|
+
}
|
|
1756
|
+
function UserProfile(props) {
|
|
1757
|
+
useEffect(() => {
|
|
1758
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
1759
|
+
console.warn("[@rakomi/react-native] <UserProfile /> is in preview until 0.2.0 \u2014 feedback welcome.");
|
|
1760
|
+
}
|
|
1761
|
+
}, []);
|
|
1762
|
+
const ctx = useRakomiContext();
|
|
1763
|
+
const { View, Text } = loadRnPrimitives();
|
|
1764
|
+
const user = ctx.user;
|
|
1765
|
+
if (!user) {
|
|
1766
|
+
return /* @__PURE__ */ jsx(View, { style: props.style, children: /* @__PURE__ */ jsx(Text, { children: "Not signed in" }) });
|
|
1767
|
+
}
|
|
1768
|
+
return /* @__PURE__ */ jsxs(View, { style: props.style, accessibilityRole: "summary", children: [
|
|
1769
|
+
/* @__PURE__ */ jsx(Text, { accessibilityRole: "header", children: user.email || "Anonymous user" }),
|
|
1770
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1771
|
+
"ID: ",
|
|
1772
|
+
user.id
|
|
1773
|
+
] }),
|
|
1774
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1775
|
+
"Tenant: ",
|
|
1776
|
+
user.tenantId
|
|
1777
|
+
] }),
|
|
1778
|
+
user.roles.length > 0 ? /* @__PURE__ */ jsxs(Text, { children: [
|
|
1779
|
+
"Roles: ",
|
|
1780
|
+
user.roles.join(", ")
|
|
1781
|
+
] }) : null,
|
|
1782
|
+
user.permissions.length > 0 ? /* @__PURE__ */ jsxs(Text, { children: [
|
|
1783
|
+
"Permissions: ",
|
|
1784
|
+
user.permissions.length
|
|
1785
|
+
] }) : null,
|
|
1786
|
+
user.mfaVerified ? /* @__PURE__ */ jsx(Text, { accessibilityLabel: "MFA verified", children: "MFA: verified" }) : null
|
|
1787
|
+
] });
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// src/native/dpop-prover.ts
|
|
1791
|
+
function createNativeDpopProver(options) {
|
|
1792
|
+
const mod = options.module;
|
|
1793
|
+
return {
|
|
1794
|
+
async createProof(input) {
|
|
1795
|
+
const proof = await mod.createDpopProof(input.htm, input.htu, input.nonce ?? null);
|
|
1796
|
+
if (typeof proof !== "string" || proof.length === 0) {
|
|
1797
|
+
throw new Error("native DPoP signer returned an empty proof");
|
|
1798
|
+
}
|
|
1799
|
+
return proof;
|
|
1800
|
+
},
|
|
1801
|
+
async jktHint() {
|
|
1802
|
+
return mod.dpopJktThumbprint();
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// src/internal/webhook-verify.ts
|
|
1808
|
+
var RKSEC_PREFIX = "rksec_";
|
|
1809
|
+
var EXPECTED_KEY_LENGTH = 32;
|
|
1810
|
+
var DEFAULT_WEBHOOK_TOLERANCE = 300;
|
|
1811
|
+
var MAX_WEBHOOK_TOLERANCE = 600;
|
|
1812
|
+
function getHeader(headers, name) {
|
|
1813
|
+
const lower = name.toLowerCase();
|
|
1814
|
+
for (const key of Object.keys(headers)) {
|
|
1815
|
+
if (key.toLowerCase() === lower) {
|
|
1816
|
+
const value = headers[key];
|
|
1817
|
+
const v = Array.isArray(value) ? value[0] : value;
|
|
1818
|
+
return v?.trim();
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return void 0;
|
|
1822
|
+
}
|
|
1823
|
+
function base64ToBytes(input, urlSafe) {
|
|
1824
|
+
try {
|
|
1825
|
+
let s = urlSafe ? input.replace(/-/g, "+").replace(/_/g, "/") : input;
|
|
1826
|
+
const pad = s.length % 4;
|
|
1827
|
+
if (pad) s += "=".repeat(4 - pad);
|
|
1828
|
+
const bin = atob(s);
|
|
1829
|
+
const out = new Uint8Array(bin.length);
|
|
1830
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
1831
|
+
return out;
|
|
1832
|
+
} catch {
|
|
1833
|
+
return null;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function deriveKey(secret) {
|
|
1837
|
+
const encoded = secret.startsWith(RKSEC_PREFIX) ? secret.slice(RKSEC_PREFIX.length) : secret;
|
|
1838
|
+
const key = base64ToBytes(encoded, true);
|
|
1839
|
+
if (!key || key.length !== EXPECTED_KEY_LENGTH) return null;
|
|
1840
|
+
return key;
|
|
1841
|
+
}
|
|
1842
|
+
function timingSafeEqual(a, b) {
|
|
1843
|
+
let diff = a.length ^ b.length;
|
|
1844
|
+
const len = Math.min(a.length, b.length);
|
|
1845
|
+
for (let i = 0; i < len; i++) diff |= a[i] ^ b[i];
|
|
1846
|
+
return diff === 0;
|
|
1847
|
+
}
|
|
1848
|
+
function toArrayBuffer(u) {
|
|
1849
|
+
const out = new ArrayBuffer(u.byteLength);
|
|
1850
|
+
new Uint8Array(out).set(u);
|
|
1851
|
+
return out;
|
|
1852
|
+
}
|
|
1853
|
+
async function hmacSha256(key, data) {
|
|
1854
|
+
const subtle = globalThis.crypto?.subtle;
|
|
1855
|
+
if (!subtle) {
|
|
1856
|
+
throw new Error("WebCrypto subtle is unavailable \u2014 a crypto polyfill (e.g. expo-crypto) is required");
|
|
1857
|
+
}
|
|
1858
|
+
const cryptoKey = await subtle.importKey(
|
|
1859
|
+
"raw",
|
|
1860
|
+
toArrayBuffer(key),
|
|
1861
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1862
|
+
false,
|
|
1863
|
+
["sign"]
|
|
1864
|
+
);
|
|
1865
|
+
const sig = await subtle.sign("HMAC", cryptoKey, toArrayBuffer(data));
|
|
1866
|
+
return new Uint8Array(sig);
|
|
1867
|
+
}
|
|
1868
|
+
function toBytes(body) {
|
|
1869
|
+
return typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
1870
|
+
}
|
|
1871
|
+
function bytesToUtf8(body) {
|
|
1872
|
+
return typeof body === "string" ? body : new TextDecoder("utf-8", { fatal: false }).decode(body);
|
|
1873
|
+
}
|
|
1874
|
+
async function verifyWebhook(body, headers, secret, tolerance = DEFAULT_WEBHOOK_TOLERANCE) {
|
|
1875
|
+
const signature = getHeader(headers, "webhook-signature");
|
|
1876
|
+
const timestampStr = getHeader(headers, "webhook-timestamp");
|
|
1877
|
+
const webhookId = getHeader(headers, "webhook-id");
|
|
1878
|
+
if (!signature || !timestampStr || !webhookId) {
|
|
1879
|
+
return { ok: false, error: "missing_header" };
|
|
1880
|
+
}
|
|
1881
|
+
if (!/^-?\d+$/.test(timestampStr)) {
|
|
1882
|
+
return { ok: false, error: "missing_header" };
|
|
1883
|
+
}
|
|
1884
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
1885
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1886
|
+
const diff = now - timestamp;
|
|
1887
|
+
if (diff > tolerance) return { ok: false, error: "timestamp_too_old" };
|
|
1888
|
+
if (diff < -tolerance) return { ok: false, error: "timestamp_too_new" };
|
|
1889
|
+
const key = deriveKey(secret);
|
|
1890
|
+
if (!key) return { ok: false, error: "invalid_secret" };
|
|
1891
|
+
const bodyString = bytesToUtf8(body);
|
|
1892
|
+
const signedContent = `${webhookId}.${timestampStr}.${bodyString}`;
|
|
1893
|
+
const expected = await hmacSha256(key, toBytes(signedContent));
|
|
1894
|
+
let matched = false;
|
|
1895
|
+
for (const raw of signature.split(" ")) {
|
|
1896
|
+
const entry = raw.trim();
|
|
1897
|
+
if (!entry.startsWith("v1,")) continue;
|
|
1898
|
+
const candidate = base64ToBytes(entry.slice(3), false);
|
|
1899
|
+
if (!candidate || candidate.length !== EXPECTED_KEY_LENGTH) continue;
|
|
1900
|
+
if (timingSafeEqual(expected, candidate)) {
|
|
1901
|
+
matched = true;
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
if (!matched) return { ok: false, error: "invalid_signature" };
|
|
1906
|
+
let payload;
|
|
1907
|
+
try {
|
|
1908
|
+
payload = JSON.parse(bodyString);
|
|
1909
|
+
if (typeof payload !== "object" || payload === null) return { ok: false, error: "invalid_body" };
|
|
1910
|
+
} catch {
|
|
1911
|
+
return { ok: false, error: "invalid_body" };
|
|
1912
|
+
}
|
|
1913
|
+
const deliveryId = getHeader(headers, "x-rakomi-delivery-id") ?? webhookId;
|
|
1914
|
+
const eventType = getHeader(headers, "x-rakomi-event") ?? "";
|
|
1915
|
+
return { ok: true, data: { webhookId, deliveryId, eventType, timestamp, payload } };
|
|
1916
|
+
}
|
|
1917
|
+
async function verifyPublisherWebhook(body, headers, secret, tolerance = DEFAULT_WEBHOOK_TOLERANCE) {
|
|
1918
|
+
if (!secret.startsWith(RKSEC_PREFIX)) return { ok: false, error: "invalid_secret" };
|
|
1919
|
+
const clamped = Math.min(Math.max(0, tolerance), MAX_WEBHOOK_TOLERANCE);
|
|
1920
|
+
return verifyWebhook(body, headers, secret, clamped);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// src/hooks/use-submit-oauth-tokens.ts
|
|
1924
|
+
function useSubmitOAuthTokens() {
|
|
1925
|
+
const ctx = useRakomiContext();
|
|
1926
|
+
return async (tokens) => {
|
|
1927
|
+
const nonce = await ctx.beginAuthFlow();
|
|
1928
|
+
await ctx.submitOAuthTokens(tokens, nonce);
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
function useMagicLink() {
|
|
1932
|
+
const ctx = useRakomiContext();
|
|
1933
|
+
return {
|
|
1934
|
+
request: async (email) => {
|
|
1935
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { method: "magic_link" } });
|
|
1936
|
+
return requestMagicLink({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email });
|
|
1937
|
+
},
|
|
1938
|
+
verify: async (token) => {
|
|
1939
|
+
const result = await verifyMagicLink({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, token });
|
|
1940
|
+
if (result.ok && "tokens" in result) {
|
|
1941
|
+
const nonce = await ctx.beginAuthFlow();
|
|
1942
|
+
await ctx.submitOAuthTokens(result.tokens, nonce);
|
|
1943
|
+
} else if (!result.ok && "error" in result) {
|
|
1944
|
+
emitError(ctx.events, result.error);
|
|
1945
|
+
}
|
|
1946
|
+
return result;
|
|
1947
|
+
}
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
function useEmailOtp() {
|
|
1951
|
+
const ctx = useRakomiContext();
|
|
1952
|
+
return {
|
|
1953
|
+
request: async (email, mode) => {
|
|
1954
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { method: "email_otp", mode } });
|
|
1955
|
+
return requestEmailOtp({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email, mode });
|
|
1956
|
+
},
|
|
1957
|
+
verify: async (email, code) => {
|
|
1958
|
+
const result = await verifyEmailOtp({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email, code });
|
|
1959
|
+
if (result.ok && "tokens" in result) {
|
|
1960
|
+
const nonce = await ctx.beginAuthFlow();
|
|
1961
|
+
await ctx.submitOAuthTokens(result.tokens, nonce);
|
|
1962
|
+
} else if (!result.ok && "error" in result) {
|
|
1963
|
+
emitError(ctx.events, result.error);
|
|
1964
|
+
}
|
|
1965
|
+
return result;
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
function useRegister() {
|
|
1970
|
+
const ctx = useRakomiContext();
|
|
1971
|
+
return {
|
|
1972
|
+
register: async (email, password) => {
|
|
1973
|
+
ctx.events.push({ type: "sign_in_attempted", severity: "info", metadata: { method: "register" } });
|
|
1974
|
+
return register({ http: ctx.http, baseUrl: ctx.baseUrl, clientId: ctx.publishableKey, email, password });
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
function emitError(events, error) {
|
|
1979
|
+
events.push({ type: "sign_in_failed", severity: "warning", error });
|
|
1980
|
+
}
|
|
1981
|
+
function useMfa() {
|
|
1982
|
+
const ctx = useRakomiContext();
|
|
1983
|
+
return {
|
|
1984
|
+
verifyTotp: async (challengeToken, code, endpoint) => {
|
|
1985
|
+
const url = endpoint ?? `${ctx.baseUrl}/v1/auth/mfa/totp/verify`;
|
|
1986
|
+
const result = await verifyTotp({ http: ctx.http, endpoint: url, challengeToken, code });
|
|
1987
|
+
if (result.ok) {
|
|
1988
|
+
const nonce = await ctx.beginAuthFlow();
|
|
1989
|
+
await ctx.submitOAuthTokens({
|
|
1990
|
+
access_token: result.tokens.access_token,
|
|
1991
|
+
token_type: result.tokens.token_type,
|
|
1992
|
+
expires_in: result.tokens.expires_in,
|
|
1993
|
+
refresh_token: result.tokens.refresh_token
|
|
1994
|
+
}, nonce);
|
|
1995
|
+
ctx.events.push({ type: "signed_in", severity: "info", metadata: { method: "mfa_totp" } });
|
|
1996
|
+
return { ok: true };
|
|
1997
|
+
}
|
|
1998
|
+
emitError(ctx.events, result.error);
|
|
1999
|
+
return { ok: false, error: result.error };
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
export { DEFAULT_WEBHOOK_TOLERANCE, DpopSession, Feature, MAX_WEBHOOK_TOLERANCE, Protect, RakomiProvider, SignIn, SignUp, SignedIn, SignedOut, UserButton, UserProfile, createDefaultExpoAdapter, createDpopSession, createNativeDpopProver, createRnHttpClient, startSocialSignIn, useAnonymousSignin, useAuth, useAuthConfig, useBaasPlans, useBaasSubscription, useBranding, useEmailOtp, useFlag, useLinkedAccounts, useMagicLink, useMfa, useOrganization, useOrganizationList, useRegister, useSession, useSubmitOAuthTokens, useTranslation, useUser, verifyPublisherWebhook, verifyWebhook };
|