@iqauth/sdk 2.6.4 → 2.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.
Files changed (110) 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 +181 -41
  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 +271 -32
  9. package/dist/browser.mjs +5 -5
  10. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  11. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  12. package/dist/chunk-GLXSIGVS.mjs +66 -0
  13. package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
  14. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  15. package/dist/{chunk-W3F4JYGP.mjs → chunk-JXQI62A7.mjs} +108 -18
  16. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  17. package/dist/chunk-PMAFENVI.mjs +229 -0
  18. package/dist/chunk-RR2MGPTK.mjs +2724 -0
  19. package/dist/{chunk-XAWYUPMO.mjs → chunk-RTJAIBXY.mjs} +220 -20
  20. package/dist/{chunk-6TDJJER7.mjs → chunk-RUJXRTEW.mjs} +164 -5
  21. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  22. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  23. package/dist/{chunk-BVV54LPI.mjs → chunk-YVALAG3B.mjs} +10 -4
  24. package/dist/cli/index.js +2 -2
  25. package/dist/cli/index.mjs +2 -2
  26. package/dist/{client-kYlJFgPv.d.mts → client-BGFnBpfc.d.mts} +47 -4
  27. package/dist/{client-BNQe3AgF.d.ts → client-CDQ21LvW.d.ts} +47 -4
  28. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  29. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  30. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  31. package/dist/{express-B6_1vBYZ.d.mts → express-CVNQEkOr.d.mts} +2 -2
  32. package/dist/{express-CHpfa7D_.d.ts → express-Piv2WhWM.d.ts} +2 -2
  33. package/dist/express.d.mts +7 -6
  34. package/dist/express.d.ts +7 -6
  35. package/dist/express.js +349 -52
  36. package/dist/express.mjs +39 -12
  37. package/dist/fastify.d.mts +2 -0
  38. package/dist/fastify.d.ts +2 -0
  39. package/dist/fastify.js +332 -52
  40. package/dist/fastify.mjs +23 -8
  41. package/dist/hono.d.mts +2 -0
  42. package/dist/hono.d.ts +2 -0
  43. package/dist/hono.js +329 -52
  44. package/dist/hono.mjs +20 -8
  45. package/dist/index-5KSZEnDe.d.ts +1626 -0
  46. package/dist/index-CKoZHAoc.d.mts +1626 -0
  47. package/dist/index.d.mts +56 -8
  48. package/dist/index.d.ts +56 -8
  49. package/dist/index.js +565 -69
  50. package/dist/index.mjs +29 -9
  51. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  52. package/dist/locales.d.mts +1 -1
  53. package/dist/locales.d.ts +1 -1
  54. package/dist/mobile.d.mts +77 -7
  55. package/dist/mobile.d.ts +77 -7
  56. package/dist/mobile.js +276 -41
  57. package/dist/mobile.mjs +98 -3
  58. package/dist/next.d.mts +2 -1
  59. package/dist/next.d.ts +2 -1
  60. package/dist/next.js +391 -201
  61. package/dist/next.mjs +22 -7
  62. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-CGpMRie4.d.ts} +1 -1
  63. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-M5G47LWO.d.mts} +1 -1
  64. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  65. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  66. package/dist/react-permissions.d.mts +52 -0
  67. package/dist/react-permissions.d.ts +52 -0
  68. package/dist/react-permissions.js +239 -0
  69. package/dist/react-permissions.mjs +97 -0
  70. package/dist/react.d.mts +9 -1624
  71. package/dist/react.d.ts +9 -1624
  72. package/dist/react.js +313 -33
  73. package/dist/react.mjs +58 -2632
  74. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  75. package/dist/server/handlers.d.mts +148 -3
  76. package/dist/server/handlers.d.ts +148 -3
  77. package/dist/server/handlers.js +410 -11
  78. package/dist/server/handlers.mjs +12 -3
  79. package/dist/server.d.mts +151 -8
  80. package/dist/server.d.ts +151 -8
  81. package/dist/server.js +406 -50
  82. package/dist/server.mjs +93 -11
  83. package/dist/service.d.mts +4 -4
  84. package/dist/service.d.ts +4 -4
  85. package/dist/service.js +181 -41
  86. package/dist/service.mjs +3 -3
  87. package/dist/{signIn-OCr88Zf8.d.ts → signIn-BLFnz8SV.d.ts} +78 -3
  88. package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
  89. package/dist/{signIn-CiIBTJIh.d.mts → signIn-T-CZ6t6r.d.mts} +78 -3
  90. package/dist/test.mjs +3 -3
  91. package/dist/{tokens-DCyzzn8L.d.mts → tokens-Bqhmqq_R.d.ts} +9 -2
  92. package/dist/{tokens-aHiGFr_E.d.ts → tokens-CITeoG6P.d.mts} +9 -2
  93. package/dist/{types-6bNdxesb.d.ts → types-BdQ2lqfT.d.mts} +1 -1
  94. package/dist/{types-6bNdxesb.d.mts → types-BdQ2lqfT.d.ts} +1 -1
  95. package/dist/{types-DZAflmmq.d.mts → types-XOV9XPVi.d.mts} +99 -10
  96. package/dist/{types-DZAflmmq.d.ts → types-XOV9XPVi.d.ts} +99 -10
  97. package/dist/webhooks.d.mts +100 -17
  98. package/dist/webhooks.d.ts +100 -17
  99. package/dist/webhooks.js +164 -15
  100. package/dist/webhooks.mjs +7 -1
  101. package/dist/ws.d.mts +2 -2
  102. package/dist/ws.d.ts +2 -2
  103. package/dist/ws.js +80 -30
  104. package/dist/ws.mjs +4 -4
  105. package/docs/error-handling.md +101 -0
  106. package/docs/guides/effective-permissions.md +171 -0
  107. package/package.json +13 -3
  108. package/dist/chunk-UKZLOHZG.mjs +0 -83
  109. package/dist/errors-CDdl24MP.d.mts +0 -52
  110. package/dist/errors-CDdl24MP.d.ts +0 -52
package/dist/react.js CHANGED
@@ -25,13 +25,30 @@ var IQAuthError;
25
25
  var init_errors = __esm({
26
26
  "src/errors.ts"() {
27
27
  "use strict";
28
- IQAuthError = class extends Error {
29
- constructor(code, message, status, raw) {
28
+ IQAuthError = class _IQAuthError extends Error {
29
+ constructor(code, message, status, cause) {
30
30
  super(message);
31
31
  this.name = "IQAuthError";
32
32
  this.code = code;
33
33
  this.status = status;
34
- this.raw = raw;
34
+ this.cause = cause;
35
+ this.raw = cause;
36
+ }
37
+ /**
38
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
39
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
40
+ */
41
+ static isIQAuthError(value) {
42
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
43
+ }
44
+ /**
45
+ * Type-narrowed code check. Lets callers write
46
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
47
+ * taxonomy without losing the ability to handle server codes via
48
+ * `err.code === "TOKEN_REVOKED"`.
49
+ */
50
+ is(code) {
51
+ return this.code === code;
35
52
  }
36
53
  };
37
54
  }
@@ -177,7 +194,7 @@ async function buildSignInUrl(manager, opts = {}) {
177
194
  returnTo,
178
195
  createdAt: Date.now()
179
196
  });
180
- const url2 = new URL(opts.signInPath ?? DEFAULT_SIGN_IN_PATH, manager.issuerUrl);
197
+ const url2 = new URL(opts.signInPath ?? DEFAULT_SIGN_IN_PATH, manager.hostedIssuerUrl);
181
198
  url2.searchParams.set("response_type", "code");
182
199
  url2.searchParams.set("app", manager.appKey);
183
200
  url2.searchParams.set("publishable_key", manager.publishableKey.raw);
@@ -192,33 +209,50 @@ async function buildSignInUrl(manager, opts = {}) {
192
209
  return url2.toString();
193
210
  }
194
211
  async function redirectToSignIn(manager, opts = {}) {
195
- const url2 = await buildSignInUrl(manager, opts);
196
- if (typeof window === "undefined") {
197
- throw new Error("redirectToSignIn requires a browser environment");
212
+ const t0 = Date.now();
213
+ let ok = false;
214
+ let code;
215
+ try {
216
+ const url2 = await buildSignInUrl(manager, opts);
217
+ if (typeof window === "undefined") {
218
+ code = "NO_WINDOW";
219
+ throw new Error("redirectToSignIn requires a browser environment");
220
+ }
221
+ ok = true;
222
+ manager.recordTiming("signIn", Date.now() - t0, true);
223
+ window.location.assign(url2);
224
+ } catch (err) {
225
+ if (!ok) manager.recordTiming("signIn", Date.now() - t0, false, code ?? (err instanceof Error ? err.message : "ERROR"));
226
+ throw err;
198
227
  }
199
- window.location.assign(url2);
200
228
  }
201
229
  async function signIn(manager, opts = {}) {
202
230
  return redirectToSignIn(manager, opts);
203
231
  }
204
232
  async function handleAuthCallback(manager, options = {}) {
233
+ const t0 = Date.now();
234
+ const emit = (ok, code2) => manager.recordTiming("signIn", Date.now() - t0, ok, code2);
205
235
  const url2 = new URL(options.url ?? (typeof window !== "undefined" ? window.location.href : ""));
206
236
  const code = url2.searchParams.get("code");
207
237
  const state = url2.searchParams.get("state");
208
238
  const errorParam = url2.searchParams.get("error");
209
239
  if (errorParam) {
240
+ emit(false, errorParam);
210
241
  return { ok: false, returnTo: "/", error: errorParam };
211
242
  }
212
243
  if (!code || !state) {
244
+ emit(false, "missing_code_or_state");
213
245
  return { ok: false, returnTo: "/", error: "missing_code_or_state" };
214
246
  }
215
247
  const record = loadPkce(state);
216
248
  if (!record) {
249
+ emit(false, "unknown_state");
217
250
  return { ok: false, returnTo: "/", error: "unknown_state" };
218
251
  }
219
252
  clearPkce(state);
220
253
  const fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
221
254
  if (!fetchImpl) {
255
+ emit(false, "no_fetch");
222
256
  return { ok: false, returnTo: record.returnTo, error: "no_fetch" };
223
257
  }
224
258
  const tokenUrl = `${manager.issuerUrl}${options.tokenPath ?? DEFAULT_TOKEN_PATH}`;
@@ -237,10 +271,12 @@ async function handleAuthCallback(manager, options = {}) {
237
271
  const body = await res.json().catch(() => ({}));
238
272
  if (!res.ok) {
239
273
  const desc = body.error_description ?? body.error ?? "token_exchange_failed";
274
+ emit(false, desc);
240
275
  return { ok: false, returnTo: record.returnTo, error: desc };
241
276
  }
242
277
  const tokens = body;
243
278
  if (!tokens.access_token) {
279
+ emit(false, "missing_access_token");
244
280
  return { ok: false, returnTo: record.returnTo, error: "missing_access_token" };
245
281
  }
246
282
  if (tokens.refresh_token) {
@@ -248,21 +284,24 @@ async function handleAuthCallback(manager, options = {}) {
248
284
  setCookie(cookieName, tokens.refresh_token, { maxAgeSeconds: 60 * 60 * 24 * 30 });
249
285
  }
250
286
  manager.applyAccessToken(tokens.access_token, tokens.refresh_token);
287
+ emit(true);
251
288
  return { ok: true, returnTo: record.returnTo };
252
289
  }
253
290
  async function signOut(manager, opts = {}) {
254
291
  if (!opts.localOnly) {
255
292
  const issuer = manager.issuerUrl.replace(/\/$/, "");
293
+ const idempotency = manager.getIdempotencyToken();
256
294
  try {
257
295
  const url2 = `${issuer}${opts.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
258
- await manager.fetch(url2, { method: "POST" }).catch(() => void 0);
296
+ await manager.fetch(url2, { method: "POST", headers: { "X-IQAuth-Idempotency": idempotency } }).catch(() => void 0);
259
297
  } catch {
260
298
  }
261
299
  if (opts.endSsoSession !== false) {
262
300
  try {
263
301
  await fetch(`${issuer}${DEFAULT_SSO_LOGOUT_PATH}`, {
264
302
  method: "POST",
265
- credentials: "include"
303
+ credentials: "include",
304
+ headers: { "X-IQAuth-Idempotency": idempotency }
266
305
  }).catch(() => void 0);
267
306
  } catch {
268
307
  }
@@ -789,6 +828,7 @@ __export(react_exports, {
789
828
  UserButton: () => UserButton,
790
829
  UserProfile: () => UserProfile,
791
830
  Waitlist: () => Waitlist,
831
+ __useIQAuthInternal: () => __useIQAuthInternal,
792
832
  __version__: () => __version__,
793
833
  isReturnToAllowed: () => isReturnToAllowed,
794
834
  isSilentSsoEligible: () => isSilentSsoEligible,
@@ -854,14 +894,14 @@ function assertPublishableKey(raw, opts) {
854
894
  const ctx = opts?.context ? `${opts.context}: ` : "";
855
895
  if (typeof raw !== "string" || raw.length === 0) {
856
896
  throw new IQAuthError(
857
- "CONFIG_INVALID",
897
+ "config_invalid",
858
898
  `${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.`
859
899
  );
860
900
  }
861
901
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
862
902
  if (!shapeMatch) {
863
903
  throw new IQAuthError(
864
- "CONFIG_INVALID",
904
+ "config_invalid",
865
905
  `${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.`
866
906
  );
867
907
  }
@@ -870,19 +910,19 @@ function assertPublishableKey(raw, opts) {
870
910
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
871
911
  } catch {
872
912
  throw new IQAuthError(
873
- "CONFIG_INVALID",
913
+ "config_invalid",
874
914
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
875
915
  );
876
916
  }
877
917
  if (!isPublishableKeyPayload(decoded)) {
878
918
  throw new IQAuthError(
879
- "CONFIG_INVALID",
919
+ "config_invalid",
880
920
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
881
921
  );
882
922
  }
883
923
  if (!isValidIssuerUrl(decoded.iss)) {
884
924
  throw new IQAuthError(
885
- "CONFIG_INVALID",
925
+ "config_invalid",
886
926
  `${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.`
887
927
  );
888
928
  }
@@ -896,6 +936,7 @@ function isPublishableKeyPayload(value) {
896
936
 
897
937
  // src/browser/sessionManager.ts
898
938
  init_storage();
939
+ var PROBE_WAIT_MS = 80;
899
940
  var DEFAULT_REFRESH_PATH = "/api/v1/auth/refresh";
900
941
  var DEFAULT_USERINFO_PATH = "/api/v1/auth/me";
901
942
  async function readAuthErrorCode(res) {
@@ -933,7 +974,13 @@ function claimsToSessionUser(claims) {
933
974
  tenantId: claims.tenantId,
934
975
  vendorId: claims.vendorId,
935
976
  roles: claims.roles ?? [],
936
- entitlements: claims.entitlements ?? []
977
+ entitlements: claims.entitlements ?? [],
978
+ // SDK 2.7.0 (Task #124) — pass through identity claims when issued.
979
+ ...claims.picture !== void 0 ? { picture: claims.picture } : {},
980
+ ...claims.email_verified !== void 0 ? { emailVerified: claims.email_verified } : {},
981
+ ...claims.given_name !== void 0 ? { givenName: claims.given_name } : {},
982
+ ...claims.family_name !== void 0 ? { familyName: claims.family_name } : {},
983
+ ...claims.locale !== void 0 ? { locale: claims.locale } : {}
937
984
  };
938
985
  }
939
986
  var EMPTY = {
@@ -957,11 +1004,50 @@ var NO_OP_STORE = {
957
1004
  write: () => void 0,
958
1005
  clear: () => void 0
959
1006
  };
1007
+ var IDEMPOTENCY_HEADER = "X-IQAuth-Idempotency";
1008
+ function randomIdempotencyToken() {
1009
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
1010
+ const bytes = new Uint8Array(16);
1011
+ crypto.getRandomValues(bytes);
1012
+ let out = "";
1013
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
1014
+ return out;
1015
+ }
1016
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1017
+ }
960
1018
  var SessionManager = class {
961
1019
  constructor(options) {
962
1020
  this.snapshot = { ...EMPTY };
963
1021
  this.listeners = /* @__PURE__ */ new Set();
964
1022
  this.refreshPromise = null;
1023
+ /**
1024
+ * Cancellation handle for the in-flight refresh, if any. `signOut()` (or a
1025
+ * `session:signout` broadcast from another tab) calls `abort()` so the
1026
+ * refresh response is dropped before it can write a fresh access cookie
1027
+ * on top of the just-cleared session — the second root cause of "ghost
1028
+ * signed-in" sessions after Sign Out.
1029
+ */
1030
+ this.refreshAbort = null;
1031
+ /**
1032
+ * Set to `true` by `signOut()` / `signOutLocal()` for the lifetime of the
1033
+ * call. Used as a safety belt: even if a refresh response arrives while
1034
+ * `refreshAbort` was unable to interrupt the network call (e.g. the body
1035
+ * was already streaming back), `runRefresh` checks this flag before
1036
+ * mutating session state and bails out.
1037
+ */
1038
+ this.signoutInProgress = false;
1039
+ /**
1040
+ * Per-session opaque idempotency token. Sent as `X-IQAuth-Idempotency` on
1041
+ * every /refresh and /signout request the SDK makes through a framework
1042
+ * adapter (Express/Fastify/Hono/Next), so the adapter's `SignoutRegistry`
1043
+ * can collapse a refresh that lands moments after a signout — even when
1044
+ * the two requests are routed to different server instances (multi-replica
1045
+ * deployments).
1046
+ *
1047
+ * Generated lazily on first use, rotated on signout so the next session
1048
+ * starts with a fresh token. Opaque random — never the raw refresh token.
1049
+ */
1050
+ this.idempotencyToken = null;
965
1051
  this.channel = null;
966
1052
  this.proactiveTimer = null;
967
1053
  this.bootstrapped = false;
@@ -969,6 +1055,8 @@ var SessionManager = class {
969
1055
  this.remoteRefreshWaiters = [];
970
1056
  /** Active claims by other tabs (keyed by source tabId). */
971
1057
  this.foreignClaim = null;
1058
+ /** Resolver for an in-flight cross-tab `session:probe`, set during bootstrap. */
1059
+ this.probeResolver = null;
972
1060
  const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/browser SessionManager" });
973
1061
  this.key = parsed;
974
1062
  const inferred = options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`);
@@ -981,6 +1069,8 @@ var SessionManager = class {
981
1069
  this.refreshCookieName = options.cookieNames?.refresh ?? REFRESH_COOKIE;
982
1070
  this.tokenStore = options.tokenStore ?? (this.serverManagedSession ? NO_OP_STORE : this.useCookies ? defaultCookieStore(this.refreshCookieName) : NO_OP_STORE);
983
1071
  this.crossTabLockTimeoutMs = options.crossTabLockTimeoutMs ?? 4e3;
1072
+ this.debug = options.debug ?? false;
1073
+ this.onTimingEvent = options.onTimingEvent ?? null;
984
1074
  this.fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
985
1075
  throw new Error("global fetch is not available; pass fetchImpl");
986
1076
  }));
@@ -1007,10 +1097,35 @@ var SessionManager = class {
1007
1097
  get issuerUrl() {
1008
1098
  return this.issuer;
1009
1099
  }
1100
+ /**
1101
+ * SDK 2.7.0 (Task #124) — The hosted IQAuth host derived from the
1102
+ * publishable key's `iss` claim, normalized to URL form. This is what
1103
+ * `<SignIn/>` and `buildSignInUrl` use to talk to the hosted UI; it
1104
+ * deliberately ignores the `issuer` constructor override so a misrouted
1105
+ * `issuer` (e.g. pointed at the consumer app's own domain) cannot break
1106
+ * the hosted flow. Use {@link issuerUrl} for token / discovery endpoints.
1107
+ */
1108
+ get hostedIssuerUrl() {
1109
+ const iss = this.key.iss;
1110
+ return (iss.startsWith("http") ? iss : `https://${iss}`).replace(/\/+$/, "");
1111
+ }
1010
1112
  /** Cookie name the SDK uses for the refresh token (overridable via `cookieNames.refresh`). */
1011
1113
  get refreshCookie() {
1012
1114
  return this.refreshCookieName;
1013
1115
  }
1116
+ /**
1117
+ * Returns the current per-session idempotency token, generating one
1118
+ * lazily on first use. Sent as the `X-IQAuth-Idempotency` header on
1119
+ * /refresh and /signout requests so the framework adapter's
1120
+ * `SignoutRegistry` can collapse a refresh-vs-signout race even across
1121
+ * server instances.
1122
+ */
1123
+ getIdempotencyToken() {
1124
+ if (!this.idempotencyToken) {
1125
+ this.idempotencyToken = randomIdempotencyToken();
1126
+ }
1127
+ return this.idempotencyToken;
1128
+ }
1014
1129
  getSnapshot() {
1015
1130
  return this.snapshot;
1016
1131
  }
@@ -1024,9 +1139,44 @@ var SessionManager = class {
1024
1139
  * One-time bootstrap: warm the session from the refresh cookie if present.
1025
1140
  * Safe to call multiple times.
1026
1141
  */
1142
+ /**
1143
+ * Task #126: Public timing-event emitter. Used by the browser sign-in
1144
+ * helpers (redirectToSignIn / handleAuthCallback) to surface signIn-phase
1145
+ * timings through the same `debug` + `onTimingEvent` channel as
1146
+ * bootstrap/refresh. Safe to call from anywhere — internal callers
1147
+ * pre-compute durationMs.
1148
+ */
1149
+ recordTiming(phase, durationMs, ok, code) {
1150
+ this.emitTiming(phase, durationMs, ok, code);
1151
+ }
1152
+ /** Task #126: emit a session timing event to debug log + onTimingEvent hook. */
1153
+ emitTiming(phase, durationMs, ok, code) {
1154
+ if (this.debug) {
1155
+ try {
1156
+ console.debug("[iqauth_session]", { phase, durationMs, ok, code });
1157
+ } catch {
1158
+ }
1159
+ }
1160
+ if (this.onTimingEvent) {
1161
+ try {
1162
+ this.onTimingEvent({ phase, durationMs, ok, code });
1163
+ } catch {
1164
+ }
1165
+ }
1166
+ }
1027
1167
  async bootstrap() {
1028
1168
  if (this.bootstrapped) return;
1029
1169
  this.bootstrapped = true;
1170
+ const t0 = Date.now();
1171
+ try {
1172
+ await this.bootstrapInner();
1173
+ this.emitTiming("bootstrap", Date.now() - t0, this.snapshot.status === "authenticated");
1174
+ } catch (err) {
1175
+ this.emitTiming("bootstrap", Date.now() - t0, false, err instanceof Error ? err.message : "ERROR");
1176
+ throw err;
1177
+ }
1178
+ }
1179
+ async bootstrapInner() {
1030
1180
  if (this.serverManagedSession) {
1031
1181
  try {
1032
1182
  const res = await this.fetchImpl(`${this.issuer}${this.userinfoPath}`, {
@@ -1061,6 +1211,15 @@ var SessionManager = class {
1061
1211
  return;
1062
1212
  }
1063
1213
  }
1214
+ const peerSnapshot = await this.probePeers();
1215
+ if (peerSnapshot && peerSnapshot.status === "authenticated") {
1216
+ this.update({
1217
+ ...peerSnapshot,
1218
+ version: Math.max(this.snapshot.version, peerSnapshot.version) + 1
1219
+ });
1220
+ this.scheduleProactiveRefresh();
1221
+ return;
1222
+ }
1064
1223
  const stored = await Promise.resolve(this.tokenStore.read());
1065
1224
  if (!stored) {
1066
1225
  this.setStatus("unauthenticated");
@@ -1069,6 +1228,22 @@ var SessionManager = class {
1069
1228
  const ok = await this.refresh();
1070
1229
  if (!ok) this.setStatus("unauthenticated");
1071
1230
  }
1231
+ probePeers() {
1232
+ if (!this.channel) return Promise.resolve(null);
1233
+ return new Promise((resolve) => {
1234
+ let settled = false;
1235
+ const finish = (snap) => {
1236
+ if (settled) return;
1237
+ settled = true;
1238
+ this.probeResolver = null;
1239
+ clearTimeout(timer);
1240
+ resolve(snap);
1241
+ };
1242
+ this.probeResolver = (snap) => finish(snap);
1243
+ const timer = setTimeout(() => finish(null), PROBE_WAIT_MS);
1244
+ this.broadcastEnvelope({ type: "session:probe", source: this.tabId, ts: Date.now() });
1245
+ });
1246
+ }
1072
1247
  /**
1073
1248
  * Single-flight token refresh, coordinated across tabs via BroadcastChannel.
1074
1249
  *
@@ -1082,30 +1257,48 @@ var SessionManager = class {
1082
1257
  */
1083
1258
  refresh() {
1084
1259
  if (this.refreshPromise) return this.refreshPromise;
1085
- this.refreshPromise = this.runRefresh().finally(() => {
1260
+ const t0 = Date.now();
1261
+ this.refreshPromise = this.runRefresh().then((ok) => {
1262
+ this.emitTiming("refresh", Date.now() - t0, ok, ok ? void 0 : this.snapshot.error?.code ?? "REFRESH_FAILED");
1263
+ return ok;
1264
+ }).finally(() => {
1086
1265
  this.refreshPromise = null;
1087
1266
  });
1088
1267
  return this.refreshPromise;
1089
1268
  }
1090
1269
  async runRefresh() {
1091
- const myClaim = { source: this.tabId, ts: Date.now() };
1092
- if (this.channel) {
1093
- this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
1094
- await new Promise((r) => setTimeout(r, 25));
1095
- const foreign = this.foreignClaim;
1096
- if (foreign && this.claimWins(foreign, myClaim)) {
1097
- return this.waitForForeignRefresh();
1098
- }
1099
- }
1270
+ const abort = new AbortController();
1271
+ this.refreshAbort = abort;
1100
1272
  try {
1273
+ const myClaim = { source: this.tabId, ts: Date.now() };
1274
+ if (this.channel) {
1275
+ this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
1276
+ await new Promise((r) => setTimeout(r, 25));
1277
+ if (abort.signal.aborted || this.signoutInProgress) {
1278
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1279
+ return false;
1280
+ }
1281
+ const foreign = this.foreignClaim;
1282
+ if (foreign && this.claimWins(foreign, myClaim)) {
1283
+ return this.waitForForeignRefresh();
1284
+ }
1285
+ }
1101
1286
  const refreshToken = await Promise.resolve(this.tokenStore.read());
1102
1287
  const res = await this.fetchImpl(`${this.issuer}${this.refreshPath}`, {
1103
1288
  method: "POST",
1104
1289
  credentials: "include",
1105
- headers: { "Content-Type": "application/json" },
1106
- body: JSON.stringify(refreshToken ? { refreshToken } : {})
1290
+ headers: {
1291
+ "Content-Type": "application/json",
1292
+ [IDEMPOTENCY_HEADER]: this.getIdempotencyToken()
1293
+ },
1294
+ body: JSON.stringify(refreshToken ? { refreshToken } : {}),
1295
+ signal: abort.signal
1107
1296
  });
1108
1297
  const body = await res.json().catch(() => ({}));
1298
+ if (this.signoutInProgress || abort.signal.aborted) {
1299
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1300
+ return false;
1301
+ }
1109
1302
  const data = body.data;
1110
1303
  if (!res.ok || !body.success || !data?.accessToken) {
1111
1304
  const err = body.error;
@@ -1124,14 +1317,18 @@ var SessionManager = class {
1124
1317
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: true });
1125
1318
  return true;
1126
1319
  } catch (err) {
1127
- this.setError({
1128
- code: "NETWORK_ERROR",
1129
- message: err instanceof Error ? err.message : "Refresh request failed"
1130
- });
1320
+ const aborted = err?.name === "AbortError" || abort.signal.aborted || this.signoutInProgress;
1321
+ if (!aborted) {
1322
+ this.setError({
1323
+ code: "NETWORK_ERROR",
1324
+ message: err instanceof Error ? err.message : "Refresh request failed"
1325
+ });
1326
+ }
1131
1327
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1132
1328
  return false;
1133
1329
  } finally {
1134
1330
  this.foreignClaim = null;
1331
+ if (this.refreshAbort === abort) this.refreshAbort = null;
1135
1332
  }
1136
1333
  }
1137
1334
  claimWins(foreign, mine) {
@@ -1239,6 +1436,14 @@ var SessionManager = class {
1239
1436
  * the server-side logout request.
1240
1437
  */
1241
1438
  signOutLocal(status = "unauthenticated") {
1439
+ this.signoutInProgress = true;
1440
+ if (this.refreshAbort) {
1441
+ try {
1442
+ this.refreshAbort.abort();
1443
+ } catch {
1444
+ }
1445
+ this.refreshAbort = null;
1446
+ }
1242
1447
  void Promise.resolve(this.tokenStore.clear());
1243
1448
  if (this.proactiveTimer) {
1244
1449
  clearTimeout(this.proactiveTimer);
@@ -1253,7 +1458,12 @@ var SessionManager = class {
1253
1458
  error: null,
1254
1459
  version: this.snapshot.version + 1
1255
1460
  });
1461
+ this.broadcastEnvelope({ type: "refresh:abort", source: this.tabId, ts: Date.now() });
1256
1462
  this.broadcast("session:signout");
1463
+ this.idempotencyToken = null;
1464
+ setTimeout(() => {
1465
+ this.signoutInProgress = false;
1466
+ }, 0);
1257
1467
  }
1258
1468
  /**
1259
1469
  * Replace the refresh-token store at runtime. Used by the F22
@@ -1317,6 +1527,12 @@ var SessionManager = class {
1317
1527
  }
1318
1528
  onBroadcast(env) {
1319
1529
  if (!env || env.source === this.tabId) return;
1530
+ if (env.type === "session:probe") {
1531
+ if (this.snapshot.status === "authenticated") {
1532
+ this.broadcast("session:update");
1533
+ }
1534
+ return;
1535
+ }
1320
1536
  if (env.type === "refresh:claim") {
1321
1537
  this.foreignClaim = { source: env.source, ts: env.ts };
1322
1538
  return;
@@ -1329,6 +1545,24 @@ var SessionManager = class {
1329
1545
  this.foreignClaim = null;
1330
1546
  return;
1331
1547
  }
1548
+ if (env.type === "refresh:abort") {
1549
+ this.signoutInProgress = true;
1550
+ if (this.refreshAbort) {
1551
+ try {
1552
+ this.refreshAbort.abort();
1553
+ } catch {
1554
+ }
1555
+ this.refreshAbort = null;
1556
+ }
1557
+ const waiters = this.remoteRefreshWaiters;
1558
+ this.remoteRefreshWaiters = [];
1559
+ for (const w of waiters) w(false);
1560
+ this.foreignClaim = null;
1561
+ setTimeout(() => {
1562
+ this.signoutInProgress = false;
1563
+ }, 0);
1564
+ return;
1565
+ }
1332
1566
  if (env.type === "session:signout") {
1333
1567
  this.update({
1334
1568
  status: "unauthenticated",
@@ -1342,6 +1576,12 @@ var SessionManager = class {
1342
1576
  return;
1343
1577
  }
1344
1578
  if ((env.type === "session:update" || env.type === "session:refresh") && env.payload) {
1579
+ if (this.probeResolver && env.payload.status === "authenticated") {
1580
+ const r = this.probeResolver;
1581
+ this.probeResolver = null;
1582
+ r(env.payload);
1583
+ return;
1584
+ }
1345
1585
  this.update({
1346
1586
  ...env.payload,
1347
1587
  version: Math.max(this.snapshot.version, env.payload.version) + 1
@@ -1355,6 +1595,32 @@ var SessionManager = class {
1355
1595
  }
1356
1596
  };
1357
1597
 
1598
+ // src/browser/hostedIssuerGuard.ts
1599
+ var HOSTED_ISSUER_MISMATCH_CODE = "IQAUTH_HOSTED_ISSUER_MISMATCH";
1600
+ var HOSTED_ISSUER_MISMATCH_DOCS_URL = "https://docs.dispositioniq.com/iqauth/errors#hosted-issuer-mismatch";
1601
+ function computeHostedIssuerMismatch(input) {
1602
+ const {
1603
+ nodeEnv,
1604
+ fetchError,
1605
+ explicitOverride,
1606
+ resolvedBaseUrl,
1607
+ managerIssuerUrl,
1608
+ hostedIssuerUrl,
1609
+ appKey
1610
+ } = input;
1611
+ if (nodeEnv === "production") return null;
1612
+ if (!fetchError) return null;
1613
+ if (explicitOverride) return null;
1614
+ if (!managerIssuerUrl || !hostedIssuerUrl) return null;
1615
+ if (resolvedBaseUrl !== managerIssuerUrl) return null;
1616
+ if (resolvedBaseUrl === hostedIssuerUrl) return null;
1617
+ const e = new Error(
1618
+ `[IQAuth] ${HOSTED_ISSUER_MISMATCH_CODE}: <SignIn /> targeted "${resolvedBaseUrl}" (inherited from <IQAuthProvider issuer="\u2026"/>), but that host does not serve /api/public/apps/${appKey}/sign-in-context. The hosted UI lives at the publishable key's issuer: "${hostedIssuerUrl}". Fix: drop the <IQAuthProvider issuer="\u2026"/> override so the SDK uses the publishable key's iss, OR pass <SignIn iqAuthBaseUrl="${hostedIssuerUrl}"/> explicitly. Docs: ${HOSTED_ISSUER_MISMATCH_DOCS_URL}. Underlying fetch error: ${fetchError}`
1619
+ );
1620
+ e.code = HOSTED_ISSUER_MISMATCH_CODE;
1621
+ return e;
1622
+ }
1623
+
1358
1624
  // src/react/index.tsx
1359
1625
  init_signIn();
1360
1626
 
@@ -1909,6 +2175,9 @@ function IQAuthProvider({
1909
2175
  );
1910
2176
  return (0, import_react.createElement)(IQAuthContext.Provider, { value }, children);
1911
2177
  }
2178
+ function __useIQAuthInternal() {
2179
+ return useCtx();
2180
+ }
1912
2181
  function useCtx() {
1913
2182
  const ctx = (0, import_react.useContext)(IQAuthContext);
1914
2183
  if (!ctx) throw new Error("IQAuth hooks must be used inside <IQAuthProvider>");
@@ -2817,7 +3086,7 @@ function isSilentSsoEligible(ctx, effectivePrompt) {
2817
3086
  }
2818
3087
  function SignIn(props) {
2819
3088
  const providerCtx = (0, import_react.useContext)(IQAuthContext);
2820
- const iqAuthBaseUrl = props.iqAuthBaseUrl ?? providerCtx?.manager.issuerUrl ?? "";
3089
+ const iqAuthBaseUrl = props.iqAuthBaseUrl ?? providerCtx?.manager.hostedIssuerUrl ?? "";
2821
3090
  const appKey = props.appKey ?? providerCtx?.manager.appKey ?? "";
2822
3091
  const returnTo = props.returnTo ?? (typeof window !== "undefined" ? `${window.location.origin}/api/iqauth/callback` : "");
2823
3092
  const { onRedirect, className, prompt, appearance: instanceAppearance, silentSso: instanceSilentSso } = props;
@@ -2831,6 +3100,16 @@ function SignIn(props) {
2831
3100
  const t2 = useT();
2832
3101
  const localeBundle = useLocale();
2833
3102
  const { ctx, loading, error } = useIQAuthSignInContext(iqAuthBaseUrl, appKey, returnTo);
3103
+ const guardError = computeHostedIssuerMismatch({
3104
+ nodeEnv: typeof process !== "undefined" ? process.env?.NODE_ENV : void 0,
3105
+ fetchError: error,
3106
+ explicitOverride: !!props.iqAuthBaseUrl,
3107
+ resolvedBaseUrl: iqAuthBaseUrl,
3108
+ managerIssuerUrl: providerCtx?.manager.issuerUrl,
3109
+ hostedIssuerUrl: providerCtx?.manager.hostedIssuerUrl,
3110
+ appKey
3111
+ });
3112
+ if (guardError) throw guardError;
2834
3113
  const preflightLoggedRef = (0, import_react.useRef)(false);
2835
3114
  (0, import_react.useEffect)(() => {
2836
3115
  if (!ctx || preflightLoggedRef.current) return;
@@ -4409,6 +4688,7 @@ var __version__ = "phase-bc-1.0.0";
4409
4688
  UserButton,
4410
4689
  UserProfile,
4411
4690
  Waitlist,
4691
+ __useIQAuthInternal,
4412
4692
  __version__,
4413
4693
  isReturnToAllowed,
4414
4694
  isSilentSsoEligible,