@rakomi/react-native 0.5.0 → 0.7.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/README.md CHANGED
@@ -211,6 +211,7 @@ have to branch:
211
211
  | What happened | Code your UI handles |
212
212
  |---|---|
213
213
  | The user dismissed the sheet | `PASSKEY_CEREMONY_CANCELLED` |
214
+ | Nobody finished the sheet in time | `PASSKEY_CEREMONY_TIMED_OUT` — retryable; the SDK asked your module to cancel the request |
214
215
  | The device cannot do passkeys, or no adapter is wired | `PASSKEY_NOT_SUPPORTED` |
215
216
  | The credential is already registered for this user | `PASSKEY_ALREADY_REGISTERED` |
216
217
  | The action needs a fresh re-authentication first | `PASSKEY_STEP_UP_REQUIRED` |
@@ -1,6 +1,6 @@
1
1
  THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
2
2
 
3
- For the npm package: @rakomi/react-native@0.5.0
3
+ For the npm package: @rakomi/react-native@0.7.0
4
4
 
5
5
  This package's published bundle (`dist/`) INLINES the third-party components listed below.
6
6
  Their copyright and permission notices are reproduced verbatim, as required by their licenses
package/dist/index.cjs CHANGED
@@ -212,8 +212,8 @@ function createRnHttpClient(options = {}) {
212
212
  }
213
213
  };
214
214
  }
215
- function err(cls, message) {
216
- return { ok: false, error: { class: cls, code: `auth/${cls}`, message } };
215
+ function err(cls, message, cause) {
216
+ return { ok: false, error: { class: cls, code: `auth/${cls}`, message, ...cause && { cause } } };
217
217
  }
218
218
  function proverUnavailable(message = "DPoP prover unavailable") {
219
219
  return err("dpop_prover_unavailable", message);
@@ -271,32 +271,30 @@ async function refreshWithDpop(input) {
271
271
  return mapFailure(result, true);
272
272
  }
273
273
  function mapFailure(result, attachedProof) {
274
- const message = result.error.message ?? "refresh failed";
274
+ const message = "message" in result.error ? result.error.message : "refresh failed";
275
275
  if (attachedProof && (result.dpopProofRejected || result.dpopNonce !== void 0)) {
276
- return err("invalid_dpop_proof", message);
276
+ return err("invalid_dpop_proof", message, result.error);
277
277
  }
278
- if (result.error.code === "REFRESH_FAILED" && result.error.reason === "network") {
279
- return err("network", message);
278
+ if (result.error.code === "REFRESH_FAILED" && (result.error.reason === "revoked" || result.error.reason === "expired")) {
279
+ return err("invalid_refresh_token", message, result.error);
280
280
  }
281
- return err("invalid_refresh_token", message);
281
+ return err("network", message, result.error);
282
282
  }
283
283
 
284
284
  // src/internal/token-runtime.ts
285
- var RETRY_DELAYS_MS = [2e3, 8e3, 3e4];
285
+ function originOf(url) {
286
+ try {
287
+ return new URL(url).origin;
288
+ } catch {
289
+ return void 0;
290
+ }
291
+ }
286
292
  function bytesToBase64Url(bytes) {
287
293
  let binary = "";
288
294
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
289
295
  const std = btoa(binary);
290
296
  return std.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
291
297
  }
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
298
  var RAKOMI_PLATFORM_ISSUER_DEFAULT = "https://api.rakomi.com";
301
299
  var RAKOMI_PLATFORM_AUDIENCE_DEFAULT = "https://api.rakomi.com";
302
300
  function deriveDefaultIssuer(options) {
@@ -335,7 +333,9 @@ var TokenRuntime = class _TokenRuntime {
335
333
  storageKeyResolved = null;
336
334
  jwksKeyResolved = null;
337
335
  jwksCache = null;
338
- jwksUri;
336
+ explicitJwksUri;
337
+ deploymentBaseUrl;
338
+ jwksUriResolver;
339
339
  jwksTtlMs;
340
340
  expectedIssuer;
341
341
  expectedAudience;
@@ -347,8 +347,8 @@ var TokenRuntime = class _TokenRuntime {
347
347
  refreshAttempts = 0;
348
348
  retryTimer = null;
349
349
  destroyed = false;
350
- offlineStaleCycles = 0;
351
- static MAX_OFFLINE_STALE_CYCLES = 3;
350
+ lastRefreshError = null;
351
+ restorePending = false;
352
352
  submitNonces = /* @__PURE__ */ new Map();
353
353
  static NONCE_TTL_MS = 5 * 60 * 1e3;
354
354
  static MAX_PENDING_NONCES = 100;
@@ -369,7 +369,18 @@ var TokenRuntime = class _TokenRuntime {
369
369
  this.now = options.now ?? Date.now;
370
370
  this.setTimeoutFn = options.setTimeout ?? globalThis.setTimeout.bind(globalThis);
371
371
  this.clearTimeoutFn = options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
372
- this.jwksUri = options.jwksUri ?? deriveDefaultJwksUri(options.tokenEndpoint);
372
+ this.explicitJwksUri = options.jwksUri;
373
+ this.deploymentBaseUrl = options.baseUrl ?? originOf(options.tokenEndpoint);
374
+ this.jwksUriResolver = sdkCore.createIssuerJwksUriResolver({
375
+ fetchDiscoveryDocument: async (url) => {
376
+ const response = await this.http.fetch(url, { method: "GET", headers: { Accept: "application/json" } });
377
+ if (response.status === 404) return null;
378
+ if (!response.ok) {
379
+ throw new Error(`discovery fetch failed: HTTP ${response.status}`);
380
+ }
381
+ return response.json();
382
+ }
383
+ });
373
384
  this.jwksTtlMs = options.jwksTtlMs ?? 24 * 60 * 60 * 1e3;
374
385
  this.expectedIssuer = options.expectedIssuer ?? deriveDefaultIssuer({ baseUrl: options.baseUrl, tokenEndpoint: options.tokenEndpoint });
375
386
  this.expectedAudience = options.expectedAudience ?? RAKOMI_PLATFORM_AUDIENCE_DEFAULT;
@@ -468,7 +479,10 @@ var TokenRuntime = class _TokenRuntime {
468
479
  }
469
480
  const success = await this.performRefresh();
470
481
  if (!success || !this.accessToken || this.expiresAtMs === null) {
471
- return { ok: false, error: { code: "REFRESH_FAILED", reason: "expired", message: "No valid token available" } };
482
+ return {
483
+ ok: false,
484
+ error: this.lastRefreshError ?? { code: "REFRESH_FAILED", reason: "expired", message: "No valid token available" }
485
+ };
472
486
  }
473
487
  const expiresIn = Math.max(0, Math.floor((this.expiresAtMs - this.now()) / 1e3));
474
488
  return { ok: true, token: this.accessToken, tokenType: "Bearer", headers: {}, expiresIn };
@@ -492,6 +506,10 @@ var TokenRuntime = class _TokenRuntime {
492
506
  */
493
507
  async refreshOnForeground() {
494
508
  if (this.refreshInFlight) return;
509
+ if (this.restorePending) {
510
+ await this.performRefresh({ duringRestore: true });
511
+ return;
512
+ }
495
513
  if (!this.currentUser) return;
496
514
  this.dispatch({ type: "REFRESH_START" });
497
515
  await this.performRefresh();
@@ -506,6 +524,8 @@ var TokenRuntime = class _TokenRuntime {
506
524
  this.currentSession = null;
507
525
  this.refreshInFlight = null;
508
526
  this.refreshAttempts = 0;
527
+ this.lastRefreshError = null;
528
+ this.restorePending = false;
509
529
  if (this.retryTimer) {
510
530
  this.clearTimeoutFn(this.retryTimer);
511
531
  this.retryTimer = null;
@@ -526,18 +546,20 @@ var TokenRuntime = class _TokenRuntime {
526
546
  *
527
547
  * uses `jose.createLocalJWKSet` so verification is purely local once
528
548
  * the JWKS document has been fetched and cached. The cache is preloaded from `KeyValueStore`
529
- * on cold-start (slot `jwks_cache`) and refreshed on TTL miss; on persistent network failure
549
+ * on cold-start (slot `jwks_cache`) and refreshed on TTL miss, or once when the token's `kid` is not in
550
+ * the cached set (a rotated key; rate-limited by a cooldown); on persistent network failure
530
551
  * the stale-while-error fallback keeps verification working until the cached doc itself expires.
531
552
  */
532
553
  async verifyAccessToken(token) {
533
554
  try {
534
555
  const cache = await this.getOrCreateJwksCache();
535
- const keySet = await cache.getKeySet();
536
- const result = await jose.jwtVerify(token, keySet, {
537
- issuer: this.expectedIssuer,
538
- audience: this.expectedAudience,
539
- algorithms: ["RS256"]
540
- });
556
+ const result = await cache.withKeyRotationRetry(
557
+ (keySet) => jose.jwtVerify(token, keySet, {
558
+ issuer: this.expectedIssuer,
559
+ audience: this.expectedAudience,
560
+ algorithms: [...sdkCore.ALLOWED_SIGNING_ALGORITHMS]
561
+ })
562
+ );
541
563
  return { ok: true, payload: result.payload };
542
564
  } catch (err2) {
543
565
  return { ok: false, reason: err2 instanceof Error ? err2.message : "verify failed" };
@@ -565,7 +587,7 @@ var TokenRuntime = class _TokenRuntime {
565
587
  * simply a dev pointed at a different host between builds), a slot keyed by tenant alone would
566
588
  * silently reuse a PREVIOUS host's persisted document as this instance's "fresh" cache on cold
567
589
  * start — cross-host reuse this offline-verification path must never do. The host segment is not
568
- * secret (it is the caller's own `jwksUri` configuration), so a plain suffix is enough; this only
590
+ * secret (it is the caller's own `jwksUri` or expected-issuer configuration), so a plain suffix is enough; this only
569
591
  * needs to be a stable, DIFFERENT key per host, not a cryptographically-opaque one.
570
592
  */
571
593
  async resolveJwksKey() {
@@ -577,11 +599,25 @@ var TokenRuntime = class _TokenRuntime {
577
599
  }
578
600
  jwksHost() {
579
601
  try {
580
- return new URL(this.jwksUri).hostname;
602
+ if (this.explicitJwksUri !== void 0) return new URL(this.explicitJwksUri).hostname;
603
+ const issuer = this.expectedIssuer === void 0 ? "" : encodeURIComponent(this.expectedIssuer);
604
+ const origin = this.deploymentBaseUrl === void 0 ? "" : new URL(this.deploymentBaseUrl).hostname;
605
+ return `${origin}.i.${issuer}`;
581
606
  } catch {
582
607
  return null;
583
608
  }
584
609
  }
610
+ /** The JWKS document URL: the explicit override, or the expected issuer's discovered `jwks_uri`. */
611
+ async currentJwksUri() {
612
+ if (this.explicitJwksUri !== void 0) return this.explicitJwksUri;
613
+ if (this.expectedIssuer === void 0) {
614
+ throw new Error("no expected issuer to discover signing keys from");
615
+ }
616
+ return this.jwksUriResolver.resolve(
617
+ this.expectedIssuer,
618
+ sdkCore.discoveryOriginFor(this.expectedIssuer, this.deploymentBaseUrl)
619
+ );
620
+ }
585
621
  async getOrCreateJwksCache() {
586
622
  if (this.jwksCache) return this.jwksCache;
587
623
  const key = await this.resolveJwksKey();
@@ -599,7 +635,10 @@ var TokenRuntime = class _TokenRuntime {
599
635
  this.jwksCache = sdkCore.createJwksCache({
600
636
  ttlMs: this.jwksTtlMs,
601
637
  fetchJwks: async () => {
602
- const response = await this.http.fetch(this.jwksUri, { method: "GET", headers: { Accept: "application/json" } });
638
+ const response = await this.http.fetch(await this.currentJwksUri(), {
639
+ method: "GET",
640
+ headers: { Accept: "application/json" }
641
+ });
603
642
  if (!response.ok) {
604
643
  throw new Error(`JWKS fetch failed: HTTP ${response.status}`);
605
644
  }
@@ -678,10 +717,7 @@ var TokenRuntime = class _TokenRuntime {
678
717
  if (!dResult.ok) {
679
718
  return this.handleDpopRefreshFailure(dResult.error, opts.duringRestore ?? false);
680
719
  }
681
- this.emitEvent?.({ type: "refresh_succeeded" });
682
- this.refreshAttempts = 0;
683
- this.offlineStaleCycles = 0;
684
- await this.setTokens(dResult.tokens, opts.duringRestore ? "restore" : "refresh");
720
+ await this.completeRefresh(dResult.tokens, opts.duringRestore ?? false);
685
721
  return true;
686
722
  }
687
723
  const result = await sdkCore.refreshAccessToken({
@@ -698,12 +734,26 @@ var TokenRuntime = class _TokenRuntime {
698
734
  }
699
735
  return this.scheduleRetry(result.error, opts.duringRestore ?? false);
700
736
  }
701
- this.emitEvent?.({ type: "refresh_succeeded" });
702
- this.refreshAttempts = 0;
703
- this.offlineStaleCycles = 0;
704
- await this.setTokens(result.tokens, opts.duringRestore ? "restore" : "refresh");
737
+ await this.completeRefresh(result.tokens, opts.duringRestore ?? false);
705
738
  return true;
706
739
  }
740
+ /**
741
+ * Commit a successful refresh. A restore that only succeeded on a later retry (the first
742
+ * attempt at cold start hit a transient failure and `restore()` already returned) is completed
743
+ * here: the tokens are applied as a restore and RESTORE_SUCCESS loads the user, instead of a
744
+ * REFRESH_SUCCESS that would mark the machine authenticated with no user.
745
+ */
746
+ async completeRefresh(tokens, duringRestore) {
747
+ const resumedRestore = this.restorePending;
748
+ this.restorePending = false;
749
+ this.refreshAttempts = 0;
750
+ this.lastRefreshError = null;
751
+ this.emitEvent?.({ type: "refresh_succeeded" });
752
+ await this.setTokens(tokens, duringRestore || resumedRestore ? "restore" : "refresh");
753
+ if (resumedRestore && this.currentUser && this.currentSession) {
754
+ this.dispatch({ type: "RESTORE_SUCCESS", user: this.currentUser, session: this.currentSession });
755
+ }
756
+ }
707
757
  isAuthError(error) {
708
758
  return error.code === "REFRESH_FAILED" && (error.reason === "expired" || error.reason === "revoked");
709
759
  }
@@ -716,7 +766,7 @@ var TokenRuntime = class _TokenRuntime {
716
766
  async handleDpopRefreshFailure(error, duringRestore) {
717
767
  this.emitEvent?.({ type: "dpop_refresh_failed", metadata: { code: error.code } });
718
768
  if (error.class === "network") {
719
- return this.scheduleRetry({ code: "REFRESH_FAILED", reason: "network", message: error.message }, duringRestore);
769
+ return this.scheduleRetry(error.cause ?? { code: "REFRESH_FAILED", reason: "network", message: error.message }, duringRestore);
720
770
  }
721
771
  if (error.class === "dpop_prover_unavailable") {
722
772
  this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "dpop_prover_unavailable" } });
@@ -725,36 +775,53 @@ var TokenRuntime = class _TokenRuntime {
725
775
  await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: error.message });
726
776
  return false;
727
777
  }
778
+ /**
779
+ * Back off and retry a transient refresh failure. The session is never cleared here: when the
780
+ * budget is spent the typed transient error is surfaced (state `offline_stale` when signed in)
781
+ * and the stored refresh token waits for the next foreground / reconnect / `getToken()`.
782
+ */
728
783
  scheduleRetry(error, duringRestore) {
729
- if (this.refreshAttempts >= RETRY_DELAYS_MS.length) {
730
- if (!duringRestore && this.accessToken && this.expiresAtMs !== null && this.now() < this.expiresAtMs) {
731
- this.refreshAttempts = 0;
732
- this.offlineStaleCycles += 1;
733
- if (this.offlineStaleCycles >= _TokenRuntime.MAX_OFFLINE_STALE_CYCLES) {
734
- this.offlineStaleCycles = 0;
735
- void this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Refresh chronically failing \u2014 session lost" });
736
- return false;
737
- }
784
+ const transient = toTransientError(error);
785
+ this.lastRefreshError = transient;
786
+ if (duringRestore) this.restorePending = true;
787
+ const restoring = this.restorePending;
788
+ const delay = sdkCore.refreshRetryDelayMs(this.refreshAttempts, transient.code === "REFRESH_FAILED" ? transient.retryAfterMs : void 0);
789
+ if (delay === void 0) {
790
+ this.refreshAttempts = 0;
791
+ this.emitEvent?.({
792
+ type: "refresh_failed",
793
+ metadata: { reason: "retries_exhausted", ...transient.code === "REFRESH_FAILED" && transient.status !== void 0 && { status: transient.status } }
794
+ });
795
+ if (!restoring) {
796
+ this.dispatch({ type: "REFRESH_NETWORK_ERROR", error: transient });
738
797
  this.dispatch({ type: "OFFLINE_STALE" });
739
- return false;
740
798
  }
741
- void this.clear({ code: "REFRESH_FAILED", reason: "network", message: "Refresh exhausted retries" });
742
799
  return false;
743
800
  }
744
- const delay = RETRY_DELAYS_MS[this.refreshAttempts];
745
801
  this.refreshAttempts++;
746
802
  this.emitEvent?.({ type: "network_retry", metadata: { delayMs: delay, attempt: this.refreshAttempts } });
747
- if (!duringRestore) {
748
- this.dispatch({ type: "REFRESH_NETWORK_ERROR", error });
803
+ if (!restoring) {
804
+ this.dispatch({ type: "REFRESH_NETWORK_ERROR", error: transient });
749
805
  }
750
806
  if (this.retryTimer) this.clearTimeoutFn(this.retryTimer);
751
807
  this.retryTimer = this.setTimeoutFn(() => {
752
808
  this.retryTimer = null;
753
- void this.performRefresh();
809
+ void this.performRefresh({ duringRestore: this.restorePending });
754
810
  }, delay);
755
811
  return false;
756
812
  }
757
813
  };
814
+ function toTransientError(error) {
815
+ if (error.code === "REFRESH_FAILED" && error.reason === "network") return error;
816
+ return {
817
+ code: "REFRESH_FAILED",
818
+ reason: "network",
819
+ message: sdkCore.getErrorMessage(error),
820
+ ..."status" in error && error.status !== void 0 && { status: error.status },
821
+ ..."oauthError" in error && typeof error.oauthError === "string" && { oauthError: error.oauthError },
822
+ ...error.requestId !== void 0 && { requestId: error.requestId }
823
+ };
824
+ }
758
825
 
759
826
  // src/native/expo-adapter.ts
760
827
  function createDefaultExpoAdapter(options = {}) {
package/dist/index.d.cts CHANGED
@@ -455,7 +455,13 @@ interface UserProfileProps {
455
455
  }
456
456
  declare function UserProfile(props: UserProfileProps): ReactNode;
457
457
 
458
- /** RFC 9449 / three-class client-side refresh failure taxonomy (+ network). */
458
+ /**
459
+ * RFC 9449 / three-class client-side refresh failure taxonomy (+ network).
460
+ *
461
+ * `network` is every transient failure — no connection, a 5xx, 429 or 408, an unreadable
462
+ * response — and also any rejection that is not definitive. The session is kept and retried;
463
+ * only `invalid_refresh_token` and `invalid_dpop_proof` end it.
464
+ */
459
465
  type DpopRefreshErrorClass = 'dpop_prover_unavailable' | 'invalid_dpop_proof' | 'invalid_refresh_token' | 'network';
460
466
  interface DpopRefreshError {
461
467
  /** Stable machine token for the failure class. */
@@ -463,6 +469,8 @@ interface DpopRefreshError {
463
469
  /** Full `auth/<class>` code string — parity with `@rakomi/node`'s surfacing. */
464
470
  code: `auth/${DpopRefreshErrorClass}`;
465
471
  message: string;
472
+ /** The underlying token-endpoint failure (HTTP status, OAuth `error`, `Retry-After`), when there was one. */
473
+ cause?: AuthError;
466
474
  }
467
475
  type DpopRefreshResult = {
468
476
  ok: true;
package/dist/index.d.ts CHANGED
@@ -455,7 +455,13 @@ interface UserProfileProps {
455
455
  }
456
456
  declare function UserProfile(props: UserProfileProps): ReactNode;
457
457
 
458
- /** RFC 9449 / three-class client-side refresh failure taxonomy (+ network). */
458
+ /**
459
+ * RFC 9449 / three-class client-side refresh failure taxonomy (+ network).
460
+ *
461
+ * `network` is every transient failure — no connection, a 5xx, 429 or 408, an unreadable
462
+ * response — and also any rejection that is not definitive. The session is kept and retried;
463
+ * only `invalid_refresh_token` and `invalid_dpop_proof` end it.
464
+ */
459
465
  type DpopRefreshErrorClass = 'dpop_prover_unavailable' | 'invalid_dpop_proof' | 'invalid_refresh_token' | 'network';
460
466
  interface DpopRefreshError {
461
467
  /** Stable machine token for the failure class. */
@@ -463,6 +469,8 @@ interface DpopRefreshError {
463
469
  /** Full `auth/<class>` code string — parity with `@rakomi/node`'s surfacing. */
464
470
  code: `auth/${DpopRefreshErrorClass}`;
465
471
  message: string;
472
+ /** The underlying token-endpoint failure (HTTP status, OAuth `error`, `Retry-After`), when there was one. */
473
+ cause?: AuthError;
466
474
  }
467
475
  type DpopRefreshResult = {
468
476
  ok: true;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, useMemo, useReducer, useRef, useEffect, useState, useCallback, useContext } from 'react';
2
- import { createAuthorizationEndpointCache, EventLog, reduce, INITIAL_SNAPSHOT, createTranslator, isSignedIn, hasPermission, hasRole, extractOrgClaims, parseAuthConfigResponse, generatePkce, issueState, buildAuthorizationUrl, consumeState, exchangeAuthCode, parseOAuthCallbackError, scorePassword, isSafeUrl, passkeyError, decodeUser, assertPasskey, stepUpWithPasskey, registerPasskey, listPasskeys, renamePasskey, deletePasskey, verifyMagicLink, requestMagicLink, verifyEmailOtp, requestEmailOtp, register, verifyTotp, MfaStepUpUnavailableError, extractRequestId, decodeSession, deriveTenantStorageKey, createJwksCache, refreshAccessToken, decodeJwtPayload, PasskeyCeremonyError, resolveExpectedIssuer } from '@rakomi/sdk-core';
2
+ import { createAuthorizationEndpointCache, EventLog, reduce, INITIAL_SNAPSHOT, createTranslator, isSignedIn, hasPermission, hasRole, extractOrgClaims, parseAuthConfigResponse, generatePkce, issueState, buildAuthorizationUrl, consumeState, exchangeAuthCode, parseOAuthCallbackError, scorePassword, isSafeUrl, passkeyError, decodeUser, assertPasskey, stepUpWithPasskey, registerPasskey, listPasskeys, renamePasskey, deletePasskey, verifyMagicLink, requestMagicLink, verifyEmailOtp, requestEmailOtp, register, verifyTotp, MfaStepUpUnavailableError, extractRequestId, createIssuerJwksUriResolver, decodeSession, ALLOWED_SIGNING_ALGORITHMS, deriveTenantStorageKey, discoveryOriginFor, createJwksCache, refreshAccessToken, refreshRetryDelayMs, decodeJwtPayload, PasskeyCeremonyError, resolveExpectedIssuer, getErrorMessage } from '@rakomi/sdk-core';
3
3
  export { MfaStepUpRequiredError, MfaStepUpUnavailableError, base64url, createAuthorizationEndpointCache, createTranslator, decodeJwtPayload, decodeSession, decodeUser, deriveAuthorizationEndpointFallback, generatePkce, getErrorMessage, hasPermission, hasRole, isSafeUrl, networkError, parseOAuthCallbackError, parseTokenEndpointError, scorePassword, selectPluralForm } from '@rakomi/sdk-core';
4
4
  import { jwtVerify } from 'jose';
5
5
  import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
@@ -211,8 +211,8 @@ function createRnHttpClient(options = {}) {
211
211
  }
212
212
  };
213
213
  }
214
- function err(cls, message) {
215
- return { ok: false, error: { class: cls, code: `auth/${cls}`, message } };
214
+ function err(cls, message, cause) {
215
+ return { ok: false, error: { class: cls, code: `auth/${cls}`, message, ...cause && { cause } } };
216
216
  }
217
217
  function proverUnavailable(message = "DPoP prover unavailable") {
218
218
  return err("dpop_prover_unavailable", message);
@@ -270,32 +270,30 @@ async function refreshWithDpop(input) {
270
270
  return mapFailure(result, true);
271
271
  }
272
272
  function mapFailure(result, attachedProof) {
273
- const message = result.error.message ?? "refresh failed";
273
+ const message = "message" in result.error ? result.error.message : "refresh failed";
274
274
  if (attachedProof && (result.dpopProofRejected || result.dpopNonce !== void 0)) {
275
- return err("invalid_dpop_proof", message);
275
+ return err("invalid_dpop_proof", message, result.error);
276
276
  }
277
- if (result.error.code === "REFRESH_FAILED" && result.error.reason === "network") {
278
- return err("network", message);
277
+ if (result.error.code === "REFRESH_FAILED" && (result.error.reason === "revoked" || result.error.reason === "expired")) {
278
+ return err("invalid_refresh_token", message, result.error);
279
279
  }
280
- return err("invalid_refresh_token", message);
280
+ return err("network", message, result.error);
281
281
  }
282
282
 
283
283
  // src/internal/token-runtime.ts
284
- var RETRY_DELAYS_MS = [2e3, 8e3, 3e4];
284
+ function originOf(url) {
285
+ try {
286
+ return new URL(url).origin;
287
+ } catch {
288
+ return void 0;
289
+ }
290
+ }
285
291
  function bytesToBase64Url(bytes) {
286
292
  let binary = "";
287
293
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
288
294
  const std = btoa(binary);
289
295
  return std.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
290
296
  }
291
- function deriveDefaultJwksUri(tokenEndpoint) {
292
- try {
293
- const url = new URL(tokenEndpoint);
294
- return `${url.origin}/.well-known/jwks.json`;
295
- } catch {
296
- return tokenEndpoint.replace(/\/[^/]*$/, "") + "/.well-known/jwks.json";
297
- }
298
- }
299
297
  var RAKOMI_PLATFORM_ISSUER_DEFAULT = "https://api.rakomi.com";
300
298
  var RAKOMI_PLATFORM_AUDIENCE_DEFAULT = "https://api.rakomi.com";
301
299
  function deriveDefaultIssuer(options) {
@@ -334,7 +332,9 @@ var TokenRuntime = class _TokenRuntime {
334
332
  storageKeyResolved = null;
335
333
  jwksKeyResolved = null;
336
334
  jwksCache = null;
337
- jwksUri;
335
+ explicitJwksUri;
336
+ deploymentBaseUrl;
337
+ jwksUriResolver;
338
338
  jwksTtlMs;
339
339
  expectedIssuer;
340
340
  expectedAudience;
@@ -346,8 +346,8 @@ var TokenRuntime = class _TokenRuntime {
346
346
  refreshAttempts = 0;
347
347
  retryTimer = null;
348
348
  destroyed = false;
349
- offlineStaleCycles = 0;
350
- static MAX_OFFLINE_STALE_CYCLES = 3;
349
+ lastRefreshError = null;
350
+ restorePending = false;
351
351
  submitNonces = /* @__PURE__ */ new Map();
352
352
  static NONCE_TTL_MS = 5 * 60 * 1e3;
353
353
  static MAX_PENDING_NONCES = 100;
@@ -368,7 +368,18 @@ var TokenRuntime = class _TokenRuntime {
368
368
  this.now = options.now ?? Date.now;
369
369
  this.setTimeoutFn = options.setTimeout ?? globalThis.setTimeout.bind(globalThis);
370
370
  this.clearTimeoutFn = options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
371
- this.jwksUri = options.jwksUri ?? deriveDefaultJwksUri(options.tokenEndpoint);
371
+ this.explicitJwksUri = options.jwksUri;
372
+ this.deploymentBaseUrl = options.baseUrl ?? originOf(options.tokenEndpoint);
373
+ this.jwksUriResolver = createIssuerJwksUriResolver({
374
+ fetchDiscoveryDocument: async (url) => {
375
+ const response = await this.http.fetch(url, { method: "GET", headers: { Accept: "application/json" } });
376
+ if (response.status === 404) return null;
377
+ if (!response.ok) {
378
+ throw new Error(`discovery fetch failed: HTTP ${response.status}`);
379
+ }
380
+ return response.json();
381
+ }
382
+ });
372
383
  this.jwksTtlMs = options.jwksTtlMs ?? 24 * 60 * 60 * 1e3;
373
384
  this.expectedIssuer = options.expectedIssuer ?? deriveDefaultIssuer({ baseUrl: options.baseUrl, tokenEndpoint: options.tokenEndpoint });
374
385
  this.expectedAudience = options.expectedAudience ?? RAKOMI_PLATFORM_AUDIENCE_DEFAULT;
@@ -467,7 +478,10 @@ var TokenRuntime = class _TokenRuntime {
467
478
  }
468
479
  const success = await this.performRefresh();
469
480
  if (!success || !this.accessToken || this.expiresAtMs === null) {
470
- return { ok: false, error: { code: "REFRESH_FAILED", reason: "expired", message: "No valid token available" } };
481
+ return {
482
+ ok: false,
483
+ error: this.lastRefreshError ?? { code: "REFRESH_FAILED", reason: "expired", message: "No valid token available" }
484
+ };
471
485
  }
472
486
  const expiresIn = Math.max(0, Math.floor((this.expiresAtMs - this.now()) / 1e3));
473
487
  return { ok: true, token: this.accessToken, tokenType: "Bearer", headers: {}, expiresIn };
@@ -491,6 +505,10 @@ var TokenRuntime = class _TokenRuntime {
491
505
  */
492
506
  async refreshOnForeground() {
493
507
  if (this.refreshInFlight) return;
508
+ if (this.restorePending) {
509
+ await this.performRefresh({ duringRestore: true });
510
+ return;
511
+ }
494
512
  if (!this.currentUser) return;
495
513
  this.dispatch({ type: "REFRESH_START" });
496
514
  await this.performRefresh();
@@ -505,6 +523,8 @@ var TokenRuntime = class _TokenRuntime {
505
523
  this.currentSession = null;
506
524
  this.refreshInFlight = null;
507
525
  this.refreshAttempts = 0;
526
+ this.lastRefreshError = null;
527
+ this.restorePending = false;
508
528
  if (this.retryTimer) {
509
529
  this.clearTimeoutFn(this.retryTimer);
510
530
  this.retryTimer = null;
@@ -525,18 +545,20 @@ var TokenRuntime = class _TokenRuntime {
525
545
  *
526
546
  * uses `jose.createLocalJWKSet` so verification is purely local once
527
547
  * the JWKS document has been fetched and cached. The cache is preloaded from `KeyValueStore`
528
- * on cold-start (slot `jwks_cache`) and refreshed on TTL miss; on persistent network failure
548
+ * on cold-start (slot `jwks_cache`) and refreshed on TTL miss, or once when the token's `kid` is not in
549
+ * the cached set (a rotated key; rate-limited by a cooldown); on persistent network failure
529
550
  * the stale-while-error fallback keeps verification working until the cached doc itself expires.
530
551
  */
531
552
  async verifyAccessToken(token) {
532
553
  try {
533
554
  const cache = await this.getOrCreateJwksCache();
534
- const keySet = await cache.getKeySet();
535
- const result = await jwtVerify(token, keySet, {
536
- issuer: this.expectedIssuer,
537
- audience: this.expectedAudience,
538
- algorithms: ["RS256"]
539
- });
555
+ const result = await cache.withKeyRotationRetry(
556
+ (keySet) => jwtVerify(token, keySet, {
557
+ issuer: this.expectedIssuer,
558
+ audience: this.expectedAudience,
559
+ algorithms: [...ALLOWED_SIGNING_ALGORITHMS]
560
+ })
561
+ );
540
562
  return { ok: true, payload: result.payload };
541
563
  } catch (err2) {
542
564
  return { ok: false, reason: err2 instanceof Error ? err2.message : "verify failed" };
@@ -564,7 +586,7 @@ var TokenRuntime = class _TokenRuntime {
564
586
  * simply a dev pointed at a different host between builds), a slot keyed by tenant alone would
565
587
  * silently reuse a PREVIOUS host's persisted document as this instance's "fresh" cache on cold
566
588
  * start — cross-host reuse this offline-verification path must never do. The host segment is not
567
- * secret (it is the caller's own `jwksUri` configuration), so a plain suffix is enough; this only
589
+ * secret (it is the caller's own `jwksUri` or expected-issuer configuration), so a plain suffix is enough; this only
568
590
  * needs to be a stable, DIFFERENT key per host, not a cryptographically-opaque one.
569
591
  */
570
592
  async resolveJwksKey() {
@@ -576,11 +598,25 @@ var TokenRuntime = class _TokenRuntime {
576
598
  }
577
599
  jwksHost() {
578
600
  try {
579
- return new URL(this.jwksUri).hostname;
601
+ if (this.explicitJwksUri !== void 0) return new URL(this.explicitJwksUri).hostname;
602
+ const issuer = this.expectedIssuer === void 0 ? "" : encodeURIComponent(this.expectedIssuer);
603
+ const origin = this.deploymentBaseUrl === void 0 ? "" : new URL(this.deploymentBaseUrl).hostname;
604
+ return `${origin}.i.${issuer}`;
580
605
  } catch {
581
606
  return null;
582
607
  }
583
608
  }
609
+ /** The JWKS document URL: the explicit override, or the expected issuer's discovered `jwks_uri`. */
610
+ async currentJwksUri() {
611
+ if (this.explicitJwksUri !== void 0) return this.explicitJwksUri;
612
+ if (this.expectedIssuer === void 0) {
613
+ throw new Error("no expected issuer to discover signing keys from");
614
+ }
615
+ return this.jwksUriResolver.resolve(
616
+ this.expectedIssuer,
617
+ discoveryOriginFor(this.expectedIssuer, this.deploymentBaseUrl)
618
+ );
619
+ }
584
620
  async getOrCreateJwksCache() {
585
621
  if (this.jwksCache) return this.jwksCache;
586
622
  const key = await this.resolveJwksKey();
@@ -598,7 +634,10 @@ var TokenRuntime = class _TokenRuntime {
598
634
  this.jwksCache = createJwksCache({
599
635
  ttlMs: this.jwksTtlMs,
600
636
  fetchJwks: async () => {
601
- const response = await this.http.fetch(this.jwksUri, { method: "GET", headers: { Accept: "application/json" } });
637
+ const response = await this.http.fetch(await this.currentJwksUri(), {
638
+ method: "GET",
639
+ headers: { Accept: "application/json" }
640
+ });
602
641
  if (!response.ok) {
603
642
  throw new Error(`JWKS fetch failed: HTTP ${response.status}`);
604
643
  }
@@ -677,10 +716,7 @@ var TokenRuntime = class _TokenRuntime {
677
716
  if (!dResult.ok) {
678
717
  return this.handleDpopRefreshFailure(dResult.error, opts.duringRestore ?? false);
679
718
  }
680
- this.emitEvent?.({ type: "refresh_succeeded" });
681
- this.refreshAttempts = 0;
682
- this.offlineStaleCycles = 0;
683
- await this.setTokens(dResult.tokens, opts.duringRestore ? "restore" : "refresh");
719
+ await this.completeRefresh(dResult.tokens, opts.duringRestore ?? false);
684
720
  return true;
685
721
  }
686
722
  const result = await refreshAccessToken({
@@ -697,12 +733,26 @@ var TokenRuntime = class _TokenRuntime {
697
733
  }
698
734
  return this.scheduleRetry(result.error, opts.duringRestore ?? false);
699
735
  }
700
- this.emitEvent?.({ type: "refresh_succeeded" });
701
- this.refreshAttempts = 0;
702
- this.offlineStaleCycles = 0;
703
- await this.setTokens(result.tokens, opts.duringRestore ? "restore" : "refresh");
736
+ await this.completeRefresh(result.tokens, opts.duringRestore ?? false);
704
737
  return true;
705
738
  }
739
+ /**
740
+ * Commit a successful refresh. A restore that only succeeded on a later retry (the first
741
+ * attempt at cold start hit a transient failure and `restore()` already returned) is completed
742
+ * here: the tokens are applied as a restore and RESTORE_SUCCESS loads the user, instead of a
743
+ * REFRESH_SUCCESS that would mark the machine authenticated with no user.
744
+ */
745
+ async completeRefresh(tokens, duringRestore) {
746
+ const resumedRestore = this.restorePending;
747
+ this.restorePending = false;
748
+ this.refreshAttempts = 0;
749
+ this.lastRefreshError = null;
750
+ this.emitEvent?.({ type: "refresh_succeeded" });
751
+ await this.setTokens(tokens, duringRestore || resumedRestore ? "restore" : "refresh");
752
+ if (resumedRestore && this.currentUser && this.currentSession) {
753
+ this.dispatch({ type: "RESTORE_SUCCESS", user: this.currentUser, session: this.currentSession });
754
+ }
755
+ }
706
756
  isAuthError(error) {
707
757
  return error.code === "REFRESH_FAILED" && (error.reason === "expired" || error.reason === "revoked");
708
758
  }
@@ -715,7 +765,7 @@ var TokenRuntime = class _TokenRuntime {
715
765
  async handleDpopRefreshFailure(error, duringRestore) {
716
766
  this.emitEvent?.({ type: "dpop_refresh_failed", metadata: { code: error.code } });
717
767
  if (error.class === "network") {
718
- return this.scheduleRetry({ code: "REFRESH_FAILED", reason: "network", message: error.message }, duringRestore);
768
+ return this.scheduleRetry(error.cause ?? { code: "REFRESH_FAILED", reason: "network", message: error.message }, duringRestore);
719
769
  }
720
770
  if (error.class === "dpop_prover_unavailable") {
721
771
  this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "dpop_prover_unavailable" } });
@@ -724,36 +774,53 @@ var TokenRuntime = class _TokenRuntime {
724
774
  await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: error.message });
725
775
  return false;
726
776
  }
777
+ /**
778
+ * Back off and retry a transient refresh failure. The session is never cleared here: when the
779
+ * budget is spent the typed transient error is surfaced (state `offline_stale` when signed in)
780
+ * and the stored refresh token waits for the next foreground / reconnect / `getToken()`.
781
+ */
727
782
  scheduleRetry(error, duringRestore) {
728
- if (this.refreshAttempts >= RETRY_DELAYS_MS.length) {
729
- if (!duringRestore && this.accessToken && this.expiresAtMs !== null && this.now() < this.expiresAtMs) {
730
- this.refreshAttempts = 0;
731
- this.offlineStaleCycles += 1;
732
- if (this.offlineStaleCycles >= _TokenRuntime.MAX_OFFLINE_STALE_CYCLES) {
733
- this.offlineStaleCycles = 0;
734
- void this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Refresh chronically failing \u2014 session lost" });
735
- return false;
736
- }
783
+ const transient = toTransientError(error);
784
+ this.lastRefreshError = transient;
785
+ if (duringRestore) this.restorePending = true;
786
+ const restoring = this.restorePending;
787
+ const delay = refreshRetryDelayMs(this.refreshAttempts, transient.code === "REFRESH_FAILED" ? transient.retryAfterMs : void 0);
788
+ if (delay === void 0) {
789
+ this.refreshAttempts = 0;
790
+ this.emitEvent?.({
791
+ type: "refresh_failed",
792
+ metadata: { reason: "retries_exhausted", ...transient.code === "REFRESH_FAILED" && transient.status !== void 0 && { status: transient.status } }
793
+ });
794
+ if (!restoring) {
795
+ this.dispatch({ type: "REFRESH_NETWORK_ERROR", error: transient });
737
796
  this.dispatch({ type: "OFFLINE_STALE" });
738
- return false;
739
797
  }
740
- void this.clear({ code: "REFRESH_FAILED", reason: "network", message: "Refresh exhausted retries" });
741
798
  return false;
742
799
  }
743
- const delay = RETRY_DELAYS_MS[this.refreshAttempts];
744
800
  this.refreshAttempts++;
745
801
  this.emitEvent?.({ type: "network_retry", metadata: { delayMs: delay, attempt: this.refreshAttempts } });
746
- if (!duringRestore) {
747
- this.dispatch({ type: "REFRESH_NETWORK_ERROR", error });
802
+ if (!restoring) {
803
+ this.dispatch({ type: "REFRESH_NETWORK_ERROR", error: transient });
748
804
  }
749
805
  if (this.retryTimer) this.clearTimeoutFn(this.retryTimer);
750
806
  this.retryTimer = this.setTimeoutFn(() => {
751
807
  this.retryTimer = null;
752
- void this.performRefresh();
808
+ void this.performRefresh({ duringRestore: this.restorePending });
753
809
  }, delay);
754
810
  return false;
755
811
  }
756
812
  };
813
+ function toTransientError(error) {
814
+ if (error.code === "REFRESH_FAILED" && error.reason === "network") return error;
815
+ return {
816
+ code: "REFRESH_FAILED",
817
+ reason: "network",
818
+ message: getErrorMessage(error),
819
+ ..."status" in error && error.status !== void 0 && { status: error.status },
820
+ ..."oauthError" in error && typeof error.oauthError === "string" && { oauthError: error.oauthError },
821
+ ...error.requestId !== void 0 && { requestId: error.requestId }
822
+ };
823
+ }
757
824
 
758
825
  // src/native/expo-adapter.ts
759
826
  function createDefaultExpoAdapter(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rakomi/react-native",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "React Native / Expo SDK for Rakomi authentication.",
5
5
  "keywords": [
6
6
  "rakomi",
@@ -83,11 +83,11 @@
83
83
  }
84
84
  },
85
85
  "devDependencies": {
86
- "@rakomi/react": "0.5.0"
86
+ "@rakomi/react": "0.7.0"
87
87
  },
88
88
  "dependencies": {
89
89
  "jose": "^6.2.3",
90
- "@rakomi/sdk-core": "^0.5.0"
90
+ "@rakomi/sdk-core": "^0.7.0"
91
91
  },
92
92
  "scripts": {
93
93
  "build": "NODE_OPTIONS=--max-old-space-size=4096 tsup",
package/sbom.cdx.json CHANGED
@@ -26,10 +26,10 @@
26
26
  },
27
27
  "component": {
28
28
  "type": "library",
29
- "bom-ref": "pkg:npm/%40rakomi%2Freact-native@0.5.0",
29
+ "bom-ref": "pkg:npm/%40rakomi%2Freact-native@0.7.0",
30
30
  "name": "@rakomi/react-native",
31
- "version": "0.5.0",
32
- "purl": "pkg:npm/%40rakomi%2Freact-native@0.5.0",
31
+ "version": "0.7.0",
32
+ "purl": "pkg:npm/%40rakomi%2Freact-native@0.7.0",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {
@@ -42,10 +42,10 @@
42
42
  "components": [
43
43
  {
44
44
  "type": "library",
45
- "bom-ref": "pkg:npm/@rakomi/sdk-core@0.5.0",
45
+ "bom-ref": "pkg:npm/@rakomi/sdk-core@0.7.0",
46
46
  "name": "@rakomi/sdk-core",
47
- "version": "0.5.0",
48
- "purl": "pkg:npm/@rakomi/sdk-core@0.5.0",
47
+ "version": "0.7.0",
48
+ "purl": "pkg:npm/@rakomi/sdk-core@0.7.0",
49
49
  "licenses": [
50
50
  {
51
51
  "license": {