@iqauth/sdk 2.6.3 → 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 (112) 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 +10 -8
  10. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  11. package/dist/chunk-C2ZTBOAC.mjs +36 -0
  12. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  13. package/dist/chunk-GLXSIGVS.mjs +66 -0
  14. package/dist/{chunk-TKZTCPEK.mjs → chunk-GN37E64I.mjs} +32 -40
  15. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  16. package/dist/{chunk-W3F4JYGP.mjs → chunk-JXQI62A7.mjs} +108 -18
  17. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  18. package/dist/chunk-PMAFENVI.mjs +229 -0
  19. package/dist/chunk-RR2MGPTK.mjs +2724 -0
  20. package/dist/{chunk-76W5TLQQ.mjs → chunk-RTJAIBXY.mjs} +220 -20
  21. package/dist/{chunk-6TDJJER7.mjs → chunk-RUJXRTEW.mjs} +164 -5
  22. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  23. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  24. package/dist/{chunk-BVV54LPI.mjs → chunk-YVALAG3B.mjs} +10 -4
  25. package/dist/cli/index.js +2 -2
  26. package/dist/cli/index.mjs +2 -2
  27. package/dist/{client-kYlJFgPv.d.mts → client-BGFnBpfc.d.mts} +47 -4
  28. package/dist/{client-BNQe3AgF.d.ts → client-CDQ21LvW.d.ts} +47 -4
  29. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  30. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  31. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  32. package/dist/{express-B6_1vBYZ.d.mts → express-CVNQEkOr.d.mts} +2 -2
  33. package/dist/{express-CHpfa7D_.d.ts → express-Piv2WhWM.d.ts} +2 -2
  34. package/dist/express.d.mts +7 -6
  35. package/dist/express.d.ts +7 -6
  36. package/dist/express.js +349 -52
  37. package/dist/express.mjs +39 -12
  38. package/dist/fastify.d.mts +2 -0
  39. package/dist/fastify.d.ts +2 -0
  40. package/dist/fastify.js +332 -52
  41. package/dist/fastify.mjs +23 -8
  42. package/dist/hono.d.mts +2 -0
  43. package/dist/hono.d.ts +2 -0
  44. package/dist/hono.js +329 -52
  45. package/dist/hono.mjs +20 -8
  46. package/dist/index-5KSZEnDe.d.ts +1626 -0
  47. package/dist/index-CKoZHAoc.d.mts +1626 -0
  48. package/dist/index.d.mts +56 -8
  49. package/dist/index.d.ts +56 -8
  50. package/dist/index.js +565 -69
  51. package/dist/index.mjs +29 -9
  52. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  53. package/dist/locales.d.mts +1 -1
  54. package/dist/locales.d.ts +1 -1
  55. package/dist/mobile.d.mts +77 -7
  56. package/dist/mobile.d.ts +77 -7
  57. package/dist/mobile.js +276 -41
  58. package/dist/mobile.mjs +98 -3
  59. package/dist/next.d.mts +2 -1
  60. package/dist/next.d.ts +2 -1
  61. package/dist/next.js +391 -201
  62. package/dist/next.mjs +22 -7
  63. package/dist/pkce-7WKV4OIN.mjs +11 -0
  64. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-CGpMRie4.d.ts} +1 -1
  65. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-M5G47LWO.d.mts} +1 -1
  66. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  67. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  68. package/dist/react-permissions.d.mts +52 -0
  69. package/dist/react-permissions.d.ts +52 -0
  70. package/dist/react-permissions.js +239 -0
  71. package/dist/react-permissions.mjs +97 -0
  72. package/dist/react.d.mts +9 -1624
  73. package/dist/react.d.ts +9 -1624
  74. package/dist/react.js +343 -36
  75. package/dist/react.mjs +59 -2611
  76. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  77. package/dist/server/handlers.d.mts +148 -3
  78. package/dist/server/handlers.d.ts +148 -3
  79. package/dist/server/handlers.js +410 -11
  80. package/dist/server/handlers.mjs +12 -3
  81. package/dist/server.d.mts +151 -8
  82. package/dist/server.d.ts +151 -8
  83. package/dist/server.js +406 -50
  84. package/dist/server.mjs +93 -11
  85. package/dist/service.d.mts +4 -4
  86. package/dist/service.d.ts +4 -4
  87. package/dist/service.js +181 -41
  88. package/dist/service.mjs +3 -3
  89. package/dist/{signIn-CiIBTJIh.d.mts → signIn-BLFnz8SV.d.ts} +78 -3
  90. package/dist/{signIn-CCY4JE5G.mjs → signIn-SHBW6Z4T.mjs} +2 -1
  91. package/dist/{signIn-OCr88Zf8.d.ts → signIn-T-CZ6t6r.d.mts} +78 -3
  92. package/dist/test.mjs +3 -3
  93. package/dist/{tokens-DCyzzn8L.d.mts → tokens-Bqhmqq_R.d.ts} +9 -2
  94. package/dist/{tokens-aHiGFr_E.d.ts → tokens-CITeoG6P.d.mts} +9 -2
  95. package/dist/{types-6bNdxesb.d.ts → types-BdQ2lqfT.d.mts} +1 -1
  96. package/dist/{types-6bNdxesb.d.mts → types-BdQ2lqfT.d.ts} +1 -1
  97. package/dist/{types-DZAflmmq.d.mts → types-XOV9XPVi.d.mts} +99 -10
  98. package/dist/{types-DZAflmmq.d.ts → types-XOV9XPVi.d.ts} +99 -10
  99. package/dist/webhooks.d.mts +100 -17
  100. package/dist/webhooks.d.ts +100 -17
  101. package/dist/webhooks.js +164 -15
  102. package/dist/webhooks.mjs +7 -1
  103. package/dist/ws.d.mts +2 -2
  104. package/dist/ws.d.ts +2 -2
  105. package/dist/ws.js +80 -30
  106. package/dist/ws.mjs +4 -4
  107. package/docs/error-handling.md +101 -0
  108. package/docs/guides/effective-permissions.md +171 -0
  109. package/package.json +13 -3
  110. package/dist/chunk-UKZLOHZG.mjs +0 -83
  111. package/dist/errors-CDdl24MP.d.mts +0 -52
  112. 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
  }
