@web-ts-toolkit/express-oidc-vault 0.31.5 → 0.33.0

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 +95 -23
  2. package/index.d.mts +65 -14
  3. package/index.d.ts +65 -14
  4. package/index.js +993 -460
  5. package/index.mjs +991 -459
  6. package/package.json +8 -4
package/index.mjs CHANGED
@@ -1,29 +1,46 @@
1
1
  // src/index.ts
2
2
  import { createHash, randomBytes } from "crypto";
3
3
  import express from "express";
4
- import { createRemoteJWKSet, jwtVerify } from "jose";
5
4
 
6
5
  // src/config.ts
7
6
  var DEFAULT_OIDC_SCOPES = "openid email profile";
8
- var MANUAL_CONFIG_REQUIREMENTS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri"];
7
+ var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
9
8
  var normalizeOptionalString = (value) => {
10
9
  const trimmed = value?.trim();
11
10
  return trimmed ? trimmed : void 0;
12
11
  };
12
+ var normalizeHttpUrl = (value, optionName) => {
13
+ if (value === void 0) {
14
+ return void 0;
15
+ }
16
+ let url;
17
+ try {
18
+ url = new URL(value);
19
+ } catch {
20
+ throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
21
+ }
22
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
23
+ throw new Error(`${optionName} must use http or https.`);
24
+ }
25
+ return url.toString();
26
+ };
13
27
  var withOptionalValue = (target, key, value) => {
14
28
  if (value !== void 0) {
15
29
  target[key] = value;
16
30
  }
17
31
  };
18
32
  function resolveOidcVaultConfig(config = {}) {
19
- const issuer = normalizeOptionalString(config.issuer);
33
+ const issuer = normalizeHttpUrl(normalizeOptionalString(config.issuer), "config.issuer");
20
34
  const clientId = normalizeOptionalString(config.clientId);
21
35
  const clientSecret = normalizeOptionalString(config.clientSecret);
22
36
  const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
37
+ const hasManualEndpoints = MANUAL_CONFIG_REQUIREMENTS.some(
38
+ (key) => key !== "issuer" && normalizeOptionalString(config[key])
39
+ );
23
40
  if (!clientId) {
24
41
  throw new Error("OIDC clientId is required.");
25
42
  }
26
- if (issuer) {
43
+ if (issuer && !hasManualEndpoints) {
27
44
  const resolved2 = {
28
45
  mode: "issuer",
29
46
  issuer,
@@ -37,11 +54,28 @@ function resolveOidcVaultConfig(config = {}) {
37
54
  mode: "manual",
38
55
  scopes
39
56
  };
40
- withOptionalValue(resolved, "authorizationEndpoint", normalizeOptionalString(config.authorizationEndpoint));
41
- withOptionalValue(resolved, "tokenEndpoint", normalizeOptionalString(config.tokenEndpoint));
42
- withOptionalValue(resolved, "userInfoEndpoint", normalizeOptionalString(config.userInfoEndpoint));
43
- withOptionalValue(resolved, "jwksUri", normalizeOptionalString(config.jwksUri));
44
- withOptionalValue(resolved, "endSessionEndpoint", normalizeOptionalString(config.endSessionEndpoint));
57
+ withOptionalValue(resolved, "issuer", issuer);
58
+ withOptionalValue(
59
+ resolved,
60
+ "authorizationEndpoint",
61
+ normalizeHttpUrl(normalizeOptionalString(config.authorizationEndpoint), "config.authorizationEndpoint")
62
+ );
63
+ withOptionalValue(
64
+ resolved,
65
+ "tokenEndpoint",
66
+ normalizeHttpUrl(normalizeOptionalString(config.tokenEndpoint), "config.tokenEndpoint")
67
+ );
68
+ withOptionalValue(
69
+ resolved,
70
+ "userInfoEndpoint",
71
+ normalizeHttpUrl(normalizeOptionalString(config.userInfoEndpoint), "config.userInfoEndpoint")
72
+ );
73
+ withOptionalValue(resolved, "jwksUri", normalizeHttpUrl(normalizeOptionalString(config.jwksUri), "config.jwksUri"));
74
+ withOptionalValue(
75
+ resolved,
76
+ "endSessionEndpoint",
77
+ normalizeHttpUrl(normalizeOptionalString(config.endSessionEndpoint), "config.endSessionEndpoint")
78
+ );
45
79
  withOptionalValue(resolved, "clientId", clientId);
46
80
  withOptionalValue(resolved, "clientSecret", clientSecret);
47
81
  const missing = MANUAL_CONFIG_REQUIREMENTS.filter((key) => !resolved[key]);
@@ -64,19 +98,13 @@ function resolveOidcVaultConfigFromEnv(env = process.env) {
64
98
  });
65
99
  }
66
100
 
67
- // src/types.ts
68
- var OidcVaultStoreConflictError = class extends Error {
69
- constructor(message = "OIDC vault store operation conflicted with concurrent state changes.") {
70
- super(message);
71
- this.name = "OidcVaultStoreConflictError";
72
- }
73
- };
74
-
75
- // src/index.ts
101
+ // src/constants.ts
76
102
  var DEFAULT_OIDC_VAULT_BASE_PATH = "/auth/oidc";
77
103
  var DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
78
104
  var DEFAULT_EXCHANGE_CODE_TTL_MS = 30 * 1e3;
79
105
  var DEFAULT_SESSION_COOKIE_NAME = "oidc_vault_session";
106
+ var DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT = "16kb";
107
+ var OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT = 16;
80
108
  var OIDC_VAULT_ROUTE_PATHS = {
81
109
  login: "/login",
82
110
  callback: "/callback",
@@ -85,51 +113,89 @@ var OIDC_VAULT_ROUTE_PATHS = {
85
113
  logout: "/logout",
86
114
  "backchannel-logout": "/backchannel-logout"
87
115
  };
88
- var discoveryCache = /* @__PURE__ */ new Map();
89
- var jwksCache = /* @__PURE__ */ new Map();
116
+
117
+ // src/errors.ts
90
118
  var OidcVaultHttpError = class extends Error {
91
- constructor(status, code, message) {
119
+ constructor(status, code, message, clientMessage = message) {
92
120
  super(message);
93
121
  this.status = status;
94
122
  this.code = code;
123
+ this.clientMessage = clientMessage;
95
124
  }
96
125
  };
97
- function normalizeOidcVaultBasePath(value) {
98
- if (!value || value === "/") {
99
- return DEFAULT_OIDC_VAULT_BASE_PATH;
100
- }
101
- return `/${value.replace(/^\/+|\/+$/g, "")}`;
102
- }
103
- var isRecord = (value) => typeof value === "object" && value !== null;
104
- var isString = (value) => typeof value === "string" && value.length > 0;
105
- var getNow = (options) => (options.now ?? Date.now)();
106
- var getSessionTransport = (options) => options.sessionTransport ?? "body";
107
- var usesCookieTransport = (options) => getSessionTransport(options) === "cookie";
108
- var createOpaqueId = (prefix) => `${prefix}_${randomBytes(16).toString("base64url")}`;
109
- var createPkceVerifier = () => randomBytes(32).toString("base64url");
110
- var createPkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
111
126
  var toErrorPayload = (error) => {
112
127
  if (error instanceof OidcVaultHttpError) {
113
128
  return {
114
129
  status: error.status,
115
130
  code: error.code,
116
- message: error.message
131
+ message: error.clientMessage
117
132
  };
118
133
  }
119
134
  return {
120
135
  status: 500,
121
136
  code: "OIDC_VAULT_INTERNAL_ERROR",
122
- message: error instanceof Error ? error.message : "Unexpected OIDC vault error."
137
+ message: "Unexpected OIDC vault error."
138
+ };
139
+ };
140
+ var isBodyParserError = (error) => {
141
+ if (!(error instanceof Error)) {
142
+ return false;
143
+ }
144
+ const maybeParserError = error;
145
+ return typeof maybeParserError.type === "string" || typeof maybeParserError.status === "number";
146
+ };
147
+ var toBodyParserErrorPayload = (error) => {
148
+ if (error.type === "entity.too.large") {
149
+ return {
150
+ status: 413,
151
+ code: "OIDC_VAULT_REQUEST_BODY_TOO_LARGE",
152
+ message: "OIDC vault request body exceeds the configured size limit."
153
+ };
154
+ }
155
+ if (error.type === "parameters.too.many") {
156
+ return {
157
+ status: 413,
158
+ code: "OIDC_VAULT_REQUEST_BODY_PARAMETER_LIMIT_EXCEEDED",
159
+ message: "OIDC vault form request contains too many parameters."
160
+ };
161
+ }
162
+ if (error.type === "encoding.unsupported" || error.type === "charset.unsupported") {
163
+ return {
164
+ status: 415,
165
+ code: "OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_ENCODING",
166
+ message: "OIDC vault request body encoding is not supported."
167
+ };
168
+ }
169
+ if (error.type === "entity.parse.failed") {
170
+ return {
171
+ status: 400,
172
+ code: "OIDC_VAULT_MALFORMED_REQUEST_BODY",
173
+ message: "OIDC vault request body is malformed."
174
+ };
175
+ }
176
+ const status = error.status ?? error.statusCode ?? 400;
177
+ return {
178
+ status: status >= 400 && status < 500 ? status : 400,
179
+ code: "OIDC_VAULT_INVALID_REQUEST_BODY",
180
+ message: "OIDC vault request body could not be parsed."
123
181
  };
124
182
  };
125
- var getRequestOrigin = (req) => `${req.protocol}://${req.get("host") ?? "localhost"}`;
126
- var getCallbackUri = (req, basePath) => `${getRequestOrigin(req)}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
127
183
  var getRequiredString = (value, message, code, status = 400) => {
128
- if (!isString(value)) {
184
+ if (typeof value !== "string" || value.length === 0) {
129
185
  throw new OidcVaultHttpError(status, code, message);
130
186
  }
131
187
  return value;
132
188
  };
189
+ var getRequiredFiniteNonNegativeInteger = (value, message, code, status = 502) => {
190
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
191
+ throw new OidcVaultHttpError(status, code, message);
192
+ }
193
+ return value;
194
+ };
195
+
196
+ // src/cookies.ts
197
+ var getSessionTransport = (options) => options.sessionTransport ?? "body";
198
+ var usesCookieTransport = (options) => getSessionTransport(options) === "cookie";
133
199
  var parseCookieHeader = (headerValue) => {
134
200
  if (!headerValue) {
135
201
  return {};
@@ -144,7 +210,15 @@ var parseCookieHeader = (headerValue) => {
144
210
  if (!name) {
145
211
  return cookies;
146
212
  }
147
- cookies[name] = decodeURIComponent(value);
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
+ }
148
222
  return cookies;
149
223
  }, {});
150
224
  };
@@ -162,44 +236,36 @@ var resolveCookieOptions = (options) => {
162
236
  httpOnly: cookieOptions.httpOnly ?? true
163
237
  };
164
238
  };
165
- var usesCrossSiteCookieTransport = (options) => usesCookieTransport(options) && resolveCookieOptions(options).sameSite === "none";
166
- var resolveTrustedOrigins = (options) => new Set((options.trustedOrigins ?? []).map((value) => new URL(value).origin));
167
- var getRequestSourceOrigin = (req) => {
168
- const origin = req.get("origin");
169
- if (isString(origin)) {
170
- return origin;
171
- }
172
- const referer = req.get("referer");
173
- if (!isString(referer)) {
174
- return void 0;
175
- }
176
- try {
177
- return new URL(referer).origin;
178
- } catch {
179
- return void 0;
239
+ var COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
240
+ 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
+ var hasUnsafeCookieValueCharacters = (value) => {
242
+ for (let index = 0; index < value.length; index += 1) {
243
+ const code = value.charCodeAt(index);
244
+ if (code <= 31 || code === 127 || value[index] === ";") {
245
+ return true;
246
+ }
180
247
  }
248
+ return false;
181
249
  };
182
- var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
183
- if (!usesCrossSiteCookieTransport(options)) {
250
+ var validateCookieOptions = (options) => {
251
+ if (!usesCookieTransport(options)) {
184
252
  return;
185
253
  }
186
- const origin = getRequestSourceOrigin(req);
187
- if (!origin || !trustedOrigins.has(origin)) {
188
- throw new OidcVaultHttpError(
189
- 403,
190
- "OIDC_VAULT_UNTRUSTED_ORIGIN",
191
- `${action === "refresh" ? "Refresh" : "Logout"} request origin is not trusted.`
192
- );
254
+ const cookieOptions = resolveCookieOptions(options);
255
+ if (!COOKIE_NAME_PATTERN.test(cookieOptions.name)) {
256
+ throw new Error("cookie.name must be a valid HTTP cookie name.");
193
257
  }
194
- };
195
- var validateOidcVaultOptions = (options) => {
196
- const config = resolveOidcVaultConfig(options.config);
197
- const trustedOrigins = resolveTrustedOrigins(options);
198
- if (usesCrossSiteCookieTransport(options) && trustedOrigins.size === 0) {
199
- throw new Error("trustedOrigins is required when using cross-site cookie transport.");
258
+ if (hasUnsafeCookieValueCharacters(cookieOptions.path) || !cookieOptions.path.startsWith("/")) {
259
+ throw new Error("cookie.path must start with / and cannot contain control characters or semicolons.");
260
+ }
261
+ if (cookieOptions.domain !== void 0 && !COOKIE_DOMAIN_PATTERN.test(cookieOptions.domain)) {
262
+ throw new Error("cookie.domain must be a valid cookie domain.");
263
+ }
264
+ if (!cookieOptions.httpOnly) {
265
+ throw new Error("cookie.httpOnly must be true for cookie session transport.");
200
266
  }
201
- return { config, trustedOrigins };
202
267
  };
268
+ var usesCrossSiteCookieTransport = (options) => usesCookieTransport(options) && resolveCookieOptions(options).sameSite === "none";
203
269
  var serializeCookie = (name, value, options, overrides) => {
204
270
  const sameSite = options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1);
205
271
  const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path}`, `SameSite=${sameSite}`];
@@ -238,126 +304,735 @@ var getSessionIdFromCookie = (req, options) => {
238
304
  const cookies = parseCookieHeader(req.headers.cookie);
239
305
  return cookies[resolveCookieOptions(options).name];
240
306
  };
241
- var getSessionIdFromRequest = (req, options, action) => {
242
- const body = getBody(req);
243
- if (usesCookieTransport(options)) {
244
- const cookieSessionId = getSessionIdFromCookie(req, options);
245
- if (isString(cookieSessionId)) {
246
- return cookieSessionId;
247
- }
248
- if (isString(body.sessionId)) {
249
- return body.sessionId;
307
+
308
+ // src/utils.ts
309
+ var isRecord = (value) => typeof value === "object" && value !== null;
310
+ var isString = (value) => typeof value === "string" && value.length > 0;
311
+ var getBody = (req) => isRecord(req.body) ? req.body : {};
312
+
313
+ // src/origins.ts
314
+ var parseHttpUrl = (value, optionName, example) => {
315
+ let url;
316
+ try {
317
+ url = new URL(value);
318
+ } catch {
319
+ throw new Error(`${optionName} must be an absolute HTTP(S) URL, for example ${example}.`);
320
+ }
321
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
322
+ throw new Error(`${optionName} must use http or https, for example ${example}.`);
323
+ }
324
+ return url;
325
+ };
326
+ var resolveTrustedOrigins = (options) => {
327
+ const origins = /* @__PURE__ */ new Set();
328
+ for (const value of options.trustedOrigins ?? []) {
329
+ if (!isString(value)) {
330
+ throw new Error(
331
+ "trustedOrigins entries must be absolute HTTP(S) URLs, for example https://frontend.example.com."
332
+ );
250
333
  }
251
- throw new OidcVaultHttpError(
252
- 400,
253
- "OIDC_VAULT_MISSING_SESSION_ID",
254
- `${action === "refresh" ? "Refresh" : "Logout"} request is missing the session cookie.`
334
+ const url = parseHttpUrl(value, "trustedOrigins entries", "https://frontend.example.com");
335
+ origins.add(url.origin);
336
+ }
337
+ return origins;
338
+ };
339
+ var resolveBackendOrigin = (options) => {
340
+ if (!isString(options.backendOrigin)) {
341
+ throw new Error(
342
+ "backendOrigin is required and must be the public backend origin, for example https://api.example.com."
255
343
  );
256
344
  }
257
- return getRequiredString(
258
- body.sessionId,
259
- `${action === "refresh" ? "Refresh" : "Logout"} request is missing sessionId.`,
260
- "OIDC_VAULT_MISSING_SESSION_ID"
261
- );
345
+ const url = parseHttpUrl(options.backendOrigin, "backendOrigin", "https://api.example.com");
346
+ return url.origin;
262
347
  };
263
- var createExchangeResponse = (options, session, issuedToken) => ({
264
- sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
265
- user: session.user,
266
- ...issuedToken
267
- });
268
- var extractBearerToken = (authorizationHeader) => {
269
- if (!authorizationHeader) {
270
- throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_BEARER_TOKEN", "Missing bearer token.");
348
+ var resolveFrontendRedirectUri = (options) => {
349
+ if (options.frontendRedirectUri === void 0) {
350
+ return void 0;
271
351
  }
272
- const [scheme, token, extra] = authorizationHeader.trim().split(/\s+/);
273
- if (scheme?.toLowerCase() !== "bearer" || !token || extra) {
352
+ if (!isString(options.frontendRedirectUri)) {
353
+ throw new Error(
354
+ "frontendRedirectUri must be an absolute HTTP(S) URL, for example https://frontend.example.com/callback."
355
+ );
356
+ }
357
+ return parseHttpUrl(
358
+ options.frontendRedirectUri,
359
+ "frontendRedirectUri",
360
+ "https://frontend.example.com/callback"
361
+ ).toString();
362
+ };
363
+ var validatePostLogoutRedirectUri = (options) => {
364
+ if (options.postLogoutRedirectUri === void 0) {
365
+ return;
366
+ }
367
+ if (!isString(options.postLogoutRedirectUri)) {
368
+ throw new Error(
369
+ "postLogoutRedirectUri must be an absolute HTTP(S) URL registered with the OIDC provider, for example https://frontend.example.com/logged-out."
370
+ );
371
+ }
372
+ parseHttpUrl(options.postLogoutRedirectUri, "postLogoutRedirectUri", "https://frontend.example.com/logged-out");
373
+ };
374
+ var getRequestSourceOrigin = (req) => {
375
+ const origin = req.get("origin");
376
+ if (isString(origin)) {
377
+ return origin;
378
+ }
379
+ const referer = req.get("referer");
380
+ if (!isString(referer)) {
381
+ return void 0;
382
+ }
383
+ try {
384
+ return new URL(referer).origin;
385
+ } catch {
386
+ return void 0;
387
+ }
388
+ };
389
+ var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
390
+ if (!usesCookieTransport(options)) {
391
+ return;
392
+ }
393
+ const origin = getRequestSourceOrigin(req);
394
+ if (!origin || !trustedOrigins.has(origin)) {
274
395
  throw new OidcVaultHttpError(
275
- 401,
276
- "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
277
- "Authorization header must use the Bearer scheme."
396
+ 403,
397
+ "OIDC_VAULT_UNTRUSTED_ORIGIN",
398
+ `${action === "refresh" ? "Refresh" : "Logout"} request origin is not trusted.`
278
399
  );
279
400
  }
280
- return token;
281
401
  };
282
- var setBearerChallengeHeader = (res) => {
283
- res.setHeader("WWW-Authenticate", "Bearer");
402
+
403
+ // src/provider-client.ts
404
+ import { createRemoteJWKSet } from "jose";
405
+ var DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 5e3;
406
+ var DEFAULT_DISCOVERY_CACHE_TTL_MS = 10 * 6e4;
407
+ var DEFAULT_DISCOVERY_CACHE_CAPACITY = 32;
408
+ var DEFAULT_JWKS_CACHE_CAPACITY = 32;
409
+ var DEFAULT_PROVIDER_ERROR_BODY_LIMIT = 1024;
410
+ var DEFAULT_PROVIDER_JSON_BODY_LIMIT = 1024 * 1024;
411
+ var discoveryCache = /* @__PURE__ */ new Map();
412
+ var jwksCache = /* @__PURE__ */ new Map();
413
+ var normalizeIssuerForDiscoveryComparison = (issuer) => issuer.replace(/\/+$/g, "");
414
+ var assertDiscoveredIssuerMatchesConfig = (configuredIssuer, discoveredIssuer) => {
415
+ if (normalizeIssuerForDiscoveryComparison(configuredIssuer) !== normalizeIssuerForDiscoveryComparison(discoveredIssuer)) {
416
+ throw new OidcVaultHttpError(
417
+ 502,
418
+ "OIDC_VAULT_DISCOVERY_INVALID",
419
+ "OIDC discovery response issuer does not match the configured issuer."
420
+ );
421
+ }
284
422
  };
285
- var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
286
- var defaultJwtClaimsMapper = (claims) => {
287
- const subject = getRequiredString(
288
- claims.sub,
289
- "JWT access token is missing sub.",
290
- "OIDC_VAULT_INVALID_ACCESS_TOKEN",
291
- 401
292
- );
293
- return {
294
- subject,
295
- sessionId: typeof claims.sid === "string" ? claims.sid : void 0,
296
- scope: typeof claims.scope === "string" ? claims.scope : void 0,
297
- claims
298
- };
423
+ var validateDiscoveredHttpUrl = (value, fieldName) => {
424
+ let url;
425
+ try {
426
+ url = new URL(value);
427
+ } catch {
428
+ throw new OidcVaultHttpError(
429
+ 502,
430
+ "OIDC_VAULT_DISCOVERY_INVALID",
431
+ `OIDC discovery response ${fieldName} must be an absolute HTTP(S) URL.`
432
+ );
433
+ }
434
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
435
+ throw new OidcVaultHttpError(
436
+ 502,
437
+ "OIDC_VAULT_DISCOVERY_INVALID",
438
+ `OIDC discovery response ${fieldName} must use http or https.`
439
+ );
440
+ }
441
+ return url.toString();
299
442
  };
300
443
  var buildWellKnownUrl = (issuer) => {
301
444
  const normalizedIssuer = issuer.endsWith("/") ? issuer : `${issuer}/`;
302
445
  return new URL(".well-known/openid-configuration", normalizedIssuer);
303
446
  };
304
- var resolveJwks = (jwksUri) => {
305
- let jwks = jwksCache.get(jwksUri);
306
- if (!jwks) {
307
- jwks = createRemoteJWKSet(new URL(jwksUri));
308
- jwksCache.set(jwksUri, jwks);
447
+ var getProviderRequestTimeoutMs = (options = {}) => {
448
+ const timeout = options.providerRequestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS;
449
+ if (!Number.isFinite(timeout) || !Number.isInteger(timeout) || timeout <= 0) {
450
+ throw new OidcVaultHttpError(
451
+ 500,
452
+ "OIDC_VAULT_INVALID_CONFIG",
453
+ "providerRequestTimeoutMs must be a positive finite integer."
454
+ );
309
455
  }
310
- return jwks;
456
+ return timeout;
311
457
  };
312
- var appendQueryParam = (url, name, value) => {
313
- if (value) {
314
- url.searchParams.set(name, value);
458
+ var evictOldestEntry = (cache, capacity) => {
459
+ while (cache.size > capacity) {
460
+ const oldestKey = cache.keys().next().value;
461
+ if (typeof oldestKey !== "string") {
462
+ return;
463
+ }
464
+ cache.delete(oldestKey);
315
465
  }
316
466
  };
317
- var buildAuthorizationUrl = (metadata, transaction, redirectUri) => {
318
- const url = new URL(metadata.authorizationEndpoint);
319
- appendQueryParam(url, "response_type", "code");
320
- appendQueryParam(url, "client_id", metadata.clientId);
321
- appendQueryParam(url, "redirect_uri", redirectUri);
322
- appendQueryParam(url, "scope", metadata.scopes);
323
- appendQueryParam(url, "state", transaction.state);
324
- appendQueryParam(url, "nonce", transaction.nonce);
325
- appendQueryParam(url, "code_challenge", transaction.codeChallenge);
326
- appendQueryParam(url, "code_challenge_method", "S256");
327
- return url.toString();
467
+ var fetchProvider = async (input, init, errorCode, options) => {
468
+ const controller = new AbortController();
469
+ const timeout = setTimeout(() => controller.abort(), getProviderRequestTimeoutMs(options));
470
+ try {
471
+ return await fetch(input, {
472
+ ...init,
473
+ redirect: "manual",
474
+ signal: controller.signal
475
+ });
476
+ } catch (error) {
477
+ if (error instanceof Error && error.name === "AbortError") {
478
+ throw new OidcVaultHttpError(502, errorCode, "OIDC provider request timed out.");
479
+ }
480
+ throw error;
481
+ } finally {
482
+ clearTimeout(timeout);
483
+ }
328
484
  };
329
- var buildLogoutUrl = (endSessionEndpoint, idTokenHint, postLogoutRedirectUri) => {
330
- const url = new URL(endSessionEndpoint);
331
- appendQueryParam(url, "id_token_hint", idTokenHint);
332
- appendQueryParam(url, "post_logout_redirect_uri", postLogoutRedirectUri);
333
- return url.toString();
485
+ var readBoundedResponseText = async (response, limit, errorCode, options = {}) => {
486
+ if (!response.body) {
487
+ return "";
488
+ }
489
+ const reader = response.body.getReader();
490
+ const decoder = new TextDecoder();
491
+ let size = 0;
492
+ let text = "";
493
+ try {
494
+ for (; ; ) {
495
+ const result = await reader.read();
496
+ if (result.done) {
497
+ break;
498
+ }
499
+ size += result.value.byteLength;
500
+ if (size > limit) {
501
+ await reader.cancel();
502
+ if (options.truncate) {
503
+ return `${text.slice(0, limit)}...`;
504
+ }
505
+ throw new OidcVaultHttpError(502, errorCode, "OIDC provider response is too large.");
506
+ }
507
+ text += decoder.decode(result.value, { stream: true });
508
+ }
509
+ } finally {
510
+ reader.releaseLock();
511
+ }
512
+ return `${text}${decoder.decode()}`;
513
+ };
514
+ var resolveJwks = (jwksUri, options = {}) => {
515
+ let jwks = jwksCache.get(jwksUri);
516
+ if (!jwks) {
517
+ jwks = createRemoteJWKSet(new URL(jwksUri), {
518
+ timeoutDuration: getProviderRequestTimeoutMs(options),
519
+ cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS
520
+ });
521
+ jwksCache.set(jwksUri, jwks);
522
+ evictOldestEntry(jwksCache, DEFAULT_JWKS_CACHE_CAPACITY);
523
+ }
524
+ return jwks;
334
525
  };
335
- var toBasicAuthorization = (clientId, clientSecret) => `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
336
- var readJsonResponse = async (response, errorCode) => {
337
- const text = await response.text();
526
+ var formEncodeClientCredential = (value) => new URLSearchParams([[value, ""]]).toString().slice(0, -1);
527
+ 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);
338
530
  if (!text) {
339
531
  return {};
340
532
  }
341
533
  try {
342
534
  return JSON.parse(text);
343
535
  } catch {
344
- throw new OidcVaultHttpError(response.status, errorCode, text);
536
+ throw new OidcVaultHttpError(
537
+ response.ok ? 502 : response.status,
538
+ errorCode,
539
+ invalidJsonMessage,
540
+ invalidJsonMessage
541
+ );
345
542
  }
346
543
  };
347
- var getLogoutTokenFromRequest = (req) => {
348
- const body = getBody(req);
349
- return getRequiredString(
350
- body.logout_token,
351
- "Backchannel logout request is missing logout_token.",
352
- "OIDC_VAULT_MISSING_LOGOUT_TOKEN"
544
+ async function discoverIssuerMetadata(issuer, options) {
545
+ 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) {
552
+ discoveryPromise = (async () => {
553
+ 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(
557
+ response,
558
+ DEFAULT_PROVIDER_ERROR_BODY_LIMIT,
559
+ "OIDC_VAULT_DISCOVERY_FAILED",
560
+ {
561
+ truncate: true
562
+ }
563
+ );
564
+ throw new OidcVaultHttpError(
565
+ 502,
566
+ "OIDC_VAULT_DISCOVERY_FAILED",
567
+ errorText || `OIDC discovery failed with status ${response.status}.`,
568
+ "OIDC discovery failed."
569
+ );
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.",
598
+ "OIDC_VAULT_DISCOVERY_INVALID",
599
+ 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
+ };
618
+ })();
619
+ discoveryCache.set(issuer, {
620
+ expiresAt: now + DEFAULT_DISCOVERY_CACHE_TTL_MS,
621
+ promise: discoveryPromise
622
+ });
623
+ evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
624
+ discoveryPromise.catch(() => {
625
+ if (discoveryCache.get(issuer)?.promise === discoveryPromise) {
626
+ discoveryCache.delete(issuer);
627
+ }
628
+ });
629
+ }
630
+ return discoveryPromise;
631
+ }
632
+ async function resolveProviderMetadata(config, options = {}) {
633
+ if (config.mode === "manual") {
634
+ if (!config.issuer) {
635
+ throw new OidcVaultHttpError(500, "OIDC_VAULT_INVALID_CONFIG", "Manual OIDC configuration requires issuer.");
636
+ }
637
+ return {
638
+ issuer: config.issuer,
639
+ authorizationEndpoint: config.authorizationEndpoint,
640
+ tokenEndpoint: config.tokenEndpoint,
641
+ jwksUri: config.jwksUri,
642
+ userInfoEndpoint: config.userInfoEndpoint,
643
+ endSessionEndpoint: config.endSessionEndpoint,
644
+ clientId: config.clientId,
645
+ clientSecret: config.clientSecret,
646
+ scopes: config.scopes
647
+ };
648
+ }
649
+ if (!config.issuer) {
650
+ throw new OidcVaultHttpError(500, "OIDC_VAULT_INVALID_CONFIG", "Issuer discovery requires an issuer URL.");
651
+ }
652
+ return {
653
+ ...await discoverIssuerMetadata(config.issuer, options),
654
+ clientId: config.clientId,
655
+ clientSecret: config.clientSecret,
656
+ scopes: config.scopes
657
+ };
658
+ }
659
+ async function requestToken(metadata, params, options = {}) {
660
+ const clientId = metadata.clientId;
661
+ if (!clientId) {
662
+ throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_CLIENT_ID", "OIDC clientId is required.");
663
+ }
664
+ const body = new URLSearchParams();
665
+ body.set("client_id", clientId);
666
+ for (const [key, value] of Object.entries(params)) {
667
+ if (value !== void 0) {
668
+ body.set(key, value);
669
+ }
670
+ }
671
+ const headers = {
672
+ "content-type": "application/x-www-form-urlencoded"
673
+ };
674
+ if (metadata.clientSecret) {
675
+ headers.authorization = toBasicAuthorization(clientId, metadata.clientSecret);
676
+ }
677
+ const response = await fetchProvider(
678
+ metadata.tokenEndpoint,
679
+ {
680
+ method: "POST",
681
+ headers,
682
+ body
683
+ },
684
+ "OIDC_VAULT_TOKEN_REQUEST_FAILED",
685
+ options
353
686
  );
687
+ const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
688
+ if (!response.ok) {
689
+ throw new OidcVaultHttpError(
690
+ 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."
694
+ );
695
+ }
696
+ return json;
697
+ }
698
+ function validateTokenResponse(tokenResponse) {
699
+ if (typeof tokenResponse.token_type !== "string" || tokenResponse.token_type.toLowerCase() !== "bearer") {
700
+ throw new OidcVaultHttpError(
701
+ 502,
702
+ "OIDC_VAULT_INVALID_TOKEN_RESPONSE",
703
+ "OIDC token response token_type must be Bearer."
704
+ );
705
+ }
706
+ if (tokenResponse.expires_in !== void 0) {
707
+ getRequiredFiniteNonNegativeInteger(
708
+ tokenResponse.expires_in,
709
+ "OIDC token response expires_in must be a finite non-negative integer.",
710
+ "OIDC_VAULT_INVALID_TOKEN_RESPONSE"
711
+ );
712
+ }
713
+ }
714
+ async function fetchUserInfo(metadata, accessToken, options = {}) {
715
+ if (!metadata.userInfoEndpoint) {
716
+ return {};
717
+ }
718
+ const response = await fetchProvider(
719
+ metadata.userInfoEndpoint,
720
+ {
721
+ headers: {
722
+ authorization: `Bearer ${accessToken}`
723
+ }
724
+ },
725
+ "OIDC_VAULT_USERINFO_FAILED",
726
+ options
727
+ );
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
+ );
736
+ }
737
+ return json;
738
+ }
739
+
740
+ // src/token-validation.ts
741
+ import { jwtVerify } from "jose";
742
+ var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
743
+ var validateBackchannelLogoutTokenTyp = (typ) => {
744
+ if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
745
+ throw new OidcVaultHttpError(
746
+ 400,
747
+ "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
748
+ "Backchannel logout token protected header typ must be logout+jwt when present."
749
+ );
750
+ }
751
+ };
752
+ var defaultJwtClaimsMapper = (claims) => {
753
+ const subject = getRequiredString(
754
+ claims.sub,
755
+ "JWT access token is missing sub.",
756
+ "OIDC_VAULT_INVALID_ACCESS_TOKEN",
757
+ 401
758
+ );
759
+ return {
760
+ subject,
761
+ sessionId: typeof claims.sid === "string" ? claims.sid : void 0,
762
+ scope: typeof claims.scope === "string" ? claims.scope : void 0,
763
+ claims
764
+ };
765
+ };
766
+ var assertUserInfoSubject = (userInfo, subject) => {
767
+ if (!isString(userInfo.sub)) {
768
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is missing sub.");
769
+ }
770
+ if (userInfo.sub !== subject) {
771
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo sub does not match id_token sub.");
772
+ }
354
773
  };
355
- var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
356
774
  var mergeUserProfile = (sub, idTokenClaims, userInfo) => ({
357
775
  ...idTokenClaims,
358
776
  ...userInfo ?? {},
359
777
  sub
360
778
  });
779
+ async function verifyIdToken(metadata, idToken, nonce, options = {}) {
780
+ if (!metadata.jwksUri) {
781
+ throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_JWKS_URI", "OIDC jwksUri is required to validate id_token.");
782
+ }
783
+ if (!metadata.clientId) {
784
+ throw new OidcVaultHttpError(
785
+ 500,
786
+ "OIDC_VAULT_MISSING_CLIENT_ID",
787
+ "OIDC clientId is required to validate id_token."
788
+ );
789
+ }
790
+ const jwks = resolveJwks(metadata.jwksUri, options);
791
+ const result = await jwtVerify(idToken, jwks, {
792
+ audience: metadata.clientId,
793
+ issuer: metadata.issuer
794
+ });
795
+ if (nonce && result.payload.nonce !== nonce) {
796
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC id_token nonce validation failed.");
797
+ }
798
+ if (typeof result.payload.exp !== "number") {
799
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC id_token is missing exp.");
800
+ }
801
+ if (typeof result.payload.iat !== "number") {
802
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC id_token is missing iat.");
803
+ }
804
+ if (!isString(result.payload.sub)) {
805
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC id_token is missing sub.");
806
+ }
807
+ if (result.payload.azp !== void 0 && result.payload.azp !== metadata.clientId) {
808
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC id_token azp validation failed.");
809
+ }
810
+ if (Array.isArray(result.payload.aud) && result.payload.aud.length > 1 && result.payload.azp !== metadata.clientId) {
811
+ throw new OidcVaultHttpError(
812
+ 502,
813
+ "OIDC_VAULT_INVALID_ID_TOKEN",
814
+ "OIDC id_token azp is required for multiple audiences."
815
+ );
816
+ }
817
+ return result.payload;
818
+ }
819
+ async function verifyBackchannelLogoutToken(metadata, logoutToken, options = {}) {
820
+ if (!metadata.jwksUri) {
821
+ throw new OidcVaultHttpError(
822
+ 500,
823
+ "OIDC_VAULT_MISSING_JWKS_URI",
824
+ "OIDC jwksUri is required to validate logout_token."
825
+ );
826
+ }
827
+ if (!metadata.clientId) {
828
+ throw new OidcVaultHttpError(
829
+ 500,
830
+ "OIDC_VAULT_MISSING_CLIENT_ID",
831
+ "OIDC clientId is required to validate logout_token."
832
+ );
833
+ }
834
+ const jwks = resolveJwks(metadata.jwksUri, options);
835
+ const result = await jwtVerify(logoutToken, jwks, {
836
+ audience: metadata.clientId,
837
+ issuer: metadata.issuer
838
+ });
839
+ const claims = result.payload;
840
+ validateBackchannelLogoutTokenTyp(result.protectedHeader.typ);
841
+ if (claims.nonce !== void 0) {
842
+ throw new OidcVaultHttpError(
843
+ 400,
844
+ "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
845
+ "Backchannel logout token must not contain nonce."
846
+ );
847
+ }
848
+ if (!isRecord(claims.events) || !isRecord(claims.events[BACKCHANNEL_LOGOUT_EVENT_CLAIM])) {
849
+ throw new OidcVaultHttpError(
850
+ 400,
851
+ "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
852
+ "Backchannel logout token is missing the required event claim."
853
+ );
854
+ }
855
+ if (!isString(claims.sid) && !isString(claims.sub)) {
856
+ throw new OidcVaultHttpError(
857
+ 400,
858
+ "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
859
+ "Backchannel logout token must include sid or sub."
860
+ );
861
+ }
862
+ if (!isString(claims.jti)) {
863
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_LOGOUT_TOKEN", "Backchannel logout token must include jti.");
864
+ }
865
+ getRequiredFiniteNonNegativeInteger(
866
+ claims.iat,
867
+ "Backchannel logout token must include iat.",
868
+ "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
869
+ 400
870
+ );
871
+ getRequiredFiniteNonNegativeInteger(
872
+ claims.exp,
873
+ "Backchannel logout token must include exp.",
874
+ "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
875
+ 400
876
+ );
877
+ return claims;
878
+ }
879
+ function createOidcVaultJwtAccessTokenValidator(options) {
880
+ return {
881
+ async validate(token) {
882
+ const result = await jwtVerify(token, options.key, {
883
+ issuer: options.issuer,
884
+ audience: options.audience,
885
+ algorithms: options.algorithms
886
+ });
887
+ const claims = result.payload;
888
+ return options.mapClaims ? options.mapClaims(claims) : defaultJwtClaimsMapper(claims);
889
+ }
890
+ };
891
+ }
892
+
893
+ // src/types.ts
894
+ var OidcVaultStoreConflictError = class extends Error {
895
+ constructor(message = "OIDC vault store operation conflicted with concurrent state changes.") {
896
+ super(message);
897
+ this.name = "OidcVaultStoreConflictError";
898
+ }
899
+ };
900
+
901
+ // src/access-token-middleware.ts
902
+ var extractBearerToken = (authorizationHeader) => {
903
+ if (!authorizationHeader) {
904
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_BEARER_TOKEN", "Missing bearer token.");
905
+ }
906
+ const [scheme, token, extra] = authorizationHeader.trim().split(/\s+/);
907
+ if (scheme?.toLowerCase() !== "bearer" || !token || extra) {
908
+ throw new OidcVaultHttpError(
909
+ 401,
910
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
911
+ "Authorization header must use the Bearer scheme."
912
+ );
913
+ }
914
+ return token;
915
+ };
916
+ var setBearerChallengeHeader = (res) => {
917
+ res.setHeader("WWW-Authenticate", "Bearer");
918
+ };
919
+ function createOidcVaultAccessTokenMiddleware(options) {
920
+ return async (req, res, next) => {
921
+ try {
922
+ const token = extractBearerToken(req.get("authorization"));
923
+ const validationResult = await options.validator.validate(token);
924
+ const auth = {
925
+ token,
926
+ ...validationResult
927
+ };
928
+ const authenticatedRequest = req;
929
+ authenticatedRequest.auth = auth;
930
+ await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
931
+ next();
932
+ } catch (error) {
933
+ if (error instanceof OidcVaultHttpError) {
934
+ setBearerChallengeHeader(res);
935
+ res.status(error.status).json({
936
+ code: error.code,
937
+ message: error.clientMessage
938
+ });
939
+ return;
940
+ }
941
+ setBearerChallengeHeader(res);
942
+ res.status(401).json({
943
+ code: "OIDC_VAULT_INVALID_ACCESS_TOKEN",
944
+ message: "Access token validation failed."
945
+ });
946
+ }
947
+ };
948
+ }
949
+
950
+ // src/index.ts
951
+ function normalizeOidcVaultBasePath(value) {
952
+ if (!value || value === "/") {
953
+ return DEFAULT_OIDC_VAULT_BASE_PATH;
954
+ }
955
+ return `/${value.replace(/^\/+|\/+$/g, "")}`;
956
+ }
957
+ var getNow = (options) => (options.now ?? Date.now)();
958
+ var createOpaqueId = (prefix) => `${prefix}_${randomBytes(16).toString("base64url")}`;
959
+ var createPkceVerifier = () => randomBytes(32).toString("base64url");
960
+ var createPkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
961
+ var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
962
+ var validateOidcVaultOptions = (options) => {
963
+ const backendOrigin = resolveBackendOrigin(options);
964
+ const frontendRedirectUri = resolveFrontendRedirectUri(options);
965
+ validatePostLogoutRedirectUri(options);
966
+ validateCookieOptions(options);
967
+ const config = resolveOidcVaultConfig(options.config);
968
+ const configuredTrustedOrigins = resolveTrustedOrigins(options);
969
+ if (usesCrossSiteCookieTransport(options) && configuredTrustedOrigins.size === 0) {
970
+ throw new Error("trustedOrigins is required when using cross-site cookie transport.");
971
+ }
972
+ const trustedOrigins = new Set(configuredTrustedOrigins);
973
+ trustedOrigins.add(backendOrigin);
974
+ if (frontendRedirectUri) {
975
+ options.frontendRedirectUri = frontendRedirectUri;
976
+ }
977
+ return { backendOrigin, config, trustedOrigins };
978
+ };
979
+ var isStoreConflictError = (error) => error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
980
+ var getSessionIdFromRequest = (req, options, action) => {
981
+ if (usesCookieTransport(options)) {
982
+ const cookieSessionId = getSessionIdFromCookie(req, options);
983
+ if (isString(cookieSessionId)) {
984
+ return cookieSessionId;
985
+ }
986
+ throw new OidcVaultHttpError(
987
+ 400,
988
+ "OIDC_VAULT_MISSING_SESSION_ID",
989
+ `${action === "refresh" ? "Refresh" : "Logout"} request is missing the session cookie.`
990
+ );
991
+ }
992
+ const body = getBody(req);
993
+ return getRequiredString(
994
+ body.sessionId,
995
+ `${action === "refresh" ? "Refresh" : "Logout"} request is missing sessionId.`,
996
+ "OIDC_VAULT_MISSING_SESSION_ID"
997
+ );
998
+ };
999
+ var createExchangeResponse = (options, session, issuedToken) => ({
1000
+ sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
1001
+ user: session.user,
1002
+ ...issuedToken
1003
+ });
1004
+ var appendQueryParam = (url, name, value) => {
1005
+ if (value) {
1006
+ url.searchParams.set(name, value);
1007
+ }
1008
+ };
1009
+ var buildAuthorizationUrl = (metadata, transaction, redirectUri) => {
1010
+ const url = new URL(metadata.authorizationEndpoint);
1011
+ appendQueryParam(url, "response_type", "code");
1012
+ appendQueryParam(url, "client_id", metadata.clientId);
1013
+ appendQueryParam(url, "redirect_uri", redirectUri);
1014
+ appendQueryParam(url, "scope", metadata.scopes);
1015
+ appendQueryParam(url, "state", transaction.state);
1016
+ appendQueryParam(url, "nonce", transaction.nonce);
1017
+ appendQueryParam(url, "code_challenge", transaction.codeChallenge);
1018
+ appendQueryParam(url, "code_challenge_method", "S256");
1019
+ return url.toString();
1020
+ };
1021
+ var buildLogoutUrl = (endSessionEndpoint, idTokenHint, postLogoutRedirectUri) => {
1022
+ const url = new URL(endSessionEndpoint);
1023
+ appendQueryParam(url, "id_token_hint", idTokenHint);
1024
+ appendQueryParam(url, "post_logout_redirect_uri", postLogoutRedirectUri);
1025
+ return url.toString();
1026
+ };
1027
+ var getLogoutTokenFromRequest = (req) => {
1028
+ const body = getBody(req);
1029
+ return getRequiredString(
1030
+ body.logout_token,
1031
+ "Backchannel logout request is missing logout_token.",
1032
+ "OIDC_VAULT_MISSING_LOGOUT_TOKEN"
1033
+ );
1034
+ };
1035
+ var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
361
1036
  var resolveReturnTo = (req, options) => {
362
1037
  const rawReturnTo = req.query.returnTo;
363
1038
  if (!isString(rawReturnTo)) {
@@ -372,7 +1047,12 @@ var resolveReturnTo = (req, options) => {
372
1047
  );
373
1048
  }
374
1049
  const configuredUrl = new URL(configuredFrontendUri);
375
- const resolvedUrl = rawReturnTo.startsWith("/") ? new URL(rawReturnTo, configuredUrl) : new URL(rawReturnTo);
1050
+ let resolvedUrl;
1051
+ try {
1052
+ resolvedUrl = rawReturnTo.startsWith("/") ? new URL(rawReturnTo, configuredUrl) : new URL(rawReturnTo);
1053
+ } catch {
1054
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_RETURN_TO", "returnTo must be a valid URL.");
1055
+ }
376
1056
  if (resolvedUrl.origin !== configuredUrl.origin) {
377
1057
  throw new OidcVaultHttpError(
378
1058
  400,
@@ -387,14 +1067,13 @@ var appendCodeToRedirectUri = (redirectUri, code) => {
387
1067
  url.searchParams.set("code", code);
388
1068
  return url.toString();
389
1069
  };
390
- var getBody = (req) => isRecord(req.body) ? req.body : {};
391
1070
  var withIssuedToken = async (req, res, options, session) => {
392
1071
  if (!options.tokenIssuer) {
393
1072
  return {};
394
1073
  }
395
1074
  return options.tokenIssuer.issue({ req, res, session });
396
1075
  };
397
- var resolveFrontendRedirectUri = (transaction, options) => {
1076
+ var resolveFrontendRedirectUri2 = (transaction, options) => {
398
1077
  const redirectUri = transaction.returnTo ?? options.frontendRedirectUri;
399
1078
  if (!redirectUri) {
400
1079
  throw new OidcVaultHttpError(
@@ -412,6 +1091,22 @@ async function callHook(route, hook, req, res, session, metadata) {
412
1091
  }
413
1092
  await hook(createHookContext(route, req, res, session, metadata));
414
1093
  }
1094
+ async function callPostCommitHook(route, options, hook, req, res, session, metadata) {
1095
+ if (!hook) {
1096
+ return;
1097
+ }
1098
+ try {
1099
+ await hook(createHookContext(route, req, res, session, metadata));
1100
+ } catch (error) {
1101
+ try {
1102
+ await options.hooks?.onError?.({
1103
+ ...createHookContext(route, req, res, session, metadata),
1104
+ error
1105
+ });
1106
+ } catch {
1107
+ }
1108
+ }
1109
+ }
415
1110
  async function handleRouteError(route, req, res, next, options, error) {
416
1111
  try {
417
1112
  await options.hooks?.onError?.({
@@ -439,208 +1134,21 @@ function createAsyncHandler(route, options, handler) {
439
1134
  }
440
1135
  };
441
1136
  }
442
- async function discoverIssuerMetadata(issuer) {
443
- let discoveryPromise = discoveryCache.get(issuer);
444
- if (!discoveryPromise) {
445
- discoveryPromise = (async () => {
446
- const discoveryUrl = buildWellKnownUrl(issuer);
447
- const response = await fetch(discoveryUrl);
448
- if (!response.ok) {
449
- const errorText = await response.text();
450
- throw new OidcVaultHttpError(
451
- 502,
452
- "OIDC_VAULT_DISCOVERY_FAILED",
453
- errorText || `OIDC discovery failed with status ${response.status}.`
454
- );
455
- }
456
- const discovered = await response.json();
457
- const authorizationEndpoint = getRequiredString(
458
- discovered.authorization_endpoint,
459
- "OIDC discovery response is missing authorization_endpoint.",
460
- "OIDC_VAULT_DISCOVERY_INVALID",
461
- 502
462
- );
463
- const tokenEndpoint = getRequiredString(
464
- discovered.token_endpoint,
465
- "OIDC discovery response is missing token_endpoint.",
466
- "OIDC_VAULT_DISCOVERY_INVALID",
467
- 502
468
- );
469
- const jwksUri = getRequiredString(
470
- discovered.jwks_uri,
471
- "OIDC discovery response is missing jwks_uri.",
472
- "OIDC_VAULT_DISCOVERY_INVALID",
473
- 502
474
- );
475
- return {
476
- issuer: getRequiredString(
477
- discovered.issuer,
478
- "OIDC discovery response is missing issuer.",
479
- "OIDC_VAULT_DISCOVERY_INVALID",
480
- 502
481
- ),
482
- authorizationEndpoint,
483
- tokenEndpoint,
484
- jwksUri,
485
- userInfoEndpoint: typeof discovered.userinfo_endpoint === "string" ? discovered.userinfo_endpoint : void 0,
486
- endSessionEndpoint: typeof discovered.end_session_endpoint === "string" ? discovered.end_session_endpoint : void 0
487
- };
488
- })();
489
- discoveryCache.set(issuer, discoveryPromise);
490
- discoveryPromise.catch(() => {
491
- if (discoveryCache.get(issuer) === discoveryPromise) {
492
- discoveryCache.delete(issuer);
493
- }
494
- });
495
- }
496
- return discoveryPromise;
497
- }
498
- async function resolveProviderMetadata(config) {
499
- if (config.mode === "manual") {
500
- return {
501
- issuer: config.issuer,
502
- authorizationEndpoint: config.authorizationEndpoint,
503
- tokenEndpoint: config.tokenEndpoint,
504
- jwksUri: config.jwksUri,
505
- userInfoEndpoint: config.userInfoEndpoint,
506
- endSessionEndpoint: config.endSessionEndpoint,
507
- clientId: config.clientId,
508
- clientSecret: config.clientSecret,
509
- scopes: config.scopes
510
- };
511
- }
512
- if (!config.issuer) {
513
- throw new OidcVaultHttpError(500, "OIDC_VAULT_INVALID_CONFIG", "Issuer discovery requires an issuer URL.");
514
- }
515
- return {
516
- ...await discoverIssuerMetadata(config.issuer),
517
- clientId: config.clientId,
518
- clientSecret: config.clientSecret,
519
- scopes: config.scopes
520
- };
521
- }
522
- async function requestToken(metadata, params) {
523
- const clientId = metadata.clientId;
524
- if (!clientId) {
525
- throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_CLIENT_ID", "OIDC clientId is required.");
526
- }
527
- const body = new URLSearchParams();
528
- body.set("client_id", clientId);
529
- for (const [key, value] of Object.entries(params)) {
530
- if (value !== void 0) {
531
- body.set(key, value);
1137
+ function createBodyParserErrorHandler() {
1138
+ return (error, _req, res, next) => {
1139
+ if (!isBodyParserError(error)) {
1140
+ next(error);
1141
+ return;
532
1142
  }
533
- }
534
- const headers = {
535
- "content-type": "application/x-www-form-urlencoded"
1143
+ const payload = toBodyParserErrorPayload(error);
1144
+ res.status(payload.status).json({
1145
+ code: payload.code,
1146
+ message: payload.message
1147
+ });
536
1148
  };
537
- if (metadata.clientSecret) {
538
- headers.authorization = toBasicAuthorization(clientId, metadata.clientSecret);
539
- }
540
- const response = await fetch(metadata.tokenEndpoint, {
541
- method: "POST",
542
- headers,
543
- body
544
- });
545
- const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
546
- if (!response.ok) {
547
- throw new OidcVaultHttpError(
548
- 502,
549
- "OIDC_VAULT_TOKEN_REQUEST_FAILED",
550
- typeof json.error_description === "string" ? json.error_description : `OIDC token request failed with status ${response.status}.`
551
- );
552
- }
553
- return json;
554
- }
555
- async function fetchUserInfo(metadata, accessToken) {
556
- if (!metadata.userInfoEndpoint) {
557
- return {};
558
- }
559
- const response = await fetch(metadata.userInfoEndpoint, {
560
- headers: {
561
- authorization: `Bearer ${accessToken}`
562
- }
563
- });
564
- const json = await readJsonResponse(response, "OIDC_VAULT_USERINFO_FAILED");
565
- if (!response.ok) {
566
- throw new OidcVaultHttpError(
567
- 502,
568
- "OIDC_VAULT_USERINFO_FAILED",
569
- `OIDC userinfo request failed with status ${response.status}.`
570
- );
571
- }
572
- return json;
573
- }
574
- async function verifyIdToken(metadata, idToken, nonce) {
575
- if (!metadata.jwksUri) {
576
- throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_JWKS_URI", "OIDC jwksUri is required to validate id_token.");
577
- }
578
- if (!metadata.clientId) {
579
- throw new OidcVaultHttpError(
580
- 500,
581
- "OIDC_VAULT_MISSING_CLIENT_ID",
582
- "OIDC clientId is required to validate id_token."
583
- );
584
- }
585
- const jwks = resolveJwks(metadata.jwksUri);
586
- const result = await jwtVerify(idToken, jwks, {
587
- audience: metadata.clientId,
588
- issuer: metadata.issuer
589
- });
590
- if (nonce && result.payload.nonce !== nonce) {
591
- throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC id_token nonce validation failed.");
592
- }
593
- return result.payload;
594
- }
595
- async function verifyBackchannelLogoutToken(metadata, logoutToken) {
596
- if (!metadata.jwksUri) {
597
- throw new OidcVaultHttpError(
598
- 500,
599
- "OIDC_VAULT_MISSING_JWKS_URI",
600
- "OIDC jwksUri is required to validate logout_token."
601
- );
602
- }
603
- if (!metadata.clientId) {
604
- throw new OidcVaultHttpError(
605
- 500,
606
- "OIDC_VAULT_MISSING_CLIENT_ID",
607
- "OIDC clientId is required to validate logout_token."
608
- );
609
- }
610
- const jwks = resolveJwks(metadata.jwksUri);
611
- const result = await jwtVerify(logoutToken, jwks, {
612
- audience: metadata.clientId,
613
- issuer: metadata.issuer
614
- });
615
- const claims = result.payload;
616
- if (claims.nonce !== void 0) {
617
- throw new OidcVaultHttpError(
618
- 400,
619
- "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
620
- "Backchannel logout token must not contain nonce."
621
- );
622
- }
623
- if (!isRecord(claims.events) || !(BACKCHANNEL_LOGOUT_EVENT_CLAIM in claims.events)) {
624
- throw new OidcVaultHttpError(
625
- 400,
626
- "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
627
- "Backchannel logout token is missing the required event claim."
628
- );
629
- }
630
- if (!isString(claims.sid) && !isString(claims.sub)) {
631
- throw new OidcVaultHttpError(
632
- 400,
633
- "OIDC_VAULT_INVALID_LOGOUT_TOKEN",
634
- "Backchannel logout token must include sid or sub."
635
- );
636
- }
637
- if (!isString(claims.jti)) {
638
- throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_LOGOUT_TOKEN", "Backchannel logout token must include jti.");
639
- }
640
- return claims;
641
1149
  }
642
- var createLoginHandler = (options, config, basePath) => createAsyncHandler("login", options, async (req, res) => {
643
- const metadata = await resolveProviderMetadata(config);
1150
+ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("login", options, async (req, res) => {
1151
+ const metadata = await resolveProviderMetadata(config, options);
644
1152
  await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: metadata });
645
1153
  const now = getNow(options);
646
1154
  const pkceVerifier = createPkceVerifier();
@@ -654,14 +1162,14 @@ var createLoginHandler = (options, config, basePath) => createAsyncHandler("logi
654
1162
  expiresAt: now + (options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS)
655
1163
  };
656
1164
  await options.storeProvider.createAuthorizationTransaction(transaction);
657
- const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(req, basePath));
1165
+ const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(backendOrigin, basePath));
658
1166
  await callHook("login", options.hooks?.onAuthorizationUrl, req, res, void 0, {
659
1167
  authorizationUrl,
660
1168
  state: transaction.state
661
1169
  });
662
1170
  res.redirect(302, authorizationUrl);
663
1171
  });
664
- var createCallbackHandler = (options, config, basePath) => createAsyncHandler("callback", options, async (req, res) => {
1172
+ var createCallbackHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("callback", options, async (req, res) => {
665
1173
  if (isString(req.query.error)) {
666
1174
  throw new OidcVaultHttpError(400, "OIDC_VAULT_CALLBACK_ERROR", req.query.error);
667
1175
  }
@@ -675,13 +1183,18 @@ var createCallbackHandler = (options, config, basePath) => createAsyncHandler("c
675
1183
  if (!transaction) {
676
1184
  throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
677
1185
  }
678
- const metadata = await resolveProviderMetadata(config);
679
- const tokenResponse = await requestToken(metadata, {
680
- grant_type: "authorization_code",
681
- code,
682
- code_verifier: transaction.pkceVerifier,
683
- redirect_uri: getCallbackUri(req, basePath)
684
- });
1186
+ const metadata = await resolveProviderMetadata(config, options);
1187
+ const tokenResponse = await requestToken(
1188
+ metadata,
1189
+ {
1190
+ grant_type: "authorization_code",
1191
+ code,
1192
+ code_verifier: transaction.pkceVerifier,
1193
+ redirect_uri: getCallbackUri(backendOrigin, basePath)
1194
+ },
1195
+ options
1196
+ );
1197
+ validateTokenResponse(tokenResponse);
685
1198
  if (!isString(tokenResponse.id_token)) {
686
1199
  throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
687
1200
  }
@@ -697,9 +1210,12 @@ var createCallbackHandler = (options, config, basePath) => createAsyncHandler("c
697
1210
  hasRefreshToken: true,
698
1211
  hasIdToken: true
699
1212
  });
700
- const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce);
1213
+ const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce, options);
701
1214
  const subject = getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502);
702
- const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token) : void 0;
1215
+ const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1216
+ if (userInfo) {
1217
+ assertUserInfoSubject(userInfo, subject);
1218
+ }
703
1219
  if (userInfo) {
704
1220
  await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
705
1221
  }
@@ -707,6 +1223,7 @@ var createCallbackHandler = (options, config, basePath) => createAsyncHandler("c
707
1223
  const sessionId = createOpaqueId("sess");
708
1224
  const session = {
709
1225
  sessionId,
1226
+ logicalSessionId: sessionId,
710
1227
  subject,
711
1228
  providerSessionId: typeof claims.sid === "string" ? claims.sid : void 0,
712
1229
  provider: {
@@ -717,7 +1234,6 @@ var createCallbackHandler = (options, config, basePath) => createAsyncHandler("c
717
1234
  idToken: tokenResponse.id_token,
718
1235
  accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : void 0,
719
1236
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
720
- expiresAt: typeof tokenResponse.expires_in === "number" ? now + tokenResponse.expires_in * 1e3 : void 0,
721
1237
  createdAt: now,
722
1238
  updatedAt: now,
723
1239
  user: mergeUserProfile(subject, claims, userInfo),
@@ -727,16 +1243,25 @@ var createCallbackHandler = (options, config, basePath) => createAsyncHandler("c
727
1243
  };
728
1244
  await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
729
1245
  const createdSession = await options.storeProvider.createSession(session);
730
- await callHook("callback", options.hooks?.onSessionCreated, req, res, createdSession, { subject });
731
1246
  const exchangeCode = createOpaqueId("code");
732
- await options.storeProvider.createExchangeCode({
733
- code: exchangeCode,
734
- sessionId: createdSession.sessionId,
735
- returnTo: transaction.returnTo,
736
- createdAt: now,
737
- expiresAt: now + (options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS)
1247
+ try {
1248
+ await options.storeProvider.createExchangeCode({
1249
+ code: exchangeCode,
1250
+ sessionId: createdSession.sessionId,
1251
+ returnTo: transaction.returnTo,
1252
+ createdAt: now,
1253
+ expiresAt: now + (options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS)
1254
+ });
1255
+ } catch (error) {
1256
+ await options.storeProvider.deleteSessionsByLogicalSessionId({
1257
+ logicalSessionId: createdSession.logicalSessionId ?? createdSession.sessionId
1258
+ });
1259
+ throw error;
1260
+ }
1261
+ await callPostCommitHook("callback", options, options.hooks?.onSessionCreated, req, res, createdSession, {
1262
+ subject
738
1263
  });
739
- res.redirect(302, appendCodeToRedirectUri(resolveFrontendRedirectUri(transaction, options), exchangeCode));
1264
+ res.redirect(302, appendCodeToRedirectUri(resolveFrontendRedirectUri2(transaction, options), exchangeCode));
740
1265
  });
741
1266
  var createExchangeHandler = (options) => createAsyncHandler("exchange", options, async (req, res) => {
742
1267
  const body = getBody(req);
@@ -749,7 +1274,18 @@ var createExchangeHandler = (options) => createAsyncHandler("exchange", options,
749
1274
  if (!session) {
750
1275
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
751
1276
  }
752
- const issuedToken = await withIssuedToken(req, res, options, session);
1277
+ let issuedToken;
1278
+ try {
1279
+ issuedToken = await withIssuedToken(req, res, options, session);
1280
+ } catch (error) {
1281
+ await options.storeProvider.deleteSessionsByLogicalSessionId({
1282
+ logicalSessionId: session.logicalSessionId ?? session.sessionId
1283
+ });
1284
+ if (usesCookieTransport(options)) {
1285
+ clearSessionCookie(res, options);
1286
+ }
1287
+ throw error;
1288
+ }
753
1289
  if (usesCookieTransport(options)) {
754
1290
  setSessionCookie(res, options, session.sessionId);
755
1291
  }
@@ -766,30 +1302,43 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
766
1302
  }
767
1303
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
768
1304
  }
769
- const metadata = await resolveProviderMetadata(config);
770
- const tokenResponse = await requestToken(metadata, {
771
- grant_type: "refresh_token",
772
- refresh_token: currentSession.refreshToken
773
- });
774
- const idToken = isString(tokenResponse.id_token) ? tokenResponse.id_token : currentSession.idToken;
775
- const claims = await verifyIdToken(metadata, idToken);
776
- const subject = getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502);
777
- const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token) : void 0;
1305
+ const metadata = await resolveProviderMetadata(config, options);
1306
+ const tokenResponse = await requestToken(
1307
+ metadata,
1308
+ {
1309
+ grant_type: "refresh_token",
1310
+ refresh_token: currentSession.refreshToken
1311
+ },
1312
+ options
1313
+ );
1314
+ validateTokenResponse(tokenResponse);
1315
+ const newIdToken = isString(tokenResponse.id_token) ? tokenResponse.id_token : void 0;
1316
+ 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;
1319
+ if (newIdToken && subject !== currentSession.subject) {
1320
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC refreshed id_token sub changed.");
1321
+ }
1322
+ const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1323
+ if (userInfo) {
1324
+ assertUserInfoSubject(userInfo, subject);
1325
+ }
778
1326
  const now = getNow(options);
1327
+ const logicalSessionId = currentSession.logicalSessionId ?? currentSession.sessionId;
779
1328
  const nextSession = {
780
1329
  ...currentSession,
781
1330
  sessionId: createOpaqueId("sess"),
1331
+ logicalSessionId,
782
1332
  subject,
783
1333
  providerSessionId: typeof claims.sid === "string" ? claims.sid : currentSession.providerSessionId,
784
1334
  refreshToken: isString(tokenResponse.refresh_token) ? tokenResponse.refresh_token : currentSession.refreshToken,
785
1335
  idToken,
786
1336
  accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : currentSession.accessToken,
787
1337
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : currentSession.scope,
788
- expiresAt: typeof tokenResponse.expires_in === "number" ? now + tokenResponse.expires_in * 1e3 : currentSession.expiresAt,
1338
+ expiresAt: currentSession.expiresAt,
789
1339
  updatedAt: now,
790
1340
  user: mergeUserProfile(subject, claims, userInfo ?? currentSession.user)
791
1341
  };
792
- const issuedToken = await withIssuedToken(req, res, options, nextSession);
793
1342
  let rotatedSession;
794
1343
  try {
795
1344
  rotatedSession = await options.storeProvider.rotateSession({
@@ -797,7 +1346,7 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
797
1346
  nextSession
798
1347
  });
799
1348
  } catch (error) {
800
- if (error instanceof OidcVaultStoreConflictError) {
1349
+ if (isStoreConflictError(error)) {
801
1350
  if (usesCookieTransport(options)) {
802
1351
  clearSessionCookie(res, options);
803
1352
  }
@@ -805,7 +1354,19 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
805
1354
  }
806
1355
  throw error;
807
1356
  }
808
- await callHook("refresh", options.hooks?.onSessionRefreshed, req, res, rotatedSession, {
1357
+ let issuedToken;
1358
+ try {
1359
+ issuedToken = await withIssuedToken(req, res, options, rotatedSession);
1360
+ } catch (error) {
1361
+ await options.storeProvider.deleteSessionsByLogicalSessionId({
1362
+ logicalSessionId: rotatedSession.logicalSessionId ?? rotatedSession.sessionId
1363
+ });
1364
+ if (usesCookieTransport(options)) {
1365
+ clearSessionCookie(res, options);
1366
+ }
1367
+ throw error;
1368
+ }
1369
+ await callPostCommitHook("refresh", options, options.hooks?.onSessionRefreshed, req, res, rotatedSession, {
809
1370
  previousSessionId: currentSession.sessionId
810
1371
  });
811
1372
  if (usesCookieTransport(options)) {
@@ -821,6 +1382,7 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
821
1382
  const redirect = body.redirect === true;
822
1383
  const session = await options.storeProvider.getSession(sessionId);
823
1384
  if (!session) {
1385
+ await options.storeProvider.deleteSession(sessionId);
824
1386
  if (usesCookieTransport(options)) {
825
1387
  clearSessionCookie(res, options);
826
1388
  }
@@ -828,26 +1390,38 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
828
1390
  return;
829
1391
  }
830
1392
  await callHook("logout", options.hooks?.onBeforeLogout, req, res, session, void 0);
831
- await options.storeProvider.deleteSession(sessionId);
1393
+ await options.storeProvider.deleteSessionsByLogicalSessionId({
1394
+ logicalSessionId: session.logicalSessionId ?? session.sessionId
1395
+ });
832
1396
  if (usesCookieTransport(options)) {
833
1397
  clearSessionCookie(res, options);
834
1398
  }
835
- const metadata = await resolveProviderMetadata(config);
1399
+ const metadata = await resolveProviderMetadata(config, options);
836
1400
  const upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
837
- await callHook("logout", options.hooks?.onLogout, req, res, session, { upstreamLogoutUrl });
1401
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
838
1402
  if (redirect && upstreamLogoutUrl) {
839
1403
  res.redirect(302, upstreamLogoutUrl);
840
1404
  return;
841
1405
  }
842
1406
  res.status(200).json({
843
- loggedOut: true,
844
- upstreamLogoutUrl
1407
+ loggedOut: true
845
1408
  });
846
1409
  });
847
1410
  var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("backchannel-logout", options, async (req, res) => {
848
- const metadata = await resolveProviderMetadata(config);
1411
+ const metadata = await resolveProviderMetadata(config, options);
849
1412
  const logoutToken = getLogoutTokenFromRequest(req);
850
- const claims = await verifyBackchannelLogoutToken(metadata, logoutToken);
1413
+ const claims = await verifyBackchannelLogoutToken(metadata, logoutToken, options);
1414
+ const firstUse = await options.storeProvider.consumeBackchannelLogoutTokenJti({
1415
+ jti: claims.jti,
1416
+ expiresAt: claims.exp * 1e3
1417
+ });
1418
+ if (!firstUse) {
1419
+ res.status(200).json({
1420
+ loggedOut: true,
1421
+ revokedSessions: 0
1422
+ });
1423
+ return;
1424
+ }
851
1425
  const revokedSessions = isString(claims.sid) ? await options.storeProvider.deleteSessionsByProviderSessionId({
852
1426
  providerSessionId: claims.sid,
853
1427
  issuer: metadata.issuer,
@@ -857,7 +1431,7 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
857
1431
  issuer: metadata.issuer,
858
1432
  clientId: metadata.clientId
859
1433
  });
860
- await callHook("backchannel-logout", options.hooks?.onLogout, req, res, void 0, {
1434
+ await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
861
1435
  providerSessionId: claims.sid,
862
1436
  subject: claims.sub,
863
1437
  revokedSessions
@@ -867,73 +1441,30 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
867
1441
  revokedSessions
868
1442
  });
869
1443
  });
870
- function registerRoutes(router, options, config, trustedOrigins, basePath) {
871
- router.get(OIDC_VAULT_ROUTE_PATHS.login, createLoginHandler(options, config, basePath));
872
- router.get(OIDC_VAULT_ROUTE_PATHS.callback, createCallbackHandler(options, config, basePath));
1444
+ function registerRoutes(router, options, config, trustedOrigins, backendOrigin, basePath) {
1445
+ router.get(OIDC_VAULT_ROUTE_PATHS.login, createLoginHandler(options, config, backendOrigin, basePath));
1446
+ router.get(OIDC_VAULT_ROUTE_PATHS.callback, createCallbackHandler(options, config, backendOrigin, basePath));
873
1447
  router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options));
874
1448
  router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins));
875
1449
  router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins));
876
1450
  router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
877
1451
  }
878
- function createOidcVaultJwtAccessTokenValidator(options) {
879
- return {
880
- async validate(token) {
881
- const result = await jwtVerify(token, options.key, {
882
- issuer: options.issuer,
883
- audience: options.audience,
884
- algorithms: options.algorithms
885
- });
886
- const claims = result.payload;
887
- return options.mapClaims ? options.mapClaims(claims) : defaultJwtClaimsMapper(claims);
888
- }
889
- };
890
- }
891
- function createOidcVaultAccessTokenMiddleware(options) {
892
- return async (req, res, next) => {
893
- try {
894
- const token = extractBearerToken(req.get("authorization"));
895
- const validationResult = await options.validator.validate(token);
896
- const auth = {
897
- token,
898
- ...validationResult
899
- };
900
- const authenticatedRequest = req;
901
- authenticatedRequest.auth = auth;
902
- await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
903
- next();
904
- } catch (error) {
905
- if (error instanceof OidcVaultHttpError) {
906
- setBearerChallengeHeader(res);
907
- res.status(error.status).json({
908
- code: error.code,
909
- message: error.message
910
- });
911
- return;
912
- }
913
- if (error instanceof Error) {
914
- setBearerChallengeHeader(res);
915
- res.status(401).json({
916
- code: "OIDC_VAULT_INVALID_ACCESS_TOKEN",
917
- message: error.message || "Access token validation failed."
918
- });
919
- return;
920
- }
921
- setBearerChallengeHeader(res);
922
- res.status(401).json({
923
- code: "OIDC_VAULT_INVALID_ACCESS_TOKEN",
924
- message: "Access token validation failed."
925
- });
926
- }
927
- };
928
- }
929
1452
  function createOidcVaultMiddleware(options) {
930
- const { config, trustedOrigins } = validateOidcVaultOptions(options);
1453
+ const { backendOrigin, config, trustedOrigins } = validateOidcVaultOptions(options);
931
1454
  const rootRouter = express.Router();
932
1455
  const baseRouter = express.Router();
933
1456
  const basePath = normalizeOidcVaultBasePath(options.basePath);
934
- baseRouter.use(express.json());
935
- baseRouter.use(express.urlencoded({ extended: false }));
936
- registerRoutes(baseRouter, options, config, trustedOrigins, basePath);
1457
+ const requestBodyLimit = options.requestBodyLimit ?? DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT;
1458
+ baseRouter.use(express.json({ limit: requestBodyLimit }));
1459
+ baseRouter.use(
1460
+ express.urlencoded({
1461
+ extended: false,
1462
+ limit: requestBodyLimit,
1463
+ parameterLimit: OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT
1464
+ })
1465
+ );
1466
+ baseRouter.use(createBodyParserErrorHandler());
1467
+ registerRoutes(baseRouter, options, config, trustedOrigins, backendOrigin, basePath);
937
1468
  rootRouter.use(basePath, baseRouter);
938
1469
  return rootRouter;
939
1470
  }
@@ -942,8 +1473,9 @@ export {
942
1473
  DEFAULT_EXCHANGE_CODE_TTL_MS,
943
1474
  DEFAULT_OIDC_SCOPES,
944
1475
  DEFAULT_OIDC_VAULT_BASE_PATH,
945
- DEFAULT_SESSION_COOKIE_NAME,
1476
+ DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
946
1477
  OIDC_VAULT_ROUTE_PATHS,
1478
+ OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
947
1479
  OidcVaultStoreConflictError,
948
1480
  createOidcVaultAccessTokenMiddleware,
949
1481
  createOidcVaultJwtAccessTokenValidator,