@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.mjs CHANGED
@@ -5,6 +5,7 @@ import express from "express";
5
5
  // src/config.ts
6
6
  var DEFAULT_OIDC_SCOPES = "openid email profile";
7
7
  var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
8
+ var MANUAL_CONFIG_SELECTORS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri", "userInfoEndpoint", "endSessionEndpoint"];
8
9
  var normalizeOptionalString = (value) => {
9
10
  const trimmed = value?.trim();
10
11
  return trimmed ? trimmed : void 0;
@@ -24,19 +25,38 @@ var normalizeHttpUrl = (value, optionName) => {
24
25
  }
25
26
  return url.toString();
26
27
  };
28
+ var normalizeIssuerIdentifier = (value, optionName) => {
29
+ if (value === void 0) {
30
+ return void 0;
31
+ }
32
+ let url;
33
+ try {
34
+ url = new URL(value);
35
+ } catch {
36
+ throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
37
+ }
38
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
39
+ throw new Error(`${optionName} must use http or https.`);
40
+ }
41
+ if (url.username !== "" || url.password !== "") {
42
+ throw new Error(`${optionName} must not include userinfo.`);
43
+ }
44
+ if (url.search !== "" || url.hash !== "") {
45
+ throw new Error(`${optionName} must not include query or fragment.`);
46
+ }
47
+ return value;
48
+ };
27
49
  var withOptionalValue = (target, key, value) => {
28
50
  if (value !== void 0) {
29
51
  target[key] = value;
30
52
  }
31
53
  };
32
54
  function resolveOidcVaultConfig(config = {}) {
33
- const issuer = normalizeHttpUrl(normalizeOptionalString(config.issuer), "config.issuer");
55
+ const issuer = normalizeIssuerIdentifier(normalizeOptionalString(config.issuer), "config.issuer");
34
56
  const clientId = normalizeOptionalString(config.clientId);
35
57
  const clientSecret = normalizeOptionalString(config.clientSecret);
36
58
  const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
37
- const hasManualEndpoints = MANUAL_CONFIG_REQUIREMENTS.some(
38
- (key) => key !== "issuer" && normalizeOptionalString(config[key])
39
- );
59
+ const hasManualEndpoints = MANUAL_CONFIG_SELECTORS.some((key) => normalizeOptionalString(config[key]));
40
60
  if (!clientId) {
41
61
  throw new Error("OIDC clientId is required.");
42
62
  }
@@ -196,37 +216,11 @@ var getRequiredFiniteNonNegativeInteger = (value, message, code, status = 502) =
196
216
  // src/cookies.ts
197
217
  var getSessionTransport = (options) => options.sessionTransport ?? "body";
198
218
  var usesCookieTransport = (options) => getSessionTransport(options) === "cookie";
199
- var parseCookieHeader = (headerValue) => {
200
- if (!headerValue) {
201
- return {};
202
- }
203
- return headerValue.split(";").reduce((cookies, part) => {
204
- const separatorIndex = part.indexOf("=");
205
- if (separatorIndex === -1) {
206
- return cookies;
207
- }
208
- const name = part.slice(0, separatorIndex).trim();
209
- const value = part.slice(separatorIndex + 1).trim();
210
- if (!name) {
211
- return cookies;
212
- }
213
- try {
214
- cookies[name] = decodeURIComponent(value);
215
- } catch {
216
- throw new OidcVaultHttpError(
217
- 400,
218
- "OIDC_VAULT_MALFORMED_SESSION_COOKIE",
219
- "OIDC vault session cookie is malformed."
220
- );
221
- }
222
- return cookies;
223
- }, {});
224
- };
225
219
  var resolveCookieOptions = (options) => {
226
220
  const cookieOptions = options.cookie ?? {};
227
221
  const deploymentMode = cookieOptions.deploymentMode ?? "same-origin";
228
222
  const sameSite = cookieOptions.sameSite ?? (deploymentMode === "cross-site" ? "none" : "lax");
229
- const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site");
223
+ const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site" || isHttpsBackendOrigin(options));
230
224
  return {
231
225
  name: cookieOptions.name ?? DEFAULT_SESSION_COOKIE_NAME,
232
226
  sameSite,
@@ -236,12 +230,24 @@ var resolveCookieOptions = (options) => {
236
230
  httpOnly: cookieOptions.httpOnly ?? true
237
231
  };
238
232
  };
233
+ var isEffectivelySecureCookie = (cookieOptions) => cookieOptions.secure || cookieOptions.sameSite === "none";
234
+ var isHttpsBackendOrigin = (options) => {
235
+ try {
236
+ const raw = options.backendOrigin;
237
+ if (typeof raw !== "string") {
238
+ return false;
239
+ }
240
+ return new URL(raw.trim()).protocol === "https:";
241
+ } catch {
242
+ return false;
243
+ }
244
+ };
239
245
  var COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
240
246
  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])?)*$/;
241
247
  var hasUnsafeCookieValueCharacters = (value) => {
242
248
  for (let index = 0; index < value.length; index += 1) {
243
249
  const code = value.charCodeAt(index);
244
- if (code <= 31 || code === 127 || value[index] === ";") {
250
+ if (code < 32 || code > 126 || value[index] === ";") {
245
251
  return true;
246
252
  }
247
253
  }
@@ -264,6 +270,22 @@ var validateCookieOptions = (options) => {
264
270
  if (!cookieOptions.httpOnly) {
265
271
  throw new Error("cookie.httpOnly must be true for cookie session transport.");
266
272
  }
273
+ const effectiveSecure = isEffectivelySecureCookie(cookieOptions);
274
+ if (cookieOptions.name.startsWith("__Host-")) {
275
+ if (!effectiveSecure) {
276
+ throw new Error("cookie.name with the __Host- prefix must be Secure.");
277
+ }
278
+ if (cookieOptions.domain !== void 0) {
279
+ throw new Error("cookie.name with the __Host- prefix must not set cookie.domain.");
280
+ }
281
+ if (cookieOptions.path !== "/") {
282
+ throw new Error('cookie.name with the __Host- prefix must use cookie.path "/".');
283
+ }
284
+ } else if (cookieOptions.name.startsWith("__Secure-")) {
285
+ if (!effectiveSecure) {
286
+ throw new Error("cookie.name with the __Secure- prefix must be Secure.");
287
+ }
288
+ }
267
289
  };
268
290
  var usesCrossSiteCookieTransport = (options) => usesCookieTransport(options) && resolveCookieOptions(options).sameSite === "none";
269
291
  var serializeCookie = (name, value, options, overrides) => {
@@ -301,8 +323,62 @@ var clearSessionCookie = (res, options) => {
301
323
  );
302
324
  };
303
325
  var getSessionIdFromCookie = (req, options) => {
304
- const cookies = parseCookieHeader(req.headers.cookie);
305
- return cookies[resolveCookieOptions(options).name];
326
+ const raw = parseSelectedCookieValue(req.headers.cookie, resolveCookieOptions(options).name);
327
+ if (raw === void 0) {
328
+ return void 0;
329
+ }
330
+ try {
331
+ return decodeURIComponent(raw);
332
+ } catch {
333
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_MALFORMED_SESSION_COOKIE", "OIDC vault session cookie is malformed.");
334
+ }
335
+ };
336
+ var parseSelectedCookieValue = (headerValue, selectedName) => {
337
+ if (!headerValue) {
338
+ return void 0;
339
+ }
340
+ const segments = headerValue.split(";");
341
+ for (const segment of segments) {
342
+ const separatorIndex = segment.indexOf("=");
343
+ if (separatorIndex === -1) {
344
+ continue;
345
+ }
346
+ const name = segment.slice(0, separatorIndex).trim();
347
+ if (!name || name !== selectedName) {
348
+ continue;
349
+ }
350
+ return segment.slice(separatorIndex + 1).trim();
351
+ }
352
+ return void 0;
353
+ };
354
+
355
+ // src/lifetime-policy.ts
356
+ var MAX_EPOCH_MS = 864e13;
357
+ var isUsableEpochMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= MAX_EPOCH_MS;
358
+ var computeExpiresAt = (now, ttlMs, name) => {
359
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
360
+ throw new Error(`${name} must be a positive safe integer in milliseconds.`);
361
+ }
362
+ const expiresAt = now + ttlMs;
363
+ if (!isUsableEpochMs(now) || !isUsableEpochMs(expiresAt) || expiresAt <= now) {
364
+ throw new Error(`${name} must produce a usable epoch-millisecond expiry after now.`);
365
+ }
366
+ return expiresAt;
367
+ };
368
+ var validateLifetimeOptions = (options, now) => {
369
+ computeExpiresAt(
370
+ now,
371
+ options.authorizationTransactionTtlMs === void 0 ? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS : options.authorizationTransactionTtlMs,
372
+ "authorizationTransactionTtlMs"
373
+ );
374
+ computeExpiresAt(
375
+ now,
376
+ options.exchangeCodeTtlMs === void 0 ? DEFAULT_EXCHANGE_CODE_TTL_MS : options.exchangeCodeTtlMs,
377
+ "exchangeCodeTtlMs"
378
+ );
379
+ if (options.sessionTtlMs !== void 0) {
380
+ computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
381
+ }
306
382
  };
307
383
 
308
384
  // src/utils.ts
@@ -401,18 +477,36 @@ var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
401
477
  };