@@ -104,6 +121,12 @@ var init_storage = __esm({
104
121
  });
105
122
 
106
123
  // src/browser/pkce.ts
124
+ var pkce_exports = {};
125
+ __export(pkce_exports, {
126
+ createPkcePair: () => createPkcePair,
127
+ randomUrlSafe: () => randomUrlSafe,
128
+ s256Challenge: () => s256Challenge
129
+ });
107
130
  function getCrypto() {
108
131
  if (typeof globalThis !== "undefined" && globalThis.crypto) {
109
132
  return globalThis.crypto;
@@ -171,7 +194,7 @@ async function buildSignInUrl(manager, opts = {}) {
171
194
  returnTo,
172
195
  createdAt: Date.now()
173
196
  });
174
- 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);
175
198
  url2.searchParams.set("response_type", "code");
176
199
  url2.searchParams.set("app", manager.appKey);
177
200
  url2.searchParams.set("publishable_key", manager.publishableKey.raw);
@@ -186,33 +209,50 @@ async function buildSignInUrl(manager, opts = {}) {
186
209
  return url2.toString();
187
210
  }
188
211
  async function redirectToSignIn(manager, opts = {}) {
189
- const url2 = await buildSignInUrl(manager, opts);
190
- if (typeof window === "undefined") {
191
- 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;
192
227
  }
193
- window.location.assign(url2);
194
228
  }
195
229
  async function signIn(manager, opts = {}) {
196
230
  return redirectToSignIn(manager, opts);
197
231
  }
