@web-ts-toolkit/express-oidc-vault 0.43.0 → 0.47.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 (6) hide show
  1. package/README.md +261 -51
  2. package/index.d.mts +300 -9
  3. package/index.d.ts +300 -9
  4. package/index.js +619 -194
  5. package/index.mjs +620 -195
  6. package/package.json +2 -2
package/index.js CHANGED
@@ -52,6 +52,7 @@ var import_express = __toESM(require("express"));
52
52
  // src/config.ts
53
53
  var DEFAULT_OIDC_SCOPES = "openid email profile";
54
54
  var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
55
+ var MANUAL_CONFIG_SELECTORS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri", "userInfoEndpoint", "endSessionEndpoint"];
55
56
  var normalizeOptionalString = (value) => {
56
57
  const trimmed = value?.trim();
57
58
  return trimmed ? trimmed : void 0;
@@ -71,19 +72,38 @@ var normalizeHttpUrl = (value, optionName) => {
71
72
  }
72
73
  return url.toString();
73
74
  };
75
+ var normalizeIssuerIdentifier = (value, optionName) => {
76
+ if (value === void 0) {
77
+ return void 0;
78
+ }
79
+ let url;
80
+ try {
81
+ url = new URL(value);
82
+ } catch {
83
+ throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
84
+ }
85
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
86
+ throw new Error(`${optionName} must use http or https.`);
87
+ }
88
+ if (url.username !== "" || url.password !== "") {
89
+ throw new Error(`${optionName} must not include userinfo.`);
90
+ }
91
+ if (url.search !== "" || url.hash !== "") {
92
+ throw new Error(`${optionName} must not include query or fragment.`);
93
+ }
94
+ return value;
95
+ };
74
96
  var withOptionalValue = (target, key, value) => {
75
97
  if (value !== void 0) {
76
98
  target[key] = value;
77
99
  }
78
100
  };
