@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/express.js CHANGED
@@ -38,13 +38,30 @@ __export(express_exports, {
38
38
  module.exports = __toCommonJS(express_exports);
39
39
 
40
40
  // src/errors.ts
41
- var IQAuthError = class extends Error {
42
- constructor(code, message, status, raw) {
41
+ var IQAuthError = class _IQAuthError extends Error {
42
+ constructor(code, message, status, cause) {
43
43
  super(message);
44
44
  this.name = "IQAuthError";
45
45
  this.code = code;
46
46
  this.status = status;
47
- this.raw = raw;
47
+ this.cause = cause;
48
+ this.raw = cause;
49
+ }
50
+ /**
51
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
52
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
53
+ */
54
+ static isIQAuthError(value) {
55
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
56
+ }
57
+ /**
58
+ * Type-narrowed code check. Lets callers write
59
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
60
+ * taxonomy without losing the ability to handle server codes via
61
+ * `err.code === "TOKEN_REVOKED"`.
62
+ */
63
+ is(code) {
64
+ return this.code === code;
48
65
  }
49
66
  };
50
67
  var ErrorCodes = {
@@ -195,7 +212,7 @@ var HttpClient = class {
195
212
  headers: this.buildHeaders(),
196
213
  ...this.isBrowserSession() ? { credentials: "include" } : (() => {
197
214
  const refreshToken = this.config.getRefreshToken();
198
- if (!refreshToken) throw new IQAuthError("TOKEN_INVALID", "No refresh token available");
215
+ if (!refreshToken) throw new IQAuthError("config_invalid", "No refresh token available");
199
216
  return { body: JSON.stringify({ refreshToken }) };
200
217
  })()
201
218
  });
@@ -212,7 +229,7 @@ var HttpClient = class {
212
229
  return;
213
230
  }
214
231
  if (!body.data.accessToken || !body.data.refreshToken) {
215
- throw new IQAuthError("TOKEN_INVALID", "Refresh response did not include a token pair");
232
+ throw new IQAuthError("token_invalid", "Refresh response did not include a token pair");
216
233
  }
217
234
  const tokens = {
218
235
  accessToken: body.data.accessToken,
@@ -230,7 +247,7 @@ var HttpClient = class {
230
247
  return this.requestWithRetry(method, path, body, options, false);
231
248
  }
232
249
  async requestWithRetry(method, path, body, options, hasRetried) {
233
- if (this.config.autoRefresh && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
250
+ if (this.config.autoRefresh && this.config.proactiveRefresh !== false && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
234
251
  await this.attemptRefresh();
235
252
  }
236
253
  const url = `${this.config.baseUrl}${path}`;
@@ -317,17 +334,27 @@ function parseLoginResponse(data, browserSessionMode) {
317
334
  tenants: data.tenants
318
335
  };
319
336
  }
337
+ if (data.type === "scope_selection" && data.scopeSelectionToken && data.scopes && data.tenantId) {
338
+ return {
339
+ status: "scope_selection",
340
+ scopeSelectionToken: data.scopeSelectionToken,
341
+ tenantId: data.tenantId,
342
+ scopes: data.scopes
343
+ };
344
+ }
320
345
  throw new Error("Unexpected login response shape");
321
346
  }
322
347
  var AuthModule = class {
323
348
  constructor(http) {
324
349
  this.http = http;
325
350
  }
326
- async login(email, password) {
351
+ async login(email, password, opts) {
352
+ const body = { email, password };
353
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
327
354
  const data = await this.http.request(
328
355
  "POST",
329
356
  "/api/v1/auth/login",
330
- { email, password },
357
+ body,
331
358
  { skipAutoRefresh: true }
332
359
  );
333
360
  return parseLoginResponse(data, this.http.isBrowserSession());
@@ -365,13 +392,29 @@ var AuthModule = class {
365
392
  method
366
393
  }, { skipAutoRefresh: true });
367
394
  }
368
- async selectTenant(tenantSelectionToken, tenantId) {
395
+ async selectTenant(tenantSelectionToken, tenantId, opts) {
396
+ const body = { tenantSelectionToken, tenantId };
397
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
369
398
  const data = await this.http.request(
370
399
  "POST",
371
400
  "/api/v1/auth/select-tenant",
401
+ body,
402
+ { skipAutoRefresh: true }
403
+ );
404
+ return parseLoginResponse(data, this.http.isBrowserSession());
405
+ }
406
+ /**
407
+ * Task #171 — redeem a scope-selection token + chosen membership for a
408
+ * real authenticated session. `membershipId` must be one of the scopes
409
+ * returned in the prior `scope_selection` envelope.
410
+ */
411
+ async selectScope(scopeSelectionToken, membershipId) {
412
+ const data = await this.http.request(
413
+ "POST",
414
+ "/api/v1/auth/select-scope",
372
415
  {
373
- tenantSelectionToken,
374
- tenantId
416
+ scopeSelectionToken,
417
+ membershipId
375
418
  },
376
419
  { skipAutoRefresh: true }
377
420
  );
@@ -458,6 +501,18 @@ var DEFAULT_TOKEN_AUDIENCE = [
458
501
  "iqvalidate"
459
502
  ];
460
503
  var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
504
+ function classifyJoseError(err) {
505
+ if (err instanceof import_jose.errors.JWTExpired) {
506
+ return { code: "token_expired", message: "Token has expired" };
507
+ }
508
+ if (err instanceof import_jose.errors.JOSEError) {
509
+ return { code: "token_invalid", message: err.message };
510
+ }
511
+ if (err instanceof Error) {
512
+ return { code: "token_invalid", message: err.message };
513
+ }
514
+ return { code: "token_invalid", message: "Token verification failed" };
515
+ }
461
516
  function decodeProtectedHeader(token) {
462
517
  const parts = token.split(".");
463
518
  if (parts.length < 2) return null;
@@ -494,11 +549,11 @@ var TokensModule = class {
494
549
  async verify(token, options = {}) {
495
550
  const header = decodeProtectedHeader(token);
496
551
  if (!header) {
497
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
552
+ throw new IQAuthError("token_invalid", "Unable to decode token");
498
553
  }
499
554
  const kid = header.kid;
500
555
  if (!kid) {
501
- throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
556
+ throw new IQAuthError("token_invalid", "Token missing kid header");
502
557
  }
503
558
  let cache = await this.ensureCache();
504
559
  if (!cache.byKid.has(kid)) {
@@ -506,7 +561,7 @@ var TokensModule = class {
506
561
  cache = await this.ensureCache();
507
562
  }
508
563
  if (!cache.byKid.has(kid)) {
509
- throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
564
+ throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
510
565
  }
511
566
  const issuer = options.issuer ?? this.defaultIssuer;
512
567
  const audience = options.audience ?? this.defaultAudience;
@@ -522,16 +577,8 @@ var TokensModule = class {
522
577
  const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
523
578
  return payload;
524
579
  } catch (err) {
525
- if (err instanceof import_jose.errors.JWTExpired) {
526
- throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
527
- }
528
- if (err instanceof import_jose.errors.JOSEError) {
529
- throw new IQAuthError("TOKEN_INVALID", err.message);
530
- }
531
- if (err instanceof Error) {
532
- throw new IQAuthError("TOKEN_INVALID", err.message);
533
- }
534
- throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
580
+ const classified = classifyJoseError(err);
581
+ throw new IQAuthError(classified.code, classified.message, void 0, err);
535
582
  }
536
583
  }
537
584
  /**
@@ -573,7 +620,7 @@ var TokensModule = class {
573
620
  getClaims(token) {
574
621
  const claims = this.decode(token);
575
622
  if (!claims) {
576
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token claims");
623
+ throw new IQAuthError("token_invalid", "Unable to decode token claims");
577
624
  }
578
625
  return claims;
579
626
  }
@@ -583,7 +630,7 @@ var TokensModule = class {
583
630
  }
584
631
  await this.refreshJwks();
585
632
  if (!this.jwksCache) {
586
- throw new IQAuthError("INTERNAL_ERROR", "JWKS cache unavailable after refresh");
633
+ throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
587
634
  }
588
635
  return this.jwksCache;
589
636
  }
@@ -593,22 +640,38 @@ var TokensModule = class {
593
640
  }
594
641
  this.inFlightRefresh = (async () => {
595
642
  try {
596
- const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
643
+ let res;
644
+ try {
645
+ res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
646
+ } catch (err) {
647
+ throw new IQAuthError(
648
+ "network",
649
+ err instanceof Error ? err.message : "JWKS fetch network error",
650
+ void 0,
651
+ err
652
+ );
653
+ }
597
654
  if (!res.ok) {
598
655
  throw new IQAuthError(
599
- "INTERNAL_ERROR",
600
- `Failed to fetch JWKS: ${res.status}`
656
+ "jwks_fetch_failed",
657
+ `Failed to fetch JWKS: ${res.status}`,
658
+ res.status
601
659
  );
602
660
  }
603
661
  let jwks;
604
662
  try {
605
663
  jwks = await res.json();
606
- } catch {
607
- throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
664
+ } catch (err) {
665
+ throw new IQAuthError(
666
+ "jwks_fetch_failed",
667
+ "Malformed JWKS response: invalid JSON",
668
+ res.status,
669
+ err
670
+ );
608
671
  }
609
672
  if (!jwks || !Array.isArray(jwks.keys)) {
610
673
  throw new IQAuthError(
611
- "INTERNAL_ERROR",
674
+ "jwks_fetch_failed",
612
675
  "Malformed JWKS response: expected { keys: [...] }"
613
676
  );
614
677
  }
@@ -616,7 +679,7 @@ var TokensModule = class {
616
679
  for (const key of jwks.keys) {
617
680
  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")) {
618
681
  throw new IQAuthError(
619
- "INTERNAL_ERROR",
682
+ "jwks_fetch_failed",
620
683
  "Malformed JWKS response: key missing required fields"
621
684
  );
622
685
  }
@@ -634,6 +697,19 @@ var TokensModule = class {
634
697
  clearCache() {
635
698
  this.jwksCache = null;
636
699
  }
700
+ /**
701
+ * Task #126: Eagerly populate the JWKS cache so the first verify() call
702
+ * doesn't pay a network round-trip. Safe to call repeatedly — single-flight
703
+ * behavior is shared with the lazy refresh path. Errors are swallowed so
704
+ * callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
705
+ */
706
+ async prewarm() {
707
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
708
+ try {
709
+ await this.refreshJwks();
710
+ } catch {
711
+ }
712
+ }
637
713
  };
638
714
 
639
715
  // src/modules/sessions.ts
@@ -957,14 +1033,14 @@ var OidcModule = class {
957
1033
  */
958
1034
  async handleCallback(params) {
959
1035
  if (!params.state) {
960
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing state parameter");
1036
+ throw new IQAuthError("config_invalid", "OIDC callback missing state parameter");
961
1037
  }
962
1038
  if (!params.code) {
963
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing code parameter");
1039
+ throw new IQAuthError("config_invalid", "OIDC callback missing code parameter");
964
1040
  }
965
1041
  const stored = await this.stateStore.get(params.state);
966
1042
  if (!stored) {
967
- throw new IQAuthError("VALIDATION_ERROR", "Unknown or expired OIDC state");
1043
+ throw new IQAuthError("config_invalid", "Unknown or expired OIDC state");
968
1044
  }
969
1045
  let tokens;
970
1046
  try {
@@ -982,7 +1058,7 @@ var OidcModule = class {
982
1058
  if (tokens.id_token) {
983
1059
  if (!this.tokensModule) {
984
1060
  throw new IQAuthError(
985
- "INTERNAL_ERROR",
1061
+ "config_invalid",
986
1062
  "OIDC handleCallback received an id_token but no TokensModule is configured for verification"
987
1063
  );
988
1064
  }
@@ -993,7 +1069,7 @@ var OidcModule = class {
993
1069
  const tokenNonce = typeof claimsBag.nonce === "string" ? claimsBag.nonce : void 0;
994
1070
  if (!tokenNonce || tokenNonce !== stored.nonce) {
995
1071
  throw new IQAuthError(
996
- "TOKEN_INVALID",
1072
+ "token_invalid",
997
1073
  "OIDC id_token nonce did not match the stored value"
998
1074
  );
999
1075
  }
@@ -1194,6 +1270,9 @@ var AppsModule = class {
1194
1270
  * @remarks Wraps GET /api/v1/apps/:appKey
1195
1271
  */
1196
1272
  async get(appKey) {
1273
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1274
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1275
+ }
1197
1276
  return this.http.request("GET", `/api/v1/apps/${encodeURIComponent(appKey)}`);
1198
1277
  }
1199
1278
  /**
@@ -1213,6 +1292,16 @@ var AppsModule = class {
1213
1292
  401
1214
1293
  );
1215
1294
  }
1295
+ if (!manifest || typeof manifest.key !== "string" || manifest.key.trim() === "") {
1296
+ throw new IQAuthError("VALIDATION_ERROR", "manifest.key (appKey) is required for register().", 400);
1297
+ }
1298
+ if (!manifest.environment || !["production", "staging", "development"].includes(manifest.environment)) {
1299
+ throw new IQAuthError(
1300
+ "ENVIRONMENT_REQUIRED",
1301
+ "manifest.environment is required and must be 'production', 'staging', or 'development'. This guards against a dev workstation silently overwriting a production app's permission tree.",
1302
+ 400
1303
+ );
1304
+ }
1216
1305
  return this.http.request("POST", "/api/v1/apps/sync", manifest);
1217
1306
  }
1218
1307
  /**
@@ -1222,11 +1311,14 @@ var AppsModule = class {
1222
1311
  * @remarks Uses GET /api/v1/apps/:appKey — catches 404 errors
1223
1312
  */
1224
1313
  async isRegistered(appKey) {
1314
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1315
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1316
+ }
1225
1317
  try {
1226
1318
  await this.get(appKey);
1227
1319
  return true;
1228
1320
  } catch (err) {
1229
- if (err.code === "NOT_FOUND" || err.status === 404) {
1321
+ if (err.code === "app_not_found" || err.code === "NOT_FOUND" || err.status === 404) {
1230
1322
  return false;
1231
1323
  }
1232
1324
  throw err;
@@ -1263,6 +1355,20 @@ var RolesModule = class {
1263
1355
  };
1264
1356
 
1265
1357
  // src/modules/permissionGroups.ts
1358
+ function assertAppKey(appKey, callsite) {
1359
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1360
+ throw new IQAuthError(
1361
+ "VALIDATION_ERROR",
1362
+ `appKey is required for ${callsite} (no env-var fallback, no 'product' alias).`,
1363
+ 400
1364
+ );
1365
+ }
1366
+ }
1367
+ function assertNodeKey(nodeKey, callsite) {
1368
+ if (typeof nodeKey !== "string" || nodeKey.trim() === "") {
1369
+ throw new IQAuthError("VALIDATION_ERROR", `nodeKey is required for ${callsite}.`, 400);
1370
+ }
1371
+ }
1266
1372
  var PermissionGroupsModule = class {
1267
1373
  constructor(http) {
1268
1374
  this.http = http;
@@ -1283,7 +1389,14 @@ var PermissionGroupsModule = class {
1283
1389
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`);
1284
1390
  }
1285
1391
  async addPermission(tenantId, groupId, data) {
1286
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, data);
1392
+ assertAppKey(data?.appKey, "permissionGroups.addPermission");
1393
+ assertNodeKey(data?.nodeKey, "permissionGroups.addPermission");
1394
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, {
1395
+ appKey: data.appKey,
1396
+ nodeKey: data.nodeKey,
1397
+ effect: data.effect,
1398
+ weight: data.weight
1399
+ });
1287
1400
  }
1288
1401
  async removePermission(tenantId, groupId, permissionId) {
1289
1402
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions/${permissionId}`);
@@ -1307,21 +1420,51 @@ var PermissionGroupsModule = class {
1307
1420
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`);
1308
1421
  }
1309
1422
  async addUserOverride(tenantId, userId, data) {
1310
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, data);
1423
+ assertAppKey(data?.appKey, "permissionGroups.addUserOverride");
1424
+ assertNodeKey(data?.nodeKey, "permissionGroups.addUserOverride");
1425
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, {
1426
+ appKey: data.appKey,
1427
+ nodeKey: data.nodeKey,
1428
+ effect: data.effect,
1429
+ weight: data.weight,
1430
+ expiresAt: data.expiresAt
1431
+ });
1311
1432
  }
1312
1433
  async removeUserOverride(tenantId, userId, overrideId) {
1313
1434
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides/${overrideId}`);
1314
1435
  }
1436
+ /**
1437
+ * Task #130 — `appKey` is REQUIRED. The legacy `product` query alias is no
1438
+ * longer accepted at the SDK boundary; pass it as `appKey` instead. The
1439
+ * server still accepts `product=` from raw HTTP callers during the
1440
+ * deprecation window, but the SDK will not silently translate it.
1441
+ */
1315
1442
  async getEffectivePermissions(tenantId, userId, params) {
1316
- const query = new URLSearchParams();
1317
- if (params.product) query.set("product", params.product);
1318
- if (params.appKey) query.set("appKey", params.appKey);
1319
- const qs = query.toString();
1320
- return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective${qs ? `?${qs}` : ""}`);
1443
+ assertAppKey(params?.appKey, "permissionGroups.getEffectivePermissions");
1444
+ const qs = new URLSearchParams({ appKey: params.appKey }).toString();
1445
+ return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective?${qs}`);
1321
1446
  }
1322
1447
  async checkPermission(tenantId, userId, appKey, nodeKey) {
1448
+ assertAppKey(appKey, "permissionGroups.checkPermission");
1449
+ assertNodeKey(nodeKey, "permissionGroups.checkPermission");
1323
1450
  return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/check`, { appKey, nodeKey });
1324
1451
  }
1452
+ /**
1453
+ * Task #130 — every entry in `checks` must include a non-empty `appKey`
1454
+ * AND `nodeKey`. The SDK validates the whole batch before sending so a
1455
+ * single misconfigured entry can't slip through and silently report
1456
+ * `allowed: false` from the server's per-entry validation branch.
1457
+ */
1458
+ async batchCheckPermissions(tenantId, userId, checks) {
1459
+ if (!Array.isArray(checks) || checks.length === 0) {
1460
+ throw new IQAuthError("VALIDATION_ERROR", "checks must be a non-empty array of { appKey, nodeKey }.", 400);
1461
+ }
1462
+ checks.forEach((c, i) => {
1463
+ assertAppKey(c?.appKey, `permissionGroups.batchCheckPermissions[${i}]`);
1464
+ assertNodeKey(c?.nodeKey, `permissionGroups.batchCheckPermissions[${i}]`);
1465
+ });
1466
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/batch-check`, { checks });
1467
+ }
1325
1468
  };
1326
1469
 
1327
1470
  // src/modules/apiKeys.ts
@@ -1746,6 +1889,10 @@ var IQAuthClient = class _IQAuthClient {
1746
1889
  this._refreshToken = tokens.refreshToken;
1747
1890
  },
1748
1891
  autoRefresh: "autoRefresh" in config ? config.autoRefresh !== false : true,
1892
+ // `'app-state'` is mobile-only — on any other environment we treat it
1893
+ // as the default `true` (proactive refresh ON). Only the mobile client
1894
+ // disables proactive refresh and replaces it with an AppState listener.
1895
+ proactiveRefresh: "autoRefresh" in config && config.autoRefresh === "app-state" && _IQAuthClient.resolveEnvironment(config) === "mobile" ? false : true,
1749
1896
  onTokenRefresh: "onTokenRefresh" in config ? config.onTokenRefresh : void 0,
1750
1897
  sessionHeaderName: config.sessionHeaderName,
1751
1898
  sessionHeaderValue: config.sessionHeaderValue,
@@ -1786,6 +1933,13 @@ var IQAuthClient = class _IQAuthClient {
1786
1933
  static forServer(config) {
1787
1934
  return new _IQAuthClient({ ...config, environment: "server" });
1788
1935
  }
1936
+ /**
1937
+ * Construct a mobile-environment client. NOTE: this constructor does NOT
1938
+ * subscribe to React Native's `AppState` even when `autoRefresh: 'app-state'`
1939
+ * is passed — it only disables the per-request proactive refresh. Use
1940
+ * `createMobileClient` from `@iqauth/sdk/mobile` if you want the full
1941
+ * AppState-driven refresh behavior (recommended for Expo / React Native).
1942
+ */
1789
1943
  static forMobile(config) {
1790
1944
  return new _IQAuthClient({ ...config, environment: "mobile" });
1791
1945
  }
@@ -1802,6 +1956,18 @@ var IQAuthClient = class _IQAuthClient {
1802
1956
  getRefreshToken() {
1803
1957
  return this._refreshToken;
1804
1958
  }
1959
+ /**
1960
+ * Task #126: Eagerly fetch JWKS + OIDC discovery so the first verify() /
1961
+ * refresh round-trip on the request hot path doesn't pay the discovery
1962
+ * fetch latency. Safe to call repeatedly. Errors are swallowed; callers
1963
+ * may fire-and-forget. Called automatically by `iqAuth({...}).attachHelpers()`.
1964
+ */
1965
+ async prewarm() {
1966
+ await Promise.all([
1967
+ this.tokens.prewarm(),
1968
+ this.oidc.getDiscovery().catch(() => void 0)
1969
+ ]);
1970
+ }
1805
1971
  getCurrentClaims() {
1806
1972
  if (!this._accessToken) return null;
1807
1973
  return this.tokens.decode(this._accessToken);
@@ -1842,14 +2008,14 @@ function assertPublishableKey(raw, opts) {
1842
2008
  const ctx = opts?.context ? `${opts.context}: ` : "";
1843
2009
  if (typeof raw !== "string" || raw.length === 0) {
1844
2010
  throw new IQAuthError(
1845
- "CONFIG_INVALID",
2011
+ "config_invalid",
1846
2012
  `${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.`
1847
2013
  );
1848
2014
  }
1849
2015
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
1850
2016
  if (!shapeMatch) {
1851
2017
  throw new IQAuthError(
1852
- "CONFIG_INVALID",
2018
+ "config_invalid",
1853
2019
  `${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.`
1854
2020
  );
1855
2021
  }
@@ -1858,19 +2024,19 @@ function assertPublishableKey(raw, opts) {
1858
2024
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
1859
2025
  } catch {
1860
2026
  throw new IQAuthError(
1861
- "CONFIG_INVALID",
2027
+ "config_invalid",
1862
2028
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
1863
2029
  );
1864
2030
  }
1865
2031
  if (!isPublishableKeyPayload(decoded)) {
1866
2032
  throw new IQAuthError(
1867
- "CONFIG_INVALID",
2033
+ "config_invalid",
1868
2034
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
1869
2035
  );
1870
2036
  }
1871
2037
  if (!isValidIssuerUrl(decoded.iss)) {
1872
2038
  throw new IQAuthError(
1873
- "CONFIG_INVALID",
2039
+ "config_invalid",
1874
2040
  `${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.`
1875
2041
  );
1876
2042
  }
@@ -1884,12 +2050,18 @@ function isPublishableKeyPayload(value) {
1884
2050
 
1885
2051
  // src/middleware/express.ts
1886
2052
  var KNOWN_AUTH_ERROR_CODES = /* @__PURE__ */ new Set([
2053
+ // Legacy UPPER_SNAKE codes (server-originated and SDK ≤2.6.x throws).
1887
2054
  "TOKEN_INVALID",
1888
2055
  "TOKEN_EXPIRED",
1889
2056
  "TOKEN_REVOKED",
1890
2057
  "SESSION_EXPIRED",
1891
2058
  "SESSION_INVALID",
1892
- "AUTH_REQUIRED"
2059
+ "AUTH_REQUIRED",
2060
+ // Task #127 — typed `IQAuthErrorCode` taxonomy thrown by `tokens.verify`.
2061
+ // Mapped to 401 here so framework consumers don't have to learn the new
2062
+ // codes to keep their auth-failure handling working.
2063
+ "token_invalid",
2064
+ "token_expired"
1893
2065
  ]);
1894
2066
  var DEFAULT_ACCESS_COOKIE = "iqauth_at";
1895
2067
  var DEFAULT_REFRESH_COOKIE = "iqauth_rt";
@@ -2073,6 +2245,45 @@ function iqAuthMiddleware(clientOrOptions, options = {}) {
2073
2245
  }
2074
2246
 
2075
2247
  // src/server/handlers.ts
2248
+ async function buildUserinfoResponse(claims, opts = {}) {
2249
+ const baseUser = {
2250
+ sub: claims.sub,
2251
+ email: claims.email,
2252
+ name: claims.name,
2253
+ tenantId: claims.tenantId,
2254
+ vendorId: claims.vendorId,
2255
+ roles: claims.roles ?? [],
2256
+ entitlements: claims.entitlements ?? [],
2257
+ // Task #171 — project the active source/client scope onto the userinfo
2258
+ // payload so server handlers (`getSessionUser`, `/api/iqauth/userinfo`)
2259
+ // expose it without consumers having to re-decode the JWT.
2260
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
2261
+ };
2262
+ const enriched = opts.enrich ? await opts.enrich(claims) : null;
2263
+ const user = enriched ? { ...baseUser, ...enriched } : baseUser;
2264
+ return {
2265
+ success: true,
2266
+ data: {
2267
+ user,
2268
+ claims,
2269
+ tenantId: claims.tenantId ?? null
2270
+ }
2271
+ };
2272
+ }
2273
+ function emitTiming(cfg, event) {
2274
+ if (cfg.debug) {
2275
+ try {
2276
+ console.debug("[iqauth_helper]", event);
2277
+ } catch {
2278
+ }
2279
+ }
2280
+ if (cfg.onTimingEvent) {
2281
+ try {
2282
+ cfg.onTimingEvent(event);
2283
+ } catch {
2284
+ }
2285
+ }
2286
+ }
2076
2287
  var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
2077
2288
  "TOKEN_REVOKED",
2078
2289
  "SESSION_REVOKED",
@@ -2091,19 +2302,62 @@ function shouldClearCookiesOnFailure(policy, status, errorCode) {
2091
2302
  }
2092
2303
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
2093
2304
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
2305
+ function assertCookiePrefixInvariants(name, secure, path, domain) {
2306
+ if (name.startsWith("__Host-")) {
2307
+ if (!secure) {
2308
+ throw new IQAuthError(
2309
+ "config_invalid",
2310
+ `Cookie "${name}" uses the __Host- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2311
+ );
2312
+ }
2313
+ if (path !== "/") {
2314
+ throw new IQAuthError(
2315
+ "config_invalid",
2316
+ `Cookie "${name}" uses the __Host- prefix, which requires Path=/ (got "${path}"). Remove cookiePath or set it to "/".`
2317
+ );
2318
+ }
2319
+ if (domain) {
2320
+ throw new IQAuthError(
2321
+ "config_invalid",
2322
+ `Cookie "${name}" uses the __Host- prefix, which forbids a Domain attribute (the cookie is host-locked). Remove cookieDomain.`
2323
+ );
2324
+ }
2325
+ } else if (name.startsWith("__Secure-") && !secure) {
2326
+ throw new IQAuthError(
2327
+ "config_invalid",
2328
+ `Cookie "${name}" uses the __Secure- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2329
+ );
2330
+ }
2331
+ }
2094
2332
  function resolve(config) {
2095
2333
  const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
2096
2334
  const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
2335
+ maybeWarnDefaultSignoutRegistry(config);
2336
+ const secure = config.secure ?? true;
2337
+ if (config.secure === false && config.allowInsecureCookies !== true) {
2338
+ throw new IQAuthError(
2339
+ "config_invalid",
2340
+ "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."
2341
+ );
2342
+ }
2343
+ const accessCookieName = config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at";
2344
+ const refreshCookieName = config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt";
2345
+ const stateCookieName = config.stateCookieName ?? "iqauth_state";
2346
+ const cookiePath = config.cookiePath ?? "/";
2347
+ const cookieDomain = config.cookieDomain;
2348
+ for (const name of [accessCookieName, refreshCookieName, stateCookieName]) {
2349
+ assertCookiePrefixInvariants(name, secure, cookiePath, cookieDomain);
2350
+ }
2097
2351
  return {
2098
2352
  publishableKey: config.publishableKey,
2099
2353
  secretKey: config.secretKey,
2100
2354
  issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
2101
- accessCookieName: config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at",
2102
- refreshCookieName: config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt",
2103
- cookieDomain: config.cookieDomain,
2355
+ accessCookieName,
2356
+ refreshCookieName,
2357
+ cookieDomain,
2104
2358
  sameSite: config.sameSite ?? "lax",
2105
- secure: config.secure ?? true,
2106
- cookiePath: config.cookiePath ?? "/",
2359
+ secure,
2360
+ cookiePath,
2107
2361
  tokenPath: config.tokenPath ?? "/oidc/token",
2108
2362
  refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
2109
2363
  logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
@@ -2112,9 +2366,23 @@ function resolve(config) {
2112
2366
  })),
2113
2367
  appId: parsed.appId,
2114
2368
  tenantId: parsed.tenantId,
2115
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
2369
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
2370
+ debug: config.debug,
2371
+ onTimingEvent: config.onTimingEvent,
2372
+ signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
2373
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS,
2374
+ requireOAuthState: config.requireOAuthState ?? true,
2375
+ stateCookieName: config.stateCookieName ?? "iqauth_state"
2116
2376
  };
2117
2377
  }
2378
+ function timingSafeEqualStr(a, b) {
2379
+ const len = Math.max(a.length, b.length);
2380
+ let diff = a.length ^ b.length;
2381
+ for (let i = 0; i < len; i++) {
2382
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
2383
+ }
2384
+ return diff === 0;
2385
+ }
2118
2386
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
2119
2387
  return {
2120
2388
  name,
@@ -2129,20 +2397,77 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
2129
2397
  }
2130
2398
  function clearCookies(cfg) {
2131
2399
  return [
2132
- makeCookie(cfg, cfg.accessCookieName, "", 0),
2133
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
2400
+ { ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
2401
+ { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
2134
2402
  ];
2135
2403
  }
2404
+ function clearStateCookie(cfg) {
2405
+ return { ...makeCookie(cfg, cfg.stateCookieName, "", 0, false), clear: true };
2406
+ }
2407
+ var DEFAULT_SIGNOUT_TTL_MS = 6e4;
2408
+ var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
2409
+ function pruneInMemoryMarkers(now) {
2410
+ if (inMemorySignoutMarkers.size === 0) return;
2411
+ for (const [k, exp] of inMemorySignoutMarkers) {
2412
+ if (exp <= now) inMemorySignoutMarkers.delete(k);
2413
+ }
2414
+ }
2415
+ var defaultSignoutRegistry = {
2416
+ mark(token, ttlMs) {
2417
+ const now = Date.now();
2418
+ pruneInMemoryMarkers(now);
2419
+ inMemorySignoutMarkers.set(token, now + ttlMs);
2420
+ },
2421
+ has(token) {
2422
+ const now = Date.now();
2423
+ const exp = inMemorySignoutMarkers.get(token);
2424
+ if (!exp) return false;
2425
+ if (exp <= now) {
2426
+ inMemorySignoutMarkers.delete(token);
2427
+ return false;
2428
+ }
2429
+ return true;
2430
+ }
2431
+ };
2432
+ var warnedDefaultSignoutRegistry = false;
2433
+ function maybeWarnDefaultSignoutRegistry(config) {
2434
+ if (warnedDefaultSignoutRegistry) return;
2435
+ if (config.signoutRegistry) return;
2436
+ warnedDefaultSignoutRegistry = true;
2437
+ console.warn(
2438
+ "[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."
2439
+ );
2440
+ }
2136
2441
  async function handleCallback(config, input) {
2137
2442
  const cfg = resolve(config);
2443
+ const t0 = Date.now();
2138
2444
  if (!input.code || !input.redirectUri) {
2445
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
2139
2446
  return {
2140
2447
  status: 400,
2141
2448
  body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
2142
2449
  cookies: []
2143
2450
  };
2144
2451
  }
2452
+ const provided = input.state;
2453
+ const expected = input.expectedState;
2454
+ const stateOk = cfg.requireOAuthState ? !!expected && !!provided && timingSafeEqualStr(provided, expected) : !expected || !!provided && timingSafeEqualStr(provided, expected);
2455
+ if (!stateOk) {
2456
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "STATE_MISMATCH" });
2457
+ return {
2458
+ status: 400,
2459
+ body: {
2460
+ success: false,
2461
+ error: {
2462
+ code: "STATE_MISMATCH",
2463
+ message: "OAuth state validation failed; the sign-in could not be verified as originating from this browser."
2464
+ }
2465
+ },
2466
+ cookies: [clearStateCookie(cfg)]
2467
+ };
2468
+ }
2145
2469
  if (!cfg.secretKey) {
2470
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
2146
2471
  return {
2147
2472
  status: 500,
2148
2473
  body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
@@ -2166,6 +2491,7 @@ async function handleCallback(config, input) {
2166
2491
  });
2167
2492
  const json = await res.json().catch(() => ({}));
2168
2493
  if (!res.ok || !json.access_token) {
2494
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
2169
2495
  return {
2170
2496
  status: res.status || 502,
2171
2497
  body: {
@@ -2178,6 +2504,26 @@ async function handleCallback(config, input) {
2178
2504
  cookies: []
2179
2505
  };
2180
2506
  }
2507
+ try {
2508
+ await getTokensFor(cfg.issuer).verify(json.access_token, {
2509
+ issuer: cfg.issuer,
2510
+ ...config.verify
2511
+ });
2512
+ } catch (err) {
2513
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2514
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code });
2515
+ return {
2516
+ status: 502,
2517
+ body: {
2518
+ success: false,
2519
+ error: {
2520
+ code: "ACCESS_TOKEN_VERIFICATION_FAILED",
2521
+ message: "The issuer returned an access token that failed verification; no session was established."
2522
+ }
2523
+ },
2524
+ cookies: []
2525
+ };
2526
+ }
2181
2527
  const cookies = [];
2182
2528
  cookies.push(
2183
2529
  makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
@@ -2185,6 +2531,8 @@ async function handleCallback(config, input) {
2185
2531
  if (json.refresh_token) {
2186
2532
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
2187
2533
  }
2534
+ cookies.push(clearStateCookie(cfg));
2535
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
2188
2536
  return {
2189
2537
  status: 200,
2190
2538
  body: { success: true, data: { authenticated: true } },
@@ -2193,8 +2541,18 @@ async function handleCallback(config, input) {
2193
2541
  }
2194
2542
  async function handleRefresh(config, input) {
2195
2543
  const cfg = resolve(config);
2544
+ const t0 = Date.now();
2196
2545
  const refreshToken = input.refreshToken;
2546
+ const idemKey = input.idempotencyToken;
2547
+ if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
2548
+ return {
2549
+ status: 401,
2550
+ body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
2551
+ cookies: clearCookies(cfg)
2552
+ };
2553
+ }
2197
2554
  if (!refreshToken) {
2555
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
2198
2556
  return {
2199
2557
  status: 401,
2200
2558
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
@@ -2210,6 +2568,7 @@ async function handleRefresh(config, input) {
2210
2568
  if (!res.ok || !json.success || !json.data?.accessToken) {
2211
2569
  const status = res.status || 401;
2212
2570
  const errorCode = json.error?.code || "TOKEN_INVALID";
2571
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
2213
2572
  const shouldClear = shouldClearCookiesOnFailure(
2214
2573
  cfg.clearCookiesOnRefreshFailure,
2215
2574
  status,
@@ -2233,6 +2592,7 @@ async function handleRefresh(config, input) {
2233
2592
  if (json.data.refreshToken) {
2234
2593
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
2235
2594
  }
2595
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
2236
2596
  return {
2237
2597
  status: 200,
2238
2598
  body: { success: true, data: { accessToken: json.data.accessToken } },
@@ -2241,6 +2601,10 @@ async function handleRefresh(config, input) {
2241
2601
  }
2242
2602
  async function handleSignout(config, input) {
2243
2603
  const cfg = resolve(config);
2604
+ const t0 = Date.now();
2605
+ if (input.idempotencyToken) {
2606
+ await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
2607
+ }
2244
2608
  if (input.accessToken) {
2245
2609
  try {
2246
2610
  await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
@@ -2262,15 +2626,95 @@ async function handleSignout(config, input) {
2262
2626
  } catch {
2263
2627
  }
2264
2628
  }
2629
+ emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
2265
2630
  return {
2266
2631
  status: 200,
2267
2632
  body: { success: true, data: { signedOut: true } },
2268
2633
  cookies: clearCookies(cfg)
2269
2634
  };
2270
2635
  }
2636
+ var TOKENS_CACHE = /* @__PURE__ */ new Map();
2637
+ function getTokensFor(issuer) {
2638
+ let m = TOKENS_CACHE.get(issuer);
2639
+ if (!m) {
2640
+ m = new TokensModule(issuer);
2641
+ TOKENS_CACHE.set(issuer, m);
2642
+ }
2643
+ return m;
2644
+ }
2645
+ async function handleUserinfo(config, input) {
2646
+ const cfg = resolve(config);
2647
+ if (!input.accessToken) {
2648
+ return {
2649
+ status: 401,
2650
+ body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
2651
+ cookies: []
2652
+ };
2653
+ }
2654
+ let claims;
2655
+ try {
2656
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, {
2657
+ issuer: cfg.issuer,
2658
+ ...config.verify
2659
+ });
2660
+ } catch (err) {
2661
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2662
+ const message = err instanceof Error ? err.message : "Access token verification failed";
2663
+ return {
2664
+ status: 401,
2665
+ body: { success: false, error: { code, message } },
2666
+ cookies: []
2667
+ };
2668
+ }
2669
+ const envelope = await buildUserinfoResponse(claims, {
2670
+ enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
2671
+ });
2672
+ return {
2673
+ status: 200,
2674
+ body: envelope,
2675
+ cookies: []
2676
+ };
2677
+ }
2678
+
2679
+ // src/browser/returnTo.ts
2680
+ function normalizeOrigin(o) {
2681
+ try {
2682
+ return new URL(o).origin;
2683
+ } catch {
2684
+ return o.replace(/\/+$/, "");
2685
+ }
2686
+ }
2687
+ function sanitizeReturnTo(input, options = {}) {
2688
+ const fallback = options.fallback ?? "/";
2689
+ if (!input || typeof input !== "string") return fallback;
2690
+ const trimmed = input.trim();
2691
+ if (!trimmed) return fallback;
2692
+ if (trimmed.includes("\\")) return fallback;
2693
+ if (trimmed.startsWith("//")) return fallback;
2694
+ if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
2695
+ return trimmed;
2696
+ }
2697
+ if (!/^[a-z][a-z0-9+\-.]*:/i.test(trimmed)) {
2698
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2699
+ }
2700
+ let parsed;
2701
+ try {
2702
+ parsed = new URL(trimmed);
2703
+ } catch {
2704
+ return fallback;
2705
+ }
2706
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback;
2707
+ const currentOrigin = options.currentOrigin ?? (typeof window !== "undefined" ? window.location.origin : "");
2708
+ const allowed = /* @__PURE__ */ new Set();
2709
+ if (currentOrigin) allowed.add(normalizeOrigin(currentOrigin));
2710
+ for (const o of options.allowedOrigins ?? []) allowed.add(normalizeOrigin(o));
2711
+ if (allowed.has(parsed.origin)) return parsed.toString();
2712
+ return fallback;
2713
+ }
2271
2714
 
2272
2715
  // src/express.ts
2273
2716
  var PKCE_COOKIE = "iqauth_pkce";
2717
+ var IDEMPOTENCY_HEADER = "x-iqauth-idempotency";
2274
2718
  function escapeHtml(s) {
2275
2719
  return s.replace(/[&<>"']/g, (c) => {
2276
2720
  switch (c) {
@@ -2344,6 +2788,17 @@ function defaultBrandedSpinner(args) {
2344
2788
  }
2345
2789
  function applyHandlerResponse(res, hr) {
2346
2790
  for (const c of hr.cookies) {
2791
+ if (c.clear && typeof res.clearCookie === "function") {
2792
+ const opts = {
2793
+ httpOnly: c.httpOnly,
2794
+ secure: c.secure,
2795
+ sameSite: c.sameSite,
2796
+ path: c.path
2797
+ };
2798
+ if (c.domain) opts.domain = c.domain;
2799
+ res.clearCookie(c.name, opts);
2800
+ continue;
2801
+ }
2347
2802
  if (typeof res.cookie === "function") {
2348
2803
  const opts = {
2349
2804
  httpOnly: c.httpOnly,
@@ -2353,11 +2808,13 @@ function applyHandlerResponse(res, hr) {
2353
2808
  maxAge: c.maxAge * 1e3
2354
2809
  };
2355
2810
  if (c.domain) opts.domain = c.domain;
2811
+ if (c.clear) opts.expires = /* @__PURE__ */ new Date(0);
2356
2812
  res.cookie(c.name, c.value, opts);
2357
2813
  } else {
2358
2814
  const existing = res.getHeader?.("Set-Cookie") || [];
2359
2815
  const list = Array.isArray(existing) ? existing : [existing];
2360
2816
  const parts = [`${c.name}=${encodeURIComponent(c.value)}`, `Path=${c.path}`, `Max-Age=${c.maxAge}`, `SameSite=${c.sameSite}`];
2817
+ if (c.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
2361
2818
  if (c.secure) parts.push("Secure");
2362
2819
  if (c.httpOnly) parts.push("HttpOnly");
2363
2820
  if (c.domain) parts.push(`Domain=${c.domain}`);
@@ -2370,6 +2827,11 @@ function applyHandlerResponse(res, hr) {
2370
2827
  function readBody(req) {
2371
2828
  return req.body && typeof req.body === "object" ? req.body : {};
2372
2829
  }
2830
+ function requestOriginOf(req) {
2831
+ const proto = req.headers?.["x-forwarded-proto"]?.split(",")[0]?.trim() || (typeof req.protocol === "string" ? req.protocol : void 0) || "https";
2832
+ const host = req.headers?.["x-forwarded-host"]?.split(",")[0]?.trim() || req.headers?.host || "";
2833
+ return host ? `${proto}://${host}` : "";
2834
+ }
2373
2835
  function readCookieFromReq(req, name) {
2374
2836
  if (req.cookies && typeof req.cookies[name] === "string") return req.cookies[name];
2375
2837
  const header = req.headers?.cookie;
@@ -2408,12 +2870,19 @@ function iqAuth(options) {
2408
2870
  const inline = options.inlineCallback === true ? {} : options.inlineCallback && typeof options.inlineCallback === "object" ? options.inlineCallback : null;
2409
2871
  const inlineBranded = inline?.branded === true ? {} : inline?.branded && typeof inline.branded === "object" ? inline.branded : null;
2410
2872
  const attachHelpers = (app) => {
2873
+ void client.prewarm();
2411
2874
  app.post(`${mount}/callback`, async (req, res) => {
2412
2875
  const body = readBody(req);
2413
2876
  const hr = await handleCallback(helperConfig, {
2414
2877
  code: body.code,
2415
2878
  codeVerifier: body.codeVerifier,
2416
- redirectUri: body.redirectUri
2879
+ redirectUri: body.redirectUri,
2880
+ // M-2: bind the callback to this browser. `state` is echoed back by the
2881
+ // OAuth redirect (body); `expectedState` is the value the SDK published
2882
+ // in a first-party cookie before redirect. handleCallback fails closed
2883
+ // on mismatch/missing when requireOAuthState (default) is on.
2884
+ state: body.state,
2885
+ expectedState: readCookieFromReq(req, helperConfig.stateCookieName ?? "iqauth_state")
2417
2886
  });
2418
2887
  applyHandlerResponse(res, hr);
2419
2888
  });
@@ -2462,21 +2931,19 @@ function iqAuth(options) {
2462
2931
  });
2463
2932
  app.post(exchangePath, async (req, res) => {
2464
2933
  const body = readBody(req);
2465
- const stateFromBody = body.state || void 0;
2466
- const stateFromCookie = readCookieFromReq(req, stateCookie);
2467
- if (stateFromCookie && stateFromBody !== stateFromCookie) {
2468
- clearCookie(res, stateCookie);
2469
- res.status(400);
2470
- return res.json ? res.json({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } }) : res.end?.(JSON.stringify({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } }));
2471
- }
2472
2934
  const hr = await handleCallback(helperConfig, {
2473
2935
  code: body.code,
2474
2936
  codeVerifier: body.codeVerifier || readCookieFromReq(req, PKCE_COOKIE) || "",
2475
- redirectUri: body.redirectUri
2937
+ redirectUri: body.redirectUri,
2938
+ state: body.state,
2939
+ expectedState: readCookieFromReq(req, stateCookie)
2476
2940
  });
2477
2941
  clearCookie(res, stateCookie);
2478
2942
  clearCookie(res, PKCE_COOKIE);
2479
- const returnTo = readCookieFromReq(req, returnToCookie) || hr.body?.returnTo || "/";
2943
+ const returnTo = sanitizeReturnTo(
2944
+ readCookieFromReq(req, returnToCookie) || hr.body?.returnTo,
2945
+ { currentOrigin: requestOriginOf(req), fallback: "/" }
2946
+ );
2480
2947
  if (hr.status < 400) clearCookie(res, returnToCookie);
2481
2948
  const enriched = {
2482
2949
  ...hr,
@@ -2495,21 +2962,17 @@ function iqAuth(options) {
2495
2962
  else res.end?.("Missing authorization code");
2496
2963
  });
2497
2964
  }
2498
- const stateFromQuery = q.state;
2499
- const stateFromCookie = readCookieFromReq(req, stateCookie);
2500
- if (stateFromCookie && stateFromQuery !== stateFromCookie) {
2501
- clearCookie(res, stateCookie);
2502
- return failPlain(res, "state_mismatch", () => {
2503
- res.status(400);
2504
- if (res.json) res.json({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } });
2505
- else res.end?.("OAuth state mismatch");
2506
- });
2507
- }
2508
2965
  const codeVerifier = readCookieFromReq(req, PKCE_COOKIE) || "";
2509
2966
  const proto = req.headers?.["x-forwarded-proto"] || req.protocol || "https";
2510
2967
  const host = req.headers?.["x-forwarded-host"] || req.headers?.host || "";
2511
2968
  const redirectUri = `${proto}://${host}${callbackPath}`;
2512
- const hr = await handleCallback(helperConfig, { code, codeVerifier, redirectUri });
2969
+ const hr = await handleCallback(helperConfig, {
2970
+ code,
2971
+ codeVerifier,
2972
+ redirectUri,
2973
+ state: q.state,
2974
+ expectedState: readCookieFromReq(req, stateCookie)
2975
+ });
2513
2976
  for (const c of hr.cookies) {
2514
2977
  if (typeof res.cookie === "function") {
2515
2978
  const opts = {
@@ -2526,14 +2989,18 @@ function iqAuth(options) {
2526
2989
  clearCookie(res, stateCookie);
2527
2990
  clearCookie(res, PKCE_COOKIE);
2528
2991
  if (hr.status >= 400) {
2529
- const code2 = hr.body?.error?.code || "exchange_failed";
2992
+ const rawCode = hr.body?.error?.code || "exchange_failed";
2993
+ const code2 = rawCode === "STATE_MISMATCH" ? "state_mismatch" : rawCode;
2530
2994
  return failPlain(res, code2, () => {
2531
2995
  res.status(hr.status);
2532
2996
  if (res.json) res.json(hr.body);
2533
2997
  else res.end?.(JSON.stringify(hr.body));
2534
2998
  });
2535
2999
  }
2536
- const returnTo = readCookieFromReq(req, returnToCookie) || hr.body?.returnTo || "/";
3000
+ const returnTo = sanitizeReturnTo(
3001
+ readCookieFromReq(req, returnToCookie) || hr.body?.returnTo,
3002
+ { currentOrigin: requestOriginOf(req), fallback: "/" }
3003
+ );
2537
3004
  clearCookie(res, returnToCookie);
2538
3005
  if (typeof res.redirect === "function") return res.redirect(302, returnTo);
2539
3006
  res.status(302);
@@ -2545,13 +3012,23 @@ function iqAuth(options) {
2545
3012
  app.post(`${mount}/refresh`, async (req, res) => {
2546
3013
  const body = readBody(req);
2547
3014
  const refreshToken = body.refreshToken || readCookieFromReq(req, refreshCookie);
2548
- const hr = await handleRefresh(helperConfig, { refreshToken });
3015
+ const idempotencyToken = req.headers?.[IDEMPOTENCY_HEADER] || body.idempotencyToken;
3016
+ const hr = await handleRefresh(helperConfig, { refreshToken, idempotencyToken });
2549
3017
  applyHandlerResponse(res, hr);
2550
3018
  });
3019
+ if (options.mountUserinfo && typeof app.get === "function") {
3020
+ app.get(`${mount}/me`, async (req, res) => {
3021
+ const accessToken = req.headers?.authorization?.replace(/^Bearer /i, "") || readCookieFromReq(req, accessCookie);
3022
+ const hr = await handleUserinfo(helperConfig, { accessToken, req });
3023
+ applyHandlerResponse(res, hr);
3024
+ });
3025
+ }
2551
3026
  app.post(`${mount}/signout`, async (req, res) => {
2552
3027
  const accessToken = req.headers?.authorization?.replace(/^Bearer /i, "") || readCookieFromReq(req, accessCookie);
3028
+ const refreshToken = readCookieFromReq(req, refreshCookie);
2553
3029
  const ssoCookieHeader = req.headers?.cookie;
2554
- const hr = await handleSignout(helperConfig, { accessToken, ssoCookieHeader });
3030
+ const idempotencyToken = req.headers?.[IDEMPOTENCY_HEADER];
3031
+ const hr = await handleSignout(helperConfig, { accessToken, refreshToken, idempotencyToken, ssoCookieHeader });
2555
3032
  applyHandlerResponse(res, hr);
2556
3033
  });
2557
3034
  };
@@ -2559,6 +3036,7 @@ function iqAuth(options) {
2559
3036
  composed.middleware = middleware;
2560
3037
  composed.attachHelpers = attachHelpers;
2561
3038
  composed.client = client;
3039
+ composed.prewarm = () => client.prewarm();
2562
3040
  return composed;
2563
3041
  }
2564
3042
  // Annotate the CommonJS export names for ESM import in node: