@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/fastify.js CHANGED
@@ -36,13 +36,30 @@ __export(fastify_exports, {
36
36
  module.exports = __toCommonJS(fastify_exports);
37
37
 
38
38
  // src/errors.ts
39
- var IQAuthError = class extends Error {
40
- constructor(code, message, status, raw) {
39
+ var IQAuthError = class _IQAuthError extends Error {
40
+ constructor(code, message, status, cause) {
41
41
  super(message);
42
42
  this.name = "IQAuthError";
43
43
  this.code = code;
44
44
  this.status = status;
45
- this.raw = raw;
45
+ this.cause = cause;
46
+ this.raw = cause;
47
+ }
48
+ /**
49
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
50
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
51
+ */
52
+ static isIQAuthError(value) {
53
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
54
+ }
55
+ /**
56
+ * Type-narrowed code check. Lets callers write
57
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
58
+ * taxonomy without losing the ability to handle server codes via
59
+ * `err.code === "TOKEN_REVOKED"`.
60
+ */
61
+ is(code) {
62
+ return this.code === code;
46
63
  }
47
64
  };
48
65
 
@@ -157,7 +174,7 @@ var HttpClient = class {
157
174
  headers: this.buildHeaders(),
158
175
  ...this.isBrowserSession() ? { credentials: "include" } : (() => {
159
176
  const refreshToken = this.config.getRefreshToken();
160
- if (!refreshToken) throw new IQAuthError("TOKEN_INVALID", "No refresh token available");
177
+ if (!refreshToken) throw new IQAuthError("config_invalid", "No refresh token available");
161
178
  return { body: JSON.stringify({ refreshToken }) };
162
179
  })()
163
180
  });
@@ -174,7 +191,7 @@ var HttpClient = class {
174
191
  return;
175
192
  }
176
193
  if (!body.data.accessToken || !body.data.refreshToken) {
177
- throw new IQAuthError("TOKEN_INVALID", "Refresh response did not include a token pair");
194
+ throw new IQAuthError("token_invalid", "Refresh response did not include a token pair");
178
195
  }
179
196
  const tokens = {
180
197
  accessToken: body.data.accessToken,
@@ -192,7 +209,7 @@ var HttpClient = class {
192
209
  return this.requestWithRetry(method, path, body, options, false);
193
210
  }
194
211
  async requestWithRetry(method, path, body, options, hasRetried) {
195
- if (this.config.autoRefresh && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
212
+ if (this.config.autoRefresh && this.config.proactiveRefresh !== false && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
196
213
  await this.attemptRefresh();
197
214
  }
198
215
  const url = `${this.config.baseUrl}${path}`;
@@ -279,17 +296,27 @@ function parseLoginResponse(data, browserSessionMode) {
279
296
  tenants: data.tenants
280
297
  };
281
298
  }
299
+ if (data.type === "scope_selection" && data.scopeSelectionToken && data.scopes && data.tenantId) {
300
+ return {
301
+ status: "scope_selection",
302
+ scopeSelectionToken: data.scopeSelectionToken,
303
+ tenantId: data.tenantId,
304
+ scopes: data.scopes
305
+ };
306
+ }
282
307
  throw new Error("Unexpected login response shape");
283
308
  }
284
309
  var AuthModule = class {
285
310
  constructor(http) {
286
311
  this.http = http;
287
312
  }
288
- async login(email, password) {
313
+ async login(email, password, opts) {
314
+ const body = { email, password };
315
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
289
316
  const data = await this.http.request(
290
317
  "POST",
291
318
  "/api/v1/auth/login",
292
- { email, password },
319
+ body,
293
320
  { skipAutoRefresh: true }
294
321
  );
295
322
  return parseLoginResponse(data, this.http.isBrowserSession());
@@ -327,13 +354,29 @@ var AuthModule = class {
327
354
  method
328
355
  }, { skipAutoRefresh: true });
329
356
  }
330
- async selectTenant(tenantSelectionToken, tenantId) {
357
+ async selectTenant(tenantSelectionToken, tenantId, opts) {
358
+ const body = { tenantSelectionToken, tenantId };
359
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
331
360
  const data = await this.http.request(
332
361
  "POST",
333
362
  "/api/v1/auth/select-tenant",
363
+ body,
364
+ { skipAutoRefresh: true }
365
+ );
366
+ return parseLoginResponse(data, this.http.isBrowserSession());
367
+ }
368
+ /**
369
+ * Task #171 — redeem a scope-selection token + chosen membership for a
370
+ * real authenticated session. `membershipId` must be one of the scopes
371
+ * returned in the prior `scope_selection` envelope.
372
+ */
373
+ async selectScope(scopeSelectionToken, membershipId) {
374
+ const data = await this.http.request(
375
+ "POST",
376
+ "/api/v1/auth/select-scope",
334
377
  {
335
- tenantSelectionToken,
336
- tenantId
378
+ scopeSelectionToken,
379
+ membershipId
337
380
  },
338
381
  { skipAutoRefresh: true }
339
382
  );
@@ -420,6 +463,18 @@ var DEFAULT_TOKEN_AUDIENCE = [
420
463
  "iqvalidate"
421
464
  ];
422
465
  var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
466
+ function classifyJoseError(err) {
467
+ if (err instanceof import_jose.errors.JWTExpired) {
468
+ return { code: "token_expired", message: "Token has expired" };
469
+ }
470
+ if (err instanceof import_jose.errors.JOSEError) {
471
+ return { code: "token_invalid", message: err.message };
472
+ }
473
+ if (err instanceof Error) {
474
+ return { code: "token_invalid", message: err.message };
475
+ }
476
+ return { code: "token_invalid", message: "Token verification failed" };
477
+ }
423
478
  function decodeProtectedHeader(token) {
424
479
  const parts = token.split(".");
425
480
  if (parts.length < 2) return null;
@@ -456,11 +511,11 @@ var TokensModule = class {
456
511
  async verify(token, options = {}) {
457
512
  const header = decodeProtectedHeader(token);
458
513
  if (!header) {
459
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
514
+ throw new IQAuthError("token_invalid", "Unable to decode token");
460
515
  }
461
516
  const kid = header.kid;
462
517
  if (!kid) {
463
- throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
518
+ throw new IQAuthError("token_invalid", "Token missing kid header");
464
519
  }
465
520
  let cache = await this.ensureCache();
466
521
  if (!cache.byKid.has(kid)) {
@@ -468,7 +523,7 @@ var TokensModule = class {
468
523
  cache = await this.ensureCache();
469
524
  }
470
525
  if (!cache.byKid.has(kid)) {
471
- throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
526
+ throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
472
527
  }
473
528
  const issuer = options.issuer ?? this.defaultIssuer;
474
529
  const audience = options.audience ?? this.defaultAudience;
@@ -484,16 +539,8 @@ var TokensModule = class {
484
539
  const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
485
540
  return payload;
486
541
  } catch (err) {
487
- if (err instanceof import_jose.errors.JWTExpired) {
488
- throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
489
- }
490
- if (err instanceof import_jose.errors.JOSEError) {
491
- throw new IQAuthError("TOKEN_INVALID", err.message);
492
- }
493
- if (err instanceof Error) {
494
- throw new IQAuthError("TOKEN_INVALID", err.message);
495
- }
496
- throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
542
+ const classified = classifyJoseError(err);
543
+ throw new IQAuthError(classified.code, classified.message, void 0, err);
497
544
  }
498
545
  }
499
546
  /**
@@ -535,7 +582,7 @@ var TokensModule = class {
535
582
  getClaims(token) {
536
583
  const claims = this.decode(token);
537
584
  if (!claims) {
538
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token claims");
585
+ throw new IQAuthError("token_invalid", "Unable to decode token claims");
539
586
  }
540
587
  return claims;
541
588
  }
@@ -545,7 +592,7 @@ var TokensModule = class {
545
592
  }
546
593
  await this.refreshJwks();
547
594
  if (!this.jwksCache) {
548
- throw new IQAuthError("INTERNAL_ERROR", "JWKS cache unavailable after refresh");
595
+ throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
549
596
  }
550
597
  return this.jwksCache;
551
598
  }
@@ -555,22 +602,38 @@ var TokensModule = class {
555
602
  }
556
603
  this.inFlightRefresh = (async () => {
557
604
  try {
558
- const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
605
+ let res;
606
+ try {
607
+ res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
608
+ } catch (err) {
609
+ throw new IQAuthError(
610
+ "network",
611
+ err instanceof Error ? err.message : "JWKS fetch network error",
612
+ void 0,
613
+ err
614
+ );
615
+ }
559
616
  if (!res.ok) {
560
617
  throw new IQAuthError(
561
- "INTERNAL_ERROR",
562
- `Failed to fetch JWKS: ${res.status}`
618
+ "jwks_fetch_failed",
619
+ `Failed to fetch JWKS: ${res.status}`,
620
+ res.status
563
621
  );
564
622
  }
565
623
  let jwks;
566
624
  try {
567
625
  jwks = await res.json();
568
- } catch {
569
- throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
626
+ } catch (err) {
627
+ throw new IQAuthError(
628
+ "jwks_fetch_failed",
629
+ "Malformed JWKS response: invalid JSON",
630
+ res.status,
631
+ err
632
+ );
570
633
  }
571
634
  if (!jwks || !Array.isArray(jwks.keys)) {
572
635
  throw new IQAuthError(
573
- "INTERNAL_ERROR",
636
+ "jwks_fetch_failed",
574
637
  "Malformed JWKS response: expected { keys: [...] }"
575
638
  );
576
639
  }
@@ -578,7 +641,7 @@ var TokensModule = class {
578
641
  for (const key of jwks.keys) {
579
642
  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")) {
580
643
  throw new IQAuthError(
581
- "INTERNAL_ERROR",
644
+ "jwks_fetch_failed",
582
645
  "Malformed JWKS response: key missing required fields"
583
646
  );
584
647
  }
@@ -596,6 +659,19 @@ var TokensModule = class {
596
659
  clearCache() {
597
660
  this.jwksCache = null;
598
661
  }
662
+ /**
663
+ * Task #126: Eagerly populate the JWKS cache so the first verify() call
664
+ * doesn't pay a network round-trip. Safe to call repeatedly — single-flight
665
+ * behavior is shared with the lazy refresh path. Errors are swallowed so
666
+ * callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
667
+ */
668
+ async prewarm() {
669
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
670
+ try {
671
+ await this.refreshJwks();
672
+ } catch {
673
+ }
674
+ }
599
675
  };
600
676
 
601
677
  // src/modules/sessions.ts
@@ -919,14 +995,14 @@ var OidcModule = class {
919
995
  */
920
996
  async handleCallback(params) {
921
997
  if (!params.state) {
922
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing state parameter");
998
+ throw new IQAuthError("config_invalid", "OIDC callback missing state parameter");
923
999
  }
924
1000
  if (!params.code) {
925
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing code parameter");
1001
+ throw new IQAuthError("config_invalid", "OIDC callback missing code parameter");
926
1002
  }
927
1003
  const stored = await this.stateStore.get(params.state);
928
1004
  if (!stored) {
929
- throw new IQAuthError("VALIDATION_ERROR", "Unknown or expired OIDC state");
1005
+ throw new IQAuthError("config_invalid", "Unknown or expired OIDC state");
930
1006
  }
931
1007
  let tokens;
932
1008
  try {
@@ -944,7 +1020,7 @@ var OidcModule = class {
944
1020
  if (tokens.id_token) {
945
1021
  if (!this.tokensModule) {
946
1022
  throw new IQAuthError(
947
- "INTERNAL_ERROR",
1023
+ "config_invalid",
948
1024
  "OIDC handleCallback received an id_token but no TokensModule is configured for verification"
949
1025
  );
950
1026
  }
@@ -955,7 +1031,7 @@ var OidcModule = class {
955
1031
  const tokenNonce = typeof claimsBag.nonce === "string" ? claimsBag.nonce : void 0;
956
1032
  if (!tokenNonce || tokenNonce !== stored.nonce) {
957
1033
  throw new IQAuthError(
958
- "TOKEN_INVALID",
1034
+ "token_invalid",
959
1035
  "OIDC id_token nonce did not match the stored value"
960
1036
  );
961
1037
  }
@@ -1156,6 +1232,9 @@ var AppsModule = class {
1156
1232
  * @remarks Wraps GET /api/v1/apps/:appKey
1157
1233
  */
1158
1234
  async get(appKey) {
1235
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1236
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1237
+ }
1159
1238
  return this.http.request("GET", `/api/v1/apps/${encodeURIComponent(appKey)}`);
1160
1239
  }
1161
1240
  /**
@@ -1175,6 +1254,16 @@ var AppsModule = class {
1175
1254
  401
1176
1255
  );
1177
1256
  }
1257
+ if (!manifest || typeof manifest.key !== "string" || manifest.key.trim() === "") {
1258
+ throw new IQAuthError("VALIDATION_ERROR", "manifest.key (appKey) is required for register().", 400);
1259
+ }
1260
+ if (!manifest.environment || !["production", "staging", "development"].includes(manifest.environment)) {
1261
+ throw new IQAuthError(
1262
+ "ENVIRONMENT_REQUIRED",
1263
+ "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.",
1264
+ 400
1265
+ );
1266
+ }
1178
1267
  return this.http.request("POST", "/api/v1/apps/sync", manifest);
1179
1268
  }
1180
1269
  /**
@@ -1184,11 +1273,14 @@ var AppsModule = class {
1184
1273
  * @remarks Uses GET /api/v1/apps/:appKey — catches 404 errors
1185
1274
  */
1186
1275
  async isRegistered(appKey) {
1276
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1277
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1278
+ }
1187
1279
  try {
1188
1280
  await this.get(appKey);
1189
1281
  return true;
1190
1282
  } catch (err) {
1191
- if (err.code === "NOT_FOUND" || err.status === 404) {
1283
+ if (err.code === "app_not_found" || err.code === "NOT_FOUND" || err.status === 404) {
1192
1284
  return false;
1193
1285
  }
1194
1286
  throw err;
@@ -1225,6 +1317,20 @@ var RolesModule = class {
1225
1317
  };
1226
1318
 
1227
1319
  // src/modules/permissionGroups.ts
1320
+ function assertAppKey(appKey, callsite) {
1321
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1322
+ throw new IQAuthError(
1323
+ "VALIDATION_ERROR",
1324
+ `appKey is required for ${callsite} (no env-var fallback, no 'product' alias).`,
1325
+ 400
1326
+ );
1327
+ }
1328
+ }
1329
+ function assertNodeKey(nodeKey, callsite) {
1330
+ if (typeof nodeKey !== "string" || nodeKey.trim() === "") {
1331
+ throw new IQAuthError("VALIDATION_ERROR", `nodeKey is required for ${callsite}.`, 400);
1332
+ }
1333
+ }
1228
1334
  var PermissionGroupsModule = class {
1229
1335
  constructor(http) {
1230
1336
  this.http = http;
@@ -1245,7 +1351,14 @@ var PermissionGroupsModule = class {
1245
1351
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`);
1246
1352
  }
1247
1353
  async addPermission(tenantId, groupId, data) {
1248
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, data);
1354
+ assertAppKey(data?.appKey, "permissionGroups.addPermission");
1355
+ assertNodeKey(data?.nodeKey, "permissionGroups.addPermission");
1356
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, {
1357
+ appKey: data.appKey,
1358
+ nodeKey: data.nodeKey,
1359
+ effect: data.effect,
1360
+ weight: data.weight
1361
+ });
1249
1362
  }
1250
1363
  async removePermission(tenantId, groupId, permissionId) {
1251
1364
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions/${permissionId}`);
@@ -1269,21 +1382,51 @@ var PermissionGroupsModule = class {
1269
1382
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`);
1270
1383
  }
1271
1384
  async addUserOverride(tenantId, userId, data) {
1272
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, data);
1385
+ assertAppKey(data?.appKey, "permissionGroups.addUserOverride");
1386
+ assertNodeKey(data?.nodeKey, "permissionGroups.addUserOverride");
1387
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, {
1388
+ appKey: data.appKey,
1389
+ nodeKey: data.nodeKey,
1390
+ effect: data.effect,
1391
+ weight: data.weight,
1392
+ expiresAt: data.expiresAt
1393
+ });
1273
1394
  }
1274
1395
  async removeUserOverride(tenantId, userId, overrideId) {
1275
1396
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides/${overrideId}`);
1276
1397
  }
1398
+ /**
1399
+ * Task #130 — `appKey` is REQUIRED. The legacy `product` query alias is no
1400
+ * longer accepted at the SDK boundary; pass it as `appKey` instead. The
1401
+ * server still accepts `product=` from raw HTTP callers during the
1402
+ * deprecation window, but the SDK will not silently translate it.
1403
+ */
1277
1404
  async getEffectivePermissions(tenantId, userId, params) {
1278
- const query = new URLSearchParams();
1279
- if (params.product) query.set("product", params.product);
1280
- if (params.appKey) query.set("appKey", params.appKey);
1281
- const qs = query.toString();
1282
- return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective${qs ? `?${qs}` : ""}`);
1405
+ assertAppKey(params?.appKey, "permissionGroups.getEffectivePermissions");
1406
+ const qs = new URLSearchParams({ appKey: params.appKey }).toString();
1407
+ return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective?${qs}`);
1283
1408
  }
1284
1409
  async checkPermission(tenantId, userId, appKey, nodeKey) {
1410
+ assertAppKey(appKey, "permissionGroups.checkPermission");
1411
+ assertNodeKey(nodeKey, "permissionGroups.checkPermission");
1285
1412
  return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/check`, { appKey, nodeKey });
1286
1413
  }
1414
+ /**
1415
+ * Task #130 — every entry in `checks` must include a non-empty `appKey`
1416
+ * AND `nodeKey`. The SDK validates the whole batch before sending so a
1417
+ * single misconfigured entry can't slip through and silently report
1418
+ * `allowed: false` from the server's per-entry validation branch.
1419
+ */
1420
+ async batchCheckPermissions(tenantId, userId, checks) {
1421
+ if (!Array.isArray(checks) || checks.length === 0) {
1422
+ throw new IQAuthError("VALIDATION_ERROR", "checks must be a non-empty array of { appKey, nodeKey }.", 400);
1423
+ }
1424
+ checks.forEach((c, i) => {
1425
+ assertAppKey(c?.appKey, `permissionGroups.batchCheckPermissions[${i}]`);
1426
+ assertNodeKey(c?.nodeKey, `permissionGroups.batchCheckPermissions[${i}]`);
1427
+ });
1428
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/batch-check`, { checks });
1429
+ }
1287
1430
  };
1288
1431
 
1289
1432
  // src/modules/apiKeys.ts
@@ -1708,6 +1851,10 @@ var IQAuthClient = class _IQAuthClient {
1708
1851
  this._refreshToken = tokens.refreshToken;
1709
1852
  },
1710
1853
  autoRefresh: "autoRefresh" in config ? config.autoRefresh !== false : true,
1854
+ // `'app-state'` is mobile-only — on any other environment we treat it
1855
+ // as the default `true` (proactive refresh ON). Only the mobile client
1856
+ // disables proactive refresh and replaces it with an AppState listener.
1857
+ proactiveRefresh: "autoRefresh" in config && config.autoRefresh === "app-state" && _IQAuthClient.resolveEnvironment(config) === "mobile" ? false : true,
1711
1858
  onTokenRefresh: "onTokenRefresh" in config ? config.onTokenRefresh : void 0,
1712
1859
  sessionHeaderName: config.sessionHeaderName,
1713
1860
  sessionHeaderValue: config.sessionHeaderValue,
@@ -1748,6 +1895,13 @@ var IQAuthClient = class _IQAuthClient {
1748
1895
  static forServer(config) {
1749
1896
  return new _IQAuthClient({ ...config, environment: "server" });
1750
1897
  }
1898
+ /**
1899
+ * Construct a mobile-environment client. NOTE: this constructor does NOT
1900
+ * subscribe to React Native's `AppState` even when `autoRefresh: 'app-state'`
1901
+ * is passed — it only disables the per-request proactive refresh. Use
1902
+ * `createMobileClient` from `@iqauth/sdk/mobile` if you want the full
1903
+ * AppState-driven refresh behavior (recommended for Expo / React Native).
1904
+ */
1751
1905
  static forMobile(config) {
1752
1906
  return new _IQAuthClient({ ...config, environment: "mobile" });
1753
1907
  }
@@ -1764,6 +1918,18 @@ var IQAuthClient = class _IQAuthClient {
1764
1918
  getRefreshToken() {
1765
1919
  return this._refreshToken;
1766
1920
  }
1921
+ /**
1922
+ * Task #126: Eagerly fetch JWKS + OIDC discovery so the first verify() /
1923
+ * refresh round-trip on the request hot path doesn't pay the discovery
1924
+ * fetch latency. Safe to call repeatedly. Errors are swallowed; callers
1925
+ * may fire-and-forget. Called automatically by `iqAuth({...}).attachHelpers()`.
1926
+ */
1927
+ async prewarm() {
1928
+ await Promise.all([
1929
+ this.tokens.prewarm(),
1930
+ this.oidc.getDiscovery().catch(() => void 0)
1931
+ ]);
1932
+ }
1767
1933
  getCurrentClaims() {
1768
1934
  if (!this._accessToken) return null;
1769
1935
  return this.tokens.decode(this._accessToken);
@@ -1804,14 +1970,14 @@ function assertPublishableKey(raw, opts) {
1804
1970
  const ctx = opts?.context ? `${opts.context}: ` : "";
1805
1971
  if (typeof raw !== "string" || raw.length === 0) {
1806
1972
  throw new IQAuthError(
1807
- "CONFIG_INVALID",
1973
+ "config_invalid",
1808
1974
  `${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.`
1809
1975
  );
1810
1976
  }
1811
1977
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
1812
1978
  if (!shapeMatch) {
1813
1979
  throw new IQAuthError(
1814
- "CONFIG_INVALID",
1980
+ "config_invalid",
1815
1981
  `${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.`
1816
1982
  );
1817
1983
  }
@@ -1820,19 +1986,19 @@ function assertPublishableKey(raw, opts) {
1820
1986
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
1821
1987
  } catch {
1822
1988
  throw new IQAuthError(
1823
- "CONFIG_INVALID",
1989
+ "config_invalid",
1824
1990
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
1825
1991
  );
1826
1992
  }
1827
1993
  if (!isPublishableKeyPayload(decoded)) {
1828
1994
  throw new IQAuthError(
1829
- "CONFIG_INVALID",
1995
+ "config_invalid",
1830
1996
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
1831
1997
  );
1832
1998
  }
1833
1999
  if (!isValidIssuerUrl(decoded.iss)) {
1834
2000
  throw new IQAuthError(
1835
- "CONFIG_INVALID",
2001
+ "config_invalid",
1836
2002
  `${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.`
1837
2003
  );
1838
2004
  }
@@ -1845,6 +2011,45 @@ function isPublishableKeyPayload(value) {
1845
2011
  }
1846
2012
 
1847
2013
  // src/server/handlers.ts
2014
+ async function buildUserinfoResponse(claims, opts = {}) {
2015
+ const baseUser = {
2016
+ sub: claims.sub,
2017
+ email: claims.email,
2018
+ name: claims.name,
2019
+ tenantId: claims.tenantId,
2020
+ vendorId: claims.vendorId,
2021
+ roles: claims.roles ?? [],
2022
+ entitlements: claims.entitlements ?? [],
2023
+ // Task #171 — project the active source/client scope onto the userinfo
2024
+ // payload so server handlers (`getSessionUser`, `/api/iqauth/userinfo`)
2025
+ // expose it without consumers having to re-decode the JWT.
2026
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
2027
+ };
2028
+ const enriched = opts.enrich ? await opts.enrich(claims) : null;
2029
+ const user = enriched ? { ...baseUser, ...enriched } : baseUser;
2030
+ return {
2031
+ success: true,
2032
+ data: {
2033
+ user,
2034
+ claims,
2035
+ tenantId: claims.tenantId ?? null
2036
+ }
2037
+ };
2038
+ }
2039
+ function emitTiming(cfg, event) {
2040
+ if (cfg.debug) {
2041
+ try {
2042
+ console.debug("[iqauth_helper]", event);
2043
+ } catch {
2044
+ }
2045
+ }
2046
+ if (cfg.onTimingEvent) {
2047
+ try {
2048
+ cfg.onTimingEvent(event);
2049
+ } catch {
2050
+ }
2051
+ }
2052
+ }
1848
2053
  var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
1849
2054
  "TOKEN_REVOKED",
1850
2055
  "SESSION_REVOKED",
@@ -1863,19 +2068,62 @@ function shouldClearCookiesOnFailure(policy, status, errorCode) {
1863
2068
  }
1864
2069
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
1865
2070
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
2071
+ function assertCookiePrefixInvariants(name, secure, path, domain) {
2072
+ if (name.startsWith("__Host-")) {
2073
+ if (!secure) {
2074
+ throw new IQAuthError(
2075
+ "config_invalid",
2076
+ `Cookie "${name}" uses the __Host- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2077
+ );
2078
+ }
2079
+ if (path !== "/") {
2080
+ throw new IQAuthError(
2081
+ "config_invalid",
2082
+ `Cookie "${name}" uses the __Host- prefix, which requires Path=/ (got "${path}"). Remove cookiePath or set it to "/".`
2083
+ );
2084
+ }
2085
+ if (domain) {
2086
+ throw new IQAuthError(
2087
+ "config_invalid",
2088
+ `Cookie "${name}" uses the __Host- prefix, which forbids a Domain attribute (the cookie is host-locked). Remove cookieDomain.`
2089
+ );
2090
+ }
2091
+ } else if (name.startsWith("__Secure-") && !secure) {
2092
+ throw new IQAuthError(
2093
+ "config_invalid",
2094
+ `Cookie "${name}" uses the __Secure- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2095
+ );
2096
+ }
2097
+ }
1866
2098
  function resolve(config) {
1867
2099
  const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
1868
2100
  const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
2101
+ maybeWarnDefaultSignoutRegistry(config);
2102
+ const secure = config.secure ?? true;
2103
+ if (config.secure === false && config.allowInsecureCookies !== true) {
2104
+ throw new IQAuthError(
2105
+ "config_invalid",
2106
+ "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."
2107
+ );
2108
+ }
2109
+ const accessCookieName = config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at";
2110
+ const refreshCookieName = config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt";
2111
+ const stateCookieName = config.stateCookieName ?? "iqauth_state";
2112
+ const cookiePath = config.cookiePath ?? "/";
2113
+ const cookieDomain = config.cookieDomain;
2114
+ for (const name of [accessCookieName, refreshCookieName, stateCookieName]) {
2115
+ assertCookiePrefixInvariants(name, secure, cookiePath, cookieDomain);
2116
+ }
1869
2117
  return {
1870
2118
  publishableKey: config.publishableKey,
1871
2119
  secretKey: config.secretKey,
1872
2120
  issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
1873
- accessCookieName: config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at",
1874
- refreshCookieName: config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt",
1875
- cookieDomain: config.cookieDomain,
2121
+ accessCookieName,
2122
+ refreshCookieName,
2123
+ cookieDomain,
1876
2124
  sameSite: config.sameSite ?? "lax",
1877
- secure: config.secure ?? true,
1878
- cookiePath: config.cookiePath ?? "/",
2125
+ secure,
2126
+ cookiePath,
1879
2127
  tokenPath: config.tokenPath ?? "/oidc/token",
1880
2128
  refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
1881
2129
  logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
@@ -1884,9 +2132,23 @@ function resolve(config) {
1884
2132
  })),
1885
2133
  appId: parsed.appId,
1886
2134
  tenantId: parsed.tenantId,
1887
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
2135
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
2136
+ debug: config.debug,
2137
+ onTimingEvent: config.onTimingEvent,
2138
+ signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
2139
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS,
2140
+ requireOAuthState: config.requireOAuthState ?? true,
2141
+ stateCookieName: config.stateCookieName ?? "iqauth_state"
1888
2142
  };
1889
2143
  }
2144
+ function timingSafeEqualStr(a, b) {
2145
+ const len = Math.max(a.length, b.length);
2146
+ let diff = a.length ^ b.length;
2147
+ for (let i = 0; i < len; i++) {
2148
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
2149
+ }
2150
+ return diff === 0;
2151
+ }
1890
2152
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
1891
2153
  return {
1892
2154
  name,
@@ -1901,15 +2163,53 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
1901
2163
  }
1902
2164
  function clearCookies(cfg) {
1903
2165
  return [
1904
- makeCookie(cfg, cfg.accessCookieName, "", 0),
1905
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
2166
+ { ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
2167
+ { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
1906
2168
  ];
1907
2169
  }
2170
+ function clearStateCookie(cfg) {
2171
+ return { ...makeCookie(cfg, cfg.stateCookieName, "", 0, false), clear: true };
2172
+ }
2173
+ var DEFAULT_SIGNOUT_TTL_MS = 6e4;
2174
+ var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
2175
+ function pruneInMemoryMarkers(now) {
2176
+ if (inMemorySignoutMarkers.size === 0) return;
2177
+ for (const [k, exp] of inMemorySignoutMarkers) {
2178
+ if (exp <= now) inMemorySignoutMarkers.delete(k);
2179
+ }
2180
+ }
2181
+ var defaultSignoutRegistry = {
2182
+ mark(token, ttlMs) {
2183
+ const now = Date.now();
2184
+ pruneInMemoryMarkers(now);
2185
+ inMemorySignoutMarkers.set(token, now + ttlMs);
2186
+ },
2187
+ has(token) {
2188
+ const now = Date.now();
2189
+ const exp = inMemorySignoutMarkers.get(token);
2190
+ if (!exp) return false;
2191
+ if (exp <= now) {
2192
+ inMemorySignoutMarkers.delete(token);
2193
+ return false;
2194
+ }
2195
+ return true;
2196
+ }
2197
+ };
2198
+ var warnedDefaultSignoutRegistry = false;
2199
+ function maybeWarnDefaultSignoutRegistry(config) {
2200
+ if (warnedDefaultSignoutRegistry) return;
2201
+ if (config.signoutRegistry) return;
2202
+ warnedDefaultSignoutRegistry = true;
2203
+ console.warn(
2204
+ "[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."
2205
+ );
2206
+ }
1908
2207
  function serializeCookie(d) {
1909
2208
  const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
1910
2209
  parts.push(`Path=${d.path}`);
1911
2210
  if (d.domain) parts.push(`Domain=${d.domain}`);
1912
2211
  parts.push(`Max-Age=${d.maxAge}`);
2212
+ if (d.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
1913
2213
  if (d.secure) parts.push("Secure");
1914
2214
  if (d.httpOnly) parts.push("HttpOnly");
1915
2215
  parts.push(`SameSite=${d.sameSite}`);
@@ -1917,14 +2217,34 @@ function serializeCookie(d) {
1917
2217
  }
1918
2218
  async function handleCallback(config, input) {
1919
2219
  const cfg = resolve(config);
2220
+ const t0 = Date.now();
1920
2221
  if (!input.code || !input.redirectUri) {
2222
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
1921
2223
  return {
1922
2224
  status: 400,
1923
2225
  body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
1924
2226
  cookies: []
1925
2227
  };
1926
2228
  }
2229
+ const provided = input.state;
2230
+ const expected = input.expectedState;
2231
+ const stateOk = cfg.requireOAuthState ? !!expected && !!provided && timingSafeEqualStr(provided, expected) : !expected || !!provided && timingSafeEqualStr(provided, expected);
2232
+ if (!stateOk) {
2233
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "STATE_MISMATCH" });
2234
+ return {
2235
+ status: 400,
2236
+ body: {
2237
+ success: false,
2238
+ error: {
2239
+ code: "STATE_MISMATCH",
2240
+ message: "OAuth state validation failed; the sign-in could not be verified as originating from this browser."
2241
+ }
2242
+ },
2243
+ cookies: [clearStateCookie(cfg)]
2244
+ };
2245
+ }
1927
2246
  if (!cfg.secretKey) {
2247
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
1928
2248
  return {
1929
2249
  status: 500,
1930
2250
  body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
@@ -1948,6 +2268,7 @@ async function handleCallback(config, input) {
1948
2268
  });
1949
2269
  const json = await res.json().catch(() => ({}));
1950
2270
  if (!res.ok || !json.access_token) {
2271
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
1951
2272
  return {
1952
2273
  status: res.status || 502,
1953
2274
  body: {
@@ -1960,6 +2281,26 @@ async function handleCallback(config, input) {
1960
2281
  cookies: []
1961
2282
  };
1962
2283
  }
2284
+ try {
2285
+ await getTokensFor(cfg.issuer).verify(json.access_token, {
2286
+ issuer: cfg.issuer,
2287
+ ...config.verify
2288
+ });
2289
+ } catch (err) {
2290
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2291
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code });
2292
+ return {
2293
+ status: 502,
2294
+ body: {
2295
+ success: false,
2296
+ error: {
2297
+ code: "ACCESS_TOKEN_VERIFICATION_FAILED",
2298
+ message: "The issuer returned an access token that failed verification; no session was established."
2299
+ }
2300
+ },
2301
+ cookies: []
2302
+ };
2303
+ }
1963
2304
  const cookies = [];
1964
2305
  cookies.push(
1965
2306
  makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
@@ -1967,6 +2308,8 @@ async function handleCallback(config, input) {
1967
2308
  if (json.refresh_token) {
1968
2309
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
1969
2310
  }
2311
+ cookies.push(clearStateCookie(cfg));
2312
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
1970
2313
  return {
1971
2314
  status: 200,
1972
2315
  body: { success: true, data: { authenticated: true } },
@@ -1975,8 +2318,18 @@ async function handleCallback(config, input) {
1975
2318
  }
1976
2319
  async function handleRefresh(config, input) {
1977
2320
  const cfg = resolve(config);
2321
+ const t0 = Date.now();
1978
2322
  const refreshToken = input.refreshToken;
2323
+ const idemKey = input.idempotencyToken;
2324
+ if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
2325
+ return {
2326
+ status: 401,
2327
+ body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
2328
+ cookies: clearCookies(cfg)
2329
+ };
2330
+ }
1979
2331
  if (!refreshToken) {
2332
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
1980
2333
  return {
1981
2334
  status: 401,
1982
2335
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
@@ -1992,6 +2345,7 @@ async function handleRefresh(config, input) {
1992
2345
  if (!res.ok || !json.success || !json.data?.accessToken) {
1993
2346
  const status = res.status || 401;
1994
2347
  const errorCode = json.error?.code || "TOKEN_INVALID";
2348
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
1995
2349
  const shouldClear = shouldClearCookiesOnFailure(
1996
2350
  cfg.clearCookiesOnRefreshFailure,
1997
2351
  status,
@@ -2015,6 +2369,7 @@ async function handleRefresh(config, input) {
2015
2369
  if (json.data.refreshToken) {
2016
2370
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
2017
2371
  }
2372
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
2018
2373
  return {
2019
2374
  status: 200,
2020
2375
  body: { success: true, data: { accessToken: json.data.accessToken } },
@@ -2023,6 +2378,10 @@ async function handleRefresh(config, input) {
2023
2378
  }
2024
2379
  async function handleSignout(config, input) {
2025
2380
  const cfg = resolve(config);
2381
+ const t0 = Date.now();
2382
+ if (input.idempotencyToken) {
2383
+ await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
2384
+ }
2026
2385
  if (input.accessToken) {
2027
2386
  try {
2028
2387
  await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
@@ -2044,21 +2403,104 @@ async function handleSignout(config, input) {
2044
2403
  } catch {
2045
2404
  }
2046
2405
  }
2406
+ emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
2047
2407
  return {
2048
2408
  status: 200,
2049
2409
  body: { success: true, data: { signedOut: true } },
2050
2410
  cookies: clearCookies(cfg)
2051
2411
  };
2052
2412
  }
2413
+ var TOKENS_CACHE = /* @__PURE__ */ new Map();
2414
+ function getTokensFor(issuer) {
2415
+ let m = TOKENS_CACHE.get(issuer);
2416
+ if (!m) {
2417
+ m = new TokensModule(issuer);
2418
+ TOKENS_CACHE.set(issuer, m);
2419
+ }
2420
+ return m;
2421
+ }
2422
+ async function handleUserinfo(config, input) {
2423
+ const cfg = resolve(config);
2424
+ if (!input.accessToken) {
2425
+ return {
2426
+ status: 401,
2427
+ body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
2428
+ cookies: []
2429
+ };
2430
+ }
2431
+ let claims;
2432
+ try {
2433
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, {
2434
+ issuer: cfg.issuer,
2435
+ ...config.verify
2436
+ });
2437
+ } catch (err) {
2438
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2439
+ const message = err instanceof Error ? err.message : "Access token verification failed";
2440
+ return {
2441
+ status: 401,
2442
+ body: { success: false, error: { code, message } },
2443
+ cookies: []
2444
+ };
2445
+ }
2446
+ const envelope = await buildUserinfoResponse(claims, {
2447
+ enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
2448
+ });
2449
+ return {
2450
+ status: 200,
2451
+ body: envelope,
2452
+ cookies: []
2453
+ };
2454
+ }
2455
+
2456
+ // src/browser/returnTo.ts
2457
+ function normalizeOrigin(o) {
2458
+ try {
2459
+ return new URL(o).origin;
2460
+ } catch {
2461
+ return o.replace(/\/+$/, "");
2462
+ }
2463
+ }
2464
+ function sanitizeReturnTo(input, options = {}) {
2465
+ const fallback = options.fallback ?? "/";
2466
+ if (!input || typeof input !== "string") return fallback;
2467
+ const trimmed = input.trim();
2468
+ if (!trimmed) return fallback;
2469
+ if (trimmed.includes("\\")) return fallback;
2470
+ if (trimmed.startsWith("//")) return fallback;
2471
+ if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
2472
+ return trimmed;
2473
+ }
2474
+ if (!/^[a-z][a-z0-9+\-.]*:/i.test(trimmed)) {
2475
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2476
+ }
2477
+ let parsed;
2478
+ try {
2479
+ parsed = new URL(trimmed);
2480
+ } catch {
2481
+ return fallback;
2482
+ }
2483
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback;
2484
+ const currentOrigin = options.currentOrigin ?? (typeof window !== "undefined" ? window.location.origin : "");
2485
+ const allowed = /* @__PURE__ */ new Set();
2486
+ if (currentOrigin) allowed.add(normalizeOrigin(currentOrigin));
2487
+ for (const o of options.allowedOrigins ?? []) allowed.add(normalizeOrigin(o));
2488
+ if (allowed.has(parsed.origin)) return parsed.toString();
2489
+ return fallback;
2490
+ }
2053
2491
 
2054
2492
  // src/fastify.ts
2493
+ var PKCE_COOKIE = "iqauth_pkce";
2055
2494
  var KNOWN_AUTH_ERRORS = /* @__PURE__ */ new Set([
2056
2495
  "TOKEN_INVALID",
2057
2496
  "TOKEN_EXPIRED",
2058
2497
  "TOKEN_REVOKED",
2059
2498
  "SESSION_EXPIRED",
2060
2499
  "SESSION_INVALID",
2061
- "AUTH_REQUIRED"
2500
+ "AUTH_REQUIRED",
2501
+ // Task #127 — typed `IQAuthErrorCode` taxonomy.
2502
+ "token_invalid",
2503
+ "token_expired"
2062
2504
  ]);
2063
2505
  function applyResponse(reply, hr) {
2064
2506
  for (const c of hr.cookies) {
@@ -2070,6 +2512,54 @@ function applyResponse(reply, hr) {
2070
2512
  }
2071
2513
  reply.code(hr.status).send(hr.body);
2072
2514
  }
2515
+ function applyCallbackResponse(reply, hr, requestOrigin, returnToCookieValue, returnToCookieName) {
2516
+ const returnTo = sanitizeReturnTo(
2517
+ returnToCookieValue || hr.body?.returnTo,
2518
+ { currentOrigin: requestOrigin, fallback: "/" }
2519
+ );
2520
+ for (const c of hr.cookies) {
2521
+ const cookie = serializeCookie(c);
2522
+ const existing = reply.getHeader?.("set-cookie") ?? [];
2523
+ const list = Array.isArray(existing) ? existing : [existing];
2524
+ list.push(cookie);
2525
+ reply.header("set-cookie", list);
2526
+ }
2527
+ if (hr.status < 400) {
2528
+ const existing = reply.getHeader?.("set-cookie") ?? [];
2529
+ const list = Array.isArray(existing) ? existing : [existing];
2530
+ list.push(`${returnToCookieName}=; Path=/; Max-Age=0; SameSite=Lax`);
2531
+ reply.header("set-cookie", list);
2532
+ }
2533
+ reply.code(hr.status).send({ ...hr.body, returnTo });
2534
+ }
2535
+ function applyCallbackRedirect(reply, hr, requestOrigin, returnToCookieValue, cookieNames) {
2536
+ const pushCookie = (value) => {
2537
+ const existing = reply.getHeader?.("set-cookie") ?? [];
2538
+ const list = Array.isArray(existing) ? existing : [existing];
2539
+ list.push(value);
2540
+ reply.header("set-cookie", list);
2541
+ };
2542
+ for (const c of hr.cookies) pushCookie(serializeCookie(c));
2543
+ pushCookie(`${cookieNames.state}=; Path=/; Max-Age=0; SameSite=Lax`);
2544
+ pushCookie(`${cookieNames.pkce}=; Path=/; Max-Age=0; SameSite=Lax`);
2545
+ if (hr.status >= 400) {
2546
+ reply.header("location", "/");
2547
+ reply.code(302).send();
2548
+ return;
2549
+ }
2550
+ const dest = sanitizeReturnTo(returnToCookieValue, {
2551
+ currentOrigin: requestOrigin,
2552
+ fallback: "/"
2553
+ });
2554
+ pushCookie(`${cookieNames.returnTo}=; Path=/; Max-Age=0; SameSite=Lax`);
2555
+ reply.header("location", dest);
2556
+ reply.code(302).send();
2557
+ }
2558
+ function requestOriginOf(req) {
2559
+ const proto = req.headers?.["x-forwarded-proto"]?.split(",")[0]?.trim() || (typeof req.protocol === "string" ? req.protocol : void 0) || "https";
2560
+ const host = req.headers?.["x-forwarded-host"]?.split(",")[0]?.trim() || req.headers?.host || "";
2561
+ return host ? `${proto}://${host}` : "";
2562
+ }
2073
2563
  function readCookie(req, name) {
2074
2564
  if (req.cookies && typeof req.cookies[name] === "string") return req.cookies[name];
2075
2565
  const raw = req.headers?.cookie;
@@ -2103,10 +2593,12 @@ async function iqAuth(fastify, options) {
2103
2593
  } : void 0;
2104
2594
  const accessCookie = options.accessCookieName ?? "iqauth_at";
2105
2595
  const refreshCookie = options.refreshCookieName ?? "iqauth_rt";
2596
+ const returnToCookie = options.returnToCookieName ?? "iqauth_return_to";
2106
2597
  const mount = (options.mountPath ?? "/api/iqauth").replace(/\/+$/, "");
2107
2598
  const mountHelpers = options.mountHelperRoutes !== false;
2108
2599
  const isPublic = (p) => {
2109
2600
  if (mountHelpers && p.startsWith(mount + "/")) return true;
2601
+ if (options.mountUserinfo && p === `${mount}/me`) return true;
2110
2602
  if (Array.isArray(options.publicPaths)) return options.publicPaths.includes(p);
2111
2603
  if (typeof options.publicPaths === "function") return options.publicPaths(p);
2112
2604
  return false;
@@ -2136,22 +2628,54 @@ async function iqAuth(fastify, options) {
2136
2628
  if (mountHelpers) {
2137
2629
  fastify.post(`${mount}/callback`, async (req, reply) => {
2138
2630
  const body = req.body || {};
2139
- applyResponse(reply, await handleCallback(helperConfig, {
2631
+ const hr = await handleCallback(helperConfig, {
2140
2632
  code: body.code,
2141
2633
  codeVerifier: body.codeVerifier,
2142
- redirectUri: body.redirectUri
2143
- }));
2634
+ redirectUri: body.redirectUri,
2635
+ // M-2: bind callback to this browser; handleCallback fails closed.
2636
+ state: body.state,
2637
+ expectedState: readCookie(req, helperConfig.stateCookieName ?? "iqauth_state")
2638
+ });
2639
+ applyCallbackResponse(reply, hr, requestOriginOf(req), readCookie(req, returnToCookie), returnToCookie);
2640
+ });
2641
+ fastify.get(`${mount}/callback`, async (req, reply) => {
2642
+ const stateCookie = helperConfig.stateCookieName ?? "iqauth_state";
2643
+ const q = req.query || {};
2644
+ const origin = requestOriginOf(req);
2645
+ const redirectUri = `${origin}${mount}/callback`;
2646
+ const hr = await handleCallback(helperConfig, {
2647
+ code: q.code,
2648
+ codeVerifier: readCookie(req, PKCE_COOKIE),
2649
+ redirectUri,
2650
+ state: q.state,
2651
+ expectedState: readCookie(req, stateCookie)
2652
+ });
2653
+ applyCallbackRedirect(reply, hr, origin, readCookie(req, returnToCookie), {
2654
+ returnTo: returnToCookie,
2655
+ state: stateCookie,
2656
+ pkce: PKCE_COOKIE
2657
+ });
2144
2658
  });
2145
2659
  fastify.post(`${mount}/refresh`, async (req, reply) => {
2146
2660
  const body = req.body || {};
2147
2661
  const refreshToken = body.refreshToken || readCookie(req, refreshCookie);
2148
- applyResponse(reply, await handleRefresh(helperConfig, { refreshToken }));
2662
+ const idempotencyToken = req.headers?.["x-iqauth-idempotency"] || body.idempotencyToken;
2663
+ applyResponse(reply, await handleRefresh(helperConfig, { refreshToken, idempotencyToken }));
2149
2664
  });
2150
2665
  fastify.post(`${mount}/signout`, async (req, reply) => {
2151
2666
  const auth = req.headers?.authorization;
2152
2667
  const accessToken = (typeof auth === "string" ? auth.replace(/^Bearer /i, "") : void 0) || readCookie(req, accessCookie);
2668
+ const refreshToken = readCookie(req, refreshCookie);
2153
2669
  const ssoCookieHeader = typeof req.headers?.cookie === "string" ? req.headers.cookie : void 0;
2154
- applyResponse(reply, await handleSignout(helperConfig, { accessToken, ssoCookieHeader }));
2670
+ const idempotencyToken = req.headers?.["x-iqauth-idempotency"];
2671
+ applyResponse(reply, await handleSignout(helperConfig, { accessToken, refreshToken, idempotencyToken, ssoCookieHeader }));
2672
+ });
2673
+ }
2674
+ if (options.mountUserinfo) {
2675
+ fastify.get(`${mount}/me`, async (req, reply) => {
2676
+ const auth = req.headers?.authorization;
2677
+ const accessToken = (typeof auth === "string" ? auth.replace(/^Bearer /i, "") : void 0) || readCookie(req, accessCookie);
2678
+ applyResponse(reply, await handleUserinfo(helperConfig, { accessToken, req }));
2155
2679
  });
2156
2680
  }
2157
2681
  fastify.decorate("iqauth", { client, issuer });