@iqauth/sdk 2.6.4 → 2.8.1

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.
Files changed (117) hide show
  1. package/README.md +173 -1
  2. package/dist/browser-session.d.mts +4 -4
  3. package/dist/browser-session.d.ts +4 -4
  4. package/dist/browser-session.js +212 -46
  5. package/dist/browser-session.mjs +3 -3
  6. package/dist/browser.d.mts +5 -5
  7. package/dist/browser.d.ts +5 -5
  8. package/dist/browser.js +293 -34
  9. package/dist/browser.mjs +5 -5
  10. package/dist/{chunk-BVV54LPI.mjs → chunk-25SSYDIP.mjs} +10 -4
  11. package/dist/{chunk-XAWYUPMO.mjs → chunk-4V7FKOTG.mjs} +242 -22
  12. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  13. package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
  14. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  15. package/dist/chunk-GLXSIGVS.mjs +66 -0
  16. package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
  17. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  18. package/dist/chunk-JRDVUWAL.mjs +46 -0
  19. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  20. package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
  21. package/dist/chunk-VYQ3ETCK.mjs +244 -0
  22. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  23. package/dist/chunk-WHT6WKTY.mjs +3180 -0
  24. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  25. package/dist/chunk-WSH4SW7F.mjs +490 -0
  26. package/dist/{chunk-W3F4JYGP.mjs → chunk-ZLJPABB7.mjs} +139 -23
  27. package/dist/cli/index.js +2 -2
  28. package/dist/cli/index.mjs +2 -2
  29. package/dist/{client-BNQe3AgF.d.ts → client-D8L-PaWr.d.mts} +59 -6
  30. package/dist/{client-kYlJFgPv.d.mts → client-DkPL0EPZ.d.ts} +59 -6
  31. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  32. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  33. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  34. package/dist/{express-CHpfa7D_.d.ts → express-Budysq4h.d.ts} +2 -2
  35. package/dist/{express-B6_1vBYZ.d.mts → express-DDTA3qV1.d.mts} +2 -2
  36. package/dist/express.d.mts +7 -6
  37. package/dist/express.d.ts +7 -6
  38. package/dist/express.js +563 -85
  39. package/dist/express.mjs +73 -34
  40. package/dist/fastify.d.mts +10 -0
  41. package/dist/fastify.d.ts +10 -0
  42. package/dist/fastify.js +589 -65
  43. package/dist/fastify.mjs +101 -11
  44. package/dist/hono.d.mts +10 -0
  45. package/dist/hono.d.ts +10 -0
  46. package/dist/hono.js +566 -65
  47. package/dist/hono.mjs +78 -11
  48. package/dist/index-Cko-d5po.d.mts +1848 -0
  49. package/dist/index-RNqwEcmY.d.ts +1848 -0
  50. package/dist/index.d.mts +56 -8
  51. package/dist/index.d.ts +56 -8
  52. package/dist/index.js +694 -75
  53. package/dist/index.mjs +30 -10
  54. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  55. package/dist/locales.d.mts +1 -1
  56. package/dist/locales.d.ts +1 -1
  57. package/dist/locales.js +36 -0
  58. package/dist/locales.mjs +1 -1
  59. package/dist/mobile.d.mts +77 -7
  60. package/dist/mobile.d.ts +77 -7
  61. package/dist/mobile.js +307 -46
  62. package/dist/mobile.mjs +98 -3
  63. package/dist/next.d.mts +10 -1
  64. package/dist/next.d.ts +10 -1
  65. package/dist/next.js +596 -205
  66. package/dist/next.mjs +83 -10
  67. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
  68. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
  69. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  70. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  71. package/dist/react-permissions.d.mts +52 -0
  72. package/dist/react-permissions.d.ts +52 -0
  73. package/dist/react-permissions.js +239 -0
  74. package/dist/react-permissions.mjs +98 -0
  75. package/dist/react.d.mts +9 -1624
  76. package/dist/react.d.ts +9 -1624
  77. package/dist/react.js +882 -73
  78. package/dist/react.mjs +71 -2631
  79. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  80. package/dist/server/handlers.d.mts +200 -4
  81. package/dist/server/handlers.d.ts +200 -4
  82. package/dist/server/handlers.js +530 -16
  83. package/dist/server/handlers.mjs +14 -3
  84. package/dist/server.d.mts +171 -8
  85. package/dist/server.d.ts +171 -8
  86. package/dist/server.js +579 -61
  87. package/dist/server.mjs +99 -12
  88. package/dist/service.d.mts +4 -4
  89. package/dist/service.d.ts +4 -4
  90. package/dist/service.js +212 -46
  91. package/dist/service.mjs +3 -3
  92. package/dist/{signIn-CiIBTJIh.d.mts → signIn-CReqfXsh.d.mts} +95 -3
  93. package/dist/{signIn-OCr88Zf8.d.ts → signIn-Cfa1GTpO.d.ts} +95 -3
  94. package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
  95. package/dist/test.mjs +3 -3
  96. package/dist/{tokens-DCyzzn8L.d.mts → tokens-9F6ETrzk.d.ts} +9 -2
  97. package/dist/{tokens-aHiGFr_E.d.ts → tokens-B06VtvUi.d.mts} +9 -2
  98. package/dist/{types-DZAflmmq.d.mts → types-Bn8O-OEd.d.mts} +164 -11
  99. package/dist/{types-DZAflmmq.d.ts → types-Bn8O-OEd.d.ts} +164 -11
  100. package/dist/{types-6bNdxesb.d.ts → types-DnU2LhXR.d.mts} +7 -1
  101. package/dist/{types-6bNdxesb.d.mts → types-DnU2LhXR.d.ts} +7 -1
  102. package/dist/webhooks.d.mts +113 -17
  103. package/dist/webhooks.d.ts +113 -17
  104. package/dist/webhooks.js +179 -15
  105. package/dist/webhooks.mjs +7 -1
  106. package/dist/ws.d.mts +2 -2
  107. package/dist/ws.d.ts +2 -2
  108. package/dist/ws.js +80 -30
  109. package/dist/ws.mjs +4 -4
  110. package/docs/error-handling.md +101 -0
  111. package/docs/guides/effective-permissions.md +171 -0
  112. package/docs/guides/invitations.md +65 -0
  113. package/package.json +19 -4
  114. package/dist/chunk-6TDJJER7.mjs +0 -217
  115. package/dist/chunk-UKZLOHZG.mjs +0 -83
  116. package/dist/errors-CDdl24MP.d.mts +0 -52
  117. package/dist/errors-CDdl24MP.d.ts +0 -52
