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