@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
@@ -3,15 +3,16 @@ import {
3
3
  clearCookie,
4
4
  getCookie,
5
5
  setCookie
6
- } from "./chunk-DJIBN2N7.mjs";
6
+ } from "./chunk-GN37E64I.mjs";
7
7
  import {
8
8
  assertPublishableKey
9
- } from "./chunk-WQWBJSSS.mjs";
9
+ } from "./chunk-HVHNYPDC.mjs";
10
10
  import {
11
11
  IQAuthError
12
- } from "./chunk-6I6RM4MN.mjs";
12
+ } from "./chunk-6PJRLRB4.mjs";
13
13
 
14
14
  // src/browser/sessionManager.ts
15
+ var PROBE_WAIT_MS = 80;
15
16
  var DEFAULT_REFRESH_PATH = "/api/v1/auth/refresh";
16
17
  var DEFAULT_USERINFO_PATH = "/api/v1/auth/me";
17
18
  async function readAuthErrorCode(res) {
@@ -49,7 +50,16 @@ function claimsToSessionUser(claims) {
49
50
  tenantId: claims.tenantId,
50
51
  vendorId: claims.vendorId,
51
52
  roles: claims.roles ?? [],
52
- entitlements: claims.entitlements ?? []
53
+ entitlements: claims.entitlements ?? [],
54
+ // SDK 2.7.0 (Task #124) — pass through identity claims when issued.
55
+ ...claims.picture !== void 0 ? { picture: claims.picture } : {},
56
+ ...claims.email_verified !== void 0 ? { emailVerified: claims.email_verified } : {},
57
+ ...claims.given_name !== void 0 ? { givenName: claims.given_name } : {},
58
+ ...claims.family_name !== void 0 ? { familyName: claims.family_name } : {},
59
+ ...claims.locale !== void 0 ? { locale: claims.locale } : {},
60
+ // Task #171 — surface the active source/client scope when the token was
61
+ // minted scoped, so consumers reading useUser().user can branch on it.
62
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
53
63
  };
54
64
  }
55
65
  var EMPTY = {
@@ -73,11 +83,50 @@ var NO_OP_STORE = {
73
83
  write: () => void 0,
74
84
  clear: () => void 0
75
85
  };
86
+ var IDEMPOTENCY_HEADER = "X-IQAuth-Idempotency";
87
+ function randomIdempotencyToken() {
88
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
89
+ const bytes = new Uint8Array(16);
90
+ crypto.getRandomValues(bytes);
91
+ let out = "";
92
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
93
+ return out;
94
+ }
95
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
96
+ }
76
97
  var SessionManager = class {
77
98
  constructor(options) {
78
99
  this.snapshot = { ...EMPTY };
79
100
  this.listeners = /* @__PURE__ */ new Set();
80
101
  this.refreshPromise = null;
102
+ /**
103
+ * Cancellation handle for the in-flight refresh, if any. `signOut()` (or a
104
+ * `session:signout` broadcast from another tab) calls `abort()` so the
105
+ * refresh response is dropped before it can write a fresh access cookie
106
+ * on top of the just-cleared session — the second root cause of "ghost
107
+ * signed-in" sessions after Sign Out.
108
+ */
109
+ this.refreshAbort = null;
110
+ /**
111
+ * Set to `true` by `signOut()` / `signOutLocal()` for the lifetime of the
112
+ * call. Used as a safety belt: even if a refresh response arrives while
113
+ * `refreshAbort` was unable to interrupt the network call (e.g. the body
114
+ * was already streaming back), `runRefresh` checks this flag before
115
+ * mutating session state and bails out.
116
+ */
117
+ this.signoutInProgress = false;
118
+ /**
119
+ * Per-session opaque idempotency token. Sent as `X-IQAuth-Idempotency` on
120
+ * every /refresh and /signout request the SDK makes through a framework
121
+ * adapter (Express/Fastify/Hono/Next), so the adapter's `SignoutRegistry`
122
+ * can collapse a refresh that lands moments after a signout — even when
123
+ * the two requests are routed to different server instances (multi-replica
124
+ * deployments).
125
+ *
126
+ * Generated lazily on first use, rotated on signout so the next session
127
+ * starts with a fresh token. Opaque random — never the raw refresh token.
128
+ */
129
+ this.idempotencyToken = null;
81
130
  this.channel = null;
82
131
  this.proactiveTimer = null;
83
132
  this.bootstrapped = false;
@@ -85,6 +134,8 @@ var SessionManager = class {
85
134
  this.remoteRefreshWaiters = [];
86
135
  /** Active claims by other tabs (keyed by source tabId). */
87
136
  this.foreignClaim = null;
137
+ /** Resolver for an in-flight cross-tab `session:probe`, set during bootstrap. */
138
+ this.probeResolver = null;
88
139
  const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/browser SessionManager" });
89
140
  this.key = parsed;
90
141
  const inferred = options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`);
@@ -97,6 +148,8 @@ var SessionManager = class {
97
148
  this.refreshCookieName = options.cookieNames?.refresh ?? REFRESH_COOKIE;
98
149
  this.tokenStore = options.tokenStore ?? (this.serverManagedSession ? NO_OP_STORE : this.useCookies ? defaultCookieStore(this.refreshCookieName) : NO_OP_STORE);
99
150
  this.crossTabLockTimeoutMs = options.crossTabLockTimeoutMs ?? 4e3;
151
+ this.debug = options.debug ?? false;
152
+ this.onTimingEvent = options.onTimingEvent ?? null;
100
153
  this.fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
101
154
  throw new Error("global fetch is not available; pass fetchImpl");
102
155
  }));
@@ -123,10 +176,35 @@ var SessionManager = class {
123
176
  get issuerUrl() {
124
177
  return this.issuer;
125
178
  }
179
+ /**
180
+ * SDK 2.7.0 (Task #124) — The hosted IQAuth host derived from the
181
+ * publishable key's `iss` claim, normalized to URL form. This is what
182
+ * `<SignIn/>` and `buildSignInUrl` use to talk to the hosted UI; it
183
+ * deliberately ignores the `issuer` constructor override so a misrouted
184
+ * `issuer` (e.g. pointed at the consumer app's own domain) cannot break
185
+ * the hosted flow. Use {@link issuerUrl} for token / discovery endpoints.
186
+ */
187
+ get hostedIssuerUrl() {
188
+ const iss = this.key.iss;
189
+ return (iss.startsWith("http") ? iss : `https://${iss}`).replace(/\/+$/, "");
190
+ }
126
191
  /** Cookie name the SDK uses for the refresh token (overridable via `cookieNames.refresh`). */
127
192
  get refreshCookie() {
128
193
  return this.refreshCookieName;
129
194
  }
195
+ /**
196
+ * Returns the current per-session idempotency token, generating one
197
+ * lazily on first use. Sent as the `X-IQAuth-Idempotency` header on
198
+ * /refresh and /signout requests so the framework adapter's
199
+ * `SignoutRegistry` can collapse a refresh-vs-signout race even across
200
+ * server instances.
201
+ */
202
+ getIdempotencyToken() {
203
+ if (!this.idempotencyToken) {
204
+ this.idempotencyToken = randomIdempotencyToken();
205
+ }
206
+ return this.idempotencyToken;
207
+ }
130
208
  getSnapshot() {
131
209
  return this.snapshot;
132
210
  }
@@ -140,9 +218,44 @@ var SessionManager = class {
140
218
  * One-time bootstrap: warm the session from the refresh cookie if present.
141
219
  * Safe to call multiple times.
142
220
  */
221
+ /**
222
+ * Task #126: Public timing-event emitter. Used by the browser sign-in
223
+ * helpers (redirectToSignIn / handleAuthCallback) to surface signIn-phase
224
+ * timings through the same `debug` + `onTimingEvent` channel as
225
+ * bootstrap/refresh. Safe to call from anywhere — internal callers
226
+ * pre-compute durationMs.
227
+ */
228
+ recordTiming(phase, durationMs, ok, code) {
229
+ this.emitTiming(phase, durationMs, ok, code);
230
+ }
231
+ /** Task #126: emit a session timing event to debug log + onTimingEvent hook. */
232
+ emitTiming(phase, durationMs, ok, code) {
233
+ if (this.debug) {
234
+ try {
235
+ console.debug("[iqauth_session]", { phase, durationMs, ok, code });
236
+ } catch {
237
+ }
238
+ }
239
+ if (this.onTimingEvent) {
240
+ try {
241
+ this.onTimingEvent({ phase, durationMs, ok, code });
242
+ } catch {
243
+ }
244
+ }
245
+ }
143
246
  async bootstrap() {
144
247
  if (this.bootstrapped) return;
145
248
  this.bootstrapped = true;
249
+ const t0 = Date.now();
250
+ try {
251
+ await this.bootstrapInner();
252
+ this.emitTiming("bootstrap", Date.now() - t0, this.snapshot.status === "authenticated");
253
+ } catch (err) {
254
+ this.emitTiming("bootstrap", Date.now() - t0, false, err instanceof Error ? err.message : "ERROR");
255
+ throw err;
256
+ }
257
+ }
258
+ async bootstrapInner() {
146
259
  if (this.serverManagedSession) {
147
260
  try {
148
261
  const res = await this.fetchImpl(`${this.issuer}${this.userinfoPath}`, {
@@ -177,6 +290,15 @@ var SessionManager = class {
177
290
  return;
178
291
  }
179
292
  }
293
+ const peerSnapshot = await this.probePeers();
294
+ if (peerSnapshot && peerSnapshot.status === "authenticated") {
295
+ this.update({
296
+ ...peerSnapshot,
297
+ version: Math.max(this.snapshot.version, peerSnapshot.version) + 1
298
+ });
299
+ this.scheduleProactiveRefresh();
300
+ return;
301
+ }
180
302
  const stored = await Promise.resolve(this.tokenStore.read());
181
303
  if (!stored) {
182
304
  this.setStatus("unauthenticated");
@@ -185,6 +307,22 @@ var SessionManager = class {
185
307
  const ok = await this.refresh();
186
308
  if (!ok) this.setStatus("unauthenticated");
187
309
  }
310
+ probePeers() {
311
+ if (!this.channel) return Promise.resolve(null);
312
+ return new Promise((resolve) => {
313
+ let settled = false;
314
+ const finish = (snap) => {
315
+ if (settled) return;
316
+ settled = true;
317
+ this.probeResolver = null;
318
+ clearTimeout(timer);
319
+ resolve(snap);
320
+ };
321
+ this.probeResolver = (snap) => finish(snap);
322
+ const timer = setTimeout(() => finish(null), PROBE_WAIT_MS);
323
+ this.broadcastEnvelope({ type: "session:probe", source: this.tabId, ts: Date.now() });
324
+ });
325
+ }
188
326
  /**
189
327
  * Single-flight token refresh, coordinated across tabs via BroadcastChannel.
190
328
  *
@@ -198,30 +336,48 @@ var SessionManager = class {
198
336
  */
199
337
  refresh() {
200
338
  if (this.refreshPromise) return this.refreshPromise;
201
- this.refreshPromise = this.runRefresh().finally(() => {
339
+ const t0 = Date.now();
340
+ this.refreshPromise = this.runRefresh().then((ok) => {
341
+ this.emitTiming("refresh", Date.now() - t0, ok, ok ? void 0 : this.snapshot.error?.code ?? "REFRESH_FAILED");
342
+ return ok;
343
+ }).finally(() => {
202
344
  this.refreshPromise = null;
203
345
  });
204
346
  return this.refreshPromise;
205
347
  }
206
348
  async runRefresh() {
207
- const myClaim = { source: this.tabId, ts: Date.now() };
208
- if (this.channel) {
209
- this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
210
- await new Promise((r) => setTimeout(r, 25));
211
- const foreign = this.foreignClaim;
212
- if (foreign && this.claimWins(foreign, myClaim)) {
213
- return this.waitForForeignRefresh();
214
- }
215
- }
349
+ const abort = new AbortController();
350
+ this.refreshAbort = abort;
216
351
  try {
352
+ const myClaim = { source: this.tabId, ts: Date.now() };
353
+ if (this.channel) {
354
+ this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
355
+ await new Promise((r) => setTimeout(r, 25));
356
+ if (abort.signal.aborted || this.signoutInProgress) {
357
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
358
+ return false;
359
+ }
360
+ const foreign = this.foreignClaim;
361
+ if (foreign && this.claimWins(foreign, myClaim)) {
362
+ return this.waitForForeignRefresh();
363
+ }
364
+ }
217
365
  const refreshToken = await Promise.resolve(this.tokenStore.read());
218
366
  const res = await this.fetchImpl(`${this.issuer}${this.refreshPath}`, {
219
367
  method: "POST",
220
368
  credentials: "include",
221
- headers: { "Content-Type": "application/json" },
222
- body: JSON.stringify(refreshToken ? { refreshToken } : {})
369
+ headers: {
370
+ "Content-Type": "application/json",
371
+ [IDEMPOTENCY_HEADER]: this.getIdempotencyToken()
372
+ },
373
+ body: JSON.stringify(refreshToken ? { refreshToken } : {}),
374
+ signal: abort.signal
223
375
  });
224
376
  const body = await res.json().catch(() => ({}));
377
+ if (this.signoutInProgress || abort.signal.aborted) {
378
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
379
+ return false;
380
+ }
225
381
  const data = body.data;
226
382
  if (!res.ok || !body.success || !data?.accessToken) {
227
383
  const err = body.error;
@@ -240,14 +396,18 @@ var SessionManager = class {
240
396
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: true });
241
397
  return true;
242
398
  } catch (err) {
243
- this.setError({
244
- code: "NETWORK_ERROR",
245
- message: err instanceof Error ? err.message : "Refresh request failed"
246
- });
399
+ const aborted = err?.name === "AbortError" || abort.signal.aborted || this.signoutInProgress;
400
+ if (!aborted) {
401
+ this.setError({
402
+ code: "NETWORK_ERROR",
403
+ message: err instanceof Error ? err.message : "Refresh request failed"
404
+ });
405
+ }
247
406
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
248
407
  return false;
249
408
  } finally {
250
409
  this.foreignClaim = null;
410
+ if (this.refreshAbort === abort) this.refreshAbort = null;
251
411
  }
252
412
  }
253
413
  claimWins(foreign, mine) {
@@ -274,10 +434,27 @@ var SessionManager = class {
274
434
  * session and notify subscribers and other tabs.
275
435
  */
276
436
  applyAccessToken(accessToken, refreshToken) {
437
+ this.adoptAccessToken(accessToken, { refreshToken });
438
+ }
439
+ /**
440
+ * Task #197 — Adopt an access token that the server has already minted
441
+ * for us (e.g. from `POST /api/v1/auth/switch-scope`) without contacting
442
+ * the issuer. Swaps the in-memory token, re-decodes claims, bumps
443
+ * `version`, schedules proactive refresh, and broadcasts a
444
+ * `session:update` to peer tabs.
445
+ *
446
+ * This is the safe path for any server endpoint that returns a fresh
447
+ * access token in its JSON body: we want the new claims (scope, roles,
448
+ * etc.) to take effect immediately, even if the refresh-cookie round-trip
449
+ * would have failed (network blip, rate limit, signout race). When the
450
+ * server also rotated the refresh token, pass it via
451
+ * `opts.refreshToken` so the cookie stays aligned.
452
+ */
453
+ adoptAccessToken(accessToken, opts) {
277
454
  const claims = decodeClaims(accessToken);
278
455
  const user = claimsToSessionUser(claims);
279
- if (refreshToken) {
280
- void Promise.resolve(this.tokenStore.write(refreshToken, { claims }));
456
+ if (opts?.refreshToken) {
457
+ void Promise.resolve(this.tokenStore.write(opts.refreshToken, { claims }));
281
458
  }
282
459
  this.update({
283
460
  status: user ? "authenticated" : "unauthenticated",
@@ -355,6 +532,14 @@ var SessionManager = class {
355
532
  * the server-side logout request.
356
533
  */
357
534
  signOutLocal(status = "unauthenticated") {
535
+ this.signoutInProgress = true;
536
+ if (this.refreshAbort) {
537
+ try {
538
+ this.refreshAbort.abort();
539
+ } catch {
540
+ }
541
+ this.refreshAbort = null;
542
+ }
358
543
  void Promise.resolve(this.tokenStore.clear());
359
544
  if (this.proactiveTimer) {
360
545
  clearTimeout(this.proactiveTimer);
@@ -369,7 +554,12 @@ var SessionManager = class {
369
554
  error: null,
370
555
  version: this.snapshot.version + 1
371
556
  });
557
+ this.broadcastEnvelope({ type: "refresh:abort", source: this.tabId, ts: Date.now() });
372
558
  this.broadcast("session:signout");
559
+ this.idempotencyToken = null;
560
+ setTimeout(() => {
561
+ this.signoutInProgress = false;
562
+ }, 0);
373
563
  }
374
564
  /**
375
565
  * Replace the refresh-token store at runtime. Used by the F22
@@ -433,6 +623,12 @@ var SessionManager = class {
433
623
  }
434
624
  onBroadcast(env) {
435
625
  if (!env || env.source === this.tabId) return;
626
+ if (env.type === "session:probe") {
627
+ if (this.snapshot.status === "authenticated") {
628
+ this.broadcast("session:update");
629
+ }
630
+ return;
631
+ }
436
632
  if (env.type === "refresh:claim") {
437
633
  this.foreignClaim = { source: env.source, ts: env.ts };
438
634
  return;
@@ -445,6 +641,24 @@ var SessionManager = class {
445
641
  this.foreignClaim = null;
446
642
  return;
447
643
  }
644
+ if (env.type === "refresh:abort") {
645
+ this.signoutInProgress = true;
646
+ if (this.refreshAbort) {
647
+ try {
648
+ this.refreshAbort.abort();
649
+ } catch {
650
+ }
651
+ this.refreshAbort = null;
652
+ }
653
+ const waiters = this.remoteRefreshWaiters;
654
+ this.remoteRefreshWaiters = [];
655
+ for (const w of waiters) w(false);
656
+ this.foreignClaim = null;
657
+ setTimeout(() => {
658
+ this.signoutInProgress = false;
659
+ }, 0);
660
+ return;
661
+ }
448
662
  if (env.type === "session:signout") {
449
663
  this.update({
450
664
  status: "unauthenticated",
@@ -458,6 +672,12 @@ var SessionManager = class {
458
672
  return;
459
673
  }
460
674
  if ((env.type === "session:update" || env.type === "session:refresh") && env.payload) {
675
+ if (this.probeResolver && env.payload.status === "authenticated") {
676
+ const r = this.probeResolver;
677
+ this.probeResolver = null;
678
+ r(env.payload);
679
+ return;
680
+ }
461
681
  this.update({
462
682
  ...env.payload,
463
683
  version: Math.max(this.snapshot.version, env.payload.version) + 1
@@ -1,11 +1,40 @@
1
1
  // src/errors.ts
2
- var IQAuthError = class extends Error {
3
- constructor(code, message, status, raw) {
2
+ var IQ_AUTH_ERROR_CODES = [
3
+ "token_expired",
4
+ "token_invalid",
5
+ "jwks_unavailable",
6
+ "jwks_fetch_failed",
7
+ "rate_limited",
8
+ "network",
9
+ "config_invalid",
10
+ "app_not_found",
11
+ "permission_denied",
12
+ "unknown"
13
+ ];
14
+ var IQAuthError = class _IQAuthError extends Error {
15
+ constructor(code, message, status, cause) {
4
16
  super(message);
5
17
  this.name = "IQAuthError";
6
18
  this.code = code;
7
19
  this.status = status;
8
- this.raw = raw;
20
+ this.cause = cause;
21
+ this.raw = cause;
22
+ }
23
+ /**
24
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
25
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
26
+ */
27
+ static isIQAuthError(value) {
28
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
29
+ }
30
+ /**
31
+ * Type-narrowed code check. Lets callers write
32
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
33
+ * taxonomy without losing the ability to handle server codes via
34
+ * `err.code === "TOKEN_REVOKED"`.
35
+ */
36
+ is(code) {
37
+ return this.code === code;
9
38
  }
10
39
  };
11
40
  var ErrorCodes = {
@@ -46,6 +75,7 @@ var ErrorCodes = {
46
75
  };
47
76
 
48
77
  export {
78
+ IQ_AUTH_ERROR_CODES,
49
79
  IQAuthError,
50
80
  ErrorCodes
51
81
  };
@@ -1,4 +1,11 @@
1
1
  // src/server/provisioningBridge.ts
2
+ var ProvisioningError = class extends Error {
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = "ProvisioningError";
6
+ this.code = code;
7
+ }
8
+ };
2
9
  function defaultIsUniqueViolation(err) {
3
10
  if (!err || typeof err !== "object") return false;
4
11
  const e = err;
@@ -10,6 +17,16 @@ function defaultIsUniqueViolation(err) {
10
17
  function createProvisioningBridge(options) {
11
18
  const { storage } = options;
12
19
  const isUniqueViolation = options.isUniqueViolation ?? defaultIsUniqueViolation;
20
+ const allowUnverifiedEmailAdopt = options.allowUnverifiedEmailAdopt === true;
21
+ const emailVerified = (claims) => claims.email_verified === true;
22
+ const assertAdoptAllowed = (claims) => {
23
+ if (!allowUnverifiedEmailAdopt && !emailVerified(claims)) {
24
+ throw new ProvisioningError(
25
+ "UNVERIFIED_EMAIL_ADOPT_REFUSED",
26
+ "Refusing to adopt a pre-existing local account from an unverified email (claims.email_verified !== true). Set allowUnverifiedEmailAdopt:true only if your issuer is trusted to never emit unverified emails for adoption."
27
+ );
28
+ }
29
+ };
13
30
  const roleOf = (claims) => {
14
31
  try {
15
32
  return options.roleMapper?.(claims) ?? null;
@@ -26,6 +43,7 @@ function createProvisioningBridge(options) {
26
43
  if (claims.email) {
27
44
  const byEmail = await storage.findByEmail(claims.email);
28
45
  if (byEmail) {
46
+ assertAdoptAllowed(claims);
29
47
  if (storage.adoptByEmail) {
30
48
  const adopted = await storage.adoptByEmail(byEmail, claims, roleOf(claims));
31
49
  return { user: adopted, claims, created: false, adopted: true };
@@ -41,7 +59,10 @@ function createProvisioningBridge(options) {
41
59
  if (after) return { user: after, claims, created: false, adopted: false };
42
60
  if (claims.email) {
43
61
  const byEmail = await storage.findByEmail(claims.email);
44
- if (byEmail) return { user: byEmail, claims, created: false, adopted: true };
62
+ if (byEmail) {
63
+ assertAdoptAllowed(claims);
64
+ return { user: byEmail, claims, created: false, adopted: true };
65
+ }
45
66
  }
46
67
  throw err;
47
68
  }
@@ -50,5 +71,6 @@ function createProvisioningBridge(options) {
50
71
  }
51
72
 
52
73
  export {
74
+ ProvisioningError,
53
75
  createProvisioningBridge
54
76
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IQAuthError
3
- } from "./chunk-6I6RM4MN.mjs";
3
+ } from "./chunk-6PJRLRB4.mjs";
4
4
 
5
5
  // src/browser/reverify.ts
6
6
  var PRIOR_SESSION_STORAGE_KEY = "iqauth_prior_admin_session";
@@ -0,0 +1,66 @@
1
+ // src/permissions/wildcard.ts
2
+ var SUFFIX = ".*";
3
+ function wildcardPrefix(pattern) {
4
+ return pattern.slice(0, -SUFFIX.length);
5
+ }
6
+ function hasPermission(set, id) {
7
+ if (!id) return false;
8
+ if (!set) return false;
9
+ if (id === "*") {
10
+ for (const entry of set) if (entry === "*") return true;
11
+ return false;
12
+ }
13
+ const queryIsWildcard = id.endsWith(SUFFIX);
14
+ const queryPrefix = queryIsWildcard ? wildcardPrefix(id) : null;
15
+ for (const entry of set) {
16
+ if (!entry) continue;
17
+ if (entry === "*") return true;
18
+ if (entry === id) return true;
19
+ if (entry.endsWith(SUFFIX)) {
20
+ const prefix = wildcardPrefix(entry);
21
+ if (!queryIsWildcard) {
22
+ if (id === prefix) return true;
23
+ if (id.startsWith(prefix + ".")) return true;
24
+ } else {
25
+ if (queryPrefix === prefix) return true;
26
+ if (queryPrefix !== null && queryPrefix.startsWith(prefix + ".")) return true;
27
+ }
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ function expandPermissions(set) {
33
+ if (!set) return [];
34
+ const seen = /* @__PURE__ */ new Set();
35
+ for (const raw of set) {
36
+ if (typeof raw !== "string" || raw.length === 0) continue;
37
+ seen.add(raw);
38
+ }
39
+ if (seen.has("*")) return ["*"];
40
+ const wildcards = [];
41
+ for (const entry of seen) if (entry.endsWith(SUFFIX)) wildcards.push(entry);
42
+ const out = [];
43
+ for (const entry of seen) {
44
+ let covered = false;
45
+ for (const w of wildcards) {
46
+ if (w === entry) continue;
47
+ const prefix = wildcardPrefix(w);
48
+ if (entry === prefix) {
49
+ covered = true;
50
+ break;
51
+ }
52
+ if (entry.startsWith(prefix + ".")) {
53
+ covered = true;
54
+ break;
55
+ }
56
+ }
57
+ if (!covered) out.push(entry);
58
+ }
59
+ out.sort();
60
+ return out;
61
+ }
62
+
63
+ export {
64
+ hasPermission,
65
+ expandPermissions
66
+ };