402
478
 
403
479
  // src/provider-client.ts
404
- import { createRemoteJWKSet } from "jose";
480
+ import { createRemoteJWKSet, customFetch } from "jose";
405
481
  var DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 5e3;
406
482
  var DEFAULT_DISCOVERY_CACHE_TTL_MS = 10 * 6e4;
407
483
  var DEFAULT_DISCOVERY_CACHE_CAPACITY = 32;
408
484
  var DEFAULT_JWKS_CACHE_CAPACITY = 32;
409
485
  var DEFAULT_PROVIDER_ERROR_BODY_LIMIT = 1024;
410
486
  var DEFAULT_PROVIDER_JSON_BODY_LIMIT = 1024 * 1024;
487
+ var DEFAULT_JWKS_BODY_LIMIT = 1024 * 1024;
488
+ var DEFAULT_JWKS_MAX_KEYS = 100;
411
489
  var discoveryCache = /* @__PURE__ */ new Map();
412
490
  var jwksCache = /* @__PURE__ */ new Map();
413
- var normalizeIssuerForDiscoveryComparison = (issuer) => issuer.replace(/\/+$/g, "");
414
491
  var assertDiscoveredIssuerMatchesConfig = (configuredIssuer, discoveredIssuer) => {
415
- if (normalizeIssuerForDiscoveryComparison(configuredIssuer) !== normalizeIssuerForDiscoveryComparison(discoveredIssuer)) {
492
+ let discoveredUrl;
493
+ try {
494
+ discoveredUrl = new URL(discoveredIssuer);
495
+ } catch {
496
+ throw new OidcVaultHttpError(
497
+ 502,
498
+ "OIDC_VAULT_DISCOVERY_INVALID",
499
+ "OIDC discovery response issuer must be an absolute HTTP(S) URL."
500
+ );
501
+ }
502
+ if (discoveredUrl.protocol !== "https:" && discoveredUrl.protocol !== "http:" || discoveredUrl.origin === "null" || discoveredUrl.username !== "" || discoveredUrl.password !== "" || discoveredUrl.search !== "" || discoveredUrl.hash !== "") {
503
+ throw new OidcVaultHttpError(
504
+ 502,
505
+ "OIDC_VAULT_DISCOVERY_INVALID",
506
+ "OIDC discovery response issuer must be an http(s) URL without userinfo, query, or fragment."
507
+ );
508
+ }
509
+ if (configuredIssuer !== discoveredIssuer) {
416
510
  throw new OidcVaultHttpError(
417
511
  502,
418
512
  "OIDC_VAULT_DISCOVERY_INVALID",
@@ -440,6 +534,20 @@ var validateDiscoveredHttpUrl = (value, fieldName) => {
440
534
  }
441
535
  return url.toString();
442
536
  };
537
+ var validateOptionalDiscoveredHttpUrl = (value, fieldName) => {
538
+ if (value === void 0) {
539
+ return void 0;
540
+ }
541
+ return validateDiscoveredHttpUrl(
542
+ getRequiredString(
543
+ value,
544
+ `OIDC discovery response ${fieldName} must be a non-empty string when present.`,
545
+ "OIDC_VAULT_DISCOVERY_INVALID",
546
+ 502
547
+ ),
548
+ fieldName
549
+ );
550
+ };
443
551
  var buildWellKnownUrl = (issuer) => {
444
552
  const normalizedIssuer = issuer.endsWith("/") ? issuer : `${issuer}/`;
445
553
  return new URL(".well-known/openid-configuration", normalizedIssuer);
@@ -464,42 +572,91 @@ var evictOldestEntry = (cache, capacity) => {
464
572
  cache.delete(oldestKey);
465
573
  }
466
574
  };
575
+ var isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
576
+ var createProviderTimeoutError = (errorCode) => new OidcVaultHttpError(502, errorCode, "OIDC provider request timed out.");
577
+ var createProviderTransportError = (errorCode, cause) => {
578
+ const error = new OidcVaultHttpError(502, errorCode, "OIDC provider request failed.");
579
+ Object.defineProperty(error, "cause", { value: cause, configurable: true, writable: true });
580
+ return error;
581
+ };
582
+ var cancelBestEffort = (cancel) => {
583
+ try {
584
+ void cancel()?.catch(() => {
585
+ });
586
+ } catch {
587
+ }
588
+ };
467
589
  var fetchProvider = async (input, init, errorCode, options) => {
590
+ const timeoutMs = getProviderRequestTimeoutMs(options);
468
591
  const controller = new AbortController();
469
- const timeout = setTimeout(() => controller.abort(), getProviderRequestTimeoutMs(options));
592
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
593
+ const done = () => clearTimeout(timeout);
470
594
  try {
471
- return await fetch(input, {
595
+ const response = await fetch(input, {
472
596
  ...init,
473
597
  redirect: "manual",
474
598
  signal: controller.signal
475
599
  });
600
+ return { response, signal: controller.signal, done };
476
601
  } catch (error) {
477
- if (error instanceof Error && error.name === "AbortError") {
478
- throw new OidcVaultHttpError(502, errorCode, "OIDC provider request timed out.");
602
+ done();
603
+ if (controller.signal.aborted || isAbortError(error)) {
604
+ throw createProviderTimeoutError(errorCode);
479
605
  }
480
- throw error;
481
- } finally {
482
- clearTimeout(timeout);
606
+ throw createProviderTransportError(errorCode, error);
483
607
  }
484
608
  };
609
+ var throwBoundedReadTimeout = (errorCode, timeoutAsTimeoutError) => {
610
+ if (timeoutAsTimeoutError) {
611
+ const timeoutError = new Error("JSON Web Key Set request timed out.");
612
+ timeoutError.name = "TimeoutError";
613
+ throw timeoutError;
614
+ }
615
+ throw createProviderTimeoutError(errorCode);
616
+ };
485
617
  var readBoundedResponseText = async (response, limit, errorCode, options = {}) => {
486
618
  if (!response.body) {
487
619
  return "";
488
620
  }
489
621
  const reader = response.body.getReader();
490
622
  const decoder = new TextDecoder();
623
+ const { signal } = options;
491
624
  let size = 0;
492
625
  let text = "";
626
+ let onAbort;
627
+ let abortPromise;
628
+ if (signal) {
629
+ if (signal.aborted) {
630
+ cancelBestEffort(() => reader.cancel());
631
+ reader.releaseLock();
632
+ throwBoundedReadTimeout(errorCode, options.timeoutAsTimeoutError === true);
633
+ }
634
+ abortPromise = new Promise((_resolve, reject) => {
635
+ onAbort = () => {
636
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
637
+ };
638
+ signal.addEventListener("abort", onAbort, { once: true });
639
+ });
640
+ abortPromise.catch(() => {
641
+ });
642
+ }
493
643
  try {
494
644
  for (; ; ) {
495
- const result = await reader.read();
645
+ let result;
646
+ try {
647
+ result = abortPromise === void 0 ? await reader.read() : await Promise.race([reader.read(), abortPromise]);
648
+ } catch (error) {
649
+ if (signal?.aborted === true || isAbortError(error)) {
650
+ throwBoundedReadTimeout(errorCode, options.timeoutAsTimeoutError === true);
651
+ }
652
+ throw createProviderTransportError(options.transportErrorCode ?? errorCode, error);
653
+ }
496
654
  if (result.done) {
497
655
  break;
498
656
  }
499
657
  size += result.value.byteLength;
500
658
  if (size > limit) {
501
- await reader.cancel();
502
- if (options.truncate) {
659
+ if (options.truncate === true) {
503
660
  return `${text.slice(0, limit)}...`;
504
661
  }
505
662
  throw new OidcVaultHttpError(502, errorCode, "OIDC provider response is too large.");
@@ -507,123 +664,219 @@ var readBoundedResponseText = async (response, limit, errorCode, options = {}) =
507
664
  text += decoder.decode(result.value, { stream: true });
508
665
  }
509
666
  } finally {
667
+ if (signal !== void 0 && onAbort !== void 0) {
668
+ signal.removeEventListener("abort", onAbort);
669
+ }
670
+ cancelBestEffort(() => reader.cancel());
510
671
  reader.releaseLock();
511
672
  }
512
673
  return `${text}${decoder.decode()}`;
513
674
  };
675
+ var createBoundedJwksFetch = (timeoutMs) => {
676
+ void timeoutMs;
677
+ return async (url, { headers, method, redirect, signal }) => {
678
+ let response;
679
+ try {
680
+ response = await fetch(url, { method, headers, redirect, signal });
681
+ } catch (error) {
682
+ if (signal.aborted || isAbortError(error)) {
683
+ const timeoutError = new Error("JSON Web Key Set request timed out.");
684
+ timeoutError.name = "TimeoutError";
685
+ throw timeoutError;
686
+ }
687
+ throw createProviderTransportError("OIDC_VAULT_JWKS_FAILED", error);
688
+ }
689
+ if (response.status !== 200) {
690
+ cancelBestEffort(() => response.body?.cancel());
691
+ return response;
692
+ }
693
+ const text = await readBoundedResponseText(response, DEFAULT_JWKS_BODY_LIMIT, "OIDC_VAULT_JWKS_FAILED", {
694
+ signal,
695
+ timeoutAsTimeoutError: true
696
+ });
697
+ try {
698
+ const parsed = JSON.parse(text);
699
+ if (Array.isArray(parsed.keys) && parsed.keys.length > DEFAULT_JWKS_MAX_KEYS) {
700
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_JWKS_FAILED", "JSON Web Key Set contains too many keys.");
701
+ }
702
+ } catch (error) {
703
+ if (error instanceof OidcVaultHttpError) {
704
+ throw error;
705
+ }
706
+ }
707
+ return new Response(text, {
708
+ status: 200,
709
+ headers: { "content-type": "application/json" }
710
+ });
711
+ };
712
+ };
514
713
  var resolveJwks = (jwksUri, options = {}) => {
515
- let jwks = jwksCache.get(jwksUri);
714
+ const timeoutMs = getProviderRequestTimeoutMs(options);
715
+ const cacheKey = `${jwksUri}\0${timeoutMs}`;
716
+ let jwks = jwksCache.get(cacheKey);
516
717
  if (!jwks) {
517
718
  jwks = createRemoteJWKSet(new URL(jwksUri), {
518
- timeoutDuration: getProviderRequestTimeoutMs(options),
519
- cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS
719
+ timeoutDuration: timeoutMs,
720
+ cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS,
721
+ [customFetch]: createBoundedJwksFetch(timeoutMs)
520
722
  });
521
- jwksCache.set(jwksUri, jwks);
723
+ jwksCache.set(cacheKey, jwks);
522
724
  evictOldestEntry(jwksCache, DEFAULT_JWKS_CACHE_CAPACITY);
523
725
  }
524
726
  return jwks;
525
727
  };
526
728
  var formEncodeClientCredential = (value) => new URLSearchParams([[value, ""]]).toString().slice(0, -1);
527
729
  var toBasicAuthorization = (clientId, clientSecret) => `Basic ${Buffer.from(`${formEncodeClientCredential(clientId)}:${formEncodeClientCredential(clientSecret)}`).toString("base64")}`;
528
- var readJsonResponse = async (response, errorCode, invalidJsonMessage = "OIDC provider returned invalid JSON.") => {
529
- const text = await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode);
730
+ var isProviderJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
731
+ var readJsonResponse = async (response, errorCode, invalidJsonMessage = "OIDC provider returned invalid JSON.", readOptions = {}) => {
732
+ const text = await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode, {
733
+ signal: readOptions.signal,
734
+ transportErrorCode: readOptions.transportErrorCode
735
+ });
530
736
  if (!text) {
531
737
  return {};
532
738
  }
739
+ let parsed;
533
740
  try {
534
- return JSON.parse(text);
741
+ parsed = JSON.parse(text);
535
742
  } catch {
536
- throw new OidcVaultHttpError(
537
- response.ok ? 502 : response.status,
538
- errorCode,
539
- invalidJsonMessage,
540
- invalidJsonMessage
541
- );
743
+ throw new OidcVaultHttpError(502, errorCode, invalidJsonMessage, invalidJsonMessage);
744
+ }
745
+ if (!isProviderJsonObject(parsed)) {
746
+ throw new OidcVaultHttpError(502, errorCode, invalidJsonMessage, invalidJsonMessage);
747
+ }
748
+ return parsed;
749
+ };
750
+ var drainProviderErrorBody = async (response, signal, errorCode) => {
751
+ try {
752
+ await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode, { signal });
753
+ } catch (error) {
754
+ if (error instanceof OidcVaultHttpError) {
755
+ throw error;
756
+ }
757
+ throw error;
542
758
  }
543
759
  };
544
760
  async function discoverIssuerMetadata(issuer, options) {
761
+ const callerTimeoutMs = getProviderRequestTimeoutMs(options);
762
+ const policyKey = `${issuer}\0${callerTimeoutMs}`;
545
763
  const now = Date.now();
546
- const cached = discoveryCache.get(issuer);
547
- let discoveryPromise = cached && cached.expiresAt > now ? cached.promise : void 0;
548
- if (cached && !discoveryPromise) {
549
- discoveryCache.delete(issuer);
550
- }
551
- if (!discoveryPromise) {
764
+ const policyCached = discoveryCache.get(policyKey);
765
+ let discoveryPromise = policyCached !== void 0 && policyCached.expiresAt > now ? policyCached.promise : void 0;
766
+ if (policyCached !== void 0 && discoveryPromise === void 0) {
767
+ discoveryCache.delete(policyKey);
768
+ }
769
+ if (discoveryPromise === void 0) {
770
+ const sharedCached = discoveryCache.get(issuer);
771
+ const sharedPromise = sharedCached !== void 0 && sharedCached.expiresAt > now ? sharedCached.promise : void 0;
772
+ if (sharedCached !== void 0 && sharedPromise === void 0) {
773
+ discoveryCache.delete(issuer);
774
+ }
775
+ if (sharedPromise !== void 0) {
776
+ return sharedPromise;
777
+ }
552
778
  discoveryPromise = (async () => {
553
779
  const discoveryUrl = buildWellKnownUrl(issuer);
554
- const response = await fetchProvider(discoveryUrl, void 0, "OIDC_VAULT_DISCOVERY_FAILED", options);
555
- if (!response.ok) {
556
- const errorText = await readBoundedResponseText(
780
+ const { response, signal, done } = await fetchProvider(
781
+ discoveryUrl,
782
+ void 0,
783
+ "OIDC_VAULT_DISCOVERY_FAILED",
784
+ options
785
+ );
786
+ try {
787
+ if (!response.ok) {
788
+ const errorText = await readBoundedResponseText(
789
+ response,
790
+ DEFAULT_PROVIDER_ERROR_BODY_LIMIT,
791
+ "OIDC_VAULT_DISCOVERY_FAILED",
792
+ {
793
+ truncate: true,
794
+ signal
795
+ }
796
+ );
797
+ throw new OidcVaultHttpError(
798
+ 502,
799
+ "OIDC_VAULT_DISCOVERY_FAILED",
800
+ errorText || `OIDC discovery failed with status ${response.status}.`,
801
+ "OIDC discovery failed."
802
+ );
803
+ }
804
+ const discovered = await readJsonResponse(
557
805
  response,
558
- DEFAULT_PROVIDER_ERROR_BODY_LIMIT,
559
- "OIDC_VAULT_DISCOVERY_FAILED",
560
- {
561
- truncate: true
562
- }
806
+ "OIDC_VAULT_DISCOVERY_INVALID",
807
+ "OIDC discovery response is malformed JSON.",
808
+ { signal, transportErrorCode: "OIDC_VAULT_DISCOVERY_FAILED" }
563
809
  );
564
- throw new OidcVaultHttpError(
565
- 502,
566
- "OIDC_VAULT_DISCOVERY_FAILED",
567
- errorText || `OIDC discovery failed with status ${response.status}.`,
568
- "OIDC discovery failed."
810
+ const authorizationEndpoint = validateDiscoveredHttpUrl(
811
+ getRequiredString(
812
+ discovered.authorization_endpoint,
813
+ "OIDC discovery response is missing authorization_endpoint.",
814
+ "OIDC_VAULT_DISCOVERY_INVALID",
815
+ 502
816
+ ),
817
+ "authorization_endpoint"
569
818
  );
570
- }
571
- const discovered = await readJsonResponse(
572
- response,
573
- "OIDC_VAULT_DISCOVERY_INVALID",
574
- "OIDC discovery response is malformed JSON."
575
- );
576
- const authorizationEndpoint = validateDiscoveredHttpUrl(
577
- getRequiredString(
578
- discovered.authorization_endpoint,
579
- "OIDC discovery response is missing authorization_endpoint.",
580
- "OIDC_VAULT_DISCOVERY_INVALID",
581
- 502
582
- ),
583
- "authorization_endpoint"
584
- );
585
- const tokenEndpoint = validateDiscoveredHttpUrl(
586
- getRequiredString(
587
- discovered.token_endpoint,
588
- "OIDC discovery response is missing token_endpoint.",
589
- "OIDC_VAULT_DISCOVERY_INVALID",
590
- 502
591
- ),
592
- "token_endpoint"
593
- );
594
- const jwksUri = validateDiscoveredHttpUrl(
595
- getRequiredString(
596
- discovered.jwks_uri,
597
- "OIDC discovery response is missing jwks_uri.",
819
+ const tokenEndpoint = validateDiscoveredHttpUrl(
820
+ getRequiredString(
821
+ discovered.token_endpoint,
822
+ "OIDC discovery response is missing token_endpoint.",
823
+ "OIDC_VAULT_DISCOVERY_INVALID",
824
+ 502
825
+ ),
826
+ "token_endpoint"
827
+ );
828
+ const jwksUri = validateDiscoveredHttpUrl(
829
+ getRequiredString(
830
+ discovered.jwks_uri,
831
+ "OIDC discovery response is missing jwks_uri.",
832
+ "OIDC_VAULT_DISCOVERY_INVALID",
833
+ 502
834
+ ),
835
+ "jwks_uri"
836
+ );
837
+ const discoveredIssuer = getRequiredString(
838
+ discovered.issuer,
839
+ "OIDC discovery response is missing issuer.",
598
840
  "OIDC_VAULT_DISCOVERY_INVALID",
599
841
  502
600
- ),
601
- "jwks_uri"
602
- );
603
- const discoveredIssuer = getRequiredString(
604
- discovered.issuer,
605
- "OIDC discovery response is missing issuer.",
606
- "OIDC_VAULT_DISCOVERY_INVALID",
607
- 502
608
- );
609
- assertDiscoveredIssuerMatchesConfig(issuer, discoveredIssuer);
610
- return {
611
- issuer: discoveredIssuer,
612
- authorizationEndpoint,
613
- tokenEndpoint,
614
- jwksUri,
615
- userInfoEndpoint: typeof discovered.userinfo_endpoint === "string" ? validateDiscoveredHttpUrl(discovered.userinfo_endpoint, "userinfo_endpoint") : void 0,
616
- endSessionEndpoint: typeof discovered.end_session_endpoint === "string" ? validateDiscoveredHttpUrl(discovered.end_session_endpoint, "end_session_endpoint") : void 0
617
- };
842
+ );
843
+ assertDiscoveredIssuerMatchesConfig(issuer, discoveredIssuer);
844
+ return {
845
+ issuer: discoveredIssuer,
846
+ authorizationEndpoint,
847
+ tokenEndpoint,
848
+ jwksUri,
849
+ userInfoEndpoint: validateOptionalDiscoveredHttpUrl(discovered.userinfo_endpoint, "userinfo_endpoint"),
850
+ endSessionEndpoint: validateOptionalDiscoveredHttpUrl(
851
+ discovered.end_session_endpoint,
852
+ "end_session_endpoint"
853
+ )
854
+ };
855
+ } finally {
856
+ done();
857
+ cancelBestEffort(() => response.body?.cancel());
858
+ }
618
859
  })();
619
- discoveryCache.set(issuer, {
860
+ discoveryPromise.then(
861
+ () => {
862
+ const fetchedAt = Date.now();
863
+ discoveryCache.set(issuer, {
864
+ expiresAt: fetchedAt + DEFAULT_DISCOVERY_CACHE_TTL_MS,
865
+ promise: discoveryPromise
866
+ });
867
+ evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
868
+ },
869
+ () => {
870
+ }
871
+ );
872
+ discoveryCache.set(policyKey, {
620
873
  expiresAt: now + DEFAULT_DISCOVERY_CACHE_TTL_MS,
621
874
  promise: discoveryPromise
622
875
  });
623
876
  evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
624
877
  discoveryPromise.catch(() => {
625
- if (discoveryCache.get(issuer)?.promise === discoveryPromise) {
626
- discoveryCache.delete(issuer);
878
+ if (discoveryCache.get(policyKey)?.promise === discoveryPromise) {
879
+ discoveryCache.delete(policyKey);
627
880
  }
628
881
  });
629
882
  }
@@ -674,7 +927,7 @@ async function requestToken(metadata, params, options = {}) {
674
927
  if (metadata.clientSecret) {
675
928
  headers.authorization = toBasicAuthorization(clientId, metadata.clientSecret);
676
929
  }
677
- const response = await fetchProvider(
930
+ const { response, signal, done } = await fetchProvider(
678
931
  metadata.tokenEndpoint,
679
932
  {
680
933
  method: "POST",
@@ -684,17 +937,33 @@ async function requestToken(metadata, params, options = {}) {
684
937
  "OIDC_VAULT_TOKEN_REQUEST_FAILED",
685
938
  options
686
939
  );
687
- const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
688
- if (!response.ok) {
940
+ try {
941
+ if (!response.ok) {
942
+ await drainProviderErrorBody(response, signal, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
943
+ throw new OidcVaultHttpError(
944
+ 502,
945
+ "OIDC_VAULT_TOKEN_REQUEST_FAILED",
946
+ "OIDC token request failed.",
947
+ "OIDC token request failed."
948
+ );
949
+ }
950
+ const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED", void 0, { signal });
951
+ return json;
952
+ } finally {
953
+ done();
954
+ cancelBestEffort(() => response.body?.cancel());
955
+ }
956
+ }
957
+ var assertPresentTokenStringField = (tokenResponse, field) => {
958
+ const value = tokenResponse[field];
959
+ if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
689
960
  throw new OidcVaultHttpError(
690
961
  502,
691
- "OIDC_VAULT_TOKEN_REQUEST_FAILED",
692
- typeof json.error_description === "string" ? json.error_description : `OIDC token request failed with status ${response.status}.`,
693
- "OIDC token request failed."
962
+ "OIDC_VAULT_INVALID_TOKEN_RESPONSE",
963
+ `OIDC token response ${field} must be a non-empty string when present.`
694
964
  );
695
965
  }
696
- return json;
697
- }
966
+ };
698
967
  function validateTokenResponse(tokenResponse) {
699
968
  if (typeof tokenResponse.token_type !== "string" || tokenResponse.token_type.toLowerCase() !== "bearer") {
700
969
  throw new OidcVaultHttpError(
@@ -710,12 +979,38 @@ function validateTokenResponse(tokenResponse) {
710
979
  "OIDC_VAULT_INVALID_TOKEN_RESPONSE"
711
980
  );
712
981
  }
982
+ assertPresentTokenStringField(tokenResponse, "access_token");
983
+ assertPresentTokenStringField(tokenResponse, "id_token");
984
+ assertPresentTokenStringField(tokenResponse, "refresh_token");
985
+ if (tokenResponse.scope !== void 0 && typeof tokenResponse.scope !== "string") {
986
+ throw new OidcVaultHttpError(
987
+ 502,
988
+ "OIDC_VAULT_INVALID_TOKEN_RESPONSE",
989
+ "OIDC token response scope must be a string when present."
990
+ );
991
+ }
992
+ }
993
+ function validateCallbackTokenResponse(tokenResponse) {
994
+ validateTokenResponse(tokenResponse);
995
+ if (typeof tokenResponse.id_token !== "string" || tokenResponse.id_token.length === 0) {
996
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
997
+ }
998
+ if (typeof tokenResponse.refresh_token !== "string" || tokenResponse.refresh_token.length === 0) {
999
+ throw new OidcVaultHttpError(
1000
+ 502,
1001
+ "OIDC_VAULT_MISSING_REFRESH_TOKEN",
1002
+ "OIDC token response is missing refresh_token."
1003
+ );
1004
+ }
1005
+ }
1006
+ function validateRefreshTokenResponse(tokenResponse) {
1007
+ validateTokenResponse(tokenResponse);
713
1008
  }
714
1009
  async function fetchUserInfo(metadata, accessToken, options = {}) {
715
1010
  if (!metadata.userInfoEndpoint) {
716
1011
  return {};
717
1012
  }
718
- const response = await fetchProvider(
1013
+ const { response, signal, done } = await fetchProvider(
719
1014
  metadata.userInfoEndpoint,
720
1015
  {
721
1016
  headers: {
@@ -725,16 +1020,22 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
725
1020
  "OIDC_VAULT_USERINFO_FAILED",
726
1021
  options
727
1022
  );
728
- const json = await readJsonResponse(response, "OIDC_VAULT_USERINFO_FAILED");
729
- if (!response.ok) {
730
- throw new OidcVaultHttpError(
731
- 502,
732
- "OIDC_VAULT_USERINFO_FAILED",
733
- `OIDC userinfo request failed with status ${response.status}.`,
734
- "OIDC userinfo request failed."
735
- );
1023
+ try {
1024
+ if (!response.ok) {
1025
+ await drainProviderErrorBody(response, signal, "OIDC_VAULT_USERINFO_FAILED");
1026
+ throw new OidcVaultHttpError(
1027
+ 502,
1028
+ "OIDC_VAULT_USERINFO_FAILED",
1029
+ "OIDC userinfo request failed.",
1030
+ "OIDC userinfo request failed."
1031
+ );
1032
+ }
1033
+ const json = await readJsonResponse(response, "OIDC_VAULT_USERINFO_FAILED", void 0, { signal });
1034
+ return json;
1035
+ } finally {
1036
+ done();
1037
+ cancelBestEffort(() => response.body?.cancel());
736
1038
  }
737
- return json;
738
1039
  }
739
1040
 
740
1041
  // src/token-validation.ts
@@ -764,6 +1065,9 @@ var defaultJwtClaimsMapper = (claims) => {
764
1065
  };
765
1066
  };
766
1067
  var assertUserInfoSubject = (userInfo, subject) => {
1068
+ if (typeof userInfo !== "object" || userInfo === null || Array.isArray(userInfo)) {
1069
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is invalid.");
1070
+ }
767
1071
  if (!isString(userInfo.sub)) {
768
1072
  throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is missing sub.");
769
1073
  }
@@ -776,6 +1080,12 @@ var mergeUserProfile = (sub, idTokenClaims, userInfo) => ({
776
1080
  ...userInfo ?? {},
777
1081
  sub
778
1082
  });
1083
+ var composeRefreshedUserProfile = (sub, currentUser, freshIdClaims, freshUserInfo) => {
1084
+ if (freshIdClaims === void 0) {
1085
+ return mergeUserProfile(sub, { ...currentUser ?? { sub } }, freshUserInfo);
1086
+ }
1087
+ return mergeUserProfile(sub, { ...freshIdClaims }, freshUserInfo);
1088
+ };
779
1089
  async function verifyIdToken(metadata, idToken, nonce, options = {}) {
780
1090
  if (!metadata.jwksUri) {
781
1091
  throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_JWKS_URI", "OIDC jwksUri is required to validate id_token.");
@@ -916,10 +1226,17 @@ var extractBearerToken = (authorizationHeader) => {
916
1226
  var setBearerChallengeHeader = (res) => {
917
1227
  res.setHeader("WWW-Authenticate", "Bearer");
918
1228
  };
1229
+ var notifyBearerMiddlewareError = async (options, context) => {
1230
+ try {
1231
+ await options.onError?.(context);
1232
+ } catch {
1233
+ }
1234
+ };
919
1235
  function createOidcVaultAccessTokenMiddleware(options) {
920
1236
  return async (req, res, next) => {
1237
+ let token;
921
1238
  try {
922
- const token = extractBearerToken(req.get("authorization"));
1239
+ token = extractBearerToken(req.get("authorization"));
923
1240
  const validationResult = await options.validator.validate(token);
924
1241
  const auth = {
925
1242
  token,
@@ -927,9 +1244,38 @@ function createOidcVaultAccessTokenMiddleware(options) {
927
1244
  };
928
1245
  const authenticatedRequest = req;
929
1246
  authenticatedRequest.auth = auth;
930
- await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
1247
+ try {
1248
+ await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
1249
+ } catch (hookError) {
1250
+ delete authenticatedRequest.auth;
1251
+ await notifyBearerMiddlewareError(options, { error: hookError, req, res, token, auth });
1252
+ if (res.headersSent) {
1253
+ next(hookError);
1254
+ return;
1255
+ }
1256
+ if (hookError instanceof OidcVaultHttpError) {
1257
+ if (hookError.status === 401) {
1258
+ setBearerChallengeHeader(res);
1259
+ }
1260
+ res.status(hookError.status).json({
1261
+ code: hookError.code,
1262
+ message: hookError.clientMessage
1263
+ });
1264
+ return;
1265
+ }
1266
+ res.status(500).json({
1267
+ code: "OIDC_VAULT_AUTH_CONTEXT_FAILED",
1268
+ message: "Auth context hook failed."
1269
+ });
1270
+ return;
1271
+ }
931
1272
  next();
932
1273
  } catch (error) {
1274
+ await notifyBearerMiddlewareError(options, { error, req, res, ...token ? { token } : {} });
1275
+ if (res.headersSent) {
1276
+ next(error);
1277
+ return;
1278
+ }
933
1279
  if (error instanceof OidcVaultHttpError) {
934
1280
  setBearerChallengeHeader(res);
935
1281
  res.status(error.status).json({
@@ -955,28 +1301,43 @@ function normalizeOidcVaultBasePath(value) {
955
1301
  return `/${value.replace(/^\/+|\/+$/g, "")}`;
956
1302
  }
957
1303
  var getNow = (options) => (options.now ?? Date.now)();
1304
+ var CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store";
1305
+ var applyCredentialResponseCachePolicy = (res) => {
1306
+ res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL);
1307
+ };
958
1308
  var createOpaqueId = (prefix) => `${prefix}_${randomBytes(16).toString("base64url")}`;
959
1309
  var createPkceVerifier = () => randomBytes(32).toString("base64url");
960
1310
  var createPkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
961
1311
  var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
962
1312
  var validateOidcVaultOptions = (options) => {
1313
+ validateLifetimeOptions(options, getNow(options));
963
1314
  const backendOrigin = resolveBackendOrigin(options);
964
1315
  const frontendRedirectUri = resolveFrontendRedirectUri(options);
965
1316
  validatePostLogoutRedirectUri(options);
966
1317
  validateCookieOptions(options);
967
- const config = resolveOidcVaultConfig(options.config);
1318
+ const config = resolveOidcVaultConfig(options.config ? { ...options.config } : void 0);
968
1319
  const configuredTrustedOrigins = resolveTrustedOrigins(options);
969
1320
  if (usesCrossSiteCookieTransport(options) && configuredTrustedOrigins.size === 0) {
970
1321
  throw new Error("trustedOrigins is required when using cross-site cookie transport.");
971
1322
  }
972
1323
  const trustedOrigins = new Set(configuredTrustedOrigins);
973
1324
  trustedOrigins.add(backendOrigin);
974
- if (frontendRedirectUri) {
975
- options.frontendRedirectUri = frontendRedirectUri;
976
- }
977
- return { backendOrigin, config, trustedOrigins };
1325
+ const resolvedOptions = {
1326
+ ...options,
1327
+ ...frontendRedirectUri !== void 0 ? { frontendRedirectUri } : {},
1328
+ ...options.trustedOrigins ? { trustedOrigins: [...options.trustedOrigins] } : {},
1329
+ ...options.cookie ? { cookie: { ...options.cookie } } : {},
1330
+ ...options.config ? { config: { ...options.config } } : {}
1331
+ };
1332
+ return { backendOrigin, config, trustedOrigins, resolvedOptions };
978
1333
  };
979
1334
  var isStoreConflictError = (error) => error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
1335
+ var assertSessionIdentity = (session, config) => {
1336
+ const provider = session.provider;
1337
+ if (provider?.issuer !== void 0 && provider.issuer !== config.issuer || provider?.clientId !== void 0 && provider.clientId !== config.clientId) {
1338
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1339
+ }
1340
+ };
980
1341
  var getSessionIdFromRequest = (req, options, action) => {
981
1342
  if (usesCookieTransport(options)) {
982
1343
  const cookieSessionId = getSessionIdFromCookie(req, options);
@@ -999,7 +1360,9 @@ var getSessionIdFromRequest = (req, options, action) => {
999
1360
  var createExchangeResponse = (options, session, issuedToken) => ({
1000
1361
  sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
1001
1362
  user: session.user,
1002
- ...issuedToken
1363
+ accessToken: issuedToken.accessToken,
1364
+ expiresIn: issuedToken.expiresIn,
1365
+ tokenType: issuedToken.tokenType
1003
1366
  });
1004
1367
  var appendQueryParam = (url, name, value) => {
1005
1368
  if (value) {
@@ -1032,6 +1395,10 @@ var getLogoutTokenFromRequest = (req) => {
1032
1395
  "OIDC_VAULT_MISSING_LOGOUT_TOKEN"
1033
1396
  );
1034
1397
  };
1398
+ var buildBackchannelLogoutReplayKey = (input) => {
1399
+ const encode = (value) => Buffer.from(value, "utf8").toString("base64url");
1400
+ return `v2:${encode(input.issuer ?? "")}:${encode(input.clientId ?? "")}:${encode(input.jti)}`;
1401
+ };
1035
1402
  var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
1036
1403
  var resolveReturnTo = (req, options) => {
1037
1404
  const rawReturnTo = req.query.returnTo;
@@ -1071,7 +1438,21 @@ var withIssuedToken = async (req, res, options, session) => {
1071
1438
  if (!options.tokenIssuer) {
1072
1439
  return {};
1073
1440
  }
1074
- return options.tokenIssuer.issue({ req, res, session });
1441
+ const result = await options.tokenIssuer.issue({ req, res, session });
1442
+ if (typeof result !== "object" || result === null || Array.isArray(result)) {
1443
+ throw new TypeError("tokenIssuer.issue must return a token result object.");
1444
+ }
1445
+ const { accessToken, expiresIn, tokenType } = result;
1446
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
1447
+ throw new TypeError("tokenIssuer.issue accessToken must be a nonempty string.");
1448
+ }
1449
+ if (typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn < 0) {
1450
+ throw new TypeError("tokenIssuer.issue expiresIn must be a finite nonnegative safe integer.");
1451
+ }
1452
+ if (tokenType !== void 0 && tokenType !== "Bearer") {
1453
+ throw new TypeError("tokenIssuer.issue tokenType must be Bearer when provided.");
1454
+ }
1455
+ return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
1075
1456
  };
1076
1457
  var resolveFrontendRedirectUri2 = (transaction, options) => {
1077
1458
  const redirectUri = transaction.returnTo ?? options.frontendRedirectUri;
@@ -1119,6 +1500,7 @@ async function handleRouteError(route, req, res, next, options, error) {
1119
1500
  next(error);
1120
1501
  return;
1121
1502
  }
1503
+ applyCredentialResponseCachePolicy(res);
1122
1504
  const payload = toErrorPayload(error);
1123
1505
  res.status(payload.status).json({
1124
1506
  code: payload.code,
@@ -1140,6 +1522,7 @@ function createBodyParserErrorHandler() {
1140
1522
  next(error);
1141
1523
  return;
1142
1524
  }
1525
+ applyCredentialResponseCachePolicy(res);
1143
1526
  const payload = toBodyParserErrorPayload(error);
1144
1527
  res.status(payload.status).json({
1145
1528
  code: payload.code,
@@ -1159,7 +1542,11 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
1159
1542
  codeChallenge: createPkceChallenge(pkceVerifier),
1160
1543
  returnTo: resolveReturnTo(req, options),
1161
1544
  createdAt: now,
1162
- expiresAt: now + (options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS)
1545
+ expiresAt: computeExpiresAt(
1546
+ now,
1547
+ options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS,
1548
+ "authorizationTransactionTtlMs"
1549
+ )
1163
1550
  };
1164
1551
  await options.storeProvider.createAuthorizationTransaction(transaction);
1165
1552
  const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(backendOrigin, basePath));
@@ -1183,6 +1570,7 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1183
1570
  if (!transaction) {
1184
1571
  throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
1185
1572
  }
1573
+ const frontendDestination = resolveFrontendRedirectUri2(transaction, options);
1186
1574
  const metadata = await resolveProviderMetadata(config, options);
1187
1575
  const tokenResponse = await requestToken(
1188
1576
  metadata,
@@ -1194,17 +1582,7 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1194
1582
  },
1195
1583
  options
1196
1584
  );
1197
- validateTokenResponse(tokenResponse);
1198
- if (!isString(tokenResponse.id_token)) {
1199
- throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
1200
- }
1201
- if (!isString(tokenResponse.refresh_token)) {
1202
- throw new OidcVaultHttpError(
1203
- 502,
1204
- "OIDC_VAULT_MISSING_REFRESH_TOKEN",
1205
- "OIDC token response is missing refresh_token."
1206
- );
1207
- }
1585
+ validateCallbackTokenResponse(tokenResponse);
1208
1586
  await callHook("callback", options.hooks?.onCallbackTokens, req, res, void 0, {
1209
1587
  hasAccessToken: Boolean(tokenResponse.access_token),
1210
1588
  hasRefreshToken: true,
@@ -1213,13 +1591,19 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1213
1591
  const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce, options);
1214
1592
  const subject = getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502);
1215
1593
  const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1216
- if (userInfo) {
1594
+ if (userInfo !== void 0) {
1217
1595
  assertUserInfoSubject(userInfo, subject);
1218
1596
  }
1219
- if (userInfo) {
1597
+ if (userInfo !== void 0) {
1220
1598
  await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
1221
1599
  }
1222
1600
  const now = getNow(options);
1601
+ const sessionExpiresAt = options.sessionTtlMs === void 0 ? void 0 : computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
1602
+ const exchangeExpiresAt = computeExpiresAt(
1603
+ now,
1604
+ options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS,
1605
+ "exchangeCodeTtlMs"
1606
+ );
1223
1607
  const sessionId = createOpaqueId("sess");
1224
1608
  const session = {
1225
1609
  sessionId,
@@ -1236,12 +1620,16 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1236
1620
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
1237
1621
  createdAt: now,
1238
1622
  updatedAt: now,
1623
+ ...sessionExpiresAt === void 0 ? {} : { expiresAt: sessionExpiresAt },
1239
1624
  user: mergeUserProfile(subject, claims, userInfo),
1240
1625
  metadata: {
1241
1626
  tokenType: tokenResponse.token_type
1242
1627
  }
1243
1628
  };
1244
1629
  await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
1630
+ if (sessionExpiresAt !== void 0) {
1631
+ session.expiresAt = isUsableEpochMs(session.expiresAt) ? Math.min(session.expiresAt, sessionExpiresAt) : sessionExpiresAt;
1632
+ }
1245
1633
  const createdSession = await options.storeProvider.createSession(session);
1246
1634
  const exchangeCode = createOpaqueId("code");
1247
1635
  try {
@@ -1250,7 +1638,7 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1250
1638
  sessionId: createdSession.sessionId,
1251
1639
  returnTo: transaction.returnTo,
1252
1640
  createdAt: now,
1253
- expiresAt: now + (options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS)
1641
+ expiresAt: exchangeExpiresAt
1254
1642
  });
1255
1643
  } catch (error) {
1256
1644
  await options.storeProvider.deleteSessionsByLogicalSessionId({
@@ -1261,9 +1649,9 @@ var createCallbackHandler = (options, config, backendOrigin, basePath) => create
1261
1649
  await callPostCommitHook("callback", options, options.hooks?.onSessionCreated, req, res, createdSession, {
1262
1650
  subject
1263
1651
  });
1264
- res.redirect(302, appendCodeToRedirectUri(resolveFrontendRedirectUri2(transaction, options), exchangeCode));
1652
+ res.redirect(302, appendCodeToRedirectUri(frontendDestination, exchangeCode));
1265
1653
  });
1266
- var createExchangeHandler = (options) => createAsyncHandler("exchange", options, async (req, res) => {
1654
+ var createExchangeHandler = (options, config) => createAsyncHandler("exchange", options, async (req, res) => {
1267
1655
  const body = getBody(req);
1268
1656
  const code = getRequiredString(body.code, "Exchange request is missing code.", "OIDC_VAULT_MISSING_EXCHANGE_CODE");
1269
1657
  const exchangeRecord = await options.storeProvider.consumeExchangeCode(code);
@@ -1274,6 +1662,7 @@ var createExchangeHandler = (options) => createAsyncHandler("exchange", options,
1274
1662
  if (!session) {
1275
1663
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1276
1664
  }
1665
+ assertSessionIdentity(session, config);
1277
1666
  let issuedToken;
1278
1667
  try {
1279
1668
  issuedToken = await withIssuedToken(req, res, options, session);
@@ -1302,6 +1691,7 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1302
1691
  }
1303
1692
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1304
1693
  }
1694
+ assertSessionIdentity(currentSession, config);
1305
1695
  const metadata = await resolveProviderMetadata(config, options);
1306
1696
  const tokenResponse = await requestToken(
1307
1697
  metadata,
@@ -1311,16 +1701,16 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1311
1701
  },
1312
1702
  options
1313
1703
  );
1314
- validateTokenResponse(tokenResponse);
1704
+ validateRefreshTokenResponse(tokenResponse);
1315
1705
  const newIdToken = isString(tokenResponse.id_token) ? tokenResponse.id_token : void 0;
1316
1706
  const idToken = newIdToken ?? currentSession.idToken;
1317
- const claims = newIdToken ? await verifyIdToken(metadata, newIdToken, void 0, options) : currentSession.user ?? { sub: currentSession.subject };
1318
- const subject = newIdToken ? getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502) : currentSession.subject;
1707
+ const freshClaims = newIdToken ? await verifyIdToken(metadata, newIdToken, void 0, options) : void 0;
1708
+ const subject = freshClaims ? getRequiredString(freshClaims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502) : currentSession.subject;
1319
1709
  if (newIdToken && subject !== currentSession.subject) {
1320
1710
  throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC refreshed id_token sub changed.");
1321
1711
  }
1322
1712
  const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1323
- if (userInfo) {
1713
+ if (userInfo !== void 0) {
1324
1714
  assertUserInfoSubject(userInfo, subject);
1325
1715
  }
1326
1716
  const now = getNow(options);
@@ -1330,14 +1720,14 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1330
1720
  sessionId: createOpaqueId("sess"),
1331
1721
  logicalSessionId,
1332
1722
  subject,
1333
- providerSessionId: typeof claims.sid === "string" ? claims.sid : currentSession.providerSessionId,
1723
+ providerSessionId: freshClaims && typeof freshClaims.sid === "string" ? freshClaims.sid : currentSession.providerSessionId,
1334
1724
  refreshToken: isString(tokenResponse.refresh_token) ? tokenResponse.refresh_token : currentSession.refreshToken,
1335
1725
  idToken,
1336
1726
  accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : currentSession.accessToken,
1337
1727
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : currentSession.scope,
1338
1728
  expiresAt: currentSession.expiresAt,
1339
1729
  updatedAt: now,
1340
- user: mergeUserProfile(subject, claims, userInfo ?? currentSession.user)
1730
+ user: composeRefreshedUserProfile(subject, currentSession.user, freshClaims, userInfo)
1341
1731
  };
1342
1732
  let rotatedSession;
1343
1733
  try {
@@ -1389,6 +1779,7 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
1389
1779
  res.status(200).json({ loggedOut: true });
1390
1780
  return;
1391
1781
  }
1782
+ assertSessionIdentity(session, config);
1392
1783
  await callHook("logout", options.hooks?.onBeforeLogout, req, res, session, void 0);
1393
1784
  await options.storeProvider.deleteSessionsByLogicalSessionId({
1394
1785
  logicalSessionId: session.logicalSessionId ?? session.sessionId
@@ -1396,8 +1787,24 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
1396
1787
  if (usesCookieTransport(options)) {
1397
1788
  clearSessionCookie(res, options);
1398
1789
  }
1399
- const metadata = await resolveProviderMetadata(config, options);
1400
- const upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
1790
+ if (!redirect) {
1791
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
1792
+ res.status(200).json({ loggedOut: true });
1793
+ return;
1794
+ }
1795
+ let upstreamLogoutUrl;
1796
+ try {
1797
+ const metadata = await resolveProviderMetadata(config, options);
1798
+ upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
1799
+ } catch (error) {
1800
+ try {
1801
+ await options.hooks?.onError?.({
1802
+ ...createHookContext("logout", req, res, session),
1803
+ error
1804
+ });
1805
+ } catch {
1806
+ }
1807
+ }
1401
1808
  await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
1402
1809
  if (redirect && upstreamLogoutUrl) {
1403
1810
  res.redirect(302, upstreamLogoutUrl);
@@ -1411,26 +1818,40 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
1411
1818
  const metadata = await resolveProviderMetadata(config, options);
1412
1819
  const logoutToken = getLogoutTokenFromRequest(req);
1413
1820
  const claims = await verifyBackchannelLogoutToken(metadata, logoutToken, options);
1821
+ const replayKey = buildBackchannelLogoutReplayKey({
1822
+ issuer: metadata.issuer,
1823
+ clientId: metadata.clientId,
1824
+ jti: claims.jti
1825
+ });
1414
1826
  const firstUse = await options.storeProvider.consumeBackchannelLogoutTokenJti({
1415
- jti: claims.jti,
1827
+ jti: replayKey,
1416
1828
  expiresAt: claims.exp * 1e3
1417
1829
  });
1418
- if (!firstUse) {
1419
- res.status(200).json({
1420
- loggedOut: true,
1421
- revokedSessions: 0
1422
- });
1423
- return;
1424
- }
1425
- const revokedSessions = isString(claims.sid) ? await options.storeProvider.deleteSessionsByProviderSessionId({
1830
+ const revokeMatchingSessions = () => isString(claims.sid) ? options.storeProvider.deleteSessionsByProviderSessionId({
1426
1831
  providerSessionId: claims.sid,
1427
1832
  issuer: metadata.issuer,
1428
1833
  clientId: metadata.clientId
1429
- }) : await options.storeProvider.deleteSessionsBySubject({
1834
+ }) : options.storeProvider.deleteSessionsBySubject({
1430
1835
  subject: String(claims.sub),
1431
1836
  issuer: metadata.issuer,
1432
1837
  clientId: metadata.clientId
1433
1838
  });
1839
+ if (!firstUse) {
1840
+ const catchUpRevokedSessions = await revokeMatchingSessions();
1841
+ if (catchUpRevokedSessions > 0) {
1842
+ await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
1843
+ providerSessionId: claims.sid,
1844
+ subject: claims.sub,
1845
+ revokedSessions: catchUpRevokedSessions
1846
+ });
1847
+ }
1848
+ res.status(200).json({
1849
+ loggedOut: true,
1850
+ revokedSessions: catchUpRevokedSessions
1851
+ });
1852
+ return;
1853
+ }
1854
+ const revokedSessions = await revokeMatchingSessions();
1434
1855
  await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
1435
1856
  providerSessionId: claims.sid,
1436
1857
  subject: claims.sub,
@@ -1444,17 +1865,21 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
1444
1865
  function registerRoutes(router, options, config, trustedOrigins, backendOrigin, basePath) {
1445
1866
  router.get(OIDC_VAULT_ROUTE_PATHS.login, createLoginHandler(options, config, backendOrigin, basePath));
1446
1867
  router.get(OIDC_VAULT_ROUTE_PATHS.callback, createCallbackHandler(options, config, backendOrigin, basePath));
1447
- router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options));
1868
+ router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config));
1448
1869
  router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins));
1449
1870
  router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins));
1450
1871
  router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
1451
1872
  }
1452
1873
  function createOidcVaultMiddleware(options) {
1453
- const { backendOrigin, config, trustedOrigins } = validateOidcVaultOptions(options);
1874
+ const { backendOrigin, config, trustedOrigins, resolvedOptions } = validateOidcVaultOptions(options);
1454
1875
  const rootRouter = express.Router();
1455
1876
  const baseRouter = express.Router();
1456
- const basePath = normalizeOidcVaultBasePath(options.basePath);
1457
- const requestBodyLimit = options.requestBodyLimit ?? DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT;
1877
+ const basePath = normalizeOidcVaultBasePath(resolvedOptions.basePath);
1878
+ const requestBodyLimit = resolvedOptions.requestBodyLimit ?? DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT;
1879
+ baseRouter.use((_req, res, next) => {
1880
+ applyCredentialResponseCachePolicy(res);
1881
+ next();
1882
+ });
1458
1883
  baseRouter.use(express.json({ limit: requestBodyLimit }));
1459
1884
  baseRouter.use(
1460
1885
  express.urlencoded({
@@ -1464,7 +1889,7 @@ function createOidcVaultMiddleware(options) {
1464
1889
  })
1465
1890
  );
1466
1891
  baseRouter.use(createBodyParserErrorHandler());
1467
- registerRoutes(baseRouter, options, config, trustedOrigins, backendOrigin, basePath);
1892
+ registerRoutes(baseRouter, resolvedOptions, config, trustedOrigins, backendOrigin, basePath);
1468
1893
  rootRouter.use(basePath, baseRouter);
1469
1894
  return rootRouter;
1470
1895
  }