@web-ts-toolkit/express-oidc-vault 0.28.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.
package/index.mjs ADDED
@@ -0,0 +1,954 @@
1
+ // src/index.ts
2
+ import { createHash, randomBytes } from "crypto";
3
+ import express from "express";
4
+ import { createRemoteJWKSet, jwtVerify } from "jose";
5
+
6
+ // src/config.ts
7
+ var DEFAULT_OIDC_SCOPES = "openid email profile";
8
+ var MANUAL_CONFIG_REQUIREMENTS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri"];
9
+ var normalizeOptionalString = (value) => {
10
+ const trimmed = value?.trim();
11
+ return trimmed ? trimmed : void 0;
12
+ };
13
+ var withOptionalValue = (target, key, value) => {
14
+ if (value !== void 0) {
15
+ target[key] = value;
16
+ }
17
+ };
18
+ function resolveOidcVaultConfig(config = {}) {
19
+ const issuer = normalizeOptionalString(config.issuer);
20
+ const clientId = normalizeOptionalString(config.clientId);
21
+ const clientSecret = normalizeOptionalString(config.clientSecret);
22
+ const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
23
+ if (!clientId) {
24
+ throw new Error("OIDC clientId is required.");
25
+ }
26
+ if (issuer) {
27
+ const resolved2 = {
28
+ mode: "issuer",
29
+ issuer,
30
+ scopes
31
+ };
32
+ withOptionalValue(resolved2, "clientId", clientId);
33
+ withOptionalValue(resolved2, "clientSecret", clientSecret);
34
+ return resolved2;
35
+ }
36
+ const resolved = {
37
+ mode: "manual",
38
+ scopes
39
+ };
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));
45
+ withOptionalValue(resolved, "clientId", clientId);
46
+ withOptionalValue(resolved, "clientSecret", clientSecret);
47
+ const missing = MANUAL_CONFIG_REQUIREMENTS.filter((key) => !resolved[key]);
48
+ if (missing.length > 0) {
49
+ throw new Error(`Missing required OIDC configuration for manual mode: ${missing.join(", ")}`);
50
+ }
51
+ return resolved;
52
+ }
53
+ function resolveOidcVaultConfigFromEnv(env = process.env) {
54
+ return resolveOidcVaultConfig({
55
+ issuer: env.OIDC_ISSUER,
56
+ authorizationEndpoint: env.OIDC_AUTHORIZATION_ENDPOINT,
57
+ tokenEndpoint: env.OIDC_TOKEN_ENDPOINT,
58
+ userInfoEndpoint: env.OIDC_USERINFO_ENDPOINT,
59
+ jwksUri: env.OIDC_JWKS_URI,
60
+ endSessionEndpoint: env.OIDC_END_SESSION_ENDPOINT,
61
+ clientId: env.OIDC_CLIENT_ID,
62
+ clientSecret: env.OIDC_CLIENT_SECRET,
63
+ scopes: env.OIDC_SCOPES
64
+ });
65
+ }
66
+
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
76
+ var DEFAULT_OIDC_VAULT_BASE_PATH = "/auth/oidc";
77
+ var DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
78
+ var DEFAULT_EXCHANGE_CODE_TTL_MS = 30 * 1e3;
79
+ var DEFAULT_SESSION_COOKIE_NAME = "oidc_vault_session";
80
+ var OIDC_VAULT_ROUTE_PATHS = {
81
+ login: "/login",
82
+ callback: "/callback",
83
+ exchange: "/exchange",
84
+ refresh: "/refresh",
85
+ logout: "/logout",
86
+ "backchannel-logout": "/backchannel-logout"
87
+ };
88
+ var discoveryCache = /* @__PURE__ */ new Map();
89
+ var jwksCache = /* @__PURE__ */ new Map();
90
+ var OidcVaultHttpError = class extends Error {
91
+ constructor(status, code, message) {
92
+ super(message);
93
+ this.status = status;
94
+ this.code = code;
95
+ }
96
+ };
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
+ var toErrorPayload = (error) => {
112
+ if (error instanceof OidcVaultHttpError) {
113
+ return {
114
+ status: error.status,
115
+ code: error.code,
116
+ message: error.message
117
+ };
118
+ }
119
+ return {
120
+ status: 500,
121
+ code: "OIDC_VAULT_INTERNAL_ERROR",
122
+ message: error instanceof Error ? error.message : "Unexpected OIDC vault error."
123
+ };
124
+ };
125
+ var getRequestOrigin = (req) => `${req.protocol}://${req.get("host") ?? "localhost"}`;
126
+ var getCallbackUri = (req, basePath) => `${getRequestOrigin(req)}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
127
+ var getRequiredString = (value, message, code, status = 400) => {
128
+ if (!isString(value)) {
129
+ throw new OidcVaultHttpError(status, code, message);
130
+ }
131
+ return value;
132
+ };
133
+ var parseCookieHeader = (headerValue) => {
134
+ if (!headerValue) {
135
+ return {};
136
+ }
137
+ return headerValue.split(";").reduce((cookies, part) => {
138
+ const separatorIndex = part.indexOf("=");
139
+ if (separatorIndex === -1) {
140
+ return cookies;
141
+ }
142
+ const name = part.slice(0, separatorIndex).trim();
143
+ const value = part.slice(separatorIndex + 1).trim();
144
+ if (!name) {
145
+ return cookies;
146
+ }
147
+ cookies[name] = decodeURIComponent(value);
148
+ return cookies;
149
+ }, {});
150
+ };
151
+ var resolveCookieOptions = (options) => {
152
+ const cookieOptions = options.cookie ?? {};
153
+ const deploymentMode = cookieOptions.deploymentMode ?? "same-origin";
154
+ const sameSite = cookieOptions.sameSite ?? (deploymentMode === "cross-site" ? "none" : "lax");
155
+ const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site");
156
+ return {
157
+ name: cookieOptions.name ?? DEFAULT_SESSION_COOKIE_NAME,
158
+ sameSite,
159
+ secure,
160
+ domain: cookieOptions.domain,
161
+ path: cookieOptions.path ?? "/",
162
+ httpOnly: cookieOptions.httpOnly ?? true
163
+ };
164
+ };
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;
180
+ }
181
+ };
182
+ var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
183
+ if (!usesCrossSiteCookieTransport(options)) {
184
+ return;
185
+ }
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
+ );
193
+ }
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.");
200
+ }
201
+ return { config, trustedOrigins };
202
+ };
203
+ var serializeCookie = (name, value, options, overrides) => {
204
+ const sameSite = options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1);
205
+ const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path}`, `SameSite=${sameSite}`];
206
+ if (options.httpOnly) {
207
+ parts.push("HttpOnly");
208
+ }
209
+ if (options.secure || options.sameSite === "none") {
210
+ parts.push("Secure");
211
+ }
212
+ if (options.domain) {
213
+ parts.push(`Domain=${options.domain}`);
214
+ }
215
+ if (typeof overrides?.maxAge === "number") {
216
+ parts.push(`Max-Age=${overrides.maxAge}`);
217
+ }
218
+ if (overrides?.expires) {
219
+ parts.push(`Expires=${overrides.expires.toUTCString()}`);
220
+ }
221
+ return parts.join("; ");
222
+ };
223
+ var setSessionCookie = (res, options, sessionId) => {
224
+ const cookieOptions = resolveCookieOptions(options);
225
+ res.append("Set-Cookie", serializeCookie(cookieOptions.name, sessionId, cookieOptions));
226
+ };
227
+ var clearSessionCookie = (res, options) => {
228
+ const cookieOptions = resolveCookieOptions(options);
229
+ res.append(
230
+ "Set-Cookie",
231
+ serializeCookie(cookieOptions.name, "", cookieOptions, {
232
+ maxAge: 0,
233
+ expires: /* @__PURE__ */ new Date(0)
234
+ })
235
+ );
236
+ };
237
+ var getSessionIdFromCookie = (req, options) => {
238
+ const cookies = parseCookieHeader(req.headers.cookie);
239
+ return cookies[resolveCookieOptions(options).name];
240
+ };
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;
250
+ }
251
+ throw new OidcVaultHttpError(
252
+ 400,
253
+ "OIDC_VAULT_MISSING_SESSION_ID",
254
+ `${action === "refresh" ? "Refresh" : "Logout"} request is missing the session cookie.`
255
+ );
256
+ }
257
+ return getRequiredString(
258
+ body.sessionId,
259
+ `${action === "refresh" ? "Refresh" : "Logout"} request is missing sessionId.`,
260
+ "OIDC_VAULT_MISSING_SESSION_ID"
261
+ );
262
+ };
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.");
271
+ }
272
+ const [scheme, token, extra] = authorizationHeader.trim().split(/\s+/);
273
+ if (scheme?.toLowerCase() !== "bearer" || !token || extra) {
274
+ throw new OidcVaultHttpError(
275
+ 401,
276
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
277
+ "Authorization header must use the Bearer scheme."
278
+ );
279
+ }
280
+ return token;
281
+ };
282
+ var setBearerChallengeHeader = (res) => {
283
+ res.setHeader("WWW-Authenticate", "Bearer");
284
+ };
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
+ };
299
+ };
300
+ var buildWellKnownUrl = (issuer) => {
301
+ const normalizedIssuer = issuer.endsWith("/") ? issuer : `${issuer}/`;
302
+ return new URL(".well-known/openid-configuration", normalizedIssuer);
303
+ };
304
+ var resolveJwks = (jwksUri) => {
305
+ let jwks = jwksCache.get(jwksUri);
306
+ if (!jwks) {
307
+ jwks = createRemoteJWKSet(new URL(jwksUri));
308
+ jwksCache.set(jwksUri, jwks);
309
+ }
310
+ return jwks;
311
+ };
312
+ var appendQueryParam = (url, name, value) => {
313
+ if (value) {
314
+ url.searchParams.set(name, value);
315
+ }
316
+ };
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();
328
+ };
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();
334
+ };
335
+ var toBasicAuthorization = (clientId, clientSecret) => `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
336
+ var readJsonResponse = async (response, errorCode) => {
337
+ const text = await response.text();
338
+ if (!text) {
339
+ return {};
340
+ }
341
+ try {
342
+ return JSON.parse(text);
343
+ } catch {
344
+ throw new OidcVaultHttpError(response.status, errorCode, text);
345
+ }
346
+ };
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"
353
+ );
354
+ };
355
+ var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
356
+ var mergeUserProfile = (sub, idTokenClaims, userInfo) => ({
357
+ ...idTokenClaims,
358
+ ...userInfo ?? {},
359
+ sub
360
+ });
361
+ var resolveReturnTo = (req, options) => {
362
+ const rawReturnTo = req.query.returnTo;
363
+ if (!isString(rawReturnTo)) {
364
+ return void 0;
365
+ }
366
+ const configuredFrontendUri = options.frontendRedirectUri;
367
+ if (!configuredFrontendUri) {
368
+ throw new OidcVaultHttpError(
369
+ 400,
370
+ "OIDC_VAULT_INVALID_RETURN_TO",
371
+ "Custom returnTo requires a configured frontendRedirectUri."
372
+ );
373
+ }
374
+ const configuredUrl = new URL(configuredFrontendUri);
375
+ const resolvedUrl = rawReturnTo.startsWith("/") ? new URL(rawReturnTo, configuredUrl) : new URL(rawReturnTo);
376
+ if (resolvedUrl.origin !== configuredUrl.origin) {
377
+ throw new OidcVaultHttpError(
378
+ 400,
379
+ "OIDC_VAULT_INVALID_RETURN_TO",
380
+ "returnTo must stay on the configured frontend origin."
381
+ );
382
+ }
383
+ return resolvedUrl.toString();
384
+ };
385
+ var appendCodeToRedirectUri = (redirectUri, code) => {
386
+ const url = new URL(redirectUri);
387
+ url.searchParams.set("code", code);
388
+ return url.toString();
389
+ };
390
+ var getBody = (req) => isRecord(req.body) ? req.body : {};
391
+ var withIssuedToken = async (req, res, options, session) => {
392
+ if (!options.tokenIssuer) {
393
+ return {};
394
+ }
395
+ return options.tokenIssuer.issue({ req, res, session });
396
+ };
397
+ var resolveFrontendRedirectUri = (transaction, options) => {
398
+ const redirectUri = transaction.returnTo ?? options.frontendRedirectUri;
399
+ if (!redirectUri) {
400
+ throw new OidcVaultHttpError(
401
+ 500,
402
+ "OIDC_VAULT_MISSING_FRONTEND_REDIRECT_URI",
403
+ "A frontendRedirectUri or login returnTo is required to complete the callback flow."
404
+ );
405
+ }
406
+ return redirectUri;
407
+ };
408
+ var shouldFetchUserInfo = (options, metadata) => options.fetchUserInfo !== false && Boolean(metadata.userInfoEndpoint);
409
+ async function callHook(route, hook, req, res, session, metadata) {
410
+ if (!hook) {
411
+ return;
412
+ }
413
+ await hook(createHookContext(route, req, res, session, metadata));
414
+ }
415
+ async function handleRouteError(route, req, res, next, options, error) {
416
+ try {
417
+ await options.hooks?.onError?.({
418
+ ...createHookContext(route, req, res),
419
+ error
420
+ });
421
+ } catch {
422
+ }
423
+ if (res.headersSent) {
424
+ next(error);
425
+ return;
426
+ }
427
+ const payload = toErrorPayload(error);
428
+ res.status(payload.status).json({
429
+ code: payload.code,
430
+ message: payload.message
431
+ });
432
+ }
433
+ function createAsyncHandler(route, options, handler) {
434
+ return async (req, res, next) => {
435
+ try {
436
+ await handler(req, res, next);
437
+ } catch (error) {
438
+ await handleRouteError(route, req, res, next, options, error);
439
+ }
440
+ };
441
+ }
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);
532
+ }
533
+ }
534
+ const headers = {
535
+ "content-type": "application/x-www-form-urlencoded"
536
+ };
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
+ }
642
+ var createLoginHandler = (options, config, basePath) => createAsyncHandler("login", options, async (req, res) => {
643
+ const metadata = await resolveProviderMetadata(config);
644
+ await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: metadata });
645
+ const now = getNow(options);
646
+ const pkceVerifier = createPkceVerifier();
647
+ const transaction = {
648
+ state: createOpaqueId("state"),
649
+ nonce: createOpaqueId("nonce"),
650
+ pkceVerifier,
651
+ codeChallenge: createPkceChallenge(pkceVerifier),
652
+ returnTo: resolveReturnTo(req, options),
653
+ createdAt: now,
654
+ expiresAt: now + (options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS)
655
+ };
656
+ await options.storeProvider.createAuthorizationTransaction(transaction);
657
+ const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(req, basePath));
658
+ await callHook("login", options.hooks?.onAuthorizationUrl, req, res, void 0, {
659
+ authorizationUrl,
660
+ state: transaction.state
661
+ });
662
+ res.redirect(302, authorizationUrl);
663
+ });
664
+ var createCallbackHandler = (options, config, basePath) => createAsyncHandler("callback", options, async (req, res) => {
665
+ if (isString(req.query.error)) {
666
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_CALLBACK_ERROR", req.query.error);
667
+ }
668
+ const code = getRequiredString(
669
+ req.query.code,
670
+ "OIDC callback is missing the authorization code.",
671
+ "OIDC_VAULT_MISSING_CODE"
672
+ );
673
+ const state = getRequiredString(req.query.state, "OIDC callback is missing state.", "OIDC_VAULT_MISSING_STATE");
674
+ const transaction = await options.storeProvider.consumeAuthorizationTransaction(state);
675
+ if (!transaction) {
676
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
677
+ }
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
+ });
685
+ if (!isString(tokenResponse.id_token)) {
686
+ throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
687
+ }
688
+ if (!isString(tokenResponse.refresh_token)) {
689
+ throw new OidcVaultHttpError(
690
+ 502,
691
+ "OIDC_VAULT_MISSING_REFRESH_TOKEN",
692
+ "OIDC token response is missing refresh_token."
693
+ );
694
+ }
695
+ await callHook("callback", options.hooks?.onCallbackTokens, req, res, void 0, {
696
+ hasAccessToken: Boolean(tokenResponse.access_token),
697
+ hasRefreshToken: true,
698
+ hasIdToken: true
699
+ });
700
+ const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce);
701
+ 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;
703
+ if (userInfo) {
704
+ await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
705
+ }
706
+ const now = getNow(options);
707
+ const sessionId = createOpaqueId("sess");
708
+ const session = {
709
+ sessionId,
710
+ subject,
711
+ providerSessionId: typeof claims.sid === "string" ? claims.sid : void 0,
712
+ provider: {
713
+ issuer: metadata.issuer ?? (typeof claims.iss === "string" ? claims.iss : void 0),
714
+ clientId: metadata.clientId
715
+ },
716
+ refreshToken: tokenResponse.refresh_token,
717
+ idToken: tokenResponse.id_token,
718
+ accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : void 0,
719
+ scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
720
+ expiresAt: typeof tokenResponse.expires_in === "number" ? now + tokenResponse.expires_in * 1e3 : void 0,
721
+ createdAt: now,
722
+ updatedAt: now,
723
+ user: mergeUserProfile(subject, claims, userInfo),
724
+ metadata: {
725
+ tokenType: tokenResponse.token_type
726
+ }
727
+ };
728
+ await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
729
+ const createdSession = await options.storeProvider.createSession(session);
730
+ await callHook("callback", options.hooks?.onSessionCreated, req, res, createdSession, { subject });
731
+ 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)
738
+ });
739
+ res.redirect(302, appendCodeToRedirectUri(resolveFrontendRedirectUri(transaction, options), exchangeCode));
740
+ });
741
+ var createExchangeHandler = (options) => createAsyncHandler("exchange", options, async (req, res) => {
742
+ const body = getBody(req);
743
+ const code = getRequiredString(body.code, "Exchange request is missing code.", "OIDC_VAULT_MISSING_EXCHANGE_CODE");
744
+ const exchangeRecord = await options.storeProvider.consumeExchangeCode(code);
745
+ if (!exchangeRecord) {
746
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
747
+ }
748
+ const session = await options.storeProvider.getSession(exchangeRecord.sessionId);
749
+ if (!session) {
750
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
751
+ }
752
+ const issuedToken = await withIssuedToken(req, res, options, session);
753
+ if (usesCookieTransport(options)) {
754
+ setSessionCookie(res, options, session.sessionId);
755
+ }
756
+ const response = createExchangeResponse(options, session, issuedToken);
757
+ res.status(200).json(response);
758
+ });
759
+ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandler("refresh", options, async (req, res) => {
760
+ assertTrustedOrigin(req, options, trustedOrigins, "refresh");
761
+ const sessionId = getSessionIdFromRequest(req, options, "refresh");
762
+ const currentSession = await options.storeProvider.getSession(sessionId);
763
+ if (!currentSession) {
764
+ if (usesCookieTransport(options)) {
765
+ clearSessionCookie(res, options);
766
+ }
767
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
768
+ }
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;
778
+ const now = getNow(options);
779
+ const nextSession = {
780
+ ...currentSession,
781
+ sessionId: createOpaqueId("sess"),
782
+ subject,
783
+ providerSessionId: typeof claims.sid === "string" ? claims.sid : currentSession.providerSessionId,
784
+ refreshToken: isString(tokenResponse.refresh_token) ? tokenResponse.refresh_token : currentSession.refreshToken,
785
+ idToken,
786
+ accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : currentSession.accessToken,
787
+ scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : currentSession.scope,
788
+ expiresAt: typeof tokenResponse.expires_in === "number" ? now + tokenResponse.expires_in * 1e3 : currentSession.expiresAt,
789
+ updatedAt: now,
790
+ user: mergeUserProfile(subject, claims, userInfo ?? currentSession.user)
791
+ };
792
+ const issuedToken = await withIssuedToken(req, res, options, nextSession);
793
+ let rotatedSession;
794
+ try {
795
+ rotatedSession = await options.storeProvider.rotateSession({
796
+ sessionId: currentSession.sessionId,
797
+ nextSession
798
+ });
799
+ } catch (error) {
800
+ if (error instanceof OidcVaultStoreConflictError) {
801
+ if (usesCookieTransport(options)) {
802
+ clearSessionCookie(res, options);
803
+ }
804
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
805
+ }
806
+ throw error;
807
+ }
808
+ await callHook("refresh", options.hooks?.onSessionRefreshed, req, res, rotatedSession, {
809
+ previousSessionId: currentSession.sessionId
810
+ });
811
+ if (usesCookieTransport(options)) {
812
+ setSessionCookie(res, options, rotatedSession.sessionId);
813
+ }
814
+ const response = createExchangeResponse(options, rotatedSession, issuedToken);
815
+ res.status(200).json(response);
816
+ });
817
+ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandler("logout", options, async (req, res) => {
818
+ assertTrustedOrigin(req, options, trustedOrigins, "logout");
819
+ const body = getBody(req);
820
+ const sessionId = getSessionIdFromRequest(req, options, "logout");
821
+ const redirect = body.redirect === true;
822
+ const session = await options.storeProvider.getSession(sessionId);
823
+ if (!session) {
824
+ if (usesCookieTransport(options)) {
825
+ clearSessionCookie(res, options);
826
+ }
827
+ res.status(200).json({ loggedOut: true });
828
+ return;
829
+ }
830
+ await callHook("logout", options.hooks?.onBeforeLogout, req, res, session, void 0);
831
+ await options.storeProvider.deleteSession(sessionId);
832
+ if (usesCookieTransport(options)) {
833
+ clearSessionCookie(res, options);
834
+ }
835
+ const metadata = await resolveProviderMetadata(config);
836
+ const upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
837
+ await callHook("logout", options.hooks?.onLogout, req, res, session, { upstreamLogoutUrl });
838
+ if (redirect && upstreamLogoutUrl) {
839
+ res.redirect(302, upstreamLogoutUrl);
840
+ return;
841
+ }
842
+ res.status(200).json({
843
+ loggedOut: true,
844
+ upstreamLogoutUrl
845
+ });
846
+ });
847
+ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("backchannel-logout", options, async (req, res) => {
848
+ const metadata = await resolveProviderMetadata(config);
849
+ const logoutToken = getLogoutTokenFromRequest(req);
850
+ const claims = await verifyBackchannelLogoutToken(metadata, logoutToken);
851
+ const revokedSessions = isString(claims.sid) ? await options.storeProvider.deleteSessionsByProviderSessionId({
852
+ providerSessionId: claims.sid,
853
+ issuer: metadata.issuer,
854
+ clientId: metadata.clientId
855
+ }) : await options.storeProvider.deleteSessionsBySubject({
856
+ subject: String(claims.sub),
857
+ issuer: metadata.issuer,
858
+ clientId: metadata.clientId
859
+ });
860
+ await callHook("backchannel-logout", options.hooks?.onLogout, req, res, void 0, {
861
+ providerSessionId: claims.sid,
862
+ subject: claims.sub,
863
+ revokedSessions
864
+ });
865
+ res.status(200).json({
866
+ loggedOut: true,
867
+ revokedSessions
868
+ });
869
+ });
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));
873
+ router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options));
874
+ router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins));
875
+ router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins));
876
+ router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
877
+ }
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
+ function createOidcVaultMiddleware(options) {
930
+ const { config, trustedOrigins } = validateOidcVaultOptions(options);
931
+ const rootRouter = express.Router();
932
+ const baseRouter = express.Router();
933
+ const basePath = normalizeOidcVaultBasePath(options.basePath);
934
+ baseRouter.use(express.json());
935
+ baseRouter.use(express.urlencoded({ extended: false }));
936
+ registerRoutes(baseRouter, options, config, trustedOrigins, basePath);
937
+ rootRouter.use(basePath, baseRouter);
938
+ return rootRouter;
939
+ }
940
+ export {
941
+ DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS,
942
+ DEFAULT_EXCHANGE_CODE_TTL_MS,
943
+ DEFAULT_OIDC_SCOPES,
944
+ DEFAULT_OIDC_VAULT_BASE_PATH,
945
+ DEFAULT_SESSION_COOKIE_NAME,
946
+ OIDC_VAULT_ROUTE_PATHS,
947
+ OidcVaultStoreConflictError,
948
+ createOidcVaultAccessTokenMiddleware,
949
+ createOidcVaultJwtAccessTokenValidator,
950
+ createOidcVaultMiddleware,
951
+ normalizeOidcVaultBasePath,
952
+ resolveOidcVaultConfig,
953
+ resolveOidcVaultConfigFromEnv
954
+ };