@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/server.js CHANGED
@@ -35,25 +35,47 @@ __export(server_exports, {
35
35
  ErrorCodes: () => ErrorCodes,
36
36
  IQAuthClient: () => IQAuthClient,
37
37
  IQAuthError: () => IQAuthError,
38
+ ProvisioningError: () => ProvisioningError,
38
39
  ServerIQAuthClient: () => ServerIQAuthClient,
40
+ buildUserinfoResponse: () => buildUserinfoResponse,
41
+ createDrizzleLinkAdapter: () => createDrizzleLinkAdapter,
39
42
  createProvisioningBridge: () => createProvisioningBridge,
40
43
  createServerClient: () => createServerClient,
41
44
  handleCallback: () => handleCallback,
42
45
  handleRefresh: () => handleRefresh,
43
46
  handleSignout: () => handleSignout,
47
+ handleUserinfo: () => handleUserinfo,
44
48
  iqAuthMiddleware: () => iqAuthMiddleware,
49
+ linkLocalUserToIqAuthSub: () => linkLocalUserToIqAuthSub,
45
50
  serializeCookie: () => serializeCookie
46
51
  });
47
52
  module.exports = __toCommonJS(server_exports);
48
53
 
49
54
  // src/errors.ts
50
- var IQAuthError = class extends Error {
51
- constructor(code, message, status, raw) {
55
+ var IQAuthError = class _IQAuthError extends Error {
56
+ constructor(code, message, status, cause) {
52
57
  super(message);
53
58
  this.name = "IQAuthError";
54
59
  this.code = code;
55
60
  this.status = status;
56
- this.raw = raw;
61
+ this.cause = cause;
62
+ this.raw = cause;
63
+ }
64
+ /**
65
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
66
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
67
+ */
68
+ static isIQAuthError(value) {
69
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
70
+ }
71
+ /**
72
+ * Type-narrowed code check. Lets callers write
73
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
74
+ * taxonomy without losing the ability to handle server codes via
75
+ * `err.code === "TOKEN_REVOKED"`.
76
+ */
77
+ is(code) {
78
+ return this.code === code;
57
79
  }
58
80
  };
59
81
  var ErrorCodes = {
@@ -204,7 +226,7 @@ var HttpClient = class {
204
226
  headers: this.buildHeaders(),
205
227
  ...this.isBrowserSession() ? { credentials: "include" } : (() => {
206
228
  const refreshToken = this.config.getRefreshToken();
207
- if (!refreshToken) throw new IQAuthError("TOKEN_INVALID", "No refresh token available");
229
+ if (!refreshToken) throw new IQAuthError("config_invalid", "No refresh token available");
208
230
  return { body: JSON.stringify({ refreshToken }) };
209
231
  })()
210
232
  });
@@ -221,7 +243,7 @@ var HttpClient = class {
221
243
  return;
222
244
  }
223
245
  if (!body.data.accessToken || !body.data.refreshToken) {
224
- throw new IQAuthError("TOKEN_INVALID", "Refresh response did not include a token pair");
246
+ throw new IQAuthError("token_invalid", "Refresh response did not include a token pair");
225
247
  }
226
248
  const tokens = {
227
249
  accessToken: body.data.accessToken,
@@ -239,7 +261,7 @@ var HttpClient = class {
239
261
  return this.requestWithRetry(method, path, body, options, false);
240
262
  }
241
263
  async requestWithRetry(method, path, body, options, hasRetried) {
242
- if (this.config.autoRefresh && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
264
+ if (this.config.autoRefresh && this.config.proactiveRefresh !== false && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
243
265
  await this.attemptRefresh();
244
266
  }
245
267
  const url = `${this.config.baseUrl}${path}`;
@@ -326,17 +348,27 @@ function parseLoginResponse(data, browserSessionMode) {
326
348
  tenants: data.tenants
327
349
  };
328
350
  }
351
+ if (data.type === "scope_selection" && data.scopeSelectionToken && data.scopes && data.tenantId) {
352
+ return {
353
+ status: "scope_selection",
354
+ scopeSelectionToken: data.scopeSelectionToken,
355
+ tenantId: data.tenantId,
356
+ scopes: data.scopes
357
+ };
358
+ }
329
359
  throw new Error("Unexpected login response shape");
330
360
  }
331
361
  var AuthModule = class {
332
362
  constructor(http) {
333
363
  this.http = http;
334
364
  }
335
- async login(email, password) {
365
+ async login(email, password, opts) {
366
+ const body = { email, password };
367
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
336
368
  const data = await this.http.request(
337
369
  "POST",
338
370
  "/api/v1/auth/login",
339
- { email, password },
371
+ body,
340
372
  { skipAutoRefresh: true }
341
373
  );
342
374
  return parseLoginResponse(data, this.http.isBrowserSession());
@@ -374,13 +406,29 @@ var AuthModule = class {
374
406
  method
375
407
  }, { skipAutoRefresh: true });
376
408
  }
377
- async selectTenant(tenantSelectionToken, tenantId) {
409
+ async selectTenant(tenantSelectionToken, tenantId, opts) {
410
+ const body = { tenantSelectionToken, tenantId };
411
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
378
412
  const data = await this.http.request(
379
413
  "POST",
380
414
  "/api/v1/auth/select-tenant",
415
+ body,
416
+ { skipAutoRefresh: true }
417
+ );
418
+ return parseLoginResponse(data, this.http.isBrowserSession());
419
+ }
420
+ /**
421
+ * Task #171 — redeem a scope-selection token + chosen membership for a
422
+ * real authenticated session. `membershipId` must be one of the scopes
423
+ * returned in the prior `scope_selection` envelope.
424
+ */
425
+ async selectScope(scopeSelectionToken, membershipId) {
426
+ const data = await this.http.request(
427
+ "POST",
428
+ "/api/v1/auth/select-scope",
381
429
  {
382
- tenantSelectionToken,
383
- tenantId
430
+ scopeSelectionToken,
431
+ membershipId
384
432
  },
385
433
  { skipAutoRefresh: true }
386
434
  );
@@ -467,6 +515,18 @@ var DEFAULT_TOKEN_AUDIENCE = [
467
515
  "iqvalidate"
468
516
  ];
469
517
  var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
518
+ function classifyJoseError(err) {
519
+ if (err instanceof import_jose.errors.JWTExpired) {
520
+ return { code: "token_expired", message: "Token has expired" };
521
+ }
522
+ if (err instanceof import_jose.errors.JOSEError) {
523
+ return { code: "token_invalid", message: err.message };
524
+ }
525
+ if (err instanceof Error) {
526
+ return { code: "token_invalid", message: err.message };
527
+ }
528
+ return { code: "token_invalid", message: "Token verification failed" };
529
+ }
470
530
  function decodeProtectedHeader(token) {
471
531
  const parts = token.split(".");
472
532
  if (parts.length < 2) return null;
@@ -503,11 +563,11 @@ var TokensModule = class {
503
563
  async verify(token, options = {}) {
504
564
  const header = decodeProtectedHeader(token);
505
565
  if (!header) {
506
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
566
+ throw new IQAuthError("token_invalid", "Unable to decode token");
507
567
  }
508
568
  const kid = header.kid;
509
569
  if (!kid) {
510
- throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
570
+ throw new IQAuthError("token_invalid", "Token missing kid header");
511
571
  }
512
572
  let cache = await this.ensureCache();
513
573
  if (!cache.byKid.has(kid)) {
@@ -515,7 +575,7 @@ var TokensModule = class {
515
575
  cache = await this.ensureCache();
516
576
  }
517
577
  if (!cache.byKid.has(kid)) {
518
- throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
578
+ throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
519
579
  }
520
580
  const issuer = options.issuer ?? this.defaultIssuer;
521
581
  const audience = options.audience ?? this.defaultAudience;
@@ -531,16 +591,8 @@ var TokensModule = class {
531
591
  const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
532
592
  return payload;
533
593
  } catch (err) {
534
- if (err instanceof import_jose.errors.JWTExpired) {
535
- throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
536
- }
537
- if (err instanceof import_jose.errors.JOSEError) {
538
- throw new IQAuthError("TOKEN_INVALID", err.message);
539
- }
540
- if (err instanceof Error) {
541
- throw new IQAuthError("TOKEN_INVALID", err.message);
542
- }
543
- throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
594
+ const classified = classifyJoseError(err);
595
+ throw new IQAuthError(classified.code, classified.message, void 0, err);
544
596
  }
545
597
  }
546
598
  /**
@@ -582,7 +634,7 @@ var TokensModule = class {
582
634
  getClaims(token) {
583
635
  const claims = this.decode(token);
584
636
  if (!claims) {
585
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token claims");
637
+ throw new IQAuthError("token_invalid", "Unable to decode token claims");
586
638
  }
587
639
  return claims;
588
640
  }
@@ -592,7 +644,7 @@ var TokensModule = class {
592
644
  }
593
645
  await this.refreshJwks();
594
646
  if (!this.jwksCache) {
595
- throw new IQAuthError("INTERNAL_ERROR", "JWKS cache unavailable after refresh");
647
+ throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
596
648
  }
597
649
  return this.jwksCache;
598
650
  }
@@ -602,22 +654,38 @@ var TokensModule = class {
602
654
  }
603
655
  this.inFlightRefresh = (async () => {
604
656
  try {
605
- const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
657
+ let res;
658
+ try {
659
+ res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
660
+ } catch (err) {
661
+ throw new IQAuthError(
662
+ "network",
663
+ err instanceof Error ? err.message : "JWKS fetch network error",
664
+ void 0,
665
+ err
666
+ );
667
+ }
606
668
  if (!res.ok) {
607
669
  throw new IQAuthError(
608
- "INTERNAL_ERROR",
609
- `Failed to fetch JWKS: ${res.status}`
670
+ "jwks_fetch_failed",
671
+ `Failed to fetch JWKS: ${res.status}`,
672
+ res.status
610
673
  );
611
674
  }
612
675
  let jwks;
613
676
  try {
614
677
  jwks = await res.json();
615
- } catch {
616
- throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
678
+ } catch (err) {
679
+ throw new IQAuthError(
680
+ "jwks_fetch_failed",
681
+ "Malformed JWKS response: invalid JSON",
682
+ res.status,
683
+ err
684
+ );
617
685
  }
618
686
  if (!jwks || !Array.isArray(jwks.keys)) {
619
687
  throw new IQAuthError(
620
- "INTERNAL_ERROR",
688
+ "jwks_fetch_failed",
621
689
  "Malformed JWKS response: expected { keys: [...] }"
622
690
  );
623
691
  }
@@ -625,7 +693,7 @@ var TokensModule = class {
625
693
  for (const key of jwks.keys) {
626
694
  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")) {
627
695
  throw new IQAuthError(
628
- "INTERNAL_ERROR",
696
+ "jwks_fetch_failed",
629
697
  "Malformed JWKS response: key missing required fields"
630
698
  );
631
699
  }
@@ -643,6 +711,19 @@ var TokensModule = class {
643
711
  clearCache() {
644
712
  this.jwksCache = null;
645
713
  }
714
+ /**
715
+ * Task #126: Eagerly populate the JWKS cache so the first verify() call
716
+ * doesn't pay a network round-trip. Safe to call repeatedly — single-flight
717
+ * behavior is shared with the lazy refresh path. Errors are swallowed so
718
+ * callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
719
+ */
720
+ async prewarm() {
721
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
722
+ try {
723
+ await this.refreshJwks();
724
+ } catch {
725
+ }
726
+ }
646
727
  };
647
728
 
648
729
  // src/modules/sessions.ts
@@ -966,14 +1047,14 @@ var OidcModule = class {
966
1047
  */
967
1048
  async handleCallback(params) {
968
1049
  if (!params.state) {
969
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing state parameter");
1050
+ throw new IQAuthError("config_invalid", "OIDC callback missing state parameter");
970
1051
  }
971
1052
  if (!params.code) {
972
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing code parameter");
1053
+ throw new IQAuthError("config_invalid", "OIDC callback missing code parameter");
973
1054
  }
974
1055
  const stored = await this.stateStore.get(params.state);
975
1056
  if (!stored) {
976
- throw new IQAuthError("VALIDATION_ERROR", "Unknown or expired OIDC state");
1057
+ throw new IQAuthError("config_invalid", "Unknown or expired OIDC state");
977
1058
  }
978
1059
  let tokens;
979
1060
  try {
@@ -991,7 +1072,7 @@ var OidcModule = class {
991
1072
  if (tokens.id_token) {
992
1073
  if (!this.tokensModule) {
993
1074
  throw new IQAuthError(
994
- "INTERNAL_ERROR",
1075
+ "config_invalid",
995
1076
  "OIDC handleCallback received an id_token but no TokensModule is configured for verification"
996
1077
  );
997
1078
  }
@@ -1002,7 +1083,7 @@ var OidcModule = class {
1002
1083
  const tokenNonce = typeof claimsBag.nonce === "string" ? claimsBag.nonce : void 0;
1003
1084
  if (!tokenNonce || tokenNonce !== stored.nonce) {
1004
1085
  throw new IQAuthError(
1005
- "TOKEN_INVALID",
1086
+ "token_invalid",
1006
1087
  "OIDC id_token nonce did not match the stored value"
1007
1088
  );
1008
1089
  }
@@ -1203,6 +1284,9 @@ var AppsModule = class {
1203
1284
  * @remarks Wraps GET /api/v1/apps/:appKey
1204
1285
  */
1205
1286
  async get(appKey) {
1287
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1288
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1289
+ }
1206
1290
  return this.http.request("GET", `/api/v1/apps/${encodeURIComponent(appKey)}`);
1207
1291
  }
1208
1292
  /**
@@ -1222,6 +1306,16 @@ var AppsModule = class {
1222
1306
  401
1223
1307
  );
1224
1308
  }
1309
+ if (!manifest || typeof manifest.key !== "string" || manifest.key.trim() === "") {
1310
+ throw new IQAuthError("VALIDATION_ERROR", "manifest.key (appKey) is required for register().", 400);
1311
+ }
1312
+ if (!manifest.environment || !["production", "staging", "development"].includes(manifest.environment)) {
1313
+ throw new IQAuthError(
1314
+ "ENVIRONMENT_REQUIRED",
1315
+ "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.",
1316
+ 400
1317
+ );
1318
+ }
1225
1319
  return this.http.request("POST", "/api/v1/apps/sync", manifest);
1226
1320
  }
1227
1321
  /**
@@ -1231,11 +1325,14 @@ var AppsModule = class {
1231
1325
  * @remarks Uses GET /api/v1/apps/:appKey — catches 404 errors
1232
1326
  */
1233
1327
  async isRegistered(appKey) {
1328
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1329
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1330
+ }
1234
1331
  try {
1235
1332
  await this.get(appKey);
1236
1333
  return true;
1237
1334
  } catch (err) {
1238
- if (err.code === "NOT_FOUND" || err.status === 404) {
1335
+ if (err.code === "app_not_found" || err.code === "NOT_FOUND" || err.status === 404) {
1239
1336
  return false;
1240
1337
  }
1241
1338
  throw err;
@@ -1272,6 +1369,20 @@ var RolesModule = class {
1272
1369
  };
1273
1370
 
1274
1371
  // src/modules/permissionGroups.ts
1372
+ function assertAppKey(appKey, callsite) {
1373
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1374
+ throw new IQAuthError(
1375
+ "VALIDATION_ERROR",
1376
+ `appKey is required for ${callsite} (no env-var fallback, no 'product' alias).`,
1377
+ 400
1378
+ );
1379
+ }
1380
+ }
1381
+ function assertNodeKey(nodeKey, callsite) {
1382
+ if (typeof nodeKey !== "string" || nodeKey.trim() === "") {
1383
+ throw new IQAuthError("VALIDATION_ERROR", `nodeKey is required for ${callsite}.`, 400);
1384
+ }
1385
+ }
1275
1386
  var PermissionGroupsModule = class {
1276
1387
  constructor(http) {
1277
1388
  this.http = http;
@@ -1292,7 +1403,14 @@ var PermissionGroupsModule = class {
1292
1403
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`);
1293
1404
  }
1294
1405
  async addPermission(tenantId, groupId, data) {
1295
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, data);
1406
+ assertAppKey(data?.appKey, "permissionGroups.addPermission");
1407
+ assertNodeKey(data?.nodeKey, "permissionGroups.addPermission");
1408
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, {
1409
+ appKey: data.appKey,
1410
+ nodeKey: data.nodeKey,
1411
+ effect: data.effect,
1412
+ weight: data.weight
1413
+ });
1296
1414
  }
1297
1415
  async removePermission(tenantId, groupId, permissionId) {
1298
1416
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions/${permissionId}`);
@@ -1316,21 +1434,51 @@ var PermissionGroupsModule = class {
1316
1434
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`);
1317
1435
  }
1318
1436
  async addUserOverride(tenantId, userId, data) {
1319
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, data);
1437
+ assertAppKey(data?.appKey, "permissionGroups.addUserOverride");
1438
+ assertNodeKey(data?.nodeKey, "permissionGroups.addUserOverride");
1439
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, {
1440
+ appKey: data.appKey,
1441
+ nodeKey: data.nodeKey,
1442
+ effect: data.effect,
1443
+ weight: data.weight,
1444
+ expiresAt: data.expiresAt
1445
+ });
1320
1446
  }
1321
1447
  async removeUserOverride(tenantId, userId, overrideId) {
1322
1448
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides/${overrideId}`);
1323
1449
  }
1450
+ /**
1451
+ * Task #130 — `appKey` is REQUIRED. The legacy `product` query alias is no
1452
+ * longer accepted at the SDK boundary; pass it as `appKey` instead. The
1453
+ * server still accepts `product=` from raw HTTP callers during the
1454
+ * deprecation window, but the SDK will not silently translate it.
1455
+ */
1324
1456
  async getEffectivePermissions(tenantId, userId, params) {
1325
- const query = new URLSearchParams();
1326
- if (params.product) query.set("product", params.product);
1327
- if (params.appKey) query.set("appKey", params.appKey);
1328
- const qs = query.toString();
1329
- return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective${qs ? `?${qs}` : ""}`);
1457
+ assertAppKey(params?.appKey, "permissionGroups.getEffectivePermissions");
1458
+ const qs = new URLSearchParams({ appKey: params.appKey }).toString();
1459
+ return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective?${qs}`);
1330
1460
  }
1331
1461
  async checkPermission(tenantId, userId, appKey, nodeKey) {
1462
+ assertAppKey(appKey, "permissionGroups.checkPermission");
1463
+ assertNodeKey(nodeKey, "permissionGroups.checkPermission");
1332
1464
  return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/check`, { appKey, nodeKey });
1333
1465
  }
1466
+ /**
1467
+ * Task #130 — every entry in `checks` must include a non-empty `appKey`
1468
+ * AND `nodeKey`. The SDK validates the whole batch before sending so a
1469
+ * single misconfigured entry can't slip through and silently report
1470
+ * `allowed: false` from the server's per-entry validation branch.
1471
+ */
1472
+ async batchCheckPermissions(tenantId, userId, checks) {
1473
+ if (!Array.isArray(checks) || checks.length === 0) {
1474
+ throw new IQAuthError("VALIDATION_ERROR", "checks must be a non-empty array of { appKey, nodeKey }.", 400);
1475
+ }
1476
+ checks.forEach((c, i) => {
1477
+ assertAppKey(c?.appKey, `permissionGroups.batchCheckPermissions[${i}]`);
1478
+ assertNodeKey(c?.nodeKey, `permissionGroups.batchCheckPermissions[${i}]`);
1479
+ });
1480
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/batch-check`, { checks });
1481
+ }
1334
1482
  };
1335
1483
 
1336
1484
  // src/modules/apiKeys.ts
@@ -1755,6 +1903,10 @@ var IQAuthClient = class _IQAuthClient {
1755
1903
  this._refreshToken = tokens.refreshToken;
1756
1904
  },
1757
1905
  autoRefresh: "autoRefresh" in config ? config.autoRefresh !== false : true,
1906
+ // `'app-state'` is mobile-only — on any other environment we treat it
1907
+ // as the default `true` (proactive refresh ON). Only the mobile client
1908
+ // disables proactive refresh and replaces it with an AppState listener.
1909
+ proactiveRefresh: "autoRefresh" in config && config.autoRefresh === "app-state" && _IQAuthClient.resolveEnvironment(config) === "mobile" ? false : true,
1758
1910
  onTokenRefresh: "onTokenRefresh" in config ? config.onTokenRefresh : void 0,
1759
1911
  sessionHeaderName: config.sessionHeaderName,
1760
1912
  sessionHeaderValue: config.sessionHeaderValue,
@@ -1795,6 +1947,13 @@ var IQAuthClient = class _IQAuthClient {
1795
1947
  static forServer(config) {
1796
1948
  return new _IQAuthClient({ ...config, environment: "server" });
1797
1949
  }
1950
+ /**
1951
+ * Construct a mobile-environment client. NOTE: this constructor does NOT
1952
+ * subscribe to React Native's `AppState` even when `autoRefresh: 'app-state'`
1953
+ * is passed — it only disables the per-request proactive refresh. Use
1954
+ * `createMobileClient` from `@iqauth/sdk/mobile` if you want the full
1955
+ * AppState-driven refresh behavior (recommended for Expo / React Native).
1956
+ */
1798
1957
  static forMobile(config) {
1799
1958
  return new _IQAuthClient({ ...config, environment: "mobile" });
1800
1959
  }
@@ -1811,6 +1970,18 @@ var IQAuthClient = class _IQAuthClient {
1811
1970
  getRefreshToken() {
1812
1971
  return this._refreshToken;
1813
1972
  }
1973
+ /**
1974
+ * Task #126: Eagerly fetch JWKS + OIDC discovery so the first verify() /
1975
+ * refresh round-trip on the request hot path doesn't pay the discovery
1976
+ * fetch latency. Safe to call repeatedly. Errors are swallowed; callers
1977
+ * may fire-and-forget. Called automatically by `iqAuth({...}).attachHelpers()`.
1978
+ */
1979
+ async prewarm() {
1980
+ await Promise.all([
1981
+ this.tokens.prewarm(),
1982
+ this.oidc.getDiscovery().catch(() => void 0)
1983
+ ]);
1984
+ }
1814
1985
  getCurrentClaims() {
1815
1986
  if (!this._accessToken) return null;
1816
1987
  return this.tokens.decode(this._accessToken);
@@ -1851,14 +2022,14 @@ function assertPublishableKey(raw, opts) {
1851
2022
  const ctx = opts?.context ? `${opts.context}: ` : "";
1852
2023
  if (typeof raw !== "string" || raw.length === 0) {
1853
2024
  throw new IQAuthError(
1854
- "CONFIG_INVALID",
2025
+ "config_invalid",
1855
2026
  `${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.`
1856
2027
  );
1857
2028
  }
1858
2029
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
1859
2030
  if (!shapeMatch) {
1860
2031
  throw new IQAuthError(
1861
- "CONFIG_INVALID",
2032
+ "config_invalid",
1862
2033
  `${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.`
1863
2034
  );
1864
2035
  }
@@ -1867,19 +2038,19 @@ function assertPublishableKey(raw, opts) {
1867
2038
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
1868
2039
  } catch {
1869
2040
  throw new IQAuthError(
1870
- "CONFIG_INVALID",
2041
+ "config_invalid",
1871
2042
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
1872
2043
  );
1873
2044
  }
1874
2045
  if (!isPublishableKeyPayload(decoded)) {
1875
2046
  throw new IQAuthError(
1876
- "CONFIG_INVALID",
2047
+ "config_invalid",
1877
2048
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
1878
2049
  );
1879
2050
  }
1880
2051
  if (!isValidIssuerUrl(decoded.iss)) {
1881
2052
  throw new IQAuthError(
1882
- "CONFIG_INVALID",
2053
+ "config_invalid",
1883
2054
  `${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.`
1884
2055
  );
1885
2056
  }
@@ -1893,12 +2064,18 @@ function isPublishableKeyPayload(value) {
1893
2064
 
1894
2065
  // src/middleware/express.ts
1895
2066
  var KNOWN_AUTH_ERROR_CODES = /* @__PURE__ */ new Set([
2067
+ // Legacy UPPER_SNAKE codes (server-originated and SDK ≤2.6.x throws).
1896
2068
  "TOKEN_INVALID",
1897
2069
  "TOKEN_EXPIRED",
1898
2070
  "TOKEN_REVOKED",
1899
2071
  "SESSION_EXPIRED",
1900
2072
  "SESSION_INVALID",
1901
- "AUTH_REQUIRED"
2073
+ "AUTH_REQUIRED",
2074
+ // Task #127 — typed `IQAuthErrorCode` taxonomy thrown by `tokens.verify`.
2075
+ // Mapped to 401 here so framework consumers don't have to learn the new
2076
+ // codes to keep their auth-failure handling working.
2077
+ "token_invalid",
2078
+ "token_expired"
1902
2079
  ]);
1903
2080
  var DEFAULT_ACCESS_COOKIE = "iqauth_at";
1904
2081
  var DEFAULT_REFRESH_COOKIE = "iqauth_rt";
@@ -2082,6 +2259,45 @@ function iqAuthMiddleware(clientOrOptions, options = {}) {
2082
2259
  }
2083
2260
 
2084
2261
  // src/server/handlers.ts
2262
+ async function buildUserinfoResponse(claims, opts = {}) {
2263
+ const baseUser = {
2264
+ sub: claims.sub,
2265
+ email: claims.email,
2266
+ name: claims.name,
2267
+ tenantId: claims.tenantId,
2268
+ vendorId: claims.vendorId,
2269
+ roles: claims.roles ?? [],
2270
+ entitlements: claims.entitlements ?? [],
2271
+ // Task #171 — project the active source/client scope onto the userinfo
2272
+ // payload so server handlers (`getSessionUser`, `/api/iqauth/userinfo`)
2273
+ // expose it without consumers having to re-decode the JWT.
2274
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
2275
+ };
2276
+ const enriched = opts.enrich ? await opts.enrich(claims) : null;
2277
+ const user = enriched ? { ...baseUser, ...enriched } : baseUser;
2278
+ return {
2279
+ success: true,
2280
+ data: {
2281
+ user,
2282
+ claims,
2283
+ tenantId: claims.tenantId ?? null
2284
+ }
2285
+ };
2286
+ }
2287
+ function emitTiming(cfg, event) {
2288
+ if (cfg.debug) {
2289
+ try {
2290
+ console.debug("[iqauth_helper]", event);
2291
+ } catch {
2292
+ }
2293
+ }
2294
+ if (cfg.onTimingEvent) {
2295
+ try {
2296
+ cfg.onTimingEvent(event);
2297
+ } catch {
2298
+ }
2299
+ }
2300
+ }
2085
2301
  var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
2086
2302
  "TOKEN_REVOKED",
2087
2303
  "SESSION_REVOKED",
@@ -2100,19 +2316,62 @@ function shouldClearCookiesOnFailure(policy, status, errorCode) {
2100
2316
  }
2101
2317
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
2102
2318
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
2319
+ function assertCookiePrefixInvariants(name, secure, path, domain) {
2320
+ if (name.startsWith("__Host-")) {
2321
+ if (!secure) {
2322
+ throw new IQAuthError(
2323
+ "config_invalid",
2324
+ `Cookie "${name}" uses the __Host- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2325
+ );
2326
+ }
2327
+ if (path !== "/") {
2328
+ throw new IQAuthError(
2329
+ "config_invalid",
2330
+ `Cookie "${name}" uses the __Host- prefix, which requires Path=/ (got "${path}"). Remove cookiePath or set it to "/".`
2331
+ );
2332
+ }
2333
+ if (domain) {
2334
+ throw new IQAuthError(
2335
+ "config_invalid",
2336
+ `Cookie "${name}" uses the __Host- prefix, which forbids a Domain attribute (the cookie is host-locked). Remove cookieDomain.`
2337
+ );
2338
+ }
2339
+ } else if (name.startsWith("__Secure-") && !secure) {
2340
+ throw new IQAuthError(
2341
+ "config_invalid",
2342
+ `Cookie "${name}" uses the __Secure- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2343
+ );
2344
+ }
2345
+ }
2103
2346
  function resolve(config) {
2104
2347
  const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
2105
2348
  const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
2349
+ maybeWarnDefaultSignoutRegistry(config);
2350
+ const secure = config.secure ?? true;
2351
+ if (config.secure === false && config.allowInsecureCookies !== true) {
2352
+ throw new IQAuthError(
2353
+ "config_invalid",
2354
+ "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."
2355
+ );
2356
+ }
2357
+ const accessCookieName = config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at";
2358
+ const refreshCookieName = config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt";
2359
+ const stateCookieName = config.stateCookieName ?? "iqauth_state";
2360
+ const cookiePath = config.cookiePath ?? "/";
2361
+ const cookieDomain = config.cookieDomain;
2362
+ for (const name of [accessCookieName, refreshCookieName, stateCookieName]) {
2363
+ assertCookiePrefixInvariants(name, secure, cookiePath, cookieDomain);
2364
+ }
2106
2365
  return {
2107
2366
  publishableKey: config.publishableKey,
2108
2367
  secretKey: config.secretKey,
2109
2368
  issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
2110
- accessCookieName: config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at",
2111
- refreshCookieName: config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt",
2112
- cookieDomain: config.cookieDomain,
2369
+ accessCookieName,
2370
+ refreshCookieName,
2371
+ cookieDomain,
2113
2372
  sameSite: config.sameSite ?? "lax",
2114
- secure: config.secure ?? true,
2115
- cookiePath: config.cookiePath ?? "/",
2373
+ secure,
2374
+ cookiePath,
2116
2375
  tokenPath: config.tokenPath ?? "/oidc/token",
2117
2376
  refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
2118
2377
  logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
@@ -2121,9 +2380,23 @@ function resolve(config) {
2121
2380
  })),
2122
2381
  appId: parsed.appId,
2123
2382
  tenantId: parsed.tenantId,
2124
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
2383
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
2384
+ debug: config.debug,
2385
+ onTimingEvent: config.onTimingEvent,
2386
+ signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
2387
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS,
2388
+ requireOAuthState: config.requireOAuthState ?? true,
2389
+ stateCookieName: config.stateCookieName ?? "iqauth_state"
2125
2390
  };
2126
2391
  }
2392
+ function timingSafeEqualStr(a, b) {
2393
+ const len = Math.max(a.length, b.length);
2394
+ let diff = a.length ^ b.length;
2395
+ for (let i = 0; i < len; i++) {
2396
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
2397
+ }
2398
+ return diff === 0;
2399
+ }
2127
2400
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
2128
2401
  return {
2129
2402
  name,
@@ -2138,15 +2411,53 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
2138
2411
  }
2139
2412
  function clearCookies(cfg) {
2140
2413
  return [
2141
- makeCookie(cfg, cfg.accessCookieName, "", 0),
2142
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
2414
+ { ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
2415
+ { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
2143
2416
  ];
2144
2417
  }
2418
+ function clearStateCookie(cfg) {
2419
+ return { ...makeCookie(cfg, cfg.stateCookieName, "", 0, false), clear: true };
2420
+ }
2421
+ var DEFAULT_SIGNOUT_TTL_MS = 6e4;
2422
+ var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
2423
+ function pruneInMemoryMarkers(now) {
2424
+ if (inMemorySignoutMarkers.size === 0) return;
2425
+ for (const [k, exp] of inMemorySignoutMarkers) {
2426
+ if (exp <= now) inMemorySignoutMarkers.delete(k);
2427
+ }
2428
+ }
2429
+ var defaultSignoutRegistry = {
2430
+ mark(token, ttlMs) {
2431
+ const now = Date.now();
2432
+ pruneInMemoryMarkers(now);
2433
+ inMemorySignoutMarkers.set(token, now + ttlMs);
2434
+ },
2435
+ has(token) {
2436
+ const now = Date.now();
2437
+ const exp = inMemorySignoutMarkers.get(token);
2438
+ if (!exp) return false;
2439
+ if (exp <= now) {
2440
+ inMemorySignoutMarkers.delete(token);
2441
+ return false;
2442
+ }
2443
+ return true;
2444
+ }
2445
+ };
2446
+ var warnedDefaultSignoutRegistry = false;
2447
+ function maybeWarnDefaultSignoutRegistry(config) {
2448
+ if (warnedDefaultSignoutRegistry) return;
2449
+ if (config.signoutRegistry) return;
2450
+ warnedDefaultSignoutRegistry = true;
2451
+ console.warn(
2452
+ "[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."
2453
+ );
2454
+ }
2145
2455
  function serializeCookie(d) {
2146
2456
  const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
2147
2457
  parts.push(`Path=${d.path}`);
2148
2458
  if (d.domain) parts.push(`Domain=${d.domain}`);
2149
2459
  parts.push(`Max-Age=${d.maxAge}`);
2460
+ if (d.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
2150
2461
  if (d.secure) parts.push("Secure");
2151
2462
  if (d.httpOnly) parts.push("HttpOnly");
2152
2463
  parts.push(`SameSite=${d.sameSite}`);
@@ -2154,14 +2465,34 @@ function serializeCookie(d) {
2154
2465
  }
2155
2466
  async function handleCallback(config, input) {
2156
2467
  const cfg = resolve(config);
2468
+ const t0 = Date.now();
2157
2469
  if (!input.code || !input.redirectUri) {
2470
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
2158
2471
  return {
2159
2472
  status: 400,
2160
2473
  body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
2161
2474
  cookies: []
2162
2475
  };
2163
2476
  }
2477
+ const provided = input.state;
2478
+ const expected = input.expectedState;
2479
+ const stateOk = cfg.requireOAuthState ? !!expected && !!provided && timingSafeEqualStr(provided, expected) : !expected || !!provided && timingSafeEqualStr(provided, expected);
2480
+ if (!stateOk) {
2481
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "STATE_MISMATCH" });
2482
+ return {
2483
+ status: 400,
2484
+ body: {
2485
+ success: false,
2486
+ error: {
2487
+ code: "STATE_MISMATCH",
2488
+ message: "OAuth state validation failed; the sign-in could not be verified as originating from this browser."
2489
+ }
2490
+ },
2491
+ cookies: [clearStateCookie(cfg)]
2492
+ };
2493
+ }
2164
2494
  if (!cfg.secretKey) {
2495
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
2165
2496
  return {
2166
2497
  status: 500,
2167
2498
  body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
@@ -2185,6 +2516,7 @@ async function handleCallback(config, input) {
2185
2516
  });
2186
2517
  const json = await res.json().catch(() => ({}));
2187
2518
  if (!res.ok || !json.access_token) {
2519
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
2188
2520
  return {
2189
2521
  status: res.status || 502,
2190
2522
  body: {
@@ -2197,6 +2529,26 @@ async function handleCallback(config, input) {
2197
2529
  cookies: []
2198
2530
  };
2199
2531
  }
2532
+ try {
2533
+ await getTokensFor(cfg.issuer).verify(json.access_token, {
2534
+ issuer: cfg.issuer,
2535
+ ...config.verify
2536
+ });
2537
+ } catch (err) {
2538
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2539
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code });
2540
+ return {
2541
+ status: 502,
2542
+ body: {
2543
+ success: false,
2544
+ error: {
2545
+ code: "ACCESS_TOKEN_VERIFICATION_FAILED",
2546
+ message: "The issuer returned an access token that failed verification; no session was established."
2547
+ }
2548
+ },
2549
+ cookies: []
2550
+ };
2551
+ }
2200
2552
  const cookies = [];
2201
2553
  cookies.push(
2202
2554
  makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
@@ -2204,6 +2556,8 @@ async function handleCallback(config, input) {
2204
2556
  if (json.refresh_token) {
2205
2557
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
2206
2558
  }
2559
+ cookies.push(clearStateCookie(cfg));
2560
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
2207
2561
  return {
2208
2562
  status: 200,
2209
2563
  body: { success: true, data: { authenticated: true } },
@@ -2212,8 +2566,18 @@ async function handleCallback(config, input) {
2212
2566
  }
2213
2567
  async function handleRefresh(config, input) {
2214
2568
  const cfg = resolve(config);
2569
+ const t0 = Date.now();
2215
2570
  const refreshToken = input.refreshToken;
2571
+ const idemKey = input.idempotencyToken;
2572
+ if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
2573
+ return {
2574
+ status: 401,
2575
+ body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
2576
+ cookies: clearCookies(cfg)
2577
+ };
2578
+ }
2216
2579
  if (!refreshToken) {
2580
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
2217
2581
  return {
2218
2582
  status: 401,
2219
2583
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
@@ -2229,6 +2593,7 @@ async function handleRefresh(config, input) {
2229
2593
  if (!res.ok || !json.success || !json.data?.accessToken) {
2230
2594
  const status = res.status || 401;
2231
2595
  const errorCode = json.error?.code || "TOKEN_INVALID";
2596
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
2232
2597
  const shouldClear = shouldClearCookiesOnFailure(
2233
2598
  cfg.clearCookiesOnRefreshFailure,
2234
2599
  status,
@@ -2252,6 +2617,7 @@ async function handleRefresh(config, input) {
2252
2617
  if (json.data.refreshToken) {
2253
2618
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
2254
2619
  }
2620
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
2255
2621
  return {
2256
2622
  status: 200,
2257
2623
  body: { success: true, data: { accessToken: json.data.accessToken } },
@@ -2260,6 +2626,10 @@ async function handleRefresh(config, input) {
2260
2626
  }
2261
2627
  async function handleSignout(config, input) {
2262
2628
  const cfg = resolve(config);
2629
+ const t0 = Date.now();
2630
+ if (input.idempotencyToken) {
2631
+ await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
2632
+ }
2263
2633
  if (input.accessToken) {
2264
2634
  try {
2265
2635
  await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
@@ -2281,14 +2651,64 @@ async function handleSignout(config, input) {
2281
2651
  } catch {
2282
2652
  }
2283
2653
  }
2654
+ emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
2284
2655
  return {
2285
2656
  status: 200,
2286
2657
  body: { success: true, data: { signedOut: true } },
2287
2658
  cookies: clearCookies(cfg)
2288
2659
  };
2289
2660
  }
2661
+ var TOKENS_CACHE = /* @__PURE__ */ new Map();
2662
+ function getTokensFor(issuer) {
2663
+ let m = TOKENS_CACHE.get(issuer);
2664
+ if (!m) {
2665
+ m = new TokensModule(issuer);
2666
+ TOKENS_CACHE.set(issuer, m);
2667
+ }
2668
+ return m;
2669
+ }
2670
+ async function handleUserinfo(config, input) {
2671
+ const cfg = resolve(config);
2672
+ if (!input.accessToken) {
2673
+ return {
2674
+ status: 401,
2675
+ body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
2676
+ cookies: []
2677
+ };
2678
+ }
2679
+ let claims;
2680
+ try {
2681
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, {
2682
+ issuer: cfg.issuer,
2683
+ ...config.verify
2684
+ });
2685
+ } catch (err) {
2686
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2687
+ const message = err instanceof Error ? err.message : "Access token verification failed";
2688
+ return {
2689
+ status: 401,
2690
+ body: { success: false, error: { code, message } },
2691
+ cookies: []
2692
+ };
2693
+ }
2694
+ const envelope = await buildUserinfoResponse(claims, {
2695
+ enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
2696
+ });
2697
+ return {
2698
+ status: 200,
2699
+ body: envelope,
2700
+ cookies: []
2701
+ };
2702
+ }
2290
2703
 
2291
2704
  // src/server/provisioningBridge.ts
2705
+ var ProvisioningError = class extends Error {
2706
+ constructor(code, message) {
2707
+ super(message);
2708
+ this.name = "ProvisioningError";
2709
+ this.code = code;
2710
+ }
2711
+ };
2292
2712
  function defaultIsUniqueViolation(err) {
2293
2713
  if (!err || typeof err !== "object") return false;
2294
2714
  const e = err;
@@ -2300,6 +2720,16 @@ function defaultIsUniqueViolation(err) {
2300
2720
  function createProvisioningBridge(options) {
2301
2721
  const { storage } = options;
2302
2722
  const isUniqueViolation = options.isUniqueViolation ?? defaultIsUniqueViolation;
2723
+ const allowUnverifiedEmailAdopt = options.allowUnverifiedEmailAdopt === true;
2724
+ const emailVerified = (claims) => claims.email_verified === true;
2725
+ const assertAdoptAllowed = (claims) => {
2726
+ if (!allowUnverifiedEmailAdopt && !emailVerified(claims)) {
2727
+ throw new ProvisioningError(
2728
+ "UNVERIFIED_EMAIL_ADOPT_REFUSED",
2729
+ "Refusing to adopt a pre-existing local account from an unverified email (claims.email_verified !== true). Set allowUnverifiedEmailAdopt:true only if your issuer is trusted to never emit unverified emails for adoption."
2730
+ );
2731
+ }
2732
+ };
2303
2733
  const roleOf = (claims) => {
2304
2734
  try {
2305
2735
  return options.roleMapper?.(claims) ?? null;
@@ -2316,6 +2746,7 @@ function createProvisioningBridge(options) {
2316
2746
  if (claims.email) {
2317
2747
  const byEmail = await storage.findByEmail(claims.email);
2318
2748
  if (byEmail) {
2749
+ assertAdoptAllowed(claims);
2319
2750
  if (storage.adoptByEmail) {
2320
2751
  const adopted = await storage.adoptByEmail(byEmail, claims, roleOf(claims));
2321
2752
  return { user: adopted, claims, created: false, adopted: true };
@@ -2331,7 +2762,10 @@ function createProvisioningBridge(options) {
2331
2762
  if (after) return { user: after, claims, created: false, adopted: false };
2332
2763
  if (claims.email) {
2333
2764
  const byEmail = await storage.findByEmail(claims.email);
2334
- if (byEmail) return { user: byEmail, claims, created: false, adopted: true };
2765
+ if (byEmail) {
2766
+ assertAdoptAllowed(claims);
2767
+ return { user: byEmail, claims, created: false, adopted: true };
2768
+ }
2335
2769
  }
2336
2770
  throw err;
2337
2771
  }
@@ -2339,6 +2773,85 @@ function createProvisioningBridge(options) {
2339
2773
  return { ensureUser };
2340
2774
  }
2341
2775
 
2776
+ // src/server/linkLocalUser.ts
2777
+ async function linkLocalUserToIqAuthSub(options) {
2778
+ const { adapter, claims } = options;
2779
+ const lookupBy = options.lookupBy ?? ["email"];
2780
+ const caseInsensitive = options.caseInsensitiveEmail !== false;
2781
+ if (!claims?.sub) throw new Error("linkLocalUserToIqAuthSub: claims.sub is required");
2782
+ return adapter.withTransaction(async (tx) => {
2783
+ const bySub = await tx.findByIqAuthSub(claims.sub);
2784
+ if (bySub) return { status: "already_linked", userId: bySub.id };
2785
+ for (const key of lookupBy) {
2786
+ if (key !== "email") continue;
2787
+ if (!claims.email) continue;
2788
+ const matches = await tx.findByEmail(claims.email, { caseInsensitive });
2789
+ if (matches.length === 0) continue;
2790
+ if (matches.length > 1) {
2791
+ return { status: "conflict", reason: "duplicate_email" };
2792
+ }
2793
+ const row = matches[0];
2794
+ if (row.iqauthSub && row.iqauthSub !== claims.sub) {
2795
+ return { status: "conflict", userId: row.id, reason: "different_sub" };
2796
+ }
2797
+ if (row.iqauthSub === claims.sub) {
2798
+ return { status: "already_linked", userId: row.id };
2799
+ }
2800
+ if (claims.email_verified !== true && options.allowUnverifiedEmail !== true) {
2801
+ return { status: "conflict", userId: row.id, reason: "unverified_email" };
2802
+ }
2803
+ const wrote = await tx.setIqAuthSub(row.id, claims.sub);
2804
+ if (wrote === false) {
2805
+ return { status: "conflict", userId: row.id, reason: "different_sub" };
2806
+ }
2807
+ return { status: "linked", userId: row.id };
2808
+ }
2809
+ return { status: "not_found" };
2810
+ });
2811
+ }
2812
+ function createDrizzleLinkAdapter(deps) {
2813
+ const { db, table, columns, eq, sql } = deps;
2814
+ const iqauthSubKey = deps.columnNames?.iqauthSub ?? "iqauthSub";
2815
+ return {
2816
+ async withTransaction(fn) {
2817
+ return db.transaction(async (txDb) => {
2818
+ const lockedRead = async (cond, limit) => {
2819
+ const built = txDb.select().from(table).where(cond).limit(limit);
2820
+ if (typeof built.for === "function") {
2821
+ return built.for("update");
2822
+ }
2823
+ return built;
2824
+ };
2825
+ const tx = {
2826
+ async findByIqAuthSub(sub) {
2827
+ const rows = await lockedRead(eq(columns.iqauthSub, sub), 1);
2828
+ return rows[0] ?? null;
2829
+ },
2830
+ async findByEmail(email, { caseInsensitive }) {
2831
+ const cond = caseInsensitive ? sql`lower(${columns.email}) = lower(${email})` : eq(columns.email, email);
2832
+ return lockedRead(cond, 2);
2833
+ },
2834
+ async setIqAuthSub(userId, sub) {
2835
+ const result = await txDb.update(table).set({ [iqauthSubKey]: sub }).where(
2836
+ sql`${columns.id} = ${userId} AND (${columns.iqauthSub} IS NULL OR ${columns.iqauthSub} = ${sub})`
2837
+ );
2838
+ const r = result;
2839
+ if (Array.isArray(r)) {
2840
+ return (r[0]?.affectedRows ?? 1) > 0;
2841
+ }
2842
+ if (r && typeof r === "object") {
2843
+ const n = r.rowCount ?? r.rowsAffected ?? r.changes;
2844
+ if (typeof n === "number") return n > 0;
2845
+ }
2846
+ return true;
2847
+ }
2848
+ };
2849
+ return fn(tx);
2850
+ });
2851
+ }
2852
+ };
2853
+ }
2854
+
2342
2855
  // src/server.ts
2343
2856
  var ServerIQAuthClient = class extends IQAuthClient {
2344
2857
  constructor(config) {
@@ -2358,12 +2871,17 @@ function createServerClient(config) {
2358
2871
  ErrorCodes,
2359
2872
  IQAuthClient,
2360
2873
  IQAuthError,
2874
+ ProvisioningError,
2361
2875
  ServerIQAuthClient,
2876
+ buildUserinfoResponse,
2877
+ createDrizzleLinkAdapter,
2362
2878
  createProvisioningBridge,
2363
2879
  createServerClient,
2364
2880
  handleCallback,
2365
2881
  handleRefresh,
2366
2882
  handleSignout,
2883
+ handleUserinfo,
2367
2884
  iqAuthMiddleware,
2885
+ linkLocalUserToIqAuthSub,
2368
2886
  serializeCookie
2369
2887
  });