@iqauth/sdk 2.7.0 → 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 (88) hide show
  1. package/dist/browser-session.d.mts +3 -3
  2. package/dist/browser-session.d.ts +3 -3
  3. package/dist/browser-session.js +31 -5
  4. package/dist/browser-session.mjs +1 -1
  5. package/dist/browser.d.mts +3 -3
  6. package/dist/browser.d.ts +3 -3
  7. package/dist/browser.js +23 -3
  8. package/dist/browser.mjs +1 -1
  9. package/dist/{chunk-YVALAG3B.mjs → chunk-25SSYDIP.mjs} +1 -1
  10. package/dist/{chunk-RTJAIBXY.mjs → chunk-4V7FKOTG.mjs} +23 -3
  11. package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
  12. package/dist/chunk-JRDVUWAL.mjs +46 -0
  13. package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
  14. package/dist/{chunk-PMAFENVI.mjs → chunk-VYQ3ETCK.mjs} +27 -12
  15. package/dist/{chunk-RR2MGPTK.mjs → chunk-WHT6WKTY.mjs} +539 -83
  16. package/dist/{chunk-RUJXRTEW.mjs → chunk-WSH4SW7F.mjs} +122 -8
  17. package/dist/{chunk-JXQI62A7.mjs → chunk-ZLJPABB7.mjs} +31 -5
  18. package/dist/{client-BGFnBpfc.d.mts → client-D8L-PaWr.d.mts} +14 -4
  19. package/dist/{client-CDQ21LvW.d.ts → client-DkPL0EPZ.d.ts} +14 -4
  20. package/dist/{express-Piv2WhWM.d.ts → express-Budysq4h.d.ts} +2 -2
  21. package/dist/{express-CVNQEkOr.d.mts → express-DDTA3qV1.d.mts} +2 -2
  22. package/dist/express.d.mts +5 -5
  23. package/dist/express.d.ts +5 -5
  24. package/dist/express.js +217 -36
  25. package/dist/express.mjs +38 -26
  26. package/dist/fastify.d.mts +10 -2
  27. package/dist/fastify.d.ts +10 -2
  28. package/dist/fastify.js +260 -16
  29. package/dist/fastify.mjs +80 -5
  30. package/dist/hono.d.mts +10 -2
  31. package/dist/hono.d.ts +10 -2
  32. package/dist/hono.js +240 -16
  33. package/dist/hono.mjs +60 -5
  34. package/dist/{index-5KSZEnDe.d.ts → index-Cko-d5po.d.mts} +227 -5
  35. package/dist/{index-CKoZHAoc.d.mts → index-RNqwEcmY.d.ts} +227 -5
  36. package/dist/index.d.mts +5 -5
  37. package/dist/index.d.ts +5 -5
  38. package/dist/index.js +149 -26
  39. package/dist/index.mjs +5 -5
  40. package/dist/locales.d.mts +1 -1
  41. package/dist/locales.d.ts +1 -1
  42. package/dist/locales.js +36 -0
  43. package/dist/locales.mjs +1 -1
  44. package/dist/mobile.d.mts +3 -3
  45. package/dist/mobile.d.ts +3 -3
  46. package/dist/mobile.js +31 -5
  47. package/dist/mobile.mjs +1 -1
  48. package/dist/next.d.mts +10 -2
  49. package/dist/next.d.ts +10 -2
  50. package/dist/next.js +212 -11
  51. package/dist/next.mjs +62 -4
  52. package/dist/{provisioningBridge-M5G47LWO.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
  53. package/dist/{provisioningBridge-CGpMRie4.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
  54. package/dist/react-permissions.d.mts +4 -4
  55. package/dist/react-permissions.d.ts +4 -4
  56. package/dist/react-permissions.mjs +4 -3
  57. package/dist/react.d.mts +4 -4
  58. package/dist/react.d.ts +4 -4
  59. package/dist/react.js +570 -41
  60. package/dist/react.mjs +19 -5
  61. package/dist/server/handlers.d.mts +56 -5
  62. package/dist/server/handlers.d.ts +56 -5
  63. package/dist/server/handlers.js +123 -8
  64. package/dist/server/handlers.mjs +3 -1
  65. package/dist/server.d.mts +28 -8
  66. package/dist/server.d.ts +28 -8
  67. package/dist/server.js +176 -14
  68. package/dist/server.mjs +9 -4
  69. package/dist/service.d.mts +3 -3
  70. package/dist/service.d.ts +3 -3
  71. package/dist/service.js +31 -5
  72. package/dist/service.mjs +1 -1
  73. package/dist/{signIn-T-CZ6t6r.d.mts → signIn-CReqfXsh.d.mts} +18 -1
  74. package/dist/{signIn-BLFnz8SV.d.ts → signIn-Cfa1GTpO.d.ts} +18 -1
  75. package/dist/{tokens-Bqhmqq_R.d.ts → tokens-9F6ETrzk.d.ts} +1 -1
  76. package/dist/{tokens-CITeoG6P.d.mts → tokens-B06VtvUi.d.mts} +1 -1
  77. package/dist/{types-XOV9XPVi.d.mts → types-Bn8O-OEd.d.mts} +66 -2
  78. package/dist/{types-XOV9XPVi.d.ts → types-Bn8O-OEd.d.ts} +66 -2
  79. package/dist/{types-BdQ2lqfT.d.mts → types-DnU2LhXR.d.mts} +6 -0
  80. package/dist/{types-BdQ2lqfT.d.ts → types-DnU2LhXR.d.ts} +6 -0
  81. package/dist/webhooks.d.mts +22 -9
  82. package/dist/webhooks.d.ts +22 -9
  83. package/dist/webhooks.js +27 -12
  84. package/dist/webhooks.mjs +1 -1
  85. package/dist/ws.d.mts +2 -2
  86. package/dist/ws.d.ts +2 -2
  87. package/docs/guides/invitations.md +65 -0
  88. package/package.json +7 -2
package/dist/express.js CHANGED
@@ -334,17 +334,27 @@ function parseLoginResponse(data, browserSessionMode) {
334
334
  tenants: data.tenants
335
335
  };
336
336
  }
337
+ if (data.type === "scope_selection" && data.scopeSelectionToken && data.scopes && data.tenantId) {
338
+ return {
339
+ status: "scope_selection",
340
+ scopeSelectionToken: data.scopeSelectionToken,
341
+ tenantId: data.tenantId,
342
+ scopes: data.scopes
343
+ };
344
+ }
337
345
  throw new Error("Unexpected login response shape");
338
346
  }
339
347
  var AuthModule = class {
340
348
  constructor(http) {
341
349
  this.http = http;
342
350
  }
343
- async login(email, password) {
351
+ async login(email, password, opts) {
352
+ const body = { email, password };
353
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
344
354
  const data = await this.http.request(
345
355
  "POST",
346
356
  "/api/v1/auth/login",
347
- { email, password },
357
+ body,
348
358
  { skipAutoRefresh: true }
349
359
  );
350
360
  return parseLoginResponse(data, this.http.isBrowserSession());
@@ -382,13 +392,29 @@ var AuthModule = class {
382
392
  method
383
393
  }, { skipAutoRefresh: true });
384
394
  }
385
- async selectTenant(tenantSelectionToken, tenantId) {
395
+ async selectTenant(tenantSelectionToken, tenantId, opts) {
396
+ const body = { tenantSelectionToken, tenantId };
397
+ if (opts?.scopeHint) body.scopeHint = opts.scopeHint;
386
398
  const data = await this.http.request(
387
399
  "POST",
388
400
  "/api/v1/auth/select-tenant",
401
+ body,
402
+ { skipAutoRefresh: true }
403
+ );
404
+ return parseLoginResponse(data, this.http.isBrowserSession());
405
+ }
406
+ /**
407
+ * Task #171 — redeem a scope-selection token + chosen membership for a
408
+ * real authenticated session. `membershipId` must be one of the scopes
409
+ * returned in the prior `scope_selection` envelope.
410
+ */
411
+ async selectScope(scopeSelectionToken, membershipId) {
412
+ const data = await this.http.request(
413
+ "POST",
414
+ "/api/v1/auth/select-scope",
389
415
  {
390
- tenantSelectionToken,
391
- tenantId
416
+ scopeSelectionToken,
417
+ membershipId
392
418
  },
393
419
  { skipAutoRefresh: true }
394
420
  );
@@ -2227,7 +2253,11 @@ async function buildUserinfoResponse(claims, opts = {}) {
2227
2253
  tenantId: claims.tenantId,
2228
2254
  vendorId: claims.vendorId,
2229
2255
  roles: claims.roles ?? [],
2230
- entitlements: claims.entitlements ?? []
2256
+ entitlements: claims.entitlements ?? [],
2257
+ // Task #171 — project the active source/client scope onto the userinfo
2258
+ // payload so server handlers (`getSessionUser`, `/api/iqauth/userinfo`)
2259
+ // expose it without consumers having to re-decode the JWT.
2260
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
2231
2261
  };
2232
2262
  const enriched = opts.enrich ? await opts.enrich(claims) : null;
2233
2263
  const user = enriched ? { ...baseUser, ...enriched } : baseUser;
@@ -2272,19 +2302,62 @@ function shouldClearCookiesOnFailure(policy, status, errorCode) {
2272
2302
  }
2273
2303
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
2274
2304
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
2305
+ function assertCookiePrefixInvariants(name, secure, path, domain) {
2306
+ if (name.startsWith("__Host-")) {
2307
+ if (!secure) {
2308
+ throw new IQAuthError(
2309
+ "config_invalid",
2310
+ `Cookie "${name}" uses the __Host- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2311
+ );
2312
+ }
2313
+ if (path !== "/") {
2314
+ throw new IQAuthError(
2315
+ "config_invalid",
2316
+ `Cookie "${name}" uses the __Host- prefix, which requires Path=/ (got "${path}"). Remove cookiePath or set it to "/".`
2317
+ );
2318
+ }
2319
+ if (domain) {
2320
+ throw new IQAuthError(
2321
+ "config_invalid",
2322
+ `Cookie "${name}" uses the __Host- prefix, which forbids a Domain attribute (the cookie is host-locked). Remove cookieDomain.`
2323
+ );
2324
+ }
2325
+ } else if (name.startsWith("__Secure-") && !secure) {
2326
+ throw new IQAuthError(
2327
+ "config_invalid",
2328
+ `Cookie "${name}" uses the __Secure- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
2329
+ );
2330
+ }
2331
+ }
2275
2332
  function resolve(config) {
2276
2333
  const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
2277
2334
  const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
2335
+ maybeWarnDefaultSignoutRegistry(config);
2336
+ const secure = config.secure ?? true;
2337
+ if (config.secure === false && config.allowInsecureCookies !== true) {
2338
+ throw new IQAuthError(
2339
+ "config_invalid",
2340
+ "Refusing to issue auth cookies with secure:false \u2014 this exposes session cookies over plaintext HTTP. For local HTTP development, set allowInsecureCookies:true to acknowledge the risk. Production MUST use HTTPS with secure cookies."
2341
+ );
2342
+ }
2343
+ const accessCookieName = config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at";
2344
+ const refreshCookieName = config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt";
2345
+ const stateCookieName = config.stateCookieName ?? "iqauth_state";
2346
+ const cookiePath = config.cookiePath ?? "/";
2347
+ const cookieDomain = config.cookieDomain;
2348
+ for (const name of [accessCookieName, refreshCookieName, stateCookieName]) {
2349
+ assertCookiePrefixInvariants(name, secure, cookiePath, cookieDomain);
2350
+ }
2278
2351
  return {
2279
2352
  publishableKey: config.publishableKey,
2280
2353
  secretKey: config.secretKey,
2281
2354
  issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
2282
- accessCookieName: config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at",
2283
- refreshCookieName: config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt",
2284
- cookieDomain: config.cookieDomain,
2355
+ accessCookieName,
2356
+ refreshCookieName,
2357
+ cookieDomain,
2285
2358
  sameSite: config.sameSite ?? "lax",
2286
- secure: config.secure ?? true,
2287
- cookiePath: config.cookiePath ?? "/",
2359
+ secure,
2360
+ cookiePath,
2288
2361
  tokenPath: config.tokenPath ?? "/oidc/token",
2289
2362
  refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
2290
2363
  logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
@@ -2297,9 +2370,19 @@ function resolve(config) {
2297
2370
  debug: config.debug,
2298
2371
  onTimingEvent: config.onTimingEvent,
2299
2372
  signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
2300
- signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS
2373
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS,
2374
+ requireOAuthState: config.requireOAuthState ?? true,
2375
+ stateCookieName: config.stateCookieName ?? "iqauth_state"
2301
2376
  };
2302
2377
  }
2378
+ function timingSafeEqualStr(a, b) {
2379
+ const len = Math.max(a.length, b.length);
2380
+ let diff = a.length ^ b.length;
2381
+ for (let i = 0; i < len; i++) {
2382
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
2383
+ }
2384
+ return diff === 0;
2385
+ }
2303
2386
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
2304
2387
  return {
2305
2388
  name,
@@ -2318,6 +2401,9 @@ function clearCookies(cfg) {
2318
2401
  { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
2319
2402
  ];
2320
2403
  }
2404
+ function clearStateCookie(cfg) {
2405
+ return { ...makeCookie(cfg, cfg.stateCookieName, "", 0, false), clear: true };
2406
+ }
2321
2407
  var DEFAULT_SIGNOUT_TTL_MS = 6e4;
2322
2408
  var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
2323
2409
  function pruneInMemoryMarkers(now) {
@@ -2343,6 +2429,15 @@ var defaultSignoutRegistry = {
2343
2429
  return true;
2344
2430
  }
2345
2431
  };
2432
+ var warnedDefaultSignoutRegistry = false;
2433
+ function maybeWarnDefaultSignoutRegistry(config) {
2434
+ if (warnedDefaultSignoutRegistry) return;
2435
+ if (config.signoutRegistry) return;
2436
+ warnedDefaultSignoutRegistry = true;
2437
+ console.warn(
2438
+ "[IQAuth] Using the in-memory signout registry (process-local). Signout idempotency is NOT shared across instances \u2014 in a multi-replica deployment a /refresh racing a /signout on another replica can reissue cookies after sign-out. Plug a shared backend (e.g. Redis) into IQAuthHelperConfig.signoutRegistry to fix this and silence this warning."
2439
+ );
2440
+ }
2346
2441
  async function handleCallback(config, input) {
2347
2442
  const cfg = resolve(config);
2348
2443
  const t0 = Date.now();
@@ -2354,6 +2449,23 @@ async function handleCallback(config, input) {
2354
2449
  cookies: []
2355
2450
  };
2356
2451
  }
2452
+ const provided = input.state;
2453
+ const expected = input.expectedState;
2454
+ const stateOk = cfg.requireOAuthState ? !!expected && !!provided && timingSafeEqualStr(provided, expected) : !expected || !!provided && timingSafeEqualStr(provided, expected);
2455
+ if (!stateOk) {
2456
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "STATE_MISMATCH" });
2457
+ return {
2458
+ status: 400,
2459
+ body: {
2460
+ success: false,
2461
+ error: {
2462
+ code: "STATE_MISMATCH",
2463
+ message: "OAuth state validation failed; the sign-in could not be verified as originating from this browser."
2464
+ }
2465
+ },
2466
+ cookies: [clearStateCookie(cfg)]
2467
+ };
2468
+ }
2357
2469
  if (!cfg.secretKey) {
2358
2470
  emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
2359
2471
  return {
@@ -2392,6 +2504,26 @@ async function handleCallback(config, input) {
2392
2504
  cookies: []
2393
2505
  };
2394
2506
  }
2507
+ try {
2508
+ await getTokensFor(cfg.issuer).verify(json.access_token, {
2509
+ issuer: cfg.issuer,
2510
+ ...config.verify
2511
+ });
2512
+ } catch (err) {
2513
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2514
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code });
2515
+ return {
2516
+ status: 502,
2517
+ body: {
2518
+ success: false,
2519
+ error: {
2520
+ code: "ACCESS_TOKEN_VERIFICATION_FAILED",
2521
+ message: "The issuer returned an access token that failed verification; no session was established."
2522
+ }
2523
+ },
2524
+ cookies: []
2525
+ };
2526
+ }
2395
2527
  const cookies = [];
2396
2528
  cookies.push(
2397
2529
  makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
@@ -2399,6 +2531,7 @@ async function handleCallback(config, input) {
2399
2531
  if (json.refresh_token) {
2400
2532
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
2401
2533
  }
2534
+ cookies.push(clearStateCookie(cfg));
2402
2535
  emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
2403
2536
  return {
2404
2537
  status: 200,
@@ -2520,7 +2653,10 @@ async function handleUserinfo(config, input) {
2520
2653
  }
2521
2654
  let claims;
2522
2655
  try {
2523
- claims = await getTokensFor(cfg.issuer).verify(input.accessToken, config.verify);
2656
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, {
2657
+ issuer: cfg.issuer,
2658
+ ...config.verify
2659
+ });
2524
2660
  } catch (err) {
2525
2661
  const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2526
2662
  const message = err instanceof Error ? err.message : "Access token verification failed";
@@ -2540,6 +2676,42 @@ async function handleUserinfo(config, input) {
2540
2676
  };
2541
2677
  }
2542
2678
 
2679
+ // src/browser/returnTo.ts
2680
+ function normalizeOrigin(o) {
2681
+ try {
2682
+ return new URL(o).origin;
2683
+ } catch {
2684
+ return o.replace(/\/+$/, "");
2685
+ }
2686
+ }
2687
+ function sanitizeReturnTo(input, options = {}) {
2688
+ const fallback = options.fallback ?? "/";
2689
+ if (!input || typeof input !== "string") return fallback;
2690
+ const trimmed = input.trim();
2691
+ if (!trimmed) return fallback;
2692
+ if (trimmed.includes("\\")) return fallback;
2693
+ if (trimmed.startsWith("//")) return fallback;
2694
+ if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
2695
+ return trimmed;
2696
+ }
2697
+ if (!/^[a-z][a-z0-9+\-.]*:/i.test(trimmed)) {
2698
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2699
+ }
2700
+ let parsed;
2701
+ try {
2702
+ parsed = new URL(trimmed);
2703
+ } catch {
2704
+ return fallback;
2705
+ }
2706
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback;
2707
+ const currentOrigin = options.currentOrigin ?? (typeof window !== "undefined" ? window.location.origin : "");
2708
+ const allowed = /* @__PURE__ */ new Set();
2709
+ if (currentOrigin) allowed.add(normalizeOrigin(currentOrigin));
2710
+ for (const o of options.allowedOrigins ?? []) allowed.add(normalizeOrigin(o));
2711
+ if (allowed.has(parsed.origin)) return parsed.toString();
2712
+ return fallback;
2713
+ }
2714
+
2543
2715
  // src/express.ts
2544
2716
  var PKCE_COOKIE = "iqauth_pkce";
2545
2717
  var IDEMPOTENCY_HEADER = "x-iqauth-idempotency";
@@ -2655,6 +2827,11 @@ function applyHandlerResponse(res, hr) {
2655
2827
  function readBody(req) {
2656
2828
  return req.body && typeof req.body === "object" ? req.body : {};
2657
2829
  }
2830
+ function requestOriginOf(req) {
2831
+ const proto = req.headers?.["x-forwarded-proto"]?.split(",")[0]?.trim() || (typeof req.protocol === "string" ? req.protocol : void 0) || "https";
2832
+ const host = req.headers?.["x-forwarded-host"]?.split(",")[0]?.trim() || req.headers?.host || "";
2833
+ return host ? `${proto}://${host}` : "";
2834
+ }
2658
2835
  function readCookieFromReq(req, name) {
2659
2836
  if (req.cookies && typeof req.cookies[name] === "string") return req.cookies[name];
2660
2837
  const header = req.headers?.cookie;
@@ -2699,7 +2876,13 @@ function iqAuth(options) {
2699
2876
  const hr = await handleCallback(helperConfig, {
2700
2877
  code: body.code,
2701
2878
  codeVerifier: body.codeVerifier,
2702
- redirectUri: body.redirectUri
2879
+ redirectUri: body.redirectUri,
2880
+ // M-2: bind the callback to this browser. `state` is echoed back by the
2881
+ // OAuth redirect (body); `expectedState` is the value the SDK published
2882
+ // in a first-party cookie before redirect. handleCallback fails closed
2883
+ // on mismatch/missing when requireOAuthState (default) is on.
2884
+ state: body.state,
2885
+ expectedState: readCookieFromReq(req, helperConfig.stateCookieName ?? "iqauth_state")
2703
2886
  });
2704
2887
  applyHandlerResponse(res, hr);
2705
2888
  });
@@ -2748,21 +2931,19 @@ function iqAuth(options) {
2748
2931
  });
2749
2932
  app.post(exchangePath, async (req, res) => {
2750
2933
  const body = readBody(req);
2751
- const stateFromBody = body.state || void 0;
2752
- const stateFromCookie = readCookieFromReq(req, stateCookie);
2753
- if (stateFromCookie && stateFromBody !== stateFromCookie) {
2754
- clearCookie(res, stateCookie);
2755
- res.status(400);
2756
- return res.json ? res.json({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } }) : res.end?.(JSON.stringify({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } }));
2757
- }
2758
2934
  const hr = await handleCallback(helperConfig, {
2759
2935
  code: body.code,
2760
2936
  codeVerifier: body.codeVerifier || readCookieFromReq(req, PKCE_COOKIE) || "",
2761
- redirectUri: body.redirectUri
2937
+ redirectUri: body.redirectUri,
2938
+ state: body.state,
2939
+ expectedState: readCookieFromReq(req, stateCookie)
2762
2940
  });
2763
2941
  clearCookie(res, stateCookie);
2764
2942
  clearCookie(res, PKCE_COOKIE);
2765
- const returnTo = readCookieFromReq(req, returnToCookie) || hr.body?.returnTo || "/";
2943
+ const returnTo = sanitizeReturnTo(
2944
+ readCookieFromReq(req, returnToCookie) || hr.body?.returnTo,
2945
+ { currentOrigin: requestOriginOf(req), fallback: "/" }
2946
+ );
2766
2947
  if (hr.status < 400) clearCookie(res, returnToCookie);
2767
2948
  const enriched = {
2768
2949
  ...hr,
@@ -2781,21 +2962,17 @@ function iqAuth(options) {
2781
2962
  else res.end?.("Missing authorization code");
2782
2963
  });
2783
2964
  }
2784
- const stateFromQuery = q.state;
2785
- const stateFromCookie = readCookieFromReq(req, stateCookie);
2786
- if (stateFromCookie && stateFromQuery !== stateFromCookie) {
2787
- clearCookie(res, stateCookie);
2788
- return failPlain(res, "state_mismatch", () => {
2789
- res.status(400);
2790
- if (res.json) res.json({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } });
2791
- else res.end?.("OAuth state mismatch");
2792
- });
2793
- }
2794
2965
  const codeVerifier = readCookieFromReq(req, PKCE_COOKIE) || "";
2795
2966
  const proto = req.headers?.["x-forwarded-proto"] || req.protocol || "https";
2796
2967
  const host = req.headers?.["x-forwarded-host"] || req.headers?.host || "";
2797
2968
  const redirectUri = `${proto}://${host}${callbackPath}`;
2798
- const hr = await handleCallback(helperConfig, { code, codeVerifier, redirectUri });
2969
+ const hr = await handleCallback(helperConfig, {
2970
+ code,
2971
+ codeVerifier,
2972
+ redirectUri,
2973
+ state: q.state,
2974
+ expectedState: readCookieFromReq(req, stateCookie)
2975
+ });
2799
2976
  for (const c of hr.cookies) {
2800
2977
  if (typeof res.cookie === "function") {
2801
2978
  const opts = {
@@ -2812,14 +2989,18 @@ function iqAuth(options) {
2812
2989
  clearCookie(res, stateCookie);
2813
2990
  clearCookie(res, PKCE_COOKIE);
2814
2991
  if (hr.status >= 400) {
2815
- const code2 = hr.body?.error?.code || "exchange_failed";
2992
+ const rawCode = hr.body?.error?.code || "exchange_failed";
2993
+ const code2 = rawCode === "STATE_MISMATCH" ? "state_mismatch" : rawCode;
2816
2994
  return failPlain(res, code2, () => {
2817
2995
  res.status(hr.status);
2818
2996
  if (res.json) res.json(hr.body);
2819
2997
  else res.end?.(JSON.stringify(hr.body));
2820
2998
  });
2821
2999
  }
2822
- const returnTo = readCookieFromReq(req, returnToCookie) || hr.body?.returnTo || "/";
3000
+ const returnTo = sanitizeReturnTo(
3001
+ readCookieFromReq(req, returnToCookie) || hr.body?.returnTo,
3002
+ { currentOrigin: requestOriginOf(req), fallback: "/" }
3003
+ );
2823
3004
  clearCookie(res, returnToCookie);
2824
3005
  if (typeof res.redirect === "function") return res.redirect(302, returnTo);
2825
3006
  res.status(302);
package/dist/express.mjs CHANGED
@@ -1,19 +1,22 @@
1
+ import {
2
+ sanitizeReturnTo
3
+ } from "./chunk-JRDVUWAL.mjs";
1
4
  import {
2
5
  DEFAULT_REFRESH_COOKIE,
3
6
  iqAuthMiddleware
4
- } from "./chunk-YVALAG3B.mjs";
7
+ } from "./chunk-25SSYDIP.mjs";
5
8
  import {
6
9
  handleCallback,
7
10
  handleRefresh,
8
11
  handleSignout,
9
12
  handleUserinfo
10
- } from "./chunk-RUJXRTEW.mjs";
13
+ } from "./chunk-WSH4SW7F.mjs";
11
14
  import {
12
15
  assertPublishableKey
13
16
  } from "./chunk-HVHNYPDC.mjs";
14
17
  import {
15
18
  IQAuthClient
16
- } from "./chunk-JXQI62A7.mjs";
19
+ } from "./chunk-ZLJPABB7.mjs";
17
20
  import "./chunk-NUO2I65G.mjs";
18
21
  import {
19
22
  ErrorCodes,
@@ -136,6 +139,11 @@ function applyHandlerResponse(res, hr) {
136
139
  function readBody(req) {
137
140
  return req.body && typeof req.body === "object" ? req.body : {};
138
141
  }
142
+ function requestOriginOf(req) {
143
+ const proto = req.headers?.["x-forwarded-proto"]?.split(",")[0]?.trim() || (typeof req.protocol === "string" ? req.protocol : void 0) || "https";
144
+ const host = req.headers?.["x-forwarded-host"]?.split(",")[0]?.trim() || req.headers?.host || "";
145
+ return host ? `${proto}://${host}` : "";
146
+ }
139
147
  function readCookieFromReq(req, name) {
140
148
  if (req.cookies && typeof req.cookies[name] === "string") return req.cookies[name];
141
149
  const header = req.headers?.cookie;
@@ -180,7 +188,13 @@ function iqAuth(options) {
180
188
  const hr = await handleCallback(helperConfig, {
181
189
  code: body.code,
182
190
  codeVerifier: body.codeVerifier,
183
- redirectUri: body.redirectUri
191
+ redirectUri: body.redirectUri,
192
+ // M-2: bind the callback to this browser. `state` is echoed back by the
193
+ // OAuth redirect (body); `expectedState` is the value the SDK published
194
+ // in a first-party cookie before redirect. handleCallback fails closed
195
+ // on mismatch/missing when requireOAuthState (default) is on.
196
+ state: body.state,
197
+ expectedState: readCookieFromReq(req, helperConfig.stateCookieName ?? "iqauth_state")
184
198
  });
185
199
  applyHandlerResponse(res, hr);
186
200
  });
@@ -229,21 +243,19 @@ function iqAuth(options) {
229
243
  });
230
244
  app.post(exchangePath, async (req, res) => {
231
245
  const body = readBody(req);
232
- const stateFromBody = body.state || void 0;
233
- const stateFromCookie = readCookieFromReq(req, stateCookie);
234
- if (stateFromCookie && stateFromBody !== stateFromCookie) {
235
- clearCookie(res, stateCookie);
236
- res.status(400);
237
- return res.json ? res.json({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } }) : res.end?.(JSON.stringify({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } }));
238
- }
239
246
  const hr = await handleCallback(helperConfig, {
240
247
  code: body.code,
241
248
  codeVerifier: body.codeVerifier || readCookieFromReq(req, PKCE_COOKIE) || "",
242
- redirectUri: body.redirectUri
249
+ redirectUri: body.redirectUri,
250
+ state: body.state,
251
+ expectedState: readCookieFromReq(req, stateCookie)
243
252
  });
244
253
  clearCookie(res, stateCookie);
245
254
  clearCookie(res, PKCE_COOKIE);
246
- const returnTo = readCookieFromReq(req, returnToCookie) || hr.body?.returnTo || "/";
255
+ const returnTo = sanitizeReturnTo(
256
+ readCookieFromReq(req, returnToCookie) || hr.body?.returnTo,
257
+ { currentOrigin: requestOriginOf(req), fallback: "/" }
258
+ );
247
259
  if (hr.status < 400) clearCookie(res, returnToCookie);
248
260
  const enriched = {
249
261
  ...hr,
@@ -262,21 +274,17 @@ function iqAuth(options) {
262
274
  else res.end?.("Missing authorization code");
263
275
  });
264
276
  }
265
- const stateFromQuery = q.state;
266
- const stateFromCookie = readCookieFromReq(req, stateCookie);
267
- if (stateFromCookie && stateFromQuery !== stateFromCookie) {
268
- clearCookie(res, stateCookie);
269
- return failPlain(res, "state_mismatch", () => {
270
- res.status(400);
271
- if (res.json) res.json({ success: false, error: { code: "STATE_MISMATCH", message: "OAuth state mismatch" } });
272
- else res.end?.("OAuth state mismatch");
273
- });
274
- }
275
277
  const codeVerifier = readCookieFromReq(req, PKCE_COOKIE) || "";
276
278
  const proto = req.headers?.["x-forwarded-proto"] || req.protocol || "https";
277
279
  const host = req.headers?.["x-forwarded-host"] || req.headers?.host || "";
278
280
  const redirectUri = `${proto}://${host}${callbackPath}`;
279
- const hr = await handleCallback(helperConfig, { code, codeVerifier, redirectUri });
281
+ const hr = await handleCallback(helperConfig, {
282
+ code,
283
+ codeVerifier,
284
+ redirectUri,
285
+ state: q.state,
286
+ expectedState: readCookieFromReq(req, stateCookie)
287
+ });
280
288
  for (const c of hr.cookies) {
281
289
  if (typeof res.cookie === "function") {
282
290
  const opts = {
@@ -293,14 +301,18 @@ function iqAuth(options) {
293
301
  clearCookie(res, stateCookie);
294
302
  clearCookie(res, PKCE_COOKIE);
295
303
  if (hr.status >= 400) {
296
- const code2 = hr.body?.error?.code || "exchange_failed";
304
+ const rawCode = hr.body?.error?.code || "exchange_failed";
305
+ const code2 = rawCode === "STATE_MISMATCH" ? "state_mismatch" : rawCode;
297
306
  return failPlain(res, code2, () => {
298
307
  res.status(hr.status);
299
308
  if (res.json) res.json(hr.body);
300
309
  else res.end?.(JSON.stringify(hr.body));
301
310
  });
302
311
  }
303
- const returnTo = readCookieFromReq(req, returnToCookie) || hr.body?.returnTo || "/";
312
+ const returnTo = sanitizeReturnTo(
313
+ readCookieFromReq(req, returnToCookie) || hr.body?.returnTo,
314
+ { currentOrigin: requestOriginOf(req), fallback: "/" }
315
+ );
304
316
  clearCookie(res, returnToCookie);
305
317
  if (typeof res.redirect === "function") return res.redirect(302, returnTo);
306
318
  res.status(302);
@@ -1,6 +1,6 @@
1
1
  import { IQAuthHelperConfig } from './server/handlers.mjs';
2
- import './tokens-CITeoG6P.mjs';
3
- import './types-XOV9XPVi.mjs';
2
+ import './tokens-B06VtvUi.mjs';
3
+ import './types-Bn8O-OEd.mjs';
4
4
 
5
5
  /**
6
6
  * @iqauth/sdk/fastify — Fastify adapter.
@@ -17,6 +17,14 @@ import './types-XOV9XPVi.mjs';
17
17
  interface IQAuthFastifyOptions extends IQAuthHelperConfig {
18
18
  mountPath?: string;
19
19
  mountHelperRoutes?: boolean;
20
+ /**
21
+ * Cookie name the browser SDK publishes the post-login destination into
22
+ * before redirect. The callback handler reads it, surfaces it as `returnTo`
23
+ * in the JSON response body after a successful exchange, and clears it on
24
+ * success. Mirrors the express inline-callback adapter. Defaults to
25
+ * `iqauth_return_to`.
26
+ */
27
+ returnToCookieName?: string;
20
28
  /** Routes that bypass verification (e.g. health checks). */
21
29
  publicPaths?: string[] | ((path: string) => boolean);
22
30
  /** Override token verification options (issuer / audience / clock tolerance). */
package/dist/fastify.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IQAuthHelperConfig } from './server/handlers.js';
2
- import './tokens-Bqhmqq_R.js';
3
- import './types-XOV9XPVi.js';
2
+ import './tokens-9F6ETrzk.js';
3
+ import './types-Bn8O-OEd.js';
4
4
 
5
5
  /**
6
6
  * @iqauth/sdk/fastify — Fastify adapter.
@@ -17,6 +17,14 @@ import './types-XOV9XPVi.js';
17
17
  interface IQAuthFastifyOptions extends IQAuthHelperConfig {
18
18
  mountPath?: string;
19
19
  mountHelperRoutes?: boolean;
20
+ /**
21
+ * Cookie name the browser SDK publishes the post-login destination into
22
+ * before redirect. The callback handler reads it, surfaces it as `returnTo`
23
+ * in the JSON response body after a successful exchange, and clears it on
24
+ * success. Mirrors the express inline-callback adapter. Defaults to
25
+ * `iqauth_return_to`.
26
+ */
27
+ returnToCookieName?: string;
20
28
  /** Routes that bypass verification (e.g. health checks). */
21
29
  publicPaths?: string[] | ((path: string) => boolean);
22
30
  /** Override token verification options (issuer / audience / clock tolerance). */