198
232
  async function handleAuthCallback(manager, options = {}) {
233
+ const t0 = Date.now();
234
+ const emit = (ok, code2) => manager.recordTiming("signIn", Date.now() - t0, ok, code2);
199
235
  const url2 = new URL(options.url ?? (typeof window !== "undefined" ? window.location.href : ""));
200
236
  const code = url2.searchParams.get("code");
201
237
  const state = url2.searchParams.get("state");
202
238
  const errorParam = url2.searchParams.get("error");
203
239
  if (errorParam) {
240
+ emit(false, errorParam);
204
241
  return { ok: false, returnTo: "/", error: errorParam };
205
242
  }
206
243
  if (!code || !state) {
244
+ emit(false, "missing_code_or_state");
207
245
  return { ok: false, returnTo: "/", error: "missing_code_or_state" };
208
246
  }
209
247
  const record = loadPkce(state);
210
248
  if (!record) {
249
+ emit(false, "unknown_state");
211
250
  return { ok: false, returnTo: "/", error: "unknown_state" };
212
251
  }
213
252
  clearPkce(state);
214
253
  const fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
215
254
  if (!fetchImpl) {
255
+ emit(false, "no_fetch");
216
256
  return { ok: false, returnTo: record.returnTo, error: "no_fetch" };
217
257
  }
218
258
  const tokenUrl = `${manager.issuerUrl}${options.tokenPath ?? DEFAULT_TOKEN_PATH}`;
@@ -231,10 +271,12 @@ async function handleAuthCallback(manager, options = {}) {
231
271
  const body = await res.json().catch(() => ({}));
232
272
  if (!res.ok) {
233
273
  const desc = body.error_description ?? body.error ?? "token_exchange_failed";
274
+ emit(false, desc);
234
275
  return { ok: false, returnTo: record.returnTo, error: desc };
235
276
  }
236
277
  const tokens = body;
237
278
  if (!tokens.access_token) {
279
+ emit(false, "missing_access_token");
238
280
  return { ok: false, returnTo: record.returnTo, error: "missing_access_token" };
239
281
  }
240
282
  if (tokens.refresh_token) {
@@ -242,21 +284,24 @@ async function handleAuthCallback(manager, options = {}) {
242
284
  setCookie(cookieName, tokens.refresh_token, { maxAgeSeconds: 60 * 60 * 24 * 30 });
243
285
  }
244
286
  manager.applyAccessToken(tokens.access_token, tokens.refresh_token);
287
+ emit(true);
245
288
  return { ok: true, returnTo: record.returnTo };
246
289
  }
247
290
  async function signOut(manager, opts = {}) {
248
291
  if (!opts.localOnly) {
249
292
  const issuer = manager.issuerUrl.replace(/\/$/, "");
293
+ const idempotency = manager.getIdempotencyToken();
250
294
  try {
251
295
  const url2 = `${issuer}${opts.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
252
- await manager.fetch(url2, { method: "POST" }).catch(() => void 0);
296
+ await manager.fetch(url2, { method: "POST", headers: { "X-IQAuth-Idempotency": idempotency } }).catch(() => void 0);
253
297
  } catch {
254
298
  }
255
299
  if (opts.endSsoSession !== false) {
256
300
  try {
257
301
  await fetch(`${issuer}${DEFAULT_SSO_LOGOUT_PATH}`, {
258
302
  method: "POST",
259
- credentials: "include"
303
+ credentials: "include",
304
+ headers: { "X-IQAuth-Idempotency": idempotency }
260
305
  }).catch(() => void 0);
261
306
  } catch {
262
307
  }
@@ -783,6 +828,7 @@ __export(react_exports, {
783
828
  UserButton: () => UserButton,
784
829
  UserProfile: () => UserProfile,
785
830
  Waitlist: () => Waitlist,
831
+ __useIQAuthInternal: () => __useIQAuthInternal,
786
832
  __version__: () => __version__,
787
833
  isReturnToAllowed: () => isReturnToAllowed,
788
834
  isSilentSsoEligible: () => isSilentSsoEligible,
@@ -848,14 +894,14 @@ function assertPublishableKey(raw, opts) {
848
894
  const ctx = opts?.context ? `${opts.context}: ` : "";
849
895
  if (typeof raw !== "string" || raw.length === 0) {
850
896
  throw new IQAuthError(
851
- "CONFIG_INVALID",
897
+ "config_invalid",
852
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.`
853
899
  );
854
900
  }
855
901
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
856
902
  if (!shapeMatch) {
857
903
  throw new IQAuthError(
858
- "CONFIG_INVALID",
904
+ "config_invalid",
859
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.`
860
906
  );
861
907
  }
@@ -864,19 +910,19 @@ function assertPublishableKey(raw, opts) {
864
910
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
865
911
  } catch {
866
912
  throw new IQAuthError(
867
- "CONFIG_INVALID",
913
+ "config_invalid",
868
914
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
869
915
  );
870
916
  }
871
917
  if (!isPublishableKeyPayload(decoded)) {
872
918
  throw new IQAuthError(
873
- "CONFIG_INVALID",
919
+ "config_invalid",
874
920
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
875
921
  );
876
922
  }
877
923
  if (!isValidIssuerUrl(decoded.iss)) {
878
924
  throw new IQAuthError(
879
- "CONFIG_INVALID",
925
+ "config_invalid",
880
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.`
881
927
  );
882
928
  }
@@ -890,6 +936,7 @@ function isPublishableKeyPayload(value) {
890
936
 
891
937
  // src/browser/sessionManager.ts
892
938
  init_storage();
939
+ var PROBE_WAIT_MS = 80;
893
940
  var DEFAULT_REFRESH_PATH = "/api/v1/auth/refresh";
894
941
  var DEFAULT_USERINFO_PATH = "/api/v1/auth/me";
895
942
  async function readAuthErrorCode(res) {
@@ -927,7 +974,13 @@ function claimsToSessionUser(claims) {
927
974
  tenantId: claims.tenantId,
928
975
  vendorId: claims.vendorId,
929
976
  roles: claims.roles ?? [],
930
- 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 } : {}
931
984
  };
932
985
  }
933
986
  var EMPTY = {
@@ -951,11 +1004,50 @@ var NO_OP_STORE = {
951
1004
  write: () => void 0,
952
1005
  clear: () => void 0
953
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
+ }
954
1018
  var SessionManager = class {
955
1019
  constructor(options) {
956
1020
  this.snapshot = { ...EMPTY };
957
1021
  this.listeners = /* @__PURE__ */ new Set();
958
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;
959
1051
  this.channel = null;
960
1052
  this.proactiveTimer = null;
961
1053
  this.bootstrapped = false;
@@ -963,6 +1055,8 @@ var SessionManager = class {
963
1055
  this.remoteRefreshWaiters = [];
964
1056
  /** Active claims by other tabs (keyed by source tabId). */
965
1057
  this.foreignClaim = null;
1058
+ /** Resolver for an in-flight cross-tab `session:probe`, set during bootstrap. */
1059
+ this.probeResolver = null;
966
1060
  const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/browser SessionManager" });
967
1061
  this.key = parsed;
968
1062
  const inferred = options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`);
@@ -975,6 +1069,8 @@ var SessionManager = class {
975
1069
  this.refreshCookieName = options.cookieNames?.refresh ?? REFRESH_COOKIE;
976
1070
  this.tokenStore = options.tokenStore ?? (this.serverManagedSession ? NO_OP_STORE : this.useCookies ? defaultCookieStore(this.refreshCookieName) : NO_OP_STORE);
977
1071
  this.crossTabLockTimeoutMs = options.crossTabLockTimeoutMs ?? 4e3;
1072
+ this.debug = options.debug ?? false;
1073
+ this.onTimingEvent = options.onTimingEvent ?? null;
978
1074
  this.fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
979
1075
  throw new Error("global fetch is not available; pass fetchImpl");
980
1076
  }));
@@ -1001,10 +1097,35 @@ var SessionManager = class {
1001
1097
  get issuerUrl() {
1002
1098
  return this.issuer;
1003
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
+ }
1004
1112
  /** Cookie name the SDK uses for the refresh token (overridable via `cookieNames.refresh`). */
1005
1113
  get refreshCookie() {
1006
1114
  return this.refreshCookieName;
1007
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
+ }
1008
1129
  getSnapshot() {
1009
1130
  return this.snapshot;
1010
1131
  }
@@ -1018,9 +1139,44 @@ var SessionManager = class {
1018
1139
  * One-time bootstrap: warm the session from the refresh cookie if present.
1019
1140
  * Safe to call multiple times.
1020
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
+ }
1021
1167
  async bootstrap() {
1022
1168
  if (this.bootstrapped) return;
1023
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() {
1024
1180
  if (this.serverManagedSession) {
1025
1181
  try {
1026
1182
  const res = await this.fetchImpl(`${this.issuer}${this.userinfoPath}`, {
@@ -1055,6 +1211,15 @@ var SessionManager = class {
1055
1211
  return;
1056
1212
  }
1057
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
+ }
1058
1223
  const stored = await Promise.resolve(this.tokenStore.read());
1059
1224
  if (!stored) {
1060
1225
  this.setStatus("unauthenticated");
@@ -1063,6 +1228,22 @@ var SessionManager = class {
1063
1228
  const ok = await this.refresh();
1064
1229
  if (!ok) this.setStatus("unauthenticated");
1065
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
+ }
1066
1247
  /**
1067
1248
  * Single-flight token refresh, coordinated across tabs via BroadcastChannel.
1068
1249
  *
@@ -1076,30 +1257,48 @@ var SessionManager = class {
1076
1257
  */
1077
1258
  refresh() {
1078
1259
  if (this.refreshPromise) return this.refreshPromise;
1079
- 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(() => {
1080
1265
  this.refreshPromise = null;
1081
1266
  });
1082
1267
  return this.refreshPromise;
1083
1268
  }
1084
1269
  async runRefresh() {
1085
- const myClaim = { source: this.tabId, ts: Date.now() };
1086
- if (this.channel) {
1087
- this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
1088
- await new Promise((r) => setTimeout(r, 25));
1089
- const foreign = this.foreignClaim;
1090
- if (foreign && this.claimWins(foreign, myClaim)) {
1091
- return this.waitForForeignRefresh();
1092
- }
1093
- }
1270
+ const abort = new AbortController();
1271
+ this.refreshAbort = abort;
1094
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
+ }
1095
1286
  const refreshToken = await Promise.resolve(this.tokenStore.read());
1096
1287
  const res = await this.fetchImpl(`${this.issuer}${this.refreshPath}`, {
1097
1288
  method: "POST",
1098
1289
  credentials: "include",
1099
- headers: { "Content-Type": "application/json" },
1100
- 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
1101
1296
  });
1102
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
+ }
1103
1302
  const data = body.data;
1104
1303
  if (!res.ok || !body.success || !data?.accessToken) {
1105
1304
  const err = body.error;
@@ -1118,14 +1317,18 @@ var SessionManager = class {
1118
1317
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: true });
1119
1318
  return true;
1120
1319
  } catch (err) {
1121
- this.setError({
1122
- code: "NETWORK_ERROR",
1123
- message: err instanceof Error ? err.message : "Refresh request failed"
1124
- });
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
+ }
1125
1327
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1126
1328
  return false;
1127
1329
  } finally {
1128
1330
  this.foreignClaim = null;
1331
+ if (this.refreshAbort === abort) this.refreshAbort = null;
1129
1332
  }
1130
1333
  }
1131
1334
  claimWins(foreign, mine) {
@@ -1233,6 +1436,14 @@ var SessionManager = class {
1233
1436
  * the server-side logout request.
1234
1437
  */
1235
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
+ }
1236
1447
  void Promise.resolve(this.tokenStore.clear());
1237
1448
  if (this.proactiveTimer) {
1238
1449
  clearTimeout(this.proactiveTimer);
@@ -1247,7 +1458,12 @@ var SessionManager = class {
1247
1458
  error: null,
1248
1459
  version: this.snapshot.version + 1
1249
1460
  });
1461
+ this.broadcastEnvelope({ type: "refresh:abort", source: this.tabId, ts: Date.now() });
1250
1462
  this.broadcast("session:signout");
1463
+ this.idempotencyToken = null;
1464
+ setTimeout(() => {
1465
+ this.signoutInProgress = false;
1466
+ }, 0);
1251
1467
  }
1252
1468
  /**
1253
1469
  * Replace the refresh-token store at runtime. Used by the F22
@@ -1311,6 +1527,12 @@ var SessionManager = class {
1311
1527
  }
1312
1528
  onBroadcast(env) {
1313
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
+ }
1314
1536
  if (env.type === "refresh:claim") {
1315
1537
  this.foreignClaim = { source: env.source, ts: env.ts };
1316
1538
  return;
@@ -1323,6 +1545,24 @@ var SessionManager = class {
1323
1545
  this.foreignClaim = null;
1324
1546
  return;
1325
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
+ }
1326
1566
  if (env.type === "session:signout") {
1327
1567
  this.update({
1328
1568
  status: "unauthenticated",
@@ -1336,6 +1576,12 @@ var SessionManager = class {
1336
1576
  return;
1337
1577
  }
1338
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
+ }
1339
1585
  this.update({
1340
1586
  ...env.payload,
1341
1587
  version: Math.max(this.snapshot.version, env.payload.version) + 1
@@ -1349,6 +1595,32 @@ var SessionManager = class {
1349
1595
  }
1350
1596
  };
1351
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
+
1352
1624
  // src/react/index.tsx
1353
1625
  init_signIn();
1354
1626
 
@@ -1903,6 +2175,9 @@ function IQAuthProvider({
1903
2175
  );
1904
2176
  return (0, import_react.createElement)(IQAuthContext.Provider, { value }, children);
1905
2177
  }
2178
+ function __useIQAuthInternal() {
2179
+ return useCtx();
2180
+ }
1906
2181
  function useCtx() {
1907
2182
  const ctx = (0, import_react.useContext)(IQAuthContext);
1908
2183
  if (!ctx) throw new Error("IQAuth hooks must be used inside <IQAuthProvider>");
@@ -2811,7 +3086,7 @@ function isSilentSsoEligible(ctx, effectivePrompt) {
2811
3086
  }
2812
3087
  function SignIn(props) {
2813
3088
  const providerCtx = (0, import_react.useContext)(IQAuthContext);
2814
- const iqAuthBaseUrl = props.iqAuthBaseUrl ?? providerCtx?.manager.issuerUrl ?? "";
3089
+ const iqAuthBaseUrl = props.iqAuthBaseUrl ?? providerCtx?.manager.hostedIssuerUrl ?? "";
2815
3090
  const appKey = props.appKey ?? providerCtx?.manager.appKey ?? "";
2816
3091
  const returnTo = props.returnTo ?? (typeof window !== "undefined" ? `${window.location.origin}/api/iqauth/callback` : "");
2817
3092
  const { onRedirect, className, prompt, appearance: instanceAppearance, silentSso: instanceSilentSso } = props;
@@ -2825,6 +3100,16 @@ function SignIn(props) {
2825
3100
  const t2 = useT();
2826
3101
  const localeBundle = useLocale();
2827
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;
2828
3113
  const preflightLoggedRef = (0, import_react.useRef)(false);
2829
3114
  (0, import_react.useEffect)(() => {
2830
3115
  if (!ctx || preflightLoggedRef.current) return;
@@ -2934,13 +3219,34 @@ function SignIn(props) {
2934
3219
  if (!handlePayload(payload)) setFormError(localizeError(localeBundle, { code: payload.error, message: payload.error_description }));
2935
3220
  setSubmitting(false);
2936
3221
  };
2937
- const startGoogleLogin = () => {
3222
+ const startGoogleLogin = async () => {
2938
3223
  if (!ctx?.app.defaultClientId) {
2939
3224
  setFormError("Application is not configured for hosted sign-in.");
2940
3225
  return;
2941
3226
  }
2942
- const bridgeUrl = window.location.href;
2943
- const url2 = `${iqAuthBaseUrl.replace(/\/$/, "")}/api/v1/auth/google?redirect_uri=${encodeURIComponent(bridgeUrl)}&client_id=${encodeURIComponent(ctx.app.defaultClientId)}`;
3227
+ let pkce;
3228
+ try {
3229
+ const mod = await Promise.resolve().then(() => (init_pkce(), pkce_exports));
3230
+ pkce = await mod.createPkcePair();
3231
+ } catch (err) {
3232
+ setFormError(err.message || "Unable to initialize Google sign-in");
3233
+ return;
3234
+ }
3235
+ if (typeof document !== "undefined") {
3236
+ const cookieAttrs = "; path=/; SameSite=Lax" + (window.location.protocol === "https:" ? "; Secure" : "");
3237
+ document.cookie = `iqauth_pkce=${pkce.codeVerifier}${cookieAttrs}`;
3238
+ document.cookie = `iqauth_state=${pkce.state}${cookieAttrs}`;
3239
+ }
3240
+ const params = new URLSearchParams({
3241
+ redirect_uri: returnTo,
3242
+ client_id: ctx.app.defaultClientId,
3243
+ state: pkce.state,
3244
+ nonce: pkce.nonce,
3245
+ code_challenge: pkce.codeChallenge,
3246
+ code_challenge_method: "S256",
3247
+ scope: "openid profile email"
3248
+ });
3249
+ const url2 = `${iqAuthBaseUrl.replace(/\/$/, "")}/api/v1/auth/google?${params.toString()}`;
2944
3250
  window.location.href = url2;
2945
3251
  };
2946
3252
  (0, import_react.useEffect)(() => {
@@ -4382,6 +4688,7 @@ var __version__ = "phase-bc-1.0.0";
4382
4688
  UserButton,
4383
4689
  UserProfile,
4384
4690
  Waitlist,
4691
+ __useIQAuthInternal,
4385
4692
  __version__,
4386
4693
  isReturnToAllowed,
4387
4694
  isSilentSsoEligible,