package/dist/browser.js CHANGED
@@ -444,13 +444,30 @@ __export(browser_exports, {
444
444
  module.exports = __toCommonJS(browser_exports);
445
445
 
446
446
  // src/errors.ts
447
- var IQAuthError = class extends Error {
448
- constructor(code, message, status, raw) {
447
+ var IQAuthError = class _IQAuthError extends Error {
448
+ constructor(code, message, status, cause) {
449
449
  super(message);
450
450
  this.name = "IQAuthError";
451
451
  this.code = code;
452
452
  this.status = status;
453
- this.raw = raw;
453
+ this.cause = cause;
454
+ this.raw = cause;
455
+ }
456
+ /**
457
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
458
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
459
+ */
460
+ static isIQAuthError(value) {
461
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
462
+ }
463
+ /**
464
+ * Type-narrowed code check. Lets callers write
465
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
466
+ * taxonomy without losing the ability to handle server codes via
467
+ * `err.code === "TOKEN_REVOKED"`.
468
+ */
469
+ is(code) {
470
+ return this.code === code;
454
471
  }
455
472
  };
456
473
  var ErrorCodes = {
@@ -549,14 +566,14 @@ function assertPublishableKey(raw, opts) {
549
566
  const ctx = opts?.context ? `${opts.context}: ` : "";
550
567
  if (typeof raw !== "string" || raw.length === 0) {
551
568
  throw new IQAuthError(
552
- "CONFIG_INVALID",
569
+ "config_invalid",
553
570
  `${ctx}IQAuth publishable key is missing. Set IQAUTH_PUBLISHABLE_KEY (or pass publishableKey) to a pk_test_\u2026 or pk_live_\u2026 value from the IQAuth admin console.`
554
571
  );
555
572
  }
556
573
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
557
574
  if (!shapeMatch) {
558
575
  throw new IQAuthError(
559
- "CONFIG_INVALID",
576
+ "config_invalid",
560
577
  `${ctx}IQAuth publishable key is malformed (got ${raw.slice(0, 12)}\u2026). Expected pk_test_\u2026 or pk_live_\u2026; regenerate the key from the IQAuth admin console.`
561
578
  );
562
579
  }
@@ -565,19 +582,19 @@ function assertPublishableKey(raw, opts) {
565
582
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
566
583
  } catch {
567
584
  throw new IQAuthError(
568
- "CONFIG_INVALID",
585
+ "config_invalid",
569
586
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
570
587
  );
571
588
  }
572
589
  if (!isPublishableKeyPayload(decoded)) {
573
590
  throw new IQAuthError(
574
- "CONFIG_INVALID",
591
+ "config_invalid",
575
592
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
576
593
  );
577
594
  }
578
595
  if (!isValidIssuerUrl(decoded.iss)) {
579
596
  throw new IQAuthError(
580
- "CONFIG_INVALID",
597
+ "config_invalid",
581
598
  `${ctx}IQAuth publishable key encodes an invalid issuer (iss=${JSON.stringify(decoded.iss)}). Expected a fully-qualified URL like "https://auth.example.com" (scheme required). Regenerate the key from the IQAuth admin console \u2014 the new key will encode a valid issuer URL.`
582
599
  );
583
600
  }
@@ -656,6 +673,7 @@ function clearPkce(state) {
656
673
  }
657
674
 
658
675
  // src/browser/sessionManager.ts
676
+ var PROBE_WAIT_MS = 80;
659
677
  var DEFAULT_REFRESH_PATH = "/api/v1/auth/refresh";
660
678
  var DEFAULT_USERINFO_PATH = "/api/v1/auth/me";
661
679
  async function readAuthErrorCode(res) {
@@ -693,7 +711,16 @@ function claimsToSessionUser(claims) {
693
711
  tenantId: claims.tenantId,
694
712
  vendorId: claims.vendorId,
695
713
  roles: claims.roles ?? [],
696
- entitlements: claims.entitlements ?? []
714
+ entitlements: claims.entitlements ?? [],
715
+ // SDK 2.7.0 (Task #124) — pass through identity claims when issued.
716
+ ...claims.picture !== void 0 ? { picture: claims.picture } : {},
717
+ ...claims.email_verified !== void 0 ? { emailVerified: claims.email_verified } : {},
718
+ ...claims.given_name !== void 0 ? { givenName: claims.given_name } : {},
719
+ ...claims.family_name !== void 0 ? { familyName: claims.family_name } : {},
720
+ ...claims.locale !== void 0 ? { locale: claims.locale } : {},
721
+ // Task #171 — surface the active source/client scope when the token was
722
+ // minted scoped, so consumers reading useUser().user can branch on it.
723
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
697
724
  };
698
725
  }
699
726
  var EMPTY = {
@@ -717,11 +744,50 @@ var NO_OP_STORE = {
717
744
  write: () => void 0,
718
745
  clear: () => void 0
719
746
  };
747
+ var IDEMPOTENCY_HEADER = "X-IQAuth-Idempotency";
748
+ function randomIdempotencyToken() {
749
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
750
+ const bytes = new Uint8Array(16);
751
+ crypto.getRandomValues(bytes);
752
+ let out = "";
753
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
754
+ return out;
755
+ }
756
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
757
+ }
720
758
  var SessionManager = class {
721
759
  constructor(options) {
722
760
  this.snapshot = { ...EMPTY };
723
761
  this.listeners = /* @__PURE__ */ new Set();
724
762
  this.refreshPromise = null;
763
+ /**
764
+ * Cancellation handle for the in-flight refresh, if any. `signOut()` (or a
765
+ * `session:signout` broadcast from another tab) calls `abort()` so the
766
+ * refresh response is dropped before it can write a fresh access cookie
767
+ * on top of the just-cleared session — the second root cause of "ghost
768
+ * signed-in" sessions after Sign Out.
769
+ */
770
+ this.refreshAbort = null;
771
+ /**
772
+ * Set to `true` by `signOut()` / `signOutLocal()` for the lifetime of the
773
+ * call. Used as a safety belt: even if a refresh response arrives while
774
+ * `refreshAbort` was unable to interrupt the network call (e.g. the body
775
+ * was already streaming back), `runRefresh` checks this flag before
776
+ * mutating session state and bails out.
777
+ */
778
+ this.signoutInProgress = false;
779
+ /**
780
+ * Per-session opaque idempotency token. Sent as `X-IQAuth-Idempotency` on
781
+ * every /refresh and /signout request the SDK makes through a framework
782
+ * adapter (Express/Fastify/Hono/Next), so the adapter's `SignoutRegistry`
783
+ * can collapse a refresh that lands moments after a signout — even when
784
+ * the two requests are routed to different server instances (multi-replica
785
+ * deployments).
786
+ *
787
+ * Generated lazily on first use, rotated on signout so the next session
788
+ * starts with a fresh token. Opaque random — never the raw refresh token.
789
+ */
790
+ this.idempotencyToken = null;
725
791
  this.channel = null;
726
792
  this.proactiveTimer = null;
727
793
  this.bootstrapped = false;
@@ -729,6 +795,8 @@ var SessionManager = class {
729
795
  this.remoteRefreshWaiters = [];
730
796
  /** Active claims by other tabs (keyed by source tabId). */
731
797
  this.foreignClaim = null;
798
+ /** Resolver for an in-flight cross-tab `session:probe`, set during bootstrap. */
799
+ this.probeResolver = null;
732
800
  const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/browser SessionManager" });
733
801
  this.key = parsed;
734
802
  const inferred = options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`);
@@ -741,6 +809,8 @@ var SessionManager = class {
741
809
  this.refreshCookieName = options.cookieNames?.refresh ?? REFRESH_COOKIE;
742
810
  this.tokenStore = options.tokenStore ?? (this.serverManagedSession ? NO_OP_STORE : this.useCookies ? defaultCookieStore(this.refreshCookieName) : NO_OP_STORE);
743
811
  this.crossTabLockTimeoutMs = options.crossTabLockTimeoutMs ?? 4e3;
812
+ this.debug = options.debug ?? false;
813
+ this.onTimingEvent = options.onTimingEvent ?? null;
744
814
  this.fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
745
815
  throw new Error("global fetch is not available; pass fetchImpl");
746
816
  }));
@@ -767,10 +837,35 @@ var SessionManager = class {
767
837
  get issuerUrl() {
768
838
  return this.issuer;
769
839
  }
840
+ /**
841
+ * SDK 2.7.0 (Task #124) — The hosted IQAuth host derived from the
842
+ * publishable key's `iss` claim, normalized to URL form. This is what
843
+ * `<SignIn/>` and `buildSignInUrl` use to talk to the hosted UI; it
844
+ * deliberately ignores the `issuer` constructor override so a misrouted
845
+ * `issuer` (e.g. pointed at the consumer app's own domain) cannot break
846
+ * the hosted flow. Use {@link issuerUrl} for token / discovery endpoints.
847
+ */
848
+ get hostedIssuerUrl() {
849
+ const iss = this.key.iss;
850
+ return (iss.startsWith("http") ? iss : `https://${iss}`).replace(/\/+$/, "");
851
+ }
770
852
  /** Cookie name the SDK uses for the refresh token (overridable via `cookieNames.refresh`). */
771
853
  get refreshCookie() {
772
854
  return this.refreshCookieName;
773
855
  }
856
+ /**
857
+ * Returns the current per-session idempotency token, generating one
858
+ * lazily on first use. Sent as the `X-IQAuth-Idempotency` header on
859
+ * /refresh and /signout requests so the framework adapter's
860
+ * `SignoutRegistry` can collapse a refresh-vs-signout race even across
861
+ * server instances.
862
+ */
863
+ getIdempotencyToken() {
864
+ if (!this.idempotencyToken) {
865
+ this.idempotencyToken = randomIdempotencyToken();
866
+ }
867
+ return this.idempotencyToken;
868
+ }
774
869
  getSnapshot() {
775
870
  return this.snapshot;
776
871
  }
@@ -784,9 +879,44 @@ var SessionManager = class {
784
879
  * One-time bootstrap: warm the session from the refresh cookie if present.
785
880
  * Safe to call multiple times.
786
881
  */
882
+ /**
883
+ * Task #126: Public timing-event emitter. Used by the browser sign-in
884
+ * helpers (redirectToSignIn / handleAuthCallback) to surface signIn-phase
885
+ * timings through the same `debug` + `onTimingEvent` channel as
886
+ * bootstrap/refresh. Safe to call from anywhere — internal callers
887
+ * pre-compute durationMs.
888
+ */
889
+ recordTiming(phase, durationMs, ok, code) {
890
+ this.emitTiming(phase, durationMs, ok, code);
891
+ }
892
+ /** Task #126: emit a session timing event to debug log + onTimingEvent hook. */
893
+ emitTiming(phase, durationMs, ok, code) {
894
+ if (this.debug) {
895
+ try {
896
+ console.debug("[iqauth_session]", { phase, durationMs, ok, code });
897
+ } catch {
898
+ }
899
+ }
900
+ if (this.onTimingEvent) {
901
+ try {
902
+ this.onTimingEvent({ phase, durationMs, ok, code });
903
+ } catch {
904
+ }
905
+ }
906
+ }
787
907
  async bootstrap() {
788
908
  if (this.bootstrapped) return;
789
909
  this.bootstrapped = true;
910
+ const t0 = Date.now();
911
+ try {
912
+ await this.bootstrapInner();
913
+ this.emitTiming("bootstrap", Date.now() - t0, this.snapshot.status === "authenticated");
914
+ } catch (err) {
915
+ this.emitTiming("bootstrap", Date.now() - t0, false, err instanceof Error ? err.message : "ERROR");
916
+ throw err;
917
+ }
918
+ }
919
+ async bootstrapInner() {
790
920
  if (this.serverManagedSession) {
791
921
  try {
792
922
  const res = await this.fetchImpl(`${this.issuer}${this.userinfoPath}`, {
@@ -821,6 +951,15 @@ var SessionManager = class {
821
951
  return;
822
952
  }
823
953
  }
954
+ const peerSnapshot = await this.probePeers();
955
+ if (peerSnapshot && peerSnapshot.status === "authenticated") {
956
+ this.update({
957
+ ...peerSnapshot,
958
+ version: Math.max(this.snapshot.version, peerSnapshot.version) + 1
959
+ });
960
+ this.scheduleProactiveRefresh();
961
+ return;
962
+ }
824
963
  const stored = await Promise.resolve(this.tokenStore.read());
825
964
  if (!stored) {
826
965
  this.setStatus("unauthenticated");
@@ -829,6 +968,22 @@ var SessionManager = class {
829
968
  const ok = await this.refresh();
830
969
  if (!ok) this.setStatus("unauthenticated");
831
970
  }
971
+ probePeers() {
972
+ if (!this.channel) return Promise.resolve(null);
973
+ return new Promise((resolve) => {
974
+ let settled = false;
975
+ const finish = (snap) => {
976
+ if (settled) return;
977
+ settled = true;
978
+ this.probeResolver = null;
979
+ clearTimeout(timer);
980
+ resolve(snap);
981
+ };
982
+ this.probeResolver = (snap) => finish(snap);
983
+ const timer = setTimeout(() => finish(null), PROBE_WAIT_MS);
984
+ this.broadcastEnvelope({ type: "session:probe", source: this.tabId, ts: Date.now() });
985
+ });
986
+ }
832
987
  /**
833
988
  * Single-flight token refresh, coordinated across tabs via BroadcastChannel.
834
989
  *
@@ -842,30 +997,48 @@ var SessionManager = class {
842
997
  */
843
998
  refresh() {
844
999
  if (this.refreshPromise) return this.refreshPromise;
845
- this.refreshPromise = this.runRefresh().finally(() => {
1000
+ const t0 = Date.now();
1001
+ this.refreshPromise = this.runRefresh().then((ok) => {
1002
+ this.emitTiming("refresh", Date.now() - t0, ok, ok ? void 0 : this.snapshot.error?.code ?? "REFRESH_FAILED");
1003
+ return ok;
1004
+ }).finally(() => {
846
1005
  this.refreshPromise = null;
847
1006
  });
848
1007
  return this.refreshPromise;
849
1008
  }
850
1009
  async runRefresh() {
851
- const myClaim = { source: this.tabId, ts: Date.now() };
852
- if (this.channel) {
853
- this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
854
- await new Promise((r) => setTimeout(r, 25));
855
- const foreign = this.foreignClaim;
856
- if (foreign && this.claimWins(foreign, myClaim)) {
857
- return this.waitForForeignRefresh();
858
- }
859
- }
1010
+ const abort = new AbortController();
1011
+ this.refreshAbort = abort;
860
1012
  try {
1013
+ const myClaim = { source: this.tabId, ts: Date.now() };
1014
+ if (this.channel) {
1015
+ this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
1016
+ await new Promise((r) => setTimeout(r, 25));
1017
+ if (abort.signal.aborted || this.signoutInProgress) {
1018
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1019
+ return false;
1020
+ }
1021
+ const foreign = this.foreignClaim;
1022
+ if (foreign && this.claimWins(foreign, myClaim)) {
1023
+ return this.waitForForeignRefresh();
1024
+ }
1025
+ }
861
1026
  const refreshToken = await Promise.resolve(this.tokenStore.read());
862
1027
  const res = await this.fetchImpl(`${this.issuer}${this.refreshPath}`, {
863
1028
  method: "POST",
864
1029
  credentials: "include",
865
- headers: { "Content-Type": "application/json" },
866
- body: JSON.stringify(refreshToken ? { refreshToken } : {})
1030
+ headers: {
1031
+ "Content-Type": "application/json",
1032
+ [IDEMPOTENCY_HEADER]: this.getIdempotencyToken()
1033
+ },
1034
+ body: JSON.stringify(refreshToken ? { refreshToken } : {}),
1035
+ signal: abort.signal
867
1036
  });
868
1037
  const body = await res.json().catch(() => ({}));
1038
+ if (this.signoutInProgress || abort.signal.aborted) {
1039
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1040
+ return false;
1041
+ }
869
1042
  const data = body.data;
870
1043
  if (!res.ok || !body.success || !data?.accessToken) {
871
1044
  const err = body.error;
@@ -884,14 +1057,18 @@ var SessionManager = class {
884
1057
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: true });
885
1058
  return true;
886
1059
  } catch (err) {
887
- this.setError({
888
- code: "NETWORK_ERROR",
889
- message: err instanceof Error ? err.message : "Refresh request failed"
890
- });
1060
+ const aborted = err?.name === "AbortError" || abort.signal.aborted || this.signoutInProgress;
1061
+ if (!aborted) {
1062
+ this.setError({
1063
+ code: "NETWORK_ERROR",
1064
+ message: err instanceof Error ? err.message : "Refresh request failed"
1065
+ });
1066
+ }
891
1067
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
892
1068
  return false;
893
1069
  } finally {
894
1070
  this.foreignClaim = null;
1071
+ if (this.refreshAbort === abort) this.refreshAbort = null;
895
1072
  }
896
1073
  }
897
1074
  claimWins(foreign, mine) {
@@ -918,10 +1095,27 @@ var SessionManager = class {
918
1095
  * session and notify subscribers and other tabs.
919
1096
  */
920
1097
  applyAccessToken(accessToken, refreshToken) {
1098
+ this.adoptAccessToken(accessToken, { refreshToken });
1099
+ }
1100
+ /**
1101
+ * Task #197 — Adopt an access token that the server has already minted
1102
+ * for us (e.g. from `POST /api/v1/auth/switch-scope`) without contacting
1103
+ * the issuer. Swaps the in-memory token, re-decodes claims, bumps
1104
+ * `version`, schedules proactive refresh, and broadcasts a
1105
+ * `session:update` to peer tabs.
1106
+ *
1107
+ * This is the safe path for any server endpoint that returns a fresh
1108
+ * access token in its JSON body: we want the new claims (scope, roles,
1109
+ * etc.) to take effect immediately, even if the refresh-cookie round-trip
1110
+ * would have failed (network blip, rate limit, signout race). When the
1111
+ * server also rotated the refresh token, pass it via
1112
+ * `opts.refreshToken` so the cookie stays aligned.
1113
+ */
1114
+ adoptAccessToken(accessToken, opts) {
921
1115
  const claims = decodeClaims(accessToken);
922
1116
  const user = claimsToSessionUser(claims);
923
- if (refreshToken) {
924
- void Promise.resolve(this.tokenStore.write(refreshToken, { claims }));
1117
+ if (opts?.refreshToken) {
1118
+ void Promise.resolve(this.tokenStore.write(opts.refreshToken, { claims }));
925
1119
  }
926
1120
  this.update({
927
1121
  status: user ? "authenticated" : "unauthenticated",
@@ -999,6 +1193,14 @@ var SessionManager = class {
999
1193
  * the server-side logout request.
1000
1194
  */
1001
1195
  signOutLocal(status = "unauthenticated") {
1196
+ this.signoutInProgress = true;
1197
+ if (this.refreshAbort) {
1198
+ try {
1199
+ this.refreshAbort.abort();
1200
+ } catch {
1201
+ }
1202
+ this.refreshAbort = null;
1203
+ }
1002
1204
  void Promise.resolve(this.tokenStore.clear());
1003
1205
  if (this.proactiveTimer) {
1004
1206
  clearTimeout(this.proactiveTimer);
@@ -1013,7 +1215,12 @@ var SessionManager = class {
1013
1215
  error: null,
1014
1216
  version: this.snapshot.version + 1
1015
1217
  });
1218
+ this.broadcastEnvelope({ type: "refresh:abort", source: this.tabId, ts: Date.now() });
1016
1219
  this.broadcast("session:signout");
1220
+ this.idempotencyToken = null;
1221
+ setTimeout(() => {
1222
+ this.signoutInProgress = false;
1223
+ }, 0);
1017
1224
  }
1018
1225
  /**
1019
1226
  * Replace the refresh-token store at runtime. Used by the F22
@@ -1077,6 +1284,12 @@ var SessionManager = class {
1077
1284
  }
1078
1285
  onBroadcast(env) {
1079
1286
  if (!env || env.source === this.tabId) return;
1287
+ if (env.type === "session:probe") {
1288
+ if (this.snapshot.status === "authenticated") {
1289
+ this.broadcast("session:update");
1290
+ }
1291
+ return;
1292
+ }
1080
1293
  if (env.type === "refresh:claim") {
1081
1294
  this.foreignClaim = { source: env.source, ts: env.ts };
1082
1295
  return;
@@ -1089,6 +1302,24 @@ var SessionManager = class {
1089
1302
  this.foreignClaim = null;
1090
1303
  return;
1091
1304
  }
1305
+ if (env.type === "refresh:abort") {
1306
+ this.signoutInProgress = true;
1307
+ if (this.refreshAbort) {
1308
+ try {
1309
+ this.refreshAbort.abort();
1310
+ } catch {
1311
+ }
1312
+ this.refreshAbort = null;
1313
+ }
1314
+ const waiters = this.remoteRefreshWaiters;
1315
+ this.remoteRefreshWaiters = [];
1316
+ for (const w of waiters) w(false);
1317
+ this.foreignClaim = null;
1318
+ setTimeout(() => {
1319
+ this.signoutInProgress = false;
1320
+ }, 0);
1321
+ return;
1322
+ }
1092
1323
  if (env.type === "session:signout") {
1093
1324
  this.update({
1094
1325
  status: "unauthenticated",
@@ -1102,6 +1333,12 @@ var SessionManager = class {
1102
1333
  return;
1103
1334
  }
1104
1335
  if ((env.type === "session:update" || env.type === "session:refresh") && env.payload) {
1336
+ if (this.probeResolver && env.payload.status === "authenticated") {
1337
+ const r = this.probeResolver;
1338
+ this.probeResolver = null;
1339
+ r(env.payload);
1340
+ return;
1341
+ }
1105
1342
  this.update({
1106
1343
  ...env.payload,
1107
1344
  version: Math.max(this.snapshot.version, env.payload.version) + 1
@@ -1386,7 +1623,7 @@ async function buildSignInUrl(manager, opts = {}) {
1386
1623
  returnTo,
1387
1624
  createdAt: Date.now()
1388
1625
  });
1389
- const url2 = new URL(opts.signInPath ?? DEFAULT_SIGN_IN_PATH, manager.issuerUrl);
1626
+ const url2 = new URL(opts.signInPath ?? DEFAULT_SIGN_IN_PATH, manager.hostedIssuerUrl);
1390
1627
  url2.searchParams.set("response_type", "code");
1391
1628
  url2.searchParams.set("app", manager.appKey);
1392
1629
  url2.searchParams.set("publishable_key", manager.publishableKey.raw);
@@ -1401,33 +1638,50 @@ async function buildSignInUrl(manager, opts = {}) {
1401
1638
  return url2.toString();
1402
1639
  }
1403
1640
  async function redirectToSignIn(manager, opts = {}) {
1404
- const url2 = await buildSignInUrl(manager, opts);
1405
- if (typeof window === "undefined") {
1406
- throw new Error("redirectToSignIn requires a browser environment");
1641
+ const t0 = Date.now();
1642
+ let ok = false;
1643
+ let code;
1644
+ try {
1645
+ const url2 = await buildSignInUrl(manager, opts);
1646
+ if (typeof window === "undefined") {
1647
+ code = "NO_WINDOW";
1648
+ throw new Error("redirectToSignIn requires a browser environment");
1649
+ }
1650
+ ok = true;
1651
+ manager.recordTiming("signIn", Date.now() - t0, true);
1652
+ window.location.assign(url2);
1653
+ } catch (err) {
1654
+ if (!ok) manager.recordTiming("signIn", Date.now() - t0, false, code ?? (err instanceof Error ? err.message : "ERROR"));
1655
+ throw err;
1407
1656
  }
1408
- window.location.assign(url2);
1409
1657
  }
1410
1658
  async function signIn(manager, opts = {}) {
1411
1659
  return redirectToSignIn(manager, opts);
1412
1660
  }
1413
1661
  async function handleAuthCallback(manager, options = {}) {
1662
+ const t0 = Date.now();
1663
+ const emit = (ok, code2) => manager.recordTiming("signIn", Date.now() - t0, ok, code2);
1414
1664
  const url2 = new URL(options.url ?? (typeof window !== "undefined" ? window.location.href : ""));
1415
1665
  const code = url2.searchParams.get("code");
1416
1666
  const state = url2.searchParams.get("state");
1417
1667
  const errorParam = url2.searchParams.get("error");
1418
1668
  if (errorParam) {
1669
+ emit(false, errorParam);
1419
1670
  return { ok: false, returnTo: "/", error: errorParam };
1420
1671
  }
1421
1672
  if (!code || !state) {
1673
+ emit(false, "missing_code_or_state");
1422
1674
  return { ok: false, returnTo: "/", error: "missing_code_or_state" };
1423
1675
  }
1424
1676
  const record = loadPkce(state);
1425
1677
  if (!record) {
1678
+ emit(false, "unknown_state");
1426
1679
  return { ok: false, returnTo: "/", error: "unknown_state" };
1427
1680
  }
1428
1681
  clearPkce(state);
1429
1682
  const fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
1430
1683
  if (!fetchImpl) {
1684
+ emit(false, "no_fetch");
1431
1685
  return { ok: false, returnTo: record.returnTo, error: "no_fetch" };
1432
1686
  }
1433
1687
  const tokenUrl = `${manager.issuerUrl}${options.tokenPath ?? DEFAULT_TOKEN_PATH}`;
@@ -1446,10 +1700,12 @@ async function handleAuthCallback(manager, options = {}) {
1446
1700
  const body = await res.json().catch(() => ({}));
1447
1701
  if (!res.ok) {
1448
1702
  const desc = body.error_description ?? body.error ?? "token_exchange_failed";
1703
+ emit(false, desc);
1449
1704
  return { ok: false, returnTo: record.returnTo, error: desc };
1450
1705
  }
1451
1706
  const tokens = body;
1452
1707
  if (!tokens.access_token) {
1708
+ emit(false, "missing_access_token");
1453
1709
  return { ok: false, returnTo: record.returnTo, error: "missing_access_token" };
1454
1710
  }
1455
1711
  if (tokens.refresh_token) {
@@ -1457,21 +1713,24 @@ async function handleAuthCallback(manager, options = {}) {
1457
1713
  setCookie(cookieName, tokens.refresh_token, { maxAgeSeconds: 60 * 60 * 24 * 30 });
1458
1714
  }
1459
1715
  manager.applyAccessToken(tokens.access_token, tokens.refresh_token);
1716
+ emit(true);
1460
1717
  return { ok: true, returnTo: record.returnTo };
1461
1718
  }
1462
1719
  async function signOut(manager, opts = {}) {
1463
1720
  if (!opts.localOnly) {
1464
1721
  const issuer = manager.issuerUrl.replace(/\/$/, "");
1722
+ const idempotency = manager.getIdempotencyToken();
1465
1723
  try {
1466
1724
  const url2 = `${issuer}${opts.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
1467
- await manager.fetch(url2, { method: "POST" }).catch(() => void 0);
1725
+ await manager.fetch(url2, { method: "POST", headers: { "X-IQAuth-Idempotency": idempotency } }).catch(() => void 0);
1468
1726
  } catch {
1469
1727
  }
1470
1728
  if (opts.endSsoSession !== false) {
1471
1729
  try {
1472
1730
  await fetch(`${issuer}${DEFAULT_SSO_LOGOUT_PATH}`, {
1473
1731
  method: "POST",
1474
- credentials: "include"
1732
+ credentials: "include",
1733
+ headers: { "X-IQAuth-Idempotency": idempotency }
1475
1734
  }).catch(() => void 0);
1476
1735
  } catch {
1477
1736
  }
package/dist/browser.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  exitImpersonation,
5
5
  reverify,
6
6
  withReverification
7
- } from "./chunk-LIZYFXH7.mjs";
7
+ } from "./chunk-DFWHSDYQ.mjs";
8
8
  import {
9
9
  AccountRegistry,
10
10
  MultiAccountTokenStore,
@@ -20,7 +20,7 @@ import {
20
20
  signInWithPasskey,
21
21
  unlinkProvider,
22
22
  verifyMagicLink
23
- } from "./chunk-XAWYUPMO.mjs";
23
+ } from "./chunk-4V7FKOTG.mjs";
24
24
  import {
25
25
  REFRESH_COOKIE,
26
26
  buildSignInUrl,
@@ -31,7 +31,7 @@ import {
31
31
  setCookie,
32
32
  signIn,
33
33
  signOut
34
- } from "./chunk-DJIBN2N7.mjs";
34
+ } from "./chunk-GN37E64I.mjs";
35
35
  import {
36
36
  createPkcePair,
37
37
  randomUrlSafe,
@@ -42,11 +42,11 @@ import {
42
42
  isPublishableKey,
43
43
  isSecretKey,
44
44
  parsePublishableKey
45
- } from "./chunk-WQWBJSSS.mjs";
45
+ } from "./chunk-HVHNYPDC.mjs";
46
46
  import {
47
47
  ErrorCodes,
48
48
  IQAuthError
49
- } from "./chunk-6I6RM4MN.mjs";
49
+ } from "./chunk-6PJRLRB4.mjs";
50
50
  import "./chunk-Y6FXYEAI.mjs";
51
51
  export {
52
52
  AccountRegistry,
@@ -1,21 +1,27 @@
1
1
  import {
2
2
  assertPublishableKey
3
- } from "./chunk-WQWBJSSS.mjs";
3
+ } from "./chunk-HVHNYPDC.mjs";
4
4
  import {
5
5
  IQAuthClient
6
- } from "./chunk-W3F4JYGP.mjs";
6
+ } from "./chunk-ZLJPABB7.mjs";
7
7
  import {
8
8
  IQAuthError
9
- } from "./chunk-6I6RM4MN.mjs";
9
+ } from "./chunk-6PJRLRB4.mjs";
10
10
 
11
11
  // src/middleware/express.ts
12
12
  var KNOWN_AUTH_ERROR_CODES = /* @__PURE__ */ new Set([
13
+ // Legacy UPPER_SNAKE codes (server-originated and SDK ≤2.6.x throws).
13
14
  "TOKEN_INVALID",
14
15
  "TOKEN_EXPIRED",
15
16
  "TOKEN_REVOKED",
16
17
  "SESSION_EXPIRED",
17
18
  "SESSION_INVALID",
18
- "AUTH_REQUIRED"
19
+ "AUTH_REQUIRED",
20
+ // Task #127 — typed `IQAuthErrorCode` taxonomy thrown by `tokens.verify`.
21
+ // Mapped to 401 here so framework consumers don't have to learn the new
22
+ // codes to keep their auth-failure handling working.
23
+ "token_invalid",
24
+ "token_expired"
19
25
  ]);
20
26
  var DEFAULT_ACCESS_COOKIE = "iqauth_at";
21
27
  var DEFAULT_REFRESH_COOKIE = "iqauth_rt";