@iqauth/sdk 2.6.4 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +173 -1
  2. package/dist/browser-session.d.mts +4 -4
  3. package/dist/browser-session.d.ts +4 -4
  4. package/dist/browser-session.js +212 -46
  5. package/dist/browser-session.mjs +3 -3
  6. package/dist/browser.d.mts +5 -5
  7. package/dist/browser.d.ts +5 -5
  8. package/dist/browser.js +293 -34
  9. package/dist/browser.mjs +5 -5
  10. package/dist/{chunk-BVV54LPI.mjs → chunk-25SSYDIP.mjs} +10 -4
  11. package/dist/{chunk-XAWYUPMO.mjs → chunk-4V7FKOTG.mjs} +242 -22
  12. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  13. package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
  14. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  15. package/dist/chunk-GLXSIGVS.mjs +66 -0
  16. package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
  17. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  18. package/dist/chunk-JRDVUWAL.mjs +46 -0
  19. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  20. package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
  21. package/dist/chunk-VYQ3ETCK.mjs +244 -0
  22. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  23. package/dist/chunk-WHT6WKTY.mjs +3180 -0
  24. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  25. package/dist/chunk-WSH4SW7F.mjs +490 -0
  26. package/dist/{chunk-W3F4JYGP.mjs → chunk-ZLJPABB7.mjs} +139 -23
  27. package/dist/cli/index.js +2 -2
  28. package/dist/cli/index.mjs +2 -2
  29. package/dist/{client-BNQe3AgF.d.ts → client-D8L-PaWr.d.mts} +59 -6
  30. package/dist/{client-kYlJFgPv.d.mts → client-DkPL0EPZ.d.ts} +59 -6
  31. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  32. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  33. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  34. package/dist/{express-CHpfa7D_.d.ts → express-Budysq4h.d.ts} +2 -2
  35. package/dist/{express-B6_1vBYZ.d.mts → express-DDTA3qV1.d.mts} +2 -2
  36. package/dist/express.d.mts +7 -6
  37. package/dist/express.d.ts +7 -6
  38. package/dist/express.js +563 -85
  39. package/dist/express.mjs +73 -34
  40. package/dist/fastify.d.mts +10 -0
  41. package/dist/fastify.d.ts +10 -0
  42. package/dist/fastify.js +589 -65
  43. package/dist/fastify.mjs +101 -11
  44. package/dist/hono.d.mts +10 -0
  45. package/dist/hono.d.ts +10 -0
  46. package/dist/hono.js +566 -65
  47. package/dist/hono.mjs +78 -11
  48. package/dist/index-Cko-d5po.d.mts +1848 -0
  49. package/dist/index-RNqwEcmY.d.ts +1848 -0
  50. package/dist/index.d.mts +56 -8
  51. package/dist/index.d.ts +56 -8
  52. package/dist/index.js +694 -75
  53. package/dist/index.mjs +30 -10
  54. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  55. package/dist/locales.d.mts +1 -1
  56. package/dist/locales.d.ts +1 -1
  57. package/dist/locales.js +36 -0
  58. package/dist/locales.mjs +1 -1
  59. package/dist/mobile.d.mts +77 -7
  60. package/dist/mobile.d.ts +77 -7
  61. package/dist/mobile.js +307 -46
  62. package/dist/mobile.mjs +98 -3
  63. package/dist/next.d.mts +10 -1
  64. package/dist/next.d.ts +10 -1
  65. package/dist/next.js +596 -205
  66. package/dist/next.mjs +83 -10
  67. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
  68. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
  69. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  70. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  71. package/dist/react-permissions.d.mts +52 -0
  72. package/dist/react-permissions.d.ts +52 -0
  73. package/dist/react-permissions.js +239 -0
  74. package/dist/react-permissions.mjs +98 -0
  75. package/dist/react.d.mts +9 -1624
  76. package/dist/react.d.ts +9 -1624
  77. package/dist/react.js +882 -73
  78. package/dist/react.mjs +71 -2631
  79. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  80. package/dist/server/handlers.d.mts +200 -4
  81. package/dist/server/handlers.d.ts +200 -4
  82. package/dist/server/handlers.js +530 -16
  83. package/dist/server/handlers.mjs +14 -3
  84. package/dist/server.d.mts +171 -8
  85. package/dist/server.d.ts +171 -8
  86. package/dist/server.js +579 -61
  87. package/dist/server.mjs +99 -12
  88. package/dist/service.d.mts +4 -4
  89. package/dist/service.d.ts +4 -4
  90. package/dist/service.js +212 -46
  91. package/dist/service.mjs +3 -3
  92. package/dist/{signIn-CiIBTJIh.d.mts → signIn-CReqfXsh.d.mts} +95 -3
  93. package/dist/{signIn-OCr88Zf8.d.ts → signIn-Cfa1GTpO.d.ts} +95 -3
  94. package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
  95. package/dist/test.mjs +3 -3
  96. package/dist/{tokens-DCyzzn8L.d.mts → tokens-9F6ETrzk.d.ts} +9 -2
  97. package/dist/{tokens-aHiGFr_E.d.ts → tokens-B06VtvUi.d.mts} +9 -2
  98. package/dist/{types-DZAflmmq.d.mts → types-Bn8O-OEd.d.mts} +164 -11
  99. package/dist/{types-DZAflmmq.d.ts → types-Bn8O-OEd.d.ts} +164 -11
  100. package/dist/{types-6bNdxesb.d.ts → types-DnU2LhXR.d.mts} +7 -1
  101. package/dist/{types-6bNdxesb.d.mts → types-DnU2LhXR.d.ts} +7 -1
  102. package/dist/webhooks.d.mts +113 -17
  103. package/dist/webhooks.d.ts +113 -17
  104. package/dist/webhooks.js +179 -15
  105. package/dist/webhooks.mjs +7 -1
  106. package/dist/ws.d.mts +2 -2
  107. package/dist/ws.d.ts +2 -2
  108. package/dist/ws.js +80 -30
  109. package/dist/ws.mjs +4 -4
  110. package/docs/error-handling.md +101 -0
  111. package/docs/guides/effective-permissions.md +171 -0
  112. package/docs/guides/invitations.md +65 -0
  113. package/package.json +19 -4
  114. package/dist/chunk-6TDJJER7.mjs +0 -217
  115. package/dist/chunk-UKZLOHZG.mjs +0 -83
  116. package/dist/errors-CDdl24MP.d.mts +0 -52
  117. package/dist/errors-CDdl24MP.d.ts +0 -52
package/dist/next.js CHANGED
@@ -27,13 +27,30 @@ __export(next_exports, {
27
27
  module.exports = __toCommonJS(next_exports);
28
28
 
29
29
  // src/errors.ts
30
- var IQAuthError = class extends Error {
31
- constructor(code, message, status, raw) {
30
+ var IQAuthError = class _IQAuthError extends Error {
31
+ constructor(code, message, status, cause) {
32
32
  super(message);
33
33
  this.name = "IQAuthError";
34
34
  this.code = code;
35
35
  this.status = status;
36
- this.raw = raw;
36
+ this.cause = cause;
37
+ this.raw = cause;
38
+ }
39
+ /**
40
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
41
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
42
+ */
43
+ static isIQAuthError(value) {
44
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
45
+ }
46
+ /**
47
+ * Type-narrowed code check. Lets callers write
48
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
49
+ * taxonomy without losing the ability to handle server codes via
50
+ * `err.code === "TOKEN_REVOKED"`.
51
+ */
52
+ is(code) {
53
+ return this.code === code;
37
54
  }
38
55
  };
39
56
 
@@ -66,14 +83,14 @@ function assertPublishableKey(raw, opts) {
66
83
  const ctx = opts?.context ? `${opts.context}: ` : "";
67
84
  if (typeof raw !== "string" || raw.length === 0) {
68
85
  throw new IQAuthError(
69
- "CONFIG_INVALID",
86
+ "config_invalid",
70
87
  `${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.`
71
88
  );
72
89
  }
73
90
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
74
91
  if (!shapeMatch) {
75
92
  throw new IQAuthError(
76
- "CONFIG_INVALID",
93
+ "config_invalid",
77
94
  `${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.`
78
95
  );
79
96
  }
@@ -82,19 +99,19 @@ function assertPublishableKey(raw, opts) {
82
99
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
83
100
  } catch {
84
101
  throw new IQAuthError(
85
- "CONFIG_INVALID",
102
+ "config_invalid",
86
103
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
87
104
  );
88
105
  }
89
106
  if (!isPublishableKeyPayload(decoded)) {
90
107
  throw new IQAuthError(
91
- "CONFIG_INVALID",
108
+ "config_invalid",
92
109
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
93
110
  );
94
111
  }
95
112
  if (!isValidIssuerUrl(decoded.iss)) {
96
113
  throw new IQAuthError(
97
- "CONFIG_INVALID",
114
+ "config_invalid",
98
115
  `${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.`
99
116
  );
100
117
  }
@@ -106,7 +123,271 @@ function isPublishableKeyPayload(value) {
106
123
  return typeof v.iss === "string" && typeof v.appId === "string" && typeof v.tenantId === "string" && typeof v.kid === "string";
107
124
  }
108
125
 
126
+ // src/modules/tokens.ts
127
+ var import_jose = require("jose");
128
+ var JWKS_CACHE_TTL_MS = 60 * 60 * 1e3;
129
+ var DEFAULT_TOKEN_ISSUER = [
130
+ "https://auth.dispositioniq.com",
131
+ "auth.dispositioniq.com"
132
+ ];
133
+ var DEFAULT_TOKEN_AUDIENCE = [
134
+ "dispositioniq",
135
+ "iqcapture",
136
+ "iqreuse",
137
+ "iqvalidate"
138
+ ];
139
+ var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
140
+ function classifyJoseError(err) {
141
+ if (err instanceof import_jose.errors.JWTExpired) {
142
+ return { code: "token_expired", message: "Token has expired" };
143
+ }
144
+ if (err instanceof import_jose.errors.JOSEError) {
145
+ return { code: "token_invalid", message: err.message };
146
+ }
147
+ if (err instanceof Error) {
148
+ return { code: "token_invalid", message: err.message };
149
+ }
150
+ return { code: "token_invalid", message: "Token verification failed" };
151
+ }
152
+ function decodeProtectedHeader(token) {
153
+ const parts = token.split(".");
154
+ if (parts.length < 2) return null;
155
+ try {
156
+ const padded = parts[0] + "=".repeat((4 - parts[0].length % 4) % 4);
157
+ const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
158
+ let json;
159
+ if (typeof atob === "function") {
160
+ json = atob(b64);
161
+ } else {
162
+ const { Buffer: Buffer2 } = require("buffer");
163
+ json = Buffer2.from(b64, "base64").toString("utf8");
164
+ }
165
+ return JSON.parse(json);
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ var TokensModule = class {
171
+ constructor(baseUrl, options = {}) {
172
+ this.jwksCache = null;
173
+ this.inFlightRefresh = null;
174
+ this.baseUrl = baseUrl;
175
+ this.defaultIssuer = options.issuer ?? DEFAULT_TOKEN_ISSUER;
176
+ this.defaultAudience = options.audience ?? DEFAULT_TOKEN_AUDIENCE;
177
+ this.defaultClockTolerance = options.clockTolerance ?? DEFAULT_CLOCK_TOLERANCE_SECONDS;
178
+ }
179
+ /**
180
+ * Verify a JWT access token using RS256/ES256 via JWKS from
181
+ * `/.well-known/jwks.json`. Backed by `jose` (Web Crypto) so it runs on
182
+ * Node, browser, and edge runtimes alike — no `node:crypto` dependency.
183
+ * Caches JWKS for 1 hour and refetches once on unknown `kid`.
184
+ */
185
+ async verify(token, options = {}) {
186
+ const header = decodeProtectedHeader(token);
187
+ if (!header) {
188
+ throw new IQAuthError("token_invalid", "Unable to decode token");
189
+ }
190
+ const kid = header.kid;
191
+ if (!kid) {
192
+ throw new IQAuthError("token_invalid", "Token missing kid header");
193
+ }
194
+ let cache = await this.ensureCache();
195
+ if (!cache.byKid.has(kid)) {
196
+ this.jwksCache = null;
197
+ cache = await this.ensureCache();
198
+ }
199
+ if (!cache.byKid.has(kid)) {
200
+ throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
201
+ }
202
+ const issuer = options.issuer ?? this.defaultIssuer;
203
+ const audience = options.audience ?? this.defaultAudience;
204
+ const clockTolerance = options.clockTolerance ?? this.defaultClockTolerance;
205
+ const algorithms = options.algorithms ?? ["RS256", "ES256"];
206
+ const verifyOptions = {
207
+ algorithms,
208
+ clockTolerance,
209
+ issuer,
210
+ audience
211
+ };
212
+ try {
213
+ const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
214
+ return payload;
215
+ } catch (err) {
216
+ const classified = classifyJoseError(err);
217
+ throw new IQAuthError(classified.code, classified.message, void 0, err);
218
+ }
219
+ }
220
+ /**
221
+ * Decode a JWT without verification. Returns null if malformed.
222
+ */
223
+ decode(token) {
224
+ try {
225
+ const parts = token.split(".");
226
+ if (parts.length < 2) return null;
227
+ const payload = parts[1];
228
+ const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
229
+ const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
230
+ let json;
231
+ if (typeof atob === "function") {
232
+ json = atob(b64);
233
+ } else {
234
+ const { Buffer: Buffer2 } = require("buffer");
235
+ json = Buffer2.from(b64, "base64").toString("utf8");
236
+ }
237
+ try {
238
+ json = decodeURIComponent(escape(json));
239
+ } catch {
240
+ }
241
+ const claims = JSON.parse(json);
242
+ if (!claims || typeof claims !== "object") return null;
243
+ return claims;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+ /** Check if a token is expired based on the `exp` claim. */
249
+ isExpired(token) {
250
+ const claims = this.decode(token);
251
+ if (!claims?.exp) return true;
252
+ const now = Math.floor(Date.now() / 1e3);
253
+ return claims.exp <= now;
254
+ }
255
+ /** Get the claims from a token without verification. */
256
+ getClaims(token) {
257
+ const claims = this.decode(token);
258
+ if (!claims) {
259
+ throw new IQAuthError("token_invalid", "Unable to decode token claims");
260
+ }
261
+ return claims;
262
+ }
263
+ async ensureCache() {
264
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) {
265
+ return this.jwksCache;
266
+ }
267
+ await this.refreshJwks();
268
+ if (!this.jwksCache) {
269
+ throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
270
+ }
271
+ return this.jwksCache;
272
+ }
273
+ async refreshJwks() {
274
+ if (this.inFlightRefresh) {
275
+ return this.inFlightRefresh;
276
+ }
277
+ this.inFlightRefresh = (async () => {
278
+ try {
279
+ let res;
280
+ try {
281
+ res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
282
+ } catch (err) {
283
+ throw new IQAuthError(
284
+ "network",
285
+ err instanceof Error ? err.message : "JWKS fetch network error",
286
+ void 0,
287
+ err
288
+ );
289
+ }
290
+ if (!res.ok) {
291
+ throw new IQAuthError(
292
+ "jwks_fetch_failed",
293
+ `Failed to fetch JWKS: ${res.status}`,
294
+ res.status
295
+ );
296
+ }
297
+ let jwks;
298
+ try {
299
+ jwks = await res.json();
300
+ } catch (err) {
301
+ throw new IQAuthError(
302
+ "jwks_fetch_failed",
303
+ "Malformed JWKS response: invalid JSON",
304
+ res.status,
305
+ err
306
+ );
307
+ }
308
+ if (!jwks || !Array.isArray(jwks.keys)) {
309
+ throw new IQAuthError(
310
+ "jwks_fetch_failed",
311
+ "Malformed JWKS response: expected { keys: [...] }"
312
+ );
313
+ }
314
+ const byKid = /* @__PURE__ */ new Set();
315
+ for (const key of jwks.keys) {
316
+ if (!key || typeof key.kid !== "string" || typeof key.n !== "string" && typeof key.x !== "string" || key.kty === "RSA" && (typeof key.n !== "string" || typeof key.e !== "string")) {
317
+ throw new IQAuthError(
318
+ "jwks_fetch_failed",
319
+ "Malformed JWKS response: key missing required fields"
320
+ );
321
+ }
322
+ byKid.add(key.kid);
323
+ }
324
+ const verifier = (0, import_jose.createLocalJWKSet)({ keys: jwks.keys });
325
+ this.jwksCache = { raw: jwks.keys, byKid, verifier, fetchedAt: Date.now() };
326
+ } finally {
327
+ this.inFlightRefresh = null;
328
+ }
329
+ })();
330
+ return this.inFlightRefresh;
331
+ }
332
+ /** @internal Exposed for testing — clears JWKS cache */
333
+ clearCache() {
334
+ this.jwksCache = null;
335
+ }
336
+ /**
337
+ * Task #126: Eagerly populate the JWKS cache so the first verify() call
338
+ * doesn't pay a network round-trip. Safe to call repeatedly — single-flight
339
+ * behavior is shared with the lazy refresh path. Errors are swallowed so
340
+ * callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
341
+ */
342
+ async prewarm() {
343
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
344
+ try {
345
+ await this.refreshJwks();
346
+ } catch {
347
+ }
348
+ }
349
+ };
350
+
109
351
  // src/server/handlers.ts
352
+ async function buildUserinfoResponse(claims, opts = {}) {
353
+ const baseUser = {
354
+ sub: claims.sub,
355
+ email: claims.email,
356
+ name: claims.name,
357
+ tenantId: claims.tenantId,
358
+ vendorId: claims.vendorId,
359
+ roles: claims.roles ?? [],
360
+ entitlements: claims.entitlements ?? [],
361
+ // Task #171 — project the active source/client scope onto the userinfo
362
+ // payload so server handlers (`getSessionUser`, `/api/iqauth/userinfo`)
363
+ // expose it without consumers having to re-decode the JWT.
364
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
365
+ };
366
+ const enriched = opts.enrich ? await opts.enrich(claims) : null;
367
+ const user = enriched ? { ...baseUser, ...enriched } : baseUser;
368
+ return {
369
+ success: true,
370
+ data: {
371
+ user,
372
+ claims,
373
+ tenantId: claims.tenantId ?? null
374
+ }
375
+ };
376
+ }
377
+ function emitTiming(cfg, event) {
378
+ if (cfg.debug) {
379
+ try {
380
+ console.debug("[iqauth_helper]", event);
381
+ } catch {
382
+ }
383
+ }
384
+ if (cfg.onTimingEvent) {
385
+ try {
386
+ cfg.onTimingEvent(event);
387
+ } catch {
388
+ }
389
+ }
390
+ }
110
391
  var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
111
392
  "TOKEN_REVOKED",
112
393
  "SESSION_REVOKED",
@@ -125,19 +406,62 @@ function shouldClearCookiesOnFailure(policy, status, errorCode) {
125
406
  }
126
407
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
127
408
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
409
+ function assertCookiePrefixInvariants(name, secure, path, domain) {
410
+ if (name.startsWith("__Host-")) {
411
+ if (!secure) {
412
+ throw new IQAuthError(
413
+ "config_invalid",
414
+ `Cookie "${name}" uses the __Host- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
415
+ );
416
+ }
417
+ if (path !== "/") {
418
+ throw new IQAuthError(
419
+ "config_invalid",
420
+ `Cookie "${name}" uses the __Host- prefix, which requires Path=/ (got "${path}"). Remove cookiePath or set it to "/".`
421
+ );
422
+ }
423
+ if (domain) {
424
+ throw new IQAuthError(
425
+ "config_invalid",
426
+ `Cookie "${name}" uses the __Host- prefix, which forbids a Domain attribute (the cookie is host-locked). Remove cookieDomain.`
427
+ );
428
+ }
429
+ } else if (name.startsWith("__Secure-") && !secure) {
430
+ throw new IQAuthError(
431
+ "config_invalid",
432
+ `Cookie "${name}" uses the __Secure- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
433
+ );
434
+ }
435
+ }
128
436
  function resolve(config) {
129
437
  const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
130
438
  const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
439
+ maybeWarnDefaultSignoutRegistry(config);
440
+ const secure = config.secure ?? true;
441
+ if (config.secure === false && config.allowInsecureCookies !== true) {
442
+ throw new IQAuthError(
443
+ "config_invalid",
444
+ "Refusing to issue auth cookies with secure:false \u2014 this exposes session cookies over plaintext HTTP. For local HTTP development, set allowInsecureCookies:true to acknowledge the risk. Production MUST use HTTPS with secure cookies."
445
+ );
446
+ }
447
+ const accessCookieName = config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at";
448
+ const refreshCookieName = config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt";
449
+ const stateCookieName = config.stateCookieName ?? "iqauth_state";
450
+ const cookiePath = config.cookiePath ?? "/";
451
+ const cookieDomain = config.cookieDomain;
452
+ for (const name of [accessCookieName, refreshCookieName, stateCookieName]) {
453
+ assertCookiePrefixInvariants(name, secure, cookiePath, cookieDomain);
454
+ }
131
455
  return {
132
456
  publishableKey: config.publishableKey,
133
457
  secretKey: config.secretKey,
134
458
  issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
135
- accessCookieName: config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at",
136
- refreshCookieName: config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt",
137
- cookieDomain: config.cookieDomain,
459
+ accessCookieName,
460
+ refreshCookieName,
461
+ cookieDomain,
138
462
  sameSite: config.sameSite ?? "lax",
139
- secure: config.secure ?? true,
140
- cookiePath: config.cookiePath ?? "/",
463
+ secure,
464
+ cookiePath,
141
465
  tokenPath: config.tokenPath ?? "/oidc/token",
142
466
  refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
143
467
  logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
@@ -146,9 +470,23 @@ function resolve(config) {
146
470
  })),
147
471
  appId: parsed.appId,
148
472
  tenantId: parsed.tenantId,
149
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
473
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
474
+ debug: config.debug,
475
+ onTimingEvent: config.onTimingEvent,
476
+ signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
477
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS,
478
+ requireOAuthState: config.requireOAuthState ?? true,
479
+ stateCookieName: config.stateCookieName ?? "iqauth_state"
150
480
  };
151
481
  }
482
+ function timingSafeEqualStr(a, b) {
483
+ const len = Math.max(a.length, b.length);
484
+ let diff = a.length ^ b.length;
485
+ for (let i = 0; i < len; i++) {
486
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
487
+ }
488
+ return diff === 0;
489
+ }
152
490
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
153
491
  return {
154
492
  name,
@@ -163,15 +501,53 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
163
501
  }
164
502
  function clearCookies(cfg) {
165
503
  return [
166
- makeCookie(cfg, cfg.accessCookieName, "", 0),
167
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
504
+ { ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
505
+ { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
168
506
  ];
169
507
  }
508
+ function clearStateCookie(cfg) {
509
+ return { ...makeCookie(cfg, cfg.stateCookieName, "", 0, false), clear: true };
510
+ }
511
+ var DEFAULT_SIGNOUT_TTL_MS = 6e4;
512
+ var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
513
+ function pruneInMemoryMarkers(now) {
514
+ if (inMemorySignoutMarkers.size === 0) return;
515
+ for (const [k, exp] of inMemorySignoutMarkers) {
516
+ if (exp <= now) inMemorySignoutMarkers.delete(k);
517
+ }
518
+ }
519
+ var defaultSignoutRegistry = {
520
+ mark(token, ttlMs) {
521
+ const now = Date.now();
522
+ pruneInMemoryMarkers(now);
523
+ inMemorySignoutMarkers.set(token, now + ttlMs);
524
+ },
525
+ has(token) {
526
+ const now = Date.now();
527
+ const exp = inMemorySignoutMarkers.get(token);
528
+ if (!exp) return false;
529
+ if (exp <= now) {
530
+ inMemorySignoutMarkers.delete(token);
531
+ return false;
532
+ }
533
+ return true;
534
+ }
535
+ };
536
+ var warnedDefaultSignoutRegistry = false;
537
+ function maybeWarnDefaultSignoutRegistry(config) {
538
+ if (warnedDefaultSignoutRegistry) return;
539
+ if (config.signoutRegistry) return;
540
+ warnedDefaultSignoutRegistry = true;
541
+ console.warn(
542
+ "[IQAuth] Using the in-memory signout registry (process-local). Signout idempotency is NOT shared across instances \u2014 in a multi-replica deployment a /refresh racing a /signout on another replica can reissue cookies after sign-out. Plug a shared backend (e.g. Redis) into IQAuthHelperConfig.signoutRegistry to fix this and silence this warning."
543
+ );
544
+ }
170
545
  function serializeCookie(d) {
171
546
  const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
172
547
  parts.push(`Path=${d.path}`);
173
548
  if (d.domain) parts.push(`Domain=${d.domain}`);
174
549
  parts.push(`Max-Age=${d.maxAge}`);
550
+ if (d.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
175
551
  if (d.secure) parts.push("Secure");
176
552
  if (d.httpOnly) parts.push("HttpOnly");
177
553
  parts.push(`SameSite=${d.sameSite}`);
@@ -179,14 +555,34 @@ function serializeCookie(d) {
179
555
  }
180
556
  async function handleCallback(config, input) {
181
557
  const cfg = resolve(config);
558
+ const t0 = Date.now();
182
559
  if (!input.code || !input.redirectUri) {
560
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
183
561
  return {
184
562
  status: 400,
185
563
  body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
186
564
  cookies: []
187
565
  };
188
566
  }
567
+ const provided = input.state;
568
+ const expected = input.expectedState;
569
+ const stateOk = cfg.requireOAuthState ? !!expected && !!provided && timingSafeEqualStr(provided, expected) : !expected || !!provided && timingSafeEqualStr(provided, expected);
570
+ if (!stateOk) {
571
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "STATE_MISMATCH" });
572
+ return {
573
+ status: 400,
574
+ body: {
575
+ success: false,
576
+ error: {
577
+ code: "STATE_MISMATCH",
578
+ message: "OAuth state validation failed; the sign-in could not be verified as originating from this browser."
579
+ }
580
+ },
581
+ cookies: [clearStateCookie(cfg)]
582
+ };
583
+ }
189
584
  if (!cfg.secretKey) {
585
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
190
586
  return {
191
587
  status: 500,
192
588
  body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
@@ -210,6 +606,7 @@ async function handleCallback(config, input) {
210
606
  });
211
607
  const json = await res.json().catch(() => ({}));
212
608
  if (!res.ok || !json.access_token) {
609
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
213
610
  return {
214
611
  status: res.status || 502,
215
612
  body: {
@@ -222,6 +619,26 @@ async function handleCallback(config, input) {
222
619
  cookies: []
223
620
  };
224
621
  }
622
+ try {
623
+ await getTokensFor(cfg.issuer).verify(json.access_token, {
624
+ issuer: cfg.issuer,
625
+ ...config.verify
626
+ });
627
+ } catch (err) {
628
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
629
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code });
630
+ return {
631
+ status: 502,
632
+ body: {
633
+ success: false,
634
+ error: {
635
+ code: "ACCESS_TOKEN_VERIFICATION_FAILED",
636
+ message: "The issuer returned an access token that failed verification; no session was established."
637
+ }
638
+ },
639
+ cookies: []
640
+ };
641
+ }
225
642
  const cookies = [];
226
643
  cookies.push(
227
644
  makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
@@ -229,6 +646,8 @@ async function handleCallback(config, input) {
229
646
  if (json.refresh_token) {
230
647
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
231
648
  }
649
+ cookies.push(clearStateCookie(cfg));
650
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
232
651
  return {
233
652
  status: 200,
234
653
  body: { success: true, data: { authenticated: true } },
@@ -237,8 +656,18 @@ async function handleCallback(config, input) {
237
656
  }
238
657
  async function handleRefresh(config, input) {
239
658
  const cfg = resolve(config);
659
+ const t0 = Date.now();
240
660
  const refreshToken = input.refreshToken;
661
+ const idemKey = input.idempotencyToken;
662
+ if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
663
+ return {
664
+ status: 401,
665
+ body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
666
+ cookies: clearCookies(cfg)
667
+ };
668
+ }
241
669
  if (!refreshToken) {
670
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
242
671
  return {
243
672
  status: 401,
244
673
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
@@ -254,6 +683,7 @@ async function handleRefresh(config, input) {
254
683
  if (!res.ok || !json.success || !json.data?.accessToken) {
255
684
  const status = res.status || 401;
256
685
  const errorCode = json.error?.code || "TOKEN_INVALID";
686
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
257
687
  const shouldClear = shouldClearCookiesOnFailure(
258
688
  cfg.clearCookiesOnRefreshFailure,
259
689
  status,
@@ -277,6 +707,7 @@ async function handleRefresh(config, input) {
277
707
  if (json.data.refreshToken) {
278
708
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
279
709
  }
710
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
280
711
  return {
281
712
  status: 200,
282
713
  body: { success: true, data: { accessToken: json.data.accessToken } },
@@ -285,6 +716,10 @@ async function handleRefresh(config, input) {
285
716
  }
286
717
  async function handleSignout(config, input) {
287
718
  const cfg = resolve(config);
719
+ const t0 = Date.now();
720
+ if (input.idempotencyToken) {
721
+ await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
722
+ }
288
723
  if (input.accessToken) {
289
724
  try {
290
725
  await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
@@ -306,206 +741,94 @@ async function handleSignout(config, input) {
306
741
  } catch {
307
742
  }
308
743
  }
744
+ emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
309
745
  return {
310
746
  status: 200,
311
747
  body: { success: true, data: { signedOut: true } },
312
748
  cookies: clearCookies(cfg)
313
749
  };
314
750
  }
315
-
316
- // src/modules/tokens.ts
317
- var import_jose = require("jose");
318
- var JWKS_CACHE_TTL_MS = 60 * 60 * 1e3;
319
- var DEFAULT_TOKEN_ISSUER = [
320
- "https://auth.dispositioniq.com",
321
- "auth.dispositioniq.com"
322
- ];
323
- var DEFAULT_TOKEN_AUDIENCE = [
324
- "dispositioniq",
325
- "iqcapture",
326
- "iqreuse",
327
- "iqvalidate"
328
- ];
329
- var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
330
- function decodeProtectedHeader(token) {
331
- const parts = token.split(".");
332
- if (parts.length < 2) return null;
333
- try {
334
- const padded = parts[0] + "=".repeat((4 - parts[0].length % 4) % 4);
335
- const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
336
- let json;
337
- if (typeof atob === "function") {
338
- json = atob(b64);
339
- } else {
340
- const { Buffer: Buffer2 } = require("buffer");
341
- json = Buffer2.from(b64, "base64").toString("utf8");
342
- }
343
- return JSON.parse(json);
344
- } catch {
345
- return null;
751
+ var TOKENS_CACHE = /* @__PURE__ */ new Map();
752
+ function getTokensFor(issuer) {
753
+ let m = TOKENS_CACHE.get(issuer);
754
+ if (!m) {
755
+ m = new TokensModule(issuer);
756
+ TOKENS_CACHE.set(issuer, m);
346
757
  }
758
+ return m;
347
759
  }
348
- var TokensModule = class {
349
- constructor(baseUrl, options = {}) {
350
- this.jwksCache = null;
351
- this.inFlightRefresh = null;
352
- this.baseUrl = baseUrl;
353
- this.defaultIssuer = options.issuer ?? DEFAULT_TOKEN_ISSUER;
354
- this.defaultAudience = options.audience ?? DEFAULT_TOKEN_AUDIENCE;
355
- this.defaultClockTolerance = options.clockTolerance ?? DEFAULT_CLOCK_TOLERANCE_SECONDS;
356
- }
357
- /**
358
- * Verify a JWT access token using RS256/ES256 via JWKS from
359
- * `/.well-known/jwks.json`. Backed by `jose` (Web Crypto) so it runs on
360
- * Node, browser, and edge runtimes alike — no `node:crypto` dependency.
361
- * Caches JWKS for 1 hour and refetches once on unknown `kid`.
362
- */
363
- async verify(token, options = {}) {
364
- const header = decodeProtectedHeader(token);
365
- if (!header) {
366
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
367
- }
368
- const kid = header.kid;
369
- if (!kid) {
370
- throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
371
- }
372
- let cache = await this.ensureCache();
373
- if (!cache.byKid.has(kid)) {
374
- this.jwksCache = null;
375
- cache = await this.ensureCache();
376
- }
377
- if (!cache.byKid.has(kid)) {
378
- throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
379
- }
380
- const issuer = options.issuer ?? this.defaultIssuer;
381
- const audience = options.audience ?? this.defaultAudience;
382
- const clockTolerance = options.clockTolerance ?? this.defaultClockTolerance;
383
- const algorithms = options.algorithms ?? ["RS256", "ES256"];
384
- const verifyOptions = {
385
- algorithms,
386
- clockTolerance,
387
- issuer,
388
- audience
760
+ async function handleUserinfo(config, input) {
761
+ const cfg = resolve(config);
762
+ if (!input.accessToken) {
763
+ return {
764
+ status: 401,
765
+ body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
766
+ cookies: []
389
767
  };
390
- try {
391
- const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
392
- return payload;
393
- } catch (err) {
394
- if (err instanceof import_jose.errors.JWTExpired) {
395
- throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
396
- }
397
- if (err instanceof import_jose.errors.JOSEError) {
398
- throw new IQAuthError("TOKEN_INVALID", err.message);
399
- }
400
- if (err instanceof Error) {
401
- throw new IQAuthError("TOKEN_INVALID", err.message);
402
- }
403
- throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
404
- }
405
- }
406
- /**
407
- * Decode a JWT without verification. Returns null if malformed.
408
- */
409
- decode(token) {
410
- try {
411
- const parts = token.split(".");
412
- if (parts.length < 2) return null;
413
- const payload = parts[1];
414
- const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
415
- const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
416
- let json;
417
- if (typeof atob === "function") {
418
- json = atob(b64);
419
- } else {
420
- const { Buffer: Buffer2 } = require("buffer");
421
- json = Buffer2.from(b64, "base64").toString("utf8");
422
- }
423
- try {
424
- json = decodeURIComponent(escape(json));
425
- } catch {
426
- }
427
- const claims = JSON.parse(json);
428
- if (!claims || typeof claims !== "object") return null;
429
- return claims;
430
- } catch {
431
- return null;
432
- }
433
768
  }
434
- /** Check if a token is expired based on the `exp` claim. */
435
- isExpired(token) {
436
- const claims = this.decode(token);
437
- if (!claims?.exp) return true;
438
- const now = Math.floor(Date.now() / 1e3);
439
- return claims.exp <= now;
769
+ let claims;
770
+ try {
771
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, {
772
+ issuer: cfg.issuer,
773
+ ...config.verify
774
+ });
775
+ } catch (err) {
776
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
777
+ const message = err instanceof Error ? err.message : "Access token verification failed";
778
+ return {
779
+ status: 401,
780
+ body: { success: false, error: { code, message } },
781
+ cookies: []
782
+ };
440
783
  }
441
- /** Get the claims from a token without verification. */
442
- getClaims(token) {
443
- const claims = this.decode(token);
444
- if (!claims) {
445
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token claims");
446
- }
447
- return claims;
784
+ const envelope = await buildUserinfoResponse(claims, {
785
+ enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
786
+ });
787
+ return {
788
+ status: 200,
789
+ body: envelope,
790
+ cookies: []
791
+ };
792
+ }
793
+
794
+ // src/browser/returnTo.ts
795
+ function normalizeOrigin(o) {
796
+ try {
797
+ return new URL(o).origin;
798
+ } catch {
799
+ return o.replace(/\/+$/, "");
448
800
  }
449
- async ensureCache() {
450
- if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) {
451
- return this.jwksCache;
452
- }
453
- await this.refreshJwks();
454
- if (!this.jwksCache) {
455
- throw new IQAuthError("INTERNAL_ERROR", "JWKS cache unavailable after refresh");
456
- }
457
- return this.jwksCache;
801
+ }
802
+ function sanitizeReturnTo(input, options = {}) {
803
+ const fallback = options.fallback ?? "/";
804
+ if (!input || typeof input !== "string") return fallback;
805
+ const trimmed = input.trim();
806
+ if (!trimmed) return fallback;
807
+ if (trimmed.includes("\\")) return fallback;
808
+ if (trimmed.startsWith("//")) return fallback;
809
+ if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
810
+ return trimmed;
458
811
  }
459
- async refreshJwks() {
460
- if (this.inFlightRefresh) {
461
- return this.inFlightRefresh;
462
- }
463
- this.inFlightRefresh = (async () => {
464
- try {
465
- const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
466
- if (!res.ok) {
467
- throw new IQAuthError(
468
- "INTERNAL_ERROR",
469
- `Failed to fetch JWKS: ${res.status}`
470
- );
471
- }
472
- let jwks;
473
- try {
474
- jwks = await res.json();
475
- } catch {
476
- throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
477
- }
478
- if (!jwks || !Array.isArray(jwks.keys)) {
479
- throw new IQAuthError(
480
- "INTERNAL_ERROR",
481
- "Malformed JWKS response: expected { keys: [...] }"
482
- );
483
- }
484
- const byKid = /* @__PURE__ */ new Set();
485
- for (const key of jwks.keys) {
486
- if (!key || typeof key.kid !== "string" || typeof key.n !== "string" && typeof key.x !== "string" || key.kty === "RSA" && (typeof key.n !== "string" || typeof key.e !== "string")) {
487
- throw new IQAuthError(
488
- "INTERNAL_ERROR",
489
- "Malformed JWKS response: key missing required fields"
490
- );
491
- }
492
- byKid.add(key.kid);
493
- }
494
- const verifier = (0, import_jose.createLocalJWKSet)({ keys: jwks.keys });
495
- this.jwksCache = { raw: jwks.keys, byKid, verifier, fetchedAt: Date.now() };
496
- } finally {
497
- this.inFlightRefresh = null;
498
- }
499
- })();
500
- return this.inFlightRefresh;
812
+ if (!/^[a-z][a-z0-9+\-.]*:/i.test(trimmed)) {
813
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
501
814
  }
502
- /** @internal Exposed for testing — clears JWKS cache */
503
- clearCache() {
504
- this.jwksCache = null;
815
+ let parsed;
816
+ try {
817
+ parsed = new URL(trimmed);
818
+ } catch {
819
+ return fallback;
505
820
  }
506
- };
821
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback;
822
+ const currentOrigin = options.currentOrigin ?? (typeof window !== "undefined" ? window.location.origin : "");
823
+ const allowed = /* @__PURE__ */ new Set();
824
+ if (currentOrigin) allowed.add(normalizeOrigin(currentOrigin));
825
+ for (const o of options.allowedOrigins ?? []) allowed.add(normalizeOrigin(o));
826
+ if (allowed.has(parsed.origin)) return parsed.toString();
827
+ return fallback;
828
+ }
507
829
 
508
830
  // src/next.ts
831
+ var PKCE_COOKIE = "iqauth_pkce";
509
832
  function readCookieFromHeader(header, name) {
510
833
  if (!header) return void 0;
511
834
  const target = `${name}=`;
@@ -526,32 +849,100 @@ function toResponse(hr) {
526
849
  for (const c of hr.cookies) headers.append("set-cookie", serializeCookie(c));
527
850
  return new Response(JSON.stringify(hr.body), { status: hr.status, headers });
528
851
  }
852
+ function callbackResponse(hr, requestOrigin, returnToCookieValue, returnToCookieName) {
853
+ const returnTo = sanitizeReturnTo(
854
+ returnToCookieValue || hr.body?.returnTo,
855
+ { currentOrigin: requestOrigin, fallback: "/" }
856
+ );
857
+ const headers = new Headers({ "Content-Type": "application/json" });
858
+ for (const c of hr.cookies) headers.append("set-cookie", serializeCookie(c));
859
+ if (hr.status < 400) {
860
+ headers.append("set-cookie", `${returnToCookieName}=; Path=/; Max-Age=0; SameSite=Lax`);
861
+ }
862
+ const body = { ...hr.body, returnTo };
863
+ return new Response(JSON.stringify(body), { status: hr.status, headers });
864
+ }
865
+ function callbackRedirectResponse(hr, requestOrigin, returnToCookieValue, cookieNames) {
866
+ const headers = new Headers();
867
+ for (const c of hr.cookies) headers.append("set-cookie", serializeCookie(c));
868
+ headers.append("set-cookie", `${cookieNames.state}=; Path=/; Max-Age=0; SameSite=Lax`);
869
+ headers.append("set-cookie", `${cookieNames.pkce}=; Path=/; Max-Age=0; SameSite=Lax`);
870
+ if (hr.status >= 400) {
871
+ headers.set("Location", "/");
872
+ return new Response(null, { status: 302, headers });
873
+ }
874
+ const dest = sanitizeReturnTo(returnToCookieValue, {
875
+ currentOrigin: requestOrigin,
876
+ fallback: "/"
877
+ });
878
+ headers.append("set-cookie", `${cookieNames.returnTo}=; Path=/; Max-Age=0; SameSite=Lax`);
879
+ headers.set("Location", dest);
880
+ return new Response(null, { status: 302, headers });
881
+ }
529
882
  function handler(options) {
530
883
  const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/next handler" });
531
884
  const issuer = (options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`)).replace(/\/+$/, "");
532
885
  const helperConfig = { ...options, issuer };
533
886
  const accessCookie = options.accessCookieName ?? "iqauth_at";
534
887
  const refreshCookie = options.refreshCookieName ?? "iqauth_rt";
888
+ const returnToCookie = options.returnToCookieName ?? "iqauth_return_to";
535
889
  return async (req) => {
536
890
  const url = new URL(req.url);
537
891
  const action = url.pathname.split("/").pop();
538
- const body = await req.json().catch(() => ({}));
539
892
  const cookieHeader = req.headers.get("cookie");
893
+ if (action === "me" && req.method === "GET") {
894
+ if (!options.mountUserinfo) {
895
+ return new Response(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: "userinfo route not enabled" } }), {
896
+ status: 404,
897
+ headers: { "Content-Type": "application/json" }
898
+ });
899
+ }
900
+ const auth = req.headers.get("authorization");
901
+ const accessToken = auth && auth.replace(/^Bearer /i, "") || readCookieFromHeader(cookieHeader, accessCookie);
902
+ return toResponse(await handleUserinfo(helperConfig, { accessToken, req }));
903
+ }
904
+ const stateCookie = helperConfig.stateCookieName ?? "iqauth_state";
905
+ if (action === "callback" && req.method === "GET") {
906
+ const code = url.searchParams.get("code") ?? void 0;
907
+ const state = url.searchParams.get("state") ?? void 0;
908
+ const redirectUri = `${url.origin}${url.pathname}`;
909
+ const hr = await handleCallback(helperConfig, {
910
+ code,
911
+ codeVerifier: readCookieFromHeader(cookieHeader, PKCE_COOKIE),
912
+ redirectUri,
913
+ state,
914
+ expectedState: readCookieFromHeader(cookieHeader, stateCookie)
915
+ });
916
+ return callbackRedirectResponse(
917
+ hr,
918
+ url.origin,
919
+ readCookieFromHeader(cookieHeader, returnToCookie),
920
+ { returnTo: returnToCookie, state: stateCookie, pkce: PKCE_COOKIE }
921
+ );
922
+ }
923
+ const body = await req.json().catch(() => ({}));
540
924
  if (action === "callback") {
541
- return toResponse(await handleCallback(helperConfig, {
925
+ const hr = await handleCallback(helperConfig, {
542
926
  code: body.code,
543
927
  codeVerifier: body.codeVerifier,
544
- redirectUri: body.redirectUri
545
- }));
928
+ redirectUri: body.redirectUri,
929
+ // M-2: bind callback to this browser; handleCallback fails closed.
930
+ state: body.state,
931
+ expectedState: readCookieFromHeader(cookieHeader, helperConfig.stateCookieName ?? "iqauth_state")
932
+ });
933
+ return callbackResponse(hr, url.origin, readCookieFromHeader(cookieHeader, returnToCookie), returnToCookie);
546
934
  }
547
935
  if (action === "refresh") {
548
936
  const refreshToken = body.refreshToken || readCookieFromHeader(cookieHeader, refreshCookie);
549
- return toResponse(await handleRefresh(helperConfig, { refreshToken }));
937
+ const idempotencyToken = req.headers.get("x-iqauth-idempotency") || body.idempotencyToken;
938
+ return toResponse(await handleRefresh(helperConfig, { refreshToken, idempotencyToken: idempotencyToken ?? void 0 }));
550
939
  }
551
940
  if (action === "signout") {
552
941
  const auth = req.headers.get("authorization");
553
942
  const accessToken = auth && auth.replace(/^Bearer /i, "") || readCookieFromHeader(cookieHeader, accessCookie);
554
- return toResponse(await handleSignout(helperConfig, { accessToken, ssoCookieHeader: cookieHeader ?? void 0 }));
943
+ const refreshToken = readCookieFromHeader(cookieHeader, refreshCookie);
944
+ const idempotencyToken = req.headers.get("x-iqauth-idempotency") ?? void 0;
945
+ return toResponse(await handleSignout(helperConfig, { accessToken, refreshToken, idempotencyToken, ssoCookieHeader: cookieHeader ?? void 0 }));
555
946
  }
556
947
  return new Response(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: `Unknown action: ${action}` } }), {
557
948
  status: 404,