79
101
  function resolveOidcVaultConfig(config = {}) {
80
- const issuer = normalizeHttpUrl(normalizeOptionalString(config.issuer), "config.issuer");
102
+ const issuer = normalizeIssuerIdentifier(normalizeOptionalString(config.issuer), "config.issuer");
81
103
  const clientId = normalizeOptionalString(config.clientId);
82
104
  const clientSecret = normalizeOptionalString(config.clientSecret);
83
105
  const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
84
- const hasManualEndpoints = MANUAL_CONFIG_REQUIREMENTS.some(
85
- (key) => key !== "issuer" && normalizeOptionalString(config[key])
86
- );
106
+ const hasManualEndpoints = MANUAL_CONFIG_SELECTORS.some((key) => normalizeOptionalString(config[key]));
87
107
  if (!clientId) {
88
108
  throw new Error("OIDC clientId is required.");
89
109
  }
@@ -243,37 +263,11 @@ var getRequiredFiniteNonNegativeInteger = (value, message, code, status = 502) =
243
263
  // src/cookies.ts
244
264
  var getSessionTransport = (options) => options.sessionTransport ?? "body";
245
265
  var usesCookieTransport = (options) => getSessionTransport(options) === "cookie";
246
- var parseCookieHeader = (headerValue) => {
247
- if (!headerValue) {
248
- return {};
249
- }
250
- return headerValue.split(";").reduce((cookies, part) => {
251
- const separatorIndex = part.indexOf("=");
252
- if (separatorIndex === -1) {
253
- return cookies;
254
- }
255
- const name = part.slice(0, separatorIndex).trim();
256
- const value = part.slice(separatorIndex + 1).trim();
257
- if (!name) {
258
- return cookies;
259
- }
260
- try {
261
- cookies[name] = decodeURIComponent(value);
262
- } catch {
263
- throw new OidcVaultHttpError(
264
- 400,
265
- "OIDC_VAULT_MALFORMED_SESSION_COOKIE",
266
- "OIDC vault session cookie is malformed."
267
- );
268
- }
269
- return cookies;
270
- }, {});
271
- };
272
266
  var resolveCookieOptions = (options) => {
273
267
  const cookieOptions = options.cookie ?? {};
274
268
  const deploymentMode = cookieOptions.deploymentMode ?? "same-origin";
275
269
  const sameSite = cookieOptions.sameSite ?? (deploymentMode === "cross-site" ? "none" : "lax");
276
- const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site");
270
+ const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site" || isHttpsBackendOrigin(options));
277
271
  return {
278
272
  name: cookieOptions.name ?? DEFAULT_SESSION_COOKIE_NAME,
279
273
  sameSite,
@@ -283,12 +277,24 @@ var resolveCookieOptions = (options) => {
283
277
  httpOnly: cookieOptions.httpOnly ?? true
284
278
  };
285
279
  };
280
+ var isEffectivelySecureCookie = (cookieOptions) => cookieOptions.secure || cookieOptions.sameSite === "none";
281
+ var isHttpsBackendOrigin = (options) => {
282
+ try {
283
+ const raw = options.backendOrigin;
284
+ if (typeof raw !== "string") {
285
+ return false;
286
+ }
287
+ return new URL(raw.trim()).protocol === "https:";
288
+ } catch {
289
+ return false;
290
+ }
291
+ };
286
292
  var COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
287
293
  var COOKIE_DOMAIN_PATTERN = /^\.?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
288
294
  var hasUnsafeCookieValueCharacters = (value) => {
289
295
  for (let index = 0; index < value.length; index += 1) {
290
296
  const code = value.charCodeAt(index);
291
- if (code <= 31 || code === 127 || value[index] === ";") {
297
+ if (code < 32 || code > 126 || value[index] === ";") {
292
298
  return true;
293
299
  }
294
300
  }
@@ -311,6 +317,22 @@ var validateCookieOptions = (options) => {
311
317
  if (!cookieOptions.httpOnly) {
312
318
  throw new Error("cookie.httpOnly must be true for cookie session transport.");
313
319
  }
320
+ const effectiveSecure = isEffectivelySecureCookie(cookieOptions);
321
+ if (cookieOptions.name.startsWith("__Host-")) {
322
+ if (!effectiveSecure) {
323
+ throw new Error("cookie.name with the __Host- prefix must be Secure.");
324
+ }
325
+ if (cookieOptions.domain !== void 0) {
326
+ throw new Error("cookie.name with the __Host- prefix must not set cookie.domain.");
327
+ }
328
+ if (cookieOptions.path !== "/") {
329
+ throw new Error('cookie.name with the __Host- prefix must use cookie.path "/".');
330
+ }
331
+ } else if (cookieOptions.name.startsWith("__Secure-")) {
332
+ if (!effectiveSecure) {
333
+ throw new Error("cookie.name with the __Secure- prefix must be Secure.");
334
+ }
335
+ }
314
336
  };
315
337
  var usesCrossSiteCookieTransport = (options) => usesCookieTransport(options) && resolveCookieOptions(options).sameSite === "none";
316
338
  var serializeCookie = (name, value, options, overrides) => {
@@ -348,8 +370,62 @@ var clearSessionCookie = (res, options) => {
348
370
  );
349
371
  };
350
372
  var getSessionIdFromCookie = (req, options) => {
351
- const cookies = parseCookieHeader(req.headers.cookie);
352
- return cookies[resolveCookieOptions(options).name];
373
+ const raw = parseSelectedCookieValue(req.headers.cookie, resolveCookieOptions(options).name);
374
+ if (raw === void 0) {
375
+ return void 0;
376
+ }
377
+ try {
378
+ return decodeURIComponent(raw);
379
+ } catch {
380
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_MALFORMED_SESSION_COOKIE", "OIDC vault session cookie is malformed.");
381
+ }
382
+ };
383
+ var parseSelectedCookieValue = (headerValue, selectedName) => {
384
+ if (!headerValue) {
385
+ return void 0;
386
+ }
387
+ const segments = headerValue.split(";");
388
+ for (const segment of segments) {
389
+ const separatorIndex = segment.indexOf("=");
390
+ if (separatorIndex === -1) {
391
+ continue;
392
+ }
393
+ const name = segment.slice(0, separatorIndex).trim();
394
+ if (!name || name !== selectedName) {
395
+ continue;
396
+ }
397
+ return segment.slice(separatorIndex + 1).trim();
398
+ }
399
+ return void 0;
400
+ };
401
+
402
+ // src/lifetime-policy.ts
403
+ var MAX_EPOCH_MS = 864e13;
404
+ var isUsableEpochMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= MAX_EPOCH_MS;
405
+ var computeExpiresAt = (now, ttlMs, name) => {
406
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
407
+ throw new Error(`${name} must be a positive safe integer in milliseconds.`);
408
+ }
409
+ const expiresAt = now + ttlMs;
410
+ if (!isUsableEpochMs(now) || !isUsableEpochMs(expiresAt) || expiresAt <= now) {
411
+ throw new Error(`${name} must produce a usable epoch-millisecond expiry after now.`);
412
+ }
413
+ return expiresAt;
414
+ };
415
+ var validateLifetimeOptions = (options, now) => {
416
+ computeExpiresAt(
417
+ now,
418
+ options.authorizationTransactionTtlMs === void 0 ? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS : options.authorizationTransactionTtlMs,
419
+ "authorizationTransactionTtlMs"
420
+ );
421
+ computeExpiresAt(
422
+ now,
423
+ options.exchangeCodeTtlMs === void 0 ? DEFAULT_EXCHANGE_CODE_TTL_MS : options.exchangeCodeTtlMs,
424
+ "exchangeCodeTtlMs"
425
+ );
426
+ if (options.sessionTtlMs !== void 0) {
427
+ computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
428
+ }
353
429
  };
354
430
 
355
431
  // src/utils.ts
@@ -455,11 +531,29 @@ var DEFAULT_DISCOVERY_CACHE_CAPACITY = 32;
455
531
  var DEFAULT_JWKS_CACHE_CAPACITY = 32;
456
532
  var DEFAULT_PROVIDER_ERROR_BODY_LIMIT = 1024;
457
533
  var DEFAULT_PROVIDER_JSON_BODY_LIMIT = 1024 * 1024;
534
+ var DEFAULT_JWKS_BODY_LIMIT = 1024 * 1024;
535
+ var DEFAULT_JWKS_MAX_KEYS = 100;
458
536
  var discoveryCache = /* @__PURE__ */ new Map();
459
537
  var jwksCache = /* @__PURE__ */ new Map();
460
- var normalizeIssuerForDiscoveryComparison = (issuer) => issuer.replace(/\/+$/g, "");
461
538
  var assertDiscoveredIssuerMatchesConfig = (configuredIssuer, discoveredIssuer) => {
462
- if (normalizeIssuerForDiscoveryComparison(configuredIssuer) !== normalizeIssuerForDiscoveryComparison(discoveredIssuer)) {
539
+ let discoveredUrl;
540
+ try {
541
+ discoveredUrl = new URL(discoveredIssuer);
542
+ } catch {
543
+ throw new OidcVaultHttpError(
544
+ 502,
545
+ "OIDC_VAULT_DISCOVERY_INVALID",
546
+ "OIDC discovery response issuer must be an absolute HTTP(S) URL."
547
+ );
548
+ }
549
+ if (discoveredUrl.protocol !== "https:" && discoveredUrl.protocol !== "http:" || discoveredUrl.origin === "null" || discoveredUrl.username !== "" || discoveredUrl.password !== "" || discoveredUrl.search !== "" || discoveredUrl.hash !== "") {
550
+ throw new OidcVaultHttpError(
551
+ 502,
552
+ "OIDC_VAULT_DISCOVERY_INVALID",
553
+ "OIDC discovery response issuer must be an http(s) URL without userinfo, query, or fragment."
554
+ );
555
+ }
556
+ if (configuredIssuer !== discoveredIssuer) {
463
557
  throw new OidcVaultHttpError(
464
558
  502,
465
559
  "OIDC_VAULT_DISCOVERY_INVALID",
@@ -487,6 +581,20 @@ var validateDiscoveredHttpUrl = (value, fieldName) => {
487
581
  }
488
582
  return url.toString();
489
583
  };
584
+ var validateOptionalDiscoveredHttpUrl = (value, fieldName) => {
585
+ if (value === void 0) {
586
+ return void 0;
587
+ }
588
+ return validateDiscoveredHttpUrl(
589
+ getRequiredString(
590
+ value,
591
+ `OIDC discovery response ${fieldName} must be a non-empty string when present.`,
592
+ "OIDC_VAULT_DISCOVERY_INVALID",
593
+ 502
594
+ ),
595
+ fieldName
596
+ );
597
+ };
490
598
  var buildWellKnownUrl = (issuer) => {
491
599
  const normalizedIssuer = issuer.endsWith("/") ? issuer : `${issuer}/`;
492
600
  return new URL(".well-known/openid-configuration", normalizedIssuer);
@@ -511,42 +619,91 @@ var evictOldestEntry = (cache, capacity) => {
511
619
  cache.delete(oldestKey);
512
620
  }
513
621
  };
622
+ var isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
623
+ var createProviderTimeoutError = (errorCode) => new OidcVaultHttpError(502, errorCode, "OIDC provider request timed out.");
624
+ var createProviderTransportError = (errorCode, cause) => {
625
+ const error = new OidcVaultHttpError(502, errorCode, "OIDC provider request failed.");
626
+ Object.defineProperty(error, "cause", { value: cause, configurable: true, writable: true });
627
+ return error;
628
+ };
629
+ var cancelBestEffort = (cancel) => {
630
+ try {
631
+ void cancel()?.catch(() => {
632
+ });
633
+ } catch {
634
+ }
635
+ };
514
636
  var fetchProvider = async (input, init, errorCode, options) => {
637
+ const timeoutMs = getProviderRequestTimeoutMs(options);
515
638
  const controller = new AbortController();
516
- const timeout = setTimeout(() => controller.abort(), getProviderRequestTimeoutMs(options));
639
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
640
+ const done = () => clearTimeout(timeout);
517
641
  try {
518
- return await fetch(input, {
642
+ const response = await fetch(input, {
519
643
  ...init,
520
644
  redirect: "manual",
521
645
  signal: controller.signal
522
646
  });
647
+ return { response, signal: controller.signal, done };
523
648
  } catch (error) {
524
- if (error instanceof Error && error.name === "AbortError") {
525
- throw new OidcVaultHttpError(502, errorCode, "OIDC provider request timed out.");
649
+ done();
650
+ if (controller.signal.aborted || isAbortError(error)) {
651
+ throw createProviderTimeoutError(errorCode);
526
652
  }
527
- throw error;
528
- } finally {
529
- clearTimeout(timeout);
653
+ throw createProviderTransportError(errorCode, error);
530
654
  }
531
655
  };
656
+ var throwBoundedReadTimeout = (errorCode, timeoutAsTimeoutError) => {
657
+ if (timeoutAsTimeoutError) {
658
+ const timeoutError = new Error("JSON Web Key Set request timed out.");
659
+ timeoutError.name = "TimeoutError";
660
+ throw timeoutError;
661
+ }
662
+ throw createProviderTimeoutError(errorCode);
663
+ };
532
664
  var readBoundedResponseText = async (response, limit, errorCode, options = {}) => {
533
665
  if (!response.body) {
534
666
  return "";
535
667
  }
536
668
  const reader = response.body.getReader();
537
669
  const decoder = new TextDecoder();
670
+ const { signal } = options;
538
671
  let size = 0;
539
672
  let text = "";
673
+ let onAbort;
674
+ let abortPromise;
675
+ if (signal) {
676
+ if (signal.aborted) {
677
+ cancelBestEffort(() => reader.cancel());
678
+ reader.releaseLock();
679
+ throwBoundedReadTimeout(errorCode, options.timeoutAsTimeoutError === true);
680
+ }
681
+ abortPromise = new Promise((_resolve, reject) => {
682
+ onAbort = () => {
683
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
684
+ };
685
+ signal.addEventListener("abort", onAbort, { once: true });
686
+ });
687
+ abortPromise.catch(() => {
688
+ });
689
+ }
540
690
  try {
541
691
  for (; ; ) {
542
- const result = await reader.read();
692
+ let result;
693
+ try {
694
+ result = abortPromise === void 0 ? await reader.read() : await Promise.race([reader.read(), abortPromise]);
695
+ } catch (error) {
696
+ if (signal?.aborted === true || isAbortError(error)) {
697
+ throwBoundedReadTimeout(errorCode, options.timeoutAsTimeoutError === true);
698
+ }
699
+ throw createProviderTransportError(options.transportErrorCode ?? errorCode, error);
700
+ }
543
701
  if (result.done) {
544
702
  break;
545
703
  }
546
704
  size += result.value.byteLength;
547
705
  if (size > limit) {
548
- await reader.cancel();
549
- if (options.truncate) {
706
+ if (options.truncate === true) {
550
707
  return `${text.slice(0, limit)}...`;
551
708
  }
552
709
  throw new OidcVaultHttpError(502, errorCode, "OIDC provider response is too large.");
@@ -554,123 +711,219 @@ var readBoundedResponseText = async (response, limit, errorCode, options = {}) =
554
711
  text += decoder.decode(result.value, { stream: true });
555
712
  }
556
713
  } finally {
714
+ if (signal !== void 0 && onAbort !== void 0) {
715
+ signal.removeEventListener("abort", onAbort);
716
+ }
717
+ cancelBestEffort(() => reader.cancel());
557
718
  reader.releaseLock();
558
719
  }
559
720
  return `${text}${decoder.decode()}`;
560
721
  };
722
+ var createBoundedJwksFetch = (timeoutMs) => {
723
+ void timeoutMs;
724
+ return async (url, { headers, method, redirect, signal }) => {
725
+ let response;
726
+ try {
727
+ response = await fetch(url, { method, headers, redirect, signal });
728
+ } catch (error) {
729
+ if (signal.aborted || isAbortError(error)) {
730
+ const timeoutError = new Error("JSON Web Key Set request timed out.");
731
+ timeoutError.name = "TimeoutError";
732
+ throw timeoutError;
733
+ }
734
+ throw createProviderTransportError("OIDC_VAULT_JWKS_FAILED", error);
735
+ }
736
+ if (response.status !== 200) {
737
+ cancelBestEffort(() => response.body?.cancel());
738
+ return response;
739
+ }
740
+ const text = await readBoundedResponseText(response, DEFAULT_JWKS_BODY_LIMIT, "OIDC_VAULT_JWKS_FAILED", {
741
+ signal,
742
+ timeoutAsTimeoutError: true
743
+ });
744
+ try {
745
+ const parsed = JSON.parse(text);
746
+ if (Array.isArray(parsed.keys) && parsed.keys.length > DEFAULT_JWKS_MAX_KEYS) {
747
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_JWKS_FAILED", "JSON Web Key Set contains too many keys.");
748
+ }
749
+ } catch (error) {
750
+ if (error instanceof OidcVaultHttpError) {
751
+ throw error;
752
+ }
753
+ }
754
+ return new Response(text, {
755
+ status: 200,
756
+ headers: { "content-type": "application/json" }
757
+ });
758
+ };
759
+ };
561
760
  var resolveJwks = (jwksUri, options = {}) => {
562
- let jwks = jwksCache.get(jwksUri);
761
+ const timeoutMs = getProviderRequestTimeoutMs(options);
762
+ const cacheKey = `${jwksUri}\0${timeoutMs}`;
763
+ let jwks = jwksCache.get(cacheKey);
563
764
  if (!jwks) {
564
765
  jwks = (0, import_jose.createRemoteJWKSet)(new URL(jwksUri), {
565
- timeoutDuration: getProviderRequestTimeoutMs(options),
566
- cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS
766
+ timeoutDuration: timeoutMs,
767
+ cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS,
768
+ [import_jose.customFetch]: createBoundedJwksFetch(timeoutMs)
567
769
  });
568
- jwksCache.set(jwksUri, jwks);
770
+ jwksCache.set(cacheKey, jwks);
569
771
  evictOldestEntry(jwksCache, DEFAULT_JWKS_CACHE_CAPACITY);
570
772
  }
571
773
  return jwks;
572
774
  };
573
775
  var formEncodeClientCredential = (value) => new URLSearchParams([[value, ""]]).toString().slice(0, -1);
574
776
  var toBasicAuthorization = (clientId, clientSecret) => `Basic ${Buffer.from(`${formEncodeClientCredential(clientId)}:${formEncodeClientCredential(clientSecret)}`).toString("base64")}`;
575
- var readJsonResponse = async (response, errorCode, invalidJsonMessage = "OIDC provider returned invalid JSON.") => {
576
- const text = await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode);
777
+ var isProviderJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
778
+ var readJsonResponse = async (response, errorCode, invalidJsonMessage = "OIDC provider returned invalid JSON.", readOptions = {}) => {
779
+ const text = await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode, {
780
+ signal: readOptions.signal,
781
+ transportErrorCode: readOptions.transportErrorCode
782
+ });
577
783
  if (!text) {
578
784
  return {};
579
785
  }
786
+ let parsed;
580
787
  try {
581
- return JSON.parse(text);
788
+ parsed = JSON.parse(text);
582
789
  } catch {
583
- throw new OidcVaultHttpError(
584
- response.ok ? 502 : response.status,
585
- errorCode,
586
- invalidJsonMessage,
587
- invalidJsonMessage
588
- );
790
+ throw new OidcVaultHttpError(502, errorCode, invalidJsonMessage, invalidJsonMessage);
791
+ }
792
+ if (!isProviderJsonObject(parsed)) {
793
+ throw new OidcVaultHttpError(502, errorCode, invalidJsonMessage, invalidJsonMessage);
794
+ }
795
+ return parsed;
796
+ };
797
+ var drainProviderErrorBody = async (response, signal, errorCode) => {
798
+ try {
799
+ await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode, { signal });
800
+ } catch (error) {
801
+ if (error instanceof OidcVaultHttpError) {
802
+ throw error;
803
+ }
804
+ throw error;
589
805
  }
590
806
  };
591
807
  async function discoverIssuerMetadata(issuer, options) {
808
+ const callerTimeoutMs = getProviderRequestTimeoutMs(options);
809
+ const policyKey = `${issuer}\0${callerTimeoutMs}`;
592
810
  const now = Date.now();
593
- const cached = discoveryCache.get(issuer);
594
- let discoveryPromise = cached && cached.expiresAt > now ? cached.promise : void 0;
595
- if (cached && !discoveryPromise) {
596
- discoveryCache.delete(issuer);
597
- }
598
- if (!discoveryPromise) {
811
+ const policyCached = discoveryCache.get(policyKey);
812
+ let discoveryPromise = policyCached !== void 0 && policyCached.expiresAt > now ? policyCached.promise : void 0;
813
+ if (policyCached !== void 0 && discoveryPromise === void 0) {
814
+ discoveryCache.delete(policyKey);
815
+ }
816
+ if (discoveryPromise === void 0) {
817
+ const sharedCached = discoveryCache.get(issuer);
818
+ const sharedPromise = sharedCached !== void 0 && sharedCached.expiresAt > now ? sharedCached.promise : void 0;
819
+ if (sharedCached !== void 0 && sharedPromise === void 0) {
820
+ discoveryCache.delete(issuer);
821
+ }
822
+ if (sharedPromise !== void 0) {
823
+ return sharedPromise;
824
+ }
599
825
  discoveryPromise = (async () => {
600
826
  const discoveryUrl = buildWellKnownUrl(issuer);
601
- const response = await fetchProvider(discoveryUrl, void 0, "OIDC_VAULT_DISCOVERY_FAILED", options);
602
- if (!response.ok) {
603
- const errorText = await readBoundedResponseText(
827
+ const { response, signal, done } = await fetchProvider(
828
+ discoveryUrl,
829
+ void 0,
830
+ "OIDC_VAULT_DISCOVERY_FAILED",
831
+ options
832
+ );
833
+ try {
834
+ if (!response.ok) {
835
+ const errorText = await readBoundedResponseText(
836
+ response,
837
+ DEFAULT_PROVIDER_ERROR_BODY_LIMIT,
838
+ "OIDC_VAULT_DISCOVERY_FAILED",
839
+ {
840
+ truncate: true,
841
+ signal
842
+ }
843
+ );
844
+ throw new OidcVaultHttpError(
845
+ 502,
846
+ "OIDC_VAULT_DISCOVERY_FAILED",
847
+ errorText || `OIDC discovery failed with status ${response.status}.`,
848
+ "OIDC discovery failed."
849
+ );
850
+ }
851
+ const discovered = await readJsonResponse(
604
852
  response,
605
- DEFAULT_PROVIDER_ERROR_BODY_LIMIT,
606
- "OIDC_VAULT_DISCOVERY_FAILED",
607
- {
608
- truncate: true
609
- }
853
+ "OIDC_VAULT_DISCOVERY_INVALID",
854
+ "OIDC discovery response is malformed JSON.",
855
+ { signal, transportErrorCode: "OIDC_VAULT_DISCOVERY_FAILED" }
610
856
  );
611
- throw new OidcVaultHttpError(
612
- 502,
613
- "OIDC_VAULT_DISCOVERY_FAILED",
614
- errorText || `OIDC discovery failed with status ${response.status}.`,
615
- "OIDC discovery failed."
857
+ const authorizationEndpoint = validateDiscoveredHttpUrl(
858
+ getRequiredString(
859
+ discovered.authorization_endpoint,
860
+ "OIDC discovery response is missing authorization_endpoint.",
861
+ "OIDC_VAULT_DISCOVERY_INVALID",
862
+ 502
863
+ ),
864
+ "authorization_endpoint"
616
865
  );
617
- }
618
- const discovered = await readJsonResponse(
619
- response,
620
- "OIDC_VAULT_DISCOVERY_INVALID",
621
- "OIDC discovery response is malformed JSON."
622
- );
623
- const authorizationEndpoint = validateDiscoveredHttpUrl(
624
- getRequiredString(
625
- discovered.authorization_endpoint,
626
- "OIDC discovery response is missing authorization_endpoint.",
627
- "OIDC_VAULT_DISCOVERY_INVALID",
628
- 502
629
- ),
630
- "authorization_endpoint"
631
- );
632
- const tokenEndpoint = validateDiscoveredHttpUrl(
633
- getRequiredString(
634
- discovered.token_endpoint,
635
- "OIDC discovery response is missing token_endpoint.",
636
- "OIDC_VAULT_DISCOVERY_INVALID",
637
- 502
638
- ),
639
- "token_endpoint"
640
- );
641
- const jwksUri = validateDiscoveredHttpUrl(
642
- getRequiredString(
643
- discovered.jwks_uri,
644
- "OIDC discovery response is missing jwks_uri.",
866
+ const tokenEndpoint = validateDiscoveredHttpUrl(
867
+ getRequiredString(
868
+ discovered.token_endpoint,
869
+ "OIDC discovery response is missing token_endpoint.",
870
+ "OIDC_VAULT_DISCOVERY_INVALID",
871
+ 502
872
+ ),
873
+ "token_endpoint"
874
+ );
875
+ const jwksUri = validateDiscoveredHttpUrl(
876
+ getRequiredString(
877
+ discovered.jwks_uri,
878
+ "OIDC discovery response is missing jwks_uri.",
879
+ "OIDC_VAULT_DISCOVERY_INVALID",
880
+ 502
881
+ ),
882
+ "jwks_uri"
883
+ );
884
+ const discoveredIssuer = getRequiredString(
885
+ discovered.issuer,
886
+ "OIDC discovery response is missing issuer.",
645
887
  "OIDC_VAULT_DISCOVERY_INVALID",
646
888
  502
647
- ),
648
- "jwks_uri"
649
- );
650
- const discoveredIssuer = getRequiredString(
651
- discovered.issuer,
652
- "OIDC discovery response is missing issuer.",
653
- "OIDC_VAULT_DISCOVERY_INVALID",
654
- 502
655
- );
656
- assertDiscoveredIssuerMatchesConfig(issuer, discoveredIssuer);
657
- return {
658
- issuer: discoveredIssuer,
659
- authorizationEndpoint,
660
- tokenEndpoint,
661
- jwksUri,
662
- userInfoEndpoint: typeof discovered.userinfo_endpoint === "string" ? validateDiscoveredHttpUrl(discovered.userinfo_endpoint, "userinfo_endpoint") : void 0,
663
- endSessionEndpoint: typeof discovered.end_session_endpoint === "string" ? validateDiscoveredHttpUrl(discovered.end_session_endpoint, "end_session_endpoint") : void 0
664
- };
889
+ );
890
+ assertDiscoveredIssuerMatchesConfig(issuer, discoveredIssuer);
891
+ return {
892
+ issuer: discoveredIssuer,
893
+ authorizationEndpoint,
894
+ tokenEndpoint,
895
+ jwksUri,
896
+ userInfoEndpoint: validateOptionalDiscoveredHttpUrl(discovered.userinfo_endpoint, "userinfo_endpoint"),
897
+ endSessionEndpoint: validateOptionalDiscoveredHttpUrl(
898
+ discovered.end_session_endpoint,
899
+ "end_session_endpoint"
900
+ )
901
+ };
902
+ } finally {
903
+ done();
904
+ cancelBestEffort(() => response.body?.cancel());
905
+ }
665
906
  })();
666
- discoveryCache.set(issuer, {
907
+ discoveryPromise.then(
908
+ () => {
909
+ const fetchedAt = Date.now();
910
+ discoveryCache.set(issuer, {
911
+ expiresAt: fetchedAt + DEFAULT_DISCOVERY_CACHE_TTL_MS,
912
+ promise: discoveryPromise
913
+ });
914
+ evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
915
+ },
916
+ () => {
917
+ }
918
+ );
919
+ discoveryCache.set(policyKey, {
667
920
  expiresAt: now + DEFAULT_DISCOVERY_CACHE_TTL_MS,
668
921
  promise: discoveryPromise
669
922
  });
670
923
  evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
671
924
  discoveryPromise.catch(() => {
672
- if (discoveryCache.get(issuer)?.promise === discoveryPromise) {
673
- discoveryCache.delete(issuer);
925
+ if (discoveryCache.get(policyKey)?.promise === discoveryPromise) {
926
+ discoveryCache.delete(policyKey);
674
927
  }
675
928
  });
676
929
  }
@@ -721,7 +974,7 @@ async function requestToken(metadata, params, options = {}) {
721
974
  if (metadata.clientSecret) {
722
975
  headers.authorization = toBasicAuthorization(clientId, metadata.clientSecret);
723
976
  }
724
- const response = await fetchProvider(
977
+ const { response, signal, done } = await fetchProvider(
725
978
  metadata.tokenEndpoint,
726
979
  {
727
980
  method: "POST",
@@ -731,17 +984,33 @@ async function requestToken(metadata, params, options = {}) {
731
984
  "OIDC_VAULT_TOKEN_REQUEST_FAILED",
732
985
  options
733
986
  );
734
- const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
735
- if (!response.ok) {
987
+ try {
988
+ if (!response.ok) {
989
+ await drainProviderErrorBody(response, signal, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
990
+ throw new OidcVaultHttpError(
991
+ 502,
992
+ "OIDC_VAULT_TOKEN_REQUEST_FAILED",
993
+ "OIDC token request failed.",
994
+ "OIDC token request failed."
995
+ );
996
+ }
997
+ const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED", void 0, { signal });
998
+ return json;
999
+ } finally {
1000
+ done();
1001
+ cancelBestEffort(() => response.body?.cancel());
1002
+ }
1003
+ }
1004
+ var assertPresentTokenStringField = (tokenResponse, field) => {
1005
+ const value = tokenResponse[field];
1006
+ if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
736
1007
  throw new OidcVaultHttpError(
737
1008
  502,
738
- "OIDC_VAULT_TOKEN_REQUEST_FAILED",
739
- typeof json.error_description === "string" ? json.error_description : `OIDC token request failed with status ${response.status}.`,
740
- "OIDC token request failed."
1009
+ "OIDC_VAULT_INVALID_TOKEN_RESPONSE",
1010
+ `OIDC token response ${field} must be a non-empty string when present.`
741
1011
  );
742
1012
  }
743
- return json;
744
- }
1013
+ };
745
1014
  function validateTokenResponse(tokenResponse) {
746
1015
  if (typeof tokenResponse.token_type !== "string" || tokenResponse.token_type.toLowerCase() !== "bearer") {
747
1016
  throw new OidcVaultHttpError(
@@ -757,12 +1026,38 @@ function validateTokenResponse(tokenResponse) {
757
1026
  "OIDC_VAULT_INVALID_TOKEN_RESPONSE"
758
1027
  );
759
1028
  }
1029
+ assertPresentTokenStringField(tokenResponse, "access_token");
1030
+ assertPresentTokenStringField(tokenResponse, "id_token");
1031
+ assertPresentTokenStringField(tokenResponse, "refresh_token");
1032
+ if (tokenResponse.scope !== void 0 && typeof tokenResponse.scope !== "string") {
1033
+ throw new OidcVaultHttpError(
1034
+ 502,
1035
+ "OIDC_VAULT_INVALID_TOKEN_RESPONSE",
1036
+ "OIDC token response scope must be a string when present."
1037
+ );
1038
+ }
1039
+ }
1040
+ function validateCallbackTokenResponse(tokenResponse) {
1041
+ validateTokenResponse(tokenResponse);
1042
+ if (typeof tokenResponse.id_token !== "string" || tokenResponse.id_token.length === 0) {
1043
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
1044
+ }
1045
+ if (typeof tokenResponse.refresh_token !== "string" || tokenResponse.refresh_token.length === 0) {
1046
+ throw new OidcVaultHttpError(
1047
+ 502,
1048
+ "OIDC_VAULT_MISSING_REFRESH_TOKEN",
1049
+ "OIDC token response is missing refresh_token."
1050
+ );
1051
+ }
1052
+ }
1053
+ function validateRefreshTokenResponse(tokenResponse) {
1054
+ validateTokenResponse(tokenResponse);
760
1055
  }
761
1056
  async function fetchUserInfo(metadata, accessToken, options = {}) {
762
1057
  if (!metadata.userInfoEndpoint) {
763
1058
  return {};
764
1059
  }
765
- const response = await fetchProvider(
1060
+ const { response, signal, done } = await fetchProvider(
766
1061
  metadata.userInfoEndpoint,
767
1062
  {
768
1063
  headers: {
@@ -772,16 +1067,22 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
772
1067
  "OIDC_VAULT_USERINFO_FAILED",
773
1068
  options
774
1069
  );
775
- const json = await readJsonResponse(response, "OIDC_VAULT_USERINFO_FAILED");
776
- if (!response.ok) {
777
- throw new OidcVaultHttpError(
778
- 502,
779
- "OIDC_VAULT_USERINFO_FAILED",
780
- `OIDC userinfo request failed with status ${response.status}.`,
781
- "OIDC userinfo request failed."
782
- );
1070
+ try {
1071
+ if (!response.ok) {
1072
+ await drainProviderErrorBody(response, signal, "OIDC_VAULT_USERINFO_FAILED");
1073
+ throw new OidcVaultHttpError(
1074
+ 502,
1075
+ "OIDC_VAULT_USERINFO_FAILED",
1076
+ "OIDC userinfo request failed.",
1077
+ "OIDC userinfo request failed."
1078
+ );
1079
+ }
1080
+ const json = await readJsonResponse(response, "OIDC_VAULT_USERINFO_FAILED", void 0, { signal });
1081
+ return json;
1082
+ } finally {
1083
+ done();
1084
+ cancelBestEffort(() => response.body?.cancel());
783
1085
  }
784
- return json;
785
1086
  }
786
1087
 
787
1088
  // src/token-validation.ts
@@ -811,6 +1112,9 @@ var defaultJwtClaimsMapper = (claims) => {
811
1112
  };
812
1113
  };
813
1114
  var assertUserInfoSubject = (userInfo, subject) => {
1115
+ if (typeof userInfo !== "object" || userInfo === null || Array.isArray(userInfo)) {
1116
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is invalid.");
1117
+ }
814
1118
  if (!isString(userInfo.sub)) {
815
1119
  throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is missing sub.");
816
1120
  }
@@ -823,6 +1127,12 @@ var mergeUserProfile = (sub, idTokenClaims, userInfo) => ({
823
1127
  ...userInfo ?? {},
824
1128
  sub
825
1129
  });
1130
+ var composeRefreshedUserProfile = (sub, currentUser, freshIdClaims, freshUserInfo) => {
1131
+ if (freshIdClaims === void 0) {
1132
+ return mergeUserProfile(sub, { ...currentUser ?? { sub } }, freshUserInfo);
1133
+ }
1134
+ return mergeUserProfile(sub, { ...freshIdClaims }, freshUserInfo);
1135
+ };
826
1136
  async function verifyIdToken(metadata, idToken, nonce, options = {}) {
827
1137
  if (!metadata.jwksUri) {
828
1138
  throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_JWKS_URI", "OIDC jwksUri is required to validate id_token.");
@@ -963,10 +1273,17 @@ var extractBearerToken = (authorizationHeader) => {
963
1273
  var setBearerChallengeHeader = (res) => {
964
1274
  res.setHeader("WWW-Authenticate", "Bearer");
965
1275
  };
1276
+ var notifyBearerMiddlewareError = async (options, context) => {
1277
+ try {
1278
+ await options.onError?.(context);
1279
+ } catch {
1280
+ }
1281
+ };
966
1282
  function createOidcVaultAccessTokenMiddleware(options) {
967
1283
  return async (req, res, next) => {
1284
+ let token;
968
1285
  try {
969
- const token = extractBearerToken(req.get("authorization"));
1286
+ token = extractBearerToken(req.get("authorization"));
970
1287
  const validationResult = await options.validator.validate(token);
971
1288
  const auth = {
972
1289
  token,
@@ -974,9 +1291,38 @@ function createOidcVaultAccessTokenMiddleware(options) {
974
1291
  };
975
1292
  const authenticatedRequest = req;
976
1293
  authenticatedRequest.auth = auth;
977
- await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
1294
+ try {
1295
+ await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
1296
+ } catch (hookError) {
1297
+ delete authenticatedRequest.auth;
1298
+ await notifyBearerMiddlewareError(options, { error: hookError, req, res, token, auth });
1299
+ if (res.headersSent) {
1300
+ next(hookError);
1301
+ return;
1302
+ }
1303
+ if (hookError instanceof OidcVaultHttpError) {
1304
+ if (hookError.status === 401) {
1305
+ setBearerChallengeHeader(res);
1306
+ }
1307
+ res.status(hookError.status).json({
1308
+ code: hookError.code,
1309
+ message: hookError.clientMessage
1310
+ });
1311
+ return;
1312
+ }
1313
+ res.status(500).json({
1314
+ code: "OIDC_VAULT_AUTH_CONTEXT_FAILED",
1315
+ message: "Auth context hook failed."
1316
+ });
1317
+ return;
1318
+ }
978
1319
  next();
979
1320
  } catch (error) {
1321
+ await notifyBearerMiddlewareError(options, { error, req, res, ...token ? { token } : {} });
1322
+ if (res.headersSent) {
1323
+ next(error);
1324
+ return;
1325
+ }
980
1326
  if (error instanceof OidcVaultHttpError) {
981
1327
  setBearerChallengeHeader(res);
982
1328
  res.status(error.status).json({
@@ -1002,28 +1348,43 @@ function normalizeOidcVaultBasePath(value) {
1002
1348
  return `/${value.replace(/^\/+|\/+$/g, "")}`;
1003
1349
  }
1004
1350
  var getNow = (options) => (options.now ?? Date.now)();
1351
+ var CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store";
1352
+ var applyCredentialResponseCachePolicy = (res) => {
1353
+ res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL);
1354
+ };
1005
1355
  var createOpaqueId = (prefix) => `${prefix}_${(0, import_node_crypto.randomBytes)(16).toString("base64url")}`;
1006
1356
  var createPkceVerifier = () => (0, import_node_crypto.randomBytes)(32).toString("base64url");
1007
1357
  var createPkceChallenge = (verifier) => (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url");
1008
1358
  var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
1009
1359
  var validateOidcVaultOptions = (options) => {
1360
+ validateLifetimeOptions(options, getNow(options));
1010
1361
  const backendOrigin = resolveBackendOrigin(options);
1011
1362
  const frontendRedirectUri = resolveFrontendRedirectUri(options);
1012
1363
  validatePostLogoutRedirectUri(options);
1013
1364
  validateCookieOptions(options);
1014
- const config = resolveOidcVaultConfig(options.config);
1365
+ const config = resolveOidcVaultConfig(options.config ? { ...options.config } : void 0);
1015
1366
  const configuredTrustedOrigins = resolveTrustedOrigins(options);
1016
1367
  if (usesCrossSiteCookieTransport(options) && configuredTrustedOrigins.size === 0) {
1017
1368
  throw new Error("trustedOrigins is required when using cross-site cookie transport.");
1018
1369
  }
1019
1370
  const trustedOrigins = new Set(configuredTrustedOrigins);
1020
1371
  trustedOrigins.add(backendOrigin);
1021
- if (frontendRedirectUri) {
1022
- options.frontendRedirectUri = frontendRedirectUri;
1023
- }
1024
- return { backendOrigin, config, trustedOrigins };
1372
+ const resolvedOptions = {
1373
+ ...options,
1374
+ ...frontendRedirectUri !== void 0 ? { frontendRedirectUri } : {},
1375
+ ...options.trustedOrigins ? { trustedOrigins: [...options.trustedOrigins] } : {},
1376
+ ...options.cookie ? { cookie: { ...options.cookie } } : {},
1377
+ ...options.config ? { config: { ...options.config } } : {}
1378
+ };
1379
+ return { backendOrigin, config, trustedOrigins, resolvedOptions };
1025
1380
  };
1026
1381
  var isStoreConflictError = (error) => error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
1382
+ var assertSessionIdentity = (session, config) => {
1383
+ const provider = session.provider;
1384
+ if (provider?.issuer !== void 0 && provider.issuer !== config.issuer || provider?.clientId !== void 0 && provider.clientId !== config.clientId) {
1385
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1386
+ }
1387
+ };
1027
1388
  var getSessionIdFromRequest = (req, options, action) => {
1028
1389
  if (usesCookieTransport(options)) {
1029
1390
  const cookieSessionId = getSessionIdFromCookie(req, options);
@@ -1046,7 +1407,9 @@ var getSessionIdFromRequest = (req, options, action) => {
1046
1407
  var createExchangeResponse = (options, session, issuedToken) => ({
1047
1408
  sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
1048
1409
  user: session.user,
1049
- ...issuedToken
1410
+ accessToken: issuedToken.accessToken,
1411
+ expiresIn: issuedToken.expiresIn,
1412
+ tokenType: issuedToken.tokenType
1050
1413
  });
1051
1414
  var appendQueryParam = (url, name, value) => {
1052
1415
  if (value) {
@@ -1079,6 +1442,10 @@ var getLogoutTokenFromRequest = (req) => {
1079
1442
  "OIDC_VAULT_MISSING_LOGOUT_TOKEN"
1080
1443
  );
1081
1444
  };
1445
+ var buildBackchannelLogoutReplayKey = (input) => {
1446
+ const encode = (value) => Buffer.from(value, "utf8").toString("base64url");
1447
+ return `v2:${encode(input.issuer ?? "")}:${encode(input.clientId ?? "")}:${encode(input.jti)}`;
1448
+ };
1082
1449
  var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
1083
1450
  var resolveReturnTo = (req, options) => {
1084
1451
  const rawReturnTo = req.query.returnTo;
@@ -1118,7 +1485,21 @@ var withIssuedToken = async (req, res, options, session) => {
1118
1485
  if (!options.tokenIssuer) {
1119
1486
  return {};
1120
1487
  }
1121
- return options.tokenIssuer.issue({ req, res, session });
1488
+ const result = await options.tokenIssuer.issue({ req, res, session });
1489
+ if (typeof result !== "object" || result === null || Array.isArray(result)) {
1490
+ throw new TypeError("tokenIssuer.issue must return a token result object.");
1491
+ }
1492
+ const { accessToken, expiresIn, tokenType } = result;
1493
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
1494
+ throw new TypeError("tokenIssuer.issue accessToken must be a nonempty string.");
1495
+ }
1496
+ if (typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn < 0) {
1497
+ throw new TypeError("tokenIssuer.issue expiresIn must be a finite nonnegative safe integer.");
1498
+ }
1499
+ if (tokenType !== void 0 && tokenType !== "Bearer") {
1500
+ throw new TypeError("tokenIssuer.issue tokenType must be Bearer when provided.");
1501
+ }
1502
+ return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
1122
1503
  };
1123
1504
  var resolveFrontendRedirectUri2 = (transaction, options) => {
1124
1505
  const redirectUri = transaction.returnTo ?? options.frontendRedirectUri;
@@ -1166,6 +1547,7 @@ async function handleRouteError(route, req, res, next, options, error) {
1166
1547
  next(error);
1167
1548
  return;
1168
1549
  }
1550
+ applyCredentialResponseCachePolicy(res);
1169
1551
  const payload = toErrorPayload(error);
1170
1552
  res.status(payload.status).json({
1171
1553
  code: payload.code,
@@ -1187,6 +1569,7 @@ function createBodyParserErrorHandler() {
1187
1569
  next(error);
1188
1570
  return;
1189
1571
  }
1572
+ applyCredentialResponseCachePolicy(res);
1190
1573
  const payload = toBodyParserErrorPayload(error);
1191
1574
  res.status(payload.status).json({
1192
1575
  code: payload.code,
@@ -1206,7 +1589,11 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
1206
1589
  codeChallenge: createPkceChallenge(pkceVerifier),
1207
1590
  returnTo: resolveReturnTo(req, options),
1208
1591
  createdAt: now,
1209
- expiresAt: now + (options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS)
1592
+ expiresAt: computeExpiresAt(
1593
+ now,
1594
+ options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS,
1595
+ "authorizationTransactionTtlMs"
1596
+ )
1210
1597
  };
1211
1598
  await options.storeProvider.createAuthorizationTransaction(transaction);
1212
1599
  const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(backendOrigin, basePath));
@@ -1230,6 +1617,7 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1230
1617
  if (!transaction) {
1231
1618
  throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
1232
1619
  }
1620
+ const frontendDestination = resolveFrontendRedirectUri2(transaction, options);
1233
1621
  const metadata = await resolveProviderMetadata(config, options);
1234
1622
  const tokenResponse = await requestToken(
1235
1623
  metadata,
@@ -1241,17 +1629,7 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1241
1629
  },
1242
1630
  options
1243
1631
  );
1244
- validateTokenResponse(tokenResponse);
1245
- if (!isString(tokenResponse.id_token)) {
1246
- throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
1247
- }
1248
- if (!isString(tokenResponse.refresh_token)) {
1249
- throw new OidcVaultHttpError(
1250
- 502,
1251
- "OIDC_VAULT_MISSING_REFRESH_TOKEN",
1252
- "OIDC token response is missing refresh_token."
1253
- );
1254
- }
1632
+ validateCallbackTokenResponse(tokenResponse);
1255
1633
  await callHook("callback", options.hooks?.onCallbackTokens, req, res, void 0, {
1256
1634
  hasAccessToken: Boolean(tokenResponse.access_token),
1257
1635
  hasRefreshToken: true,
@@ -1260,13 +1638,19 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1260
1638
  const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce, options);
1261
1639
  const subject = getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502);
1262
1640
  const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1263
- if (userInfo) {
1641
+ if (userInfo !== void 0) {
1264
1642
  assertUserInfoSubject(userInfo, subject);
1265
1643
  }
1266
- if (userInfo) {
1644
+ if (userInfo !== void 0) {
1267
1645
  await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
1268
1646
  }
1269
1647
  const now = getNow(options);
1648
+ const sessionExpiresAt = options.sessionTtlMs === void 0 ? void 0 : computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
1649
+ const exchangeExpiresAt = computeExpiresAt(
1650
+ now,
1651
+ options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS,
1652
+ "exchangeCodeTtlMs"
1653
+ );
1270
1654
  const sessionId = createOpaqueId("sess");
1271
1655
  const session = {
1272
1656
  sessionId,
@@ -1283,12 +1667,16 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1283
1667
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
1284
1668
  createdAt: now,
1285
1669
  updatedAt: now,
1670
+ ...sessionExpiresAt === void 0 ? {} : { expiresAt: sessionExpiresAt },
1286
1671
  user: mergeUserProfile(subject, claims, userInfo),
1287
1672
  metadata: {
1288
1673
  tokenType: tokenResponse.token_type
1289
1674
  }
1290
1675
  };
1291
1676
  await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
1677
+ if (sessionExpiresAt !== void 0) {
1678
+ session.expiresAt = isUsableEpochMs(session.expiresAt) ? Math.min(session.expiresAt, sessionExpiresAt) : sessionExpiresAt;
1679
+ }
1292
1680
  const createdSession = await options.storeProvider.createSession(session);
1293
1681
  const exchangeCode = createOpaqueId("code");
1294
1682
  try {
@@ -1297,7 +1685,7 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1297
1685
  sessionId: createdSession.sessionId,
1298
1686
  returnTo: transaction.returnTo,
1299
1687
  createdAt: now,
1300
- expiresAt: now + (options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS)
1688
+ expiresAt: exchangeExpiresAt
1301
1689
  });
1302
1690
  } catch (error) {
1303
1691
  await options.storeProvider.deleteSessionsByLogicalSessionId({
@@ -1308,9 +1696,9 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1308
1696
  await callPostCommitHook("callback", options, options.hooks?.onSessionCreated, req, res, createdSession, {
1309
1697
  subject
1310
1698
  });
1311
- res.redirect(302, appendCodeToRedirectUri(resolveFrontendRedirectUri2(transaction, options), exchangeCode));
1699
+ res.redirect(302, appendCodeToRedirectUri(frontendDestination, exchangeCode));
1312
1700
  });
1313
- var createExchangeHandler = (options) => createAsyncHandler("exchange", options, async (req, res) => {
1701
+ var createExchangeHandler = (options, config) => createAsyncHandler("exchange", options, async (req, res) => {
1314
1702
  const body = getBody(req);
1315
1703
  const code = getRequiredString(body.code, "Exchange request is missing code.", "OIDC_VAULT_MISSING_EXCHANGE_CODE");
1316
1704
  const exchangeRecord = await options.storeProvider.consumeExchangeCode(code);
@@ -1321,6 +1709,7 @@ var createExchangeHandler = (options) => createAsyncHandler("exchange", options,
1321
1709
  if (!session) {
1322
1710
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1323
1711
  }
1712
+ assertSessionIdentity(session, config);
1324
1713
  let issuedToken;
1325
1714
  try {
1326
1715
  issuedToken = await withIssuedToken(req, res, options, session);
@@ -1349,6 +1738,7 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1349
1738
  }
1350
1739
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1351
1740
  }
1741
+ assertSessionIdentity(currentSession, config);
1352
1742
  const metadata = await resolveProviderMetadata(config, options);
1353
1743
  const tokenResponse = await requestToken(
1354
1744
  metadata,
@@ -1358,16 +1748,16 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1358
1748
  },
1359
1749
  options
1360
1750
  );
1361
- validateTokenResponse(tokenResponse);
1751
+ validateRefreshTokenResponse(tokenResponse);
1362
1752
  const newIdToken = isString(tokenResponse.id_token) ? tokenResponse.id_token : void 0;
1363
1753
  const idToken = newIdToken ?? currentSession.idToken;
1364
- const claims = newIdToken ? await verifyIdToken(metadata, newIdToken, void 0, options) : currentSession.user ?? { sub: currentSession.subject };
1365
- const subject = newIdToken ? getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502) : currentSession.subject;
1754
+ const freshClaims = newIdToken ? await verifyIdToken(metadata, newIdToken, void 0, options) : void 0;
1755
+ const subject = freshClaims ? getRequiredString(freshClaims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502) : currentSession.subject;
1366
1756
  if (newIdToken && subject !== currentSession.subject) {
1367
1757
  throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC refreshed id_token sub changed.");
1368
1758
  }
1369
1759
  const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1370
- if (userInfo) {
1760
+ if (userInfo !== void 0) {
1371
1761
  assertUserInfoSubject(userInfo, subject);
1372
1762
  }
1373
1763
  const now = getNow(options);
@@ -1377,14 +1767,14 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1377
1767
  sessionId: createOpaqueId("sess"),
1378
1768
  logicalSessionId,
1379
1769
  subject,
1380
- providerSessionId: typeof claims.sid === "string" ? claims.sid : currentSession.providerSessionId,
1770
+ providerSessionId: freshClaims && typeof freshClaims.sid === "string" ? freshClaims.sid : currentSession.providerSessionId,
1381
1771
  refreshToken: isString(tokenResponse.refresh_token) ? tokenResponse.refresh_token : currentSession.refreshToken,
1382
1772
  idToken,
1383
1773
  accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : currentSession.accessToken,
1384
1774
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : currentSession.scope,
1385
1775
  expiresAt: currentSession.expiresAt,
1386
1776
  updatedAt: now,
1387
- user: mergeUserProfile(subject, claims, userInfo ?? currentSession.user)
1777
+ user: composeRefreshedUserProfile(subject, currentSession.user, freshClaims, userInfo)
1388
1778
  };
1389
1779
  let rotatedSession;
1390
1780
  try {
@@ -1436,6 +1826,7 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
1436
1826
  res.status(200).json({ loggedOut: true });
1437
1827
  return;
1438
1828
  }
1829
+ assertSessionIdentity(session, config);
1439
1830
  await callHook("logout", options.hooks?.onBeforeLogout, req, res, session, void 0);
1440
1831
  await options.storeProvider.deleteSessionsByLogicalSessionId({
1441
1832
  logicalSessionId: session.logicalSessionId ?? session.sessionId
@@ -1443,8 +1834,24 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
1443
1834
  if (usesCookieTransport(options)) {
1444
1835
  clearSessionCookie(res, options);
1445
1836
  }
1446
- const metadata = await resolveProviderMetadata(config, options);
1447
- const upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
1837
+ if (!redirect) {
1838
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
1839
+ res.status(200).json({ loggedOut: true });
1840
+ return;
1841
+ }
1842
+ let upstreamLogoutUrl;
1843
+ try {
1844
+ const metadata = await resolveProviderMetadata(config, options);
1845
+ upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
1846
+ } catch (error) {
1847
+ try {
1848
+ await options.hooks?.onError?.({
1849
+ ...createHookContext("logout", req, res, session),
1850
+ error
1851
+ });
1852
+ } catch {
1853
+ }
1854
+ }
1448
1855
  await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
1449
1856
  if (redirect && upstreamLogoutUrl) {
1450
1857
  res.redirect(302, upstreamLogoutUrl);
@@ -1458,26 +1865,40 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
1458
1865
  const metadata = await resolveProviderMetadata(config, options);
1459
1866
  const logoutToken = getLogoutTokenFromRequest(req);
1460
1867
  const claims = await verifyBackchannelLogoutToken(metadata, logoutToken, options);
1868
+ const replayKey = buildBackchannelLogoutReplayKey({
1869
+ issuer: metadata.issuer,
1870
+ clientId: metadata.clientId,
1871
+ jti: claims.jti
1872
+ });
1461
1873
  const firstUse = await options.storeProvider.consumeBackchannelLogoutTokenJti({
1462
- jti: claims.jti,
1874
+ jti: replayKey,
1463
1875
  expiresAt: claims.exp * 1e3
1464
1876
  });
1465
- if (!firstUse) {
1466
- res.status(200).json({
1467
- loggedOut: true,
1468
- revokedSessions: 0
1469
- });
1470
- return;
1471
- }
1472
- const revokedSessions = isString(claims.sid) ? await options.storeProvider.deleteSessionsByProviderSessionId({
1877
+ const revokeMatchingSessions = () => isString(claims.sid) ? options.storeProvider.deleteSessionsByProviderSessionId({
1473
1878
  providerSessionId: claims.sid,
1474
1879
  issuer: metadata.issuer,
1475
1880
  clientId: metadata.clientId
1476
- }) : await options.storeProvider.deleteSessionsBySubject({
1881
+ }) : options.storeProvider.deleteSessionsBySubject({
1477
1882
  subject: String(claims.sub),
1478
1883
  issuer: metadata.issuer,
1479
1884
  clientId: metadata.clientId
1480
1885
  });
1886
+ if (!firstUse) {
1887
+ const catchUpRevokedSessions = await revokeMatchingSessions();
1888
+ if (catchUpRevokedSessions > 0) {
1889
+ await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
1890
+ providerSessionId: claims.sid,
1891
+ subject: claims.sub,
1892
+ revokedSessions: catchUpRevokedSessions
1893
+ });
1894
+ }
1895
+ res.status(200).json({
1896
+ loggedOut: true,
1897
+ revokedSessions: catchUpRevokedSessions
1898
+ });
1899
+ return;
1900
+ }
1901
+ const revokedSessions = await revokeMatchingSessions();
1481
1902
  await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
1482
1903
  providerSessionId: claims.sid,
1483
1904
  subject: claims.sub,
@@ -1491,17 +1912,21 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
1491
1912
  function registerRoutes(router, options, config, trustedOrigins, backendOrigin, basePath) {
1492
1913
  router.get(OIDC_VAULT_ROUTE_PATHS.login, createLoginHandler(options, config, backendOrigin, basePath));
1493
1914
  router.get(OIDC_VAULT_ROUTE_PATHS.callback, createCallbackHandler(options, config, backendOrigin, basePath));
1494
- router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options));
1915
+ router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config));
1495
1916
  router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins));
1496
1917
  router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins));
1497
1918
  router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
1498
1919
  }
1499
1920
  function createOidcVaultMiddleware(options) {
1500
- const { backendOrigin, config, trustedOrigins } = validateOidcVaultOptions(options);
1921
+ const { backendOrigin, config, trustedOrigins, resolvedOptions } = validateOidcVaultOptions(options);
1501
1922
  const rootRouter = import_express.default.Router();
1502
1923
  const baseRouter = import_express.default.Router();
1503
- const basePath = normalizeOidcVaultBasePath(options.basePath);
1504
- const requestBodyLimit = options.requestBodyLimit ?? DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT;
1924
+ const basePath = normalizeOidcVaultBasePath(resolvedOptions.basePath);
1925
+ const requestBodyLimit = resolvedOptions.requestBodyLimit ?? DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT;
1926
+ baseRouter.use((_req, res, next) => {
1927
+ applyCredentialResponseCachePolicy(res);
1928
+ next();
1929
+ });
1505
1930
  baseRouter.use(import_express.default.json({ limit: requestBodyLimit }));
1506
1931
  baseRouter.use(
1507
1932
  import_express.default.urlencoded({
@@ -1511,7 +1936,7 @@ function createOidcVaultMiddleware(options) {
1511
1936
  })
1512
1937
  );
1513
1938
  baseRouter.use(createBodyParserErrorHandler());
1514
- registerRoutes(baseRouter, options, config, trustedOrigins, backendOrigin, basePath);
1939
+ registerRoutes(baseRouter, resolvedOptions, config, trustedOrigins, backendOrigin, basePath);
1515
1940
  rootRouter.use(basePath, baseRouter);
1516
1941
  return rootRouter;
1517
1942
  }