@rakomi/react-native 0.6.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.
@@ -1,6 +1,6 @@
1
1
  THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
2
2
 
3
- For the npm package: @rakomi/react-native@0.6.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;
@@ -537,7 +557,7 @@ var TokenRuntime = class _TokenRuntime {
537
557
  (keySet) => jose.jwtVerify(token, keySet, {
538
558
  issuer: this.expectedIssuer,
539
559
  audience: this.expectedAudience,
540
- algorithms: ["RS256"]
560
+ algorithms: [...sdkCore.ALLOWED_SIGNING_ALGORITHMS]
541
561
  })
542
562
  );
543
563
  return { ok: true, payload: result.payload };
@@ -567,7 +587,7 @@ var TokenRuntime = class _TokenRuntime {
567
587
  * simply a dev pointed at a different host between builds), a slot keyed by tenant alone would
568
588
  * silently reuse a PREVIOUS host's persisted document as this instance's "fresh" cache on cold
569
589
  * start — cross-host reuse this offline-verification path must never do. The host segment is not
570
- * 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
571
591
  * needs to be a stable, DIFFERENT key per host, not a cryptographically-opaque one.
572
592
  */
573
593
  async resolveJwksKey() {
@@ -579,11 +599,25 @@ var TokenRuntime = class _TokenRuntime {
579
599
  }
580
600
  jwksHost() {
581
601
  try {
582
- 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}`;
583
606
  } catch {
584
607
  return null;
585
608
  }
586
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
+ }
587
621
  async getOrCreateJwksCache() {
588
622
  if (this.jwksCache) return this.jwksCache;
589
623
  const key = await this.resolveJwksKey();
@@ -601,7 +635,10 @@ var TokenRuntime = class _TokenRuntime {
601
635
  this.jwksCache = sdkCore.createJwksCache({
602
636
  ttlMs: this.jwksTtlMs,
603
637
  fetchJwks: async () => {
604
- 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
+ });
605
642
  if (!response.ok) {
606
643
  throw new Error(`JWKS fetch failed: HTTP ${response.status}`);
607
644
  }
@@ -680,10 +717,7 @@ var TokenRuntime = class _TokenRuntime {
680
717
  if (!dResult.ok) {
681
718
  return this.handleDpopRefreshFailure(dResult.error, opts.duringRestore ?? false);
682
719
  }
683
- this.emitEvent?.({ type: "refresh_succeeded" });
684
- this.refreshAttempts = 0;
685
- this.offlineStaleCycles = 0;
686
- await this.setTokens(dResult.tokens, opts.duringRestore ? "restore" : "refresh");
720
+ await this.completeRefresh(dResult.tokens, opts.duringRestore ?? false);
687
721
  return true;
688
722
  }
689
723
  const result = await sdkCore.refreshAccessToken({
@@ -700,12 +734,26 @@ var TokenRuntime = class _TokenRuntime {
700
734
  }
701
735
  return this.scheduleRetry(result.error, opts.duringRestore ?? false);
702
736
  }
703
- this.emitEvent?.({ type: "refresh_succeeded" });
704
- this.refreshAttempts = 0;
705
- this.offlineStaleCycles = 0;
706
- await this.setTokens(result.tokens, opts.duringRestore ? "restore" : "refresh");
737
+ await this.completeRefresh(result.tokens, opts.duringRestore ?? false);
707
738
  return true;
708
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
+ }
709
757
  isAuthError(error) {
710
758
  return error.code === "REFRESH_FAILED" && (error.reason === "expired" || error.reason === "revoked");
711
759
  }
@@ -718,7 +766,7 @@ var TokenRuntime = class _TokenRuntime {
718
766
  async handleDpopRefreshFailure(error, duringRestore) {
719
767
  this.emitEvent?.({ type: "dpop_refresh_failed", metadata: { code: error.code } });
720
768
  if (error.class === "network") {
721
- 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);
722
770
  }
723
771
  if (error.class === "dpop_prover_unavailable") {
724
772
  this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "dpop_prover_unavailable" } });
@@ -727,36 +775,53 @@ var TokenRuntime = class _TokenRuntime {
727
775
  await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: error.message });
728
776
  return false;
729
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
+ */
730
783
  scheduleRetry(error, duringRestore) {
731
- if (this.refreshAttempts >= RETRY_DELAYS_MS.length) {
732
- if (!duringRestore && this.accessToken && this.expiresAtMs !== null && this.now() < this.expiresAtMs) {
733
- this.refreshAttempts = 0;
734
- this.offlineStaleCycles += 1;
735
- if (this.offlineStaleCycles >= _TokenRuntime.MAX_OFFLINE_STALE_CYCLES) {
736
- this.offlineStaleCycles = 0;
737
- void this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Refresh chronically failing \u2014 session lost" });
738
- return false;
739
- }
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 });
740
797
  this.dispatch({ type: "OFFLINE_STALE" });
741
- return false;
742
798
  }
743
- void this.clear({ code: "REFRESH_FAILED", reason: "network", message: "Refresh exhausted retries" });
744
799
  return false;
745
800
  }
746
- const delay = RETRY_DELAYS_MS[this.refreshAttempts];
747
801
  this.refreshAttempts++;
748
802
  this.emitEvent?.({ type: "network_retry", metadata: { delayMs: delay, attempt: this.refreshAttempts } });
749
- if (!duringRestore) {
750
- this.dispatch({ type: "REFRESH_NETWORK_ERROR", error });
803
+ if (!restoring) {
804
+ this.dispatch({ type: "REFRESH_NETWORK_ERROR", error: transient });
751
805
  }
752
806
  if (this.retryTimer) this.clearTimeoutFn(this.retryTimer);
753
807
  this.retryTimer = this.setTimeoutFn(() => {
754
808
  this.retryTimer = null;
755
- void this.performRefresh();
809
+ void this.performRefresh({ duringRestore: this.restorePending });
756
810
  }, delay);
757
811
  return false;
758
812
  }
759
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
+ }
760
825
 
761
826
  // src/native/expo-adapter.ts
762
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;
@@ -536,7 +556,7 @@ var TokenRuntime = class _TokenRuntime {
536
556
  (keySet) => jwtVerify(token, keySet, {
537
557
  issuer: this.expectedIssuer,
538
558
  audience: this.expectedAudience,
539
- algorithms: ["RS256"]
559
+ algorithms: [...ALLOWED_SIGNING_ALGORITHMS]
540
560
  })
541
561
  );
542
562
  return { ok: true, payload: result.payload };
@@ -566,7 +586,7 @@ var TokenRuntime = class _TokenRuntime {
566
586
  * simply a dev pointed at a different host between builds), a slot keyed by tenant alone would
567
587
  * silently reuse a PREVIOUS host's persisted document as this instance's "fresh" cache on cold
568
588
  * start — cross-host reuse this offline-verification path must never do. The host segment is not
569
- * 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
570
590
  * needs to be a stable, DIFFERENT key per host, not a cryptographically-opaque one.
571
591
  */
572
592
  async resolveJwksKey() {
@@ -578,11 +598,25 @@ var TokenRuntime = class _TokenRuntime {
578
598
  }
579
599
  jwksHost() {
580
600
  try {
581
- 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}`;
582
605
  } catch {
583
606
  return null;
584
607
  }
585
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
+ }
586
620
  async getOrCreateJwksCache() {
587
621
  if (this.jwksCache) return this.jwksCache;
588
622
  const key = await this.resolveJwksKey();
@@ -600,7 +634,10 @@ var TokenRuntime = class _TokenRuntime {
600
634
  this.jwksCache = createJwksCache({
601
635
  ttlMs: this.jwksTtlMs,
602
636
  fetchJwks: async () => {
603
- 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
+ });
604
641
  if (!response.ok) {
605
642
  throw new Error(`JWKS fetch failed: HTTP ${response.status}`);
606
643
  }
@@ -679,10 +716,7 @@ var TokenRuntime = class _TokenRuntime {
679
716
  if (!dResult.ok) {
680
717
  return this.handleDpopRefreshFailure(dResult.error, opts.duringRestore ?? false);
681
718
  }
682
- this.emitEvent?.({ type: "refresh_succeeded" });
683
- this.refreshAttempts = 0;
684
- this.offlineStaleCycles = 0;
685
- await this.setTokens(dResult.tokens, opts.duringRestore ? "restore" : "refresh");
719
+ await this.completeRefresh(dResult.tokens, opts.duringRestore ?? false);
686
720
  return true;
687
721
  }
688
722
  const result = await refreshAccessToken({
@@ -699,12 +733,26 @@ var TokenRuntime = class _TokenRuntime {
699
733
  }
700
734
  return this.scheduleRetry(result.error, opts.duringRestore ?? false);
701
735
  }
702
- this.emitEvent?.({ type: "refresh_succeeded" });
703
- this.refreshAttempts = 0;
704
- this.offlineStaleCycles = 0;
705
- await this.setTokens(result.tokens, opts.duringRestore ? "restore" : "refresh");
736
+ await this.completeRefresh(result.tokens, opts.duringRestore ?? false);
706
737
  return true;
707
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
+ }
708
756
  isAuthError(error) {
709
757
  return error.code === "REFRESH_FAILED" && (error.reason === "expired" || error.reason === "revoked");
710
758
  }
@@ -717,7 +765,7 @@ var TokenRuntime = class _TokenRuntime {
717
765
  async handleDpopRefreshFailure(error, duringRestore) {
718
766
  this.emitEvent?.({ type: "dpop_refresh_failed", metadata: { code: error.code } });
719
767
  if (error.class === "network") {
720
- 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);
721
769
  }
722
770
  if (error.class === "dpop_prover_unavailable") {
723
771
  this.emitEvent?.({ type: "refresh_failed", metadata: { reason: "dpop_prover_unavailable" } });
@@ -726,36 +774,53 @@ var TokenRuntime = class _TokenRuntime {
726
774
  await this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: error.message });
727
775
  return false;
728
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
+ */
729
782
  scheduleRetry(error, duringRestore) {
730
- if (this.refreshAttempts >= RETRY_DELAYS_MS.length) {
731
- if (!duringRestore && this.accessToken && this.expiresAtMs !== null && this.now() < this.expiresAtMs) {
732
- this.refreshAttempts = 0;
733
- this.offlineStaleCycles += 1;
734
- if (this.offlineStaleCycles >= _TokenRuntime.MAX_OFFLINE_STALE_CYCLES) {
735
- this.offlineStaleCycles = 0;
736
- void this.clear({ code: "REFRESH_FAILED", reason: "revoked", message: "Refresh chronically failing \u2014 session lost" });
737
- return false;
738
- }
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 });
739
796
  this.dispatch({ type: "OFFLINE_STALE" });
740
- return false;
741
797
  }
742
- void this.clear({ code: "REFRESH_FAILED", reason: "network", message: "Refresh exhausted retries" });
743
798
  return false;
744
799
  }
745
- const delay = RETRY_DELAYS_MS[this.refreshAttempts];
746
800
  this.refreshAttempts++;
747
801
  this.emitEvent?.({ type: "network_retry", metadata: { delayMs: delay, attempt: this.refreshAttempts } });
748
- if (!duringRestore) {
749
- this.dispatch({ type: "REFRESH_NETWORK_ERROR", error });
802
+ if (!restoring) {
803
+ this.dispatch({ type: "REFRESH_NETWORK_ERROR", error: transient });
750
804
  }
751
805
  if (this.retryTimer) this.clearTimeoutFn(this.retryTimer);
752
806
  this.retryTimer = this.setTimeoutFn(() => {
753
807
  this.retryTimer = null;
754
- void this.performRefresh();
808
+ void this.performRefresh({ duringRestore: this.restorePending });
755
809
  }, delay);
756
810
  return false;
757
811
  }
758
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
+ }
759
824
 
760
825
  // src/native/expo-adapter.ts
761
826
  function createDefaultExpoAdapter(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rakomi/react-native",
3
- "version": "0.6.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.6.0"
86
+ "@rakomi/react": "0.7.0"
87
87
  },
88
88
  "dependencies": {
89
89
  "jose": "^6.2.3",
90
- "@rakomi/sdk-core": "^0.6.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.6.0",
29
+ "bom-ref": "pkg:npm/%40rakomi%2Freact-native@0.7.0",
30
30
  "name": "@rakomi/react-native",
31
- "version": "0.6.0",
32
- "purl": "pkg:npm/%40rakomi%2Freact-native@0.6.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.6.0",
45
+ "bom-ref": "pkg:npm/@rakomi/sdk-core@0.7.0",
46
46
  "name": "@rakomi/sdk-core",
47
- "version": "0.6.0",
48
- "purl": "pkg:npm/@rakomi/sdk-core@0.6.0",
47
+ "version": "0.7.0",
48
+ "purl": "pkg:npm/@rakomi/sdk-core@0.7.0",
49
49
  "licenses": [
50
50
  {
51
51
  "license": {