@web-ts-toolkit/express-oidc-vault 0.43.0 → 0.48.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/README.md +1217 -88
- package/index.d.mts +697 -15
- package/index.d.ts +697 -15
- package/index.js +2494 -487
- package/index.mjs +2503 -497
- package/package.json +6 -4
package/index.js
CHANGED
|
@@ -37,6 +37,7 @@ __export(index_exports, {
|
|
|
37
37
|
DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT: () => DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
|
|
38
38
|
OIDC_VAULT_ROUTE_PATHS: () => OIDC_VAULT_ROUTE_PATHS,
|
|
39
39
|
OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT: () => OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
|
|
40
|
+
OidcVaultDpopReplayCapacityError: () => OidcVaultDpopReplayCapacityError,
|
|
40
41
|
OidcVaultStoreConflictError: () => OidcVaultStoreConflictError,
|
|
41
42
|
createOidcVaultAccessTokenMiddleware: () => createOidcVaultAccessTokenMiddleware,
|
|
42
43
|
createOidcVaultJwtAccessTokenValidator: () => createOidcVaultJwtAccessTokenValidator,
|
|
@@ -46,121 +47,9 @@ __export(index_exports, {
|
|
|
46
47
|
resolveOidcVaultConfigFromEnv: () => resolveOidcVaultConfigFromEnv
|
|
47
48
|
});
|
|
48
49
|
module.exports = __toCommonJS(index_exports);
|
|
49
|
-
var
|
|
50
|
+
var import_node_crypto6 = require("crypto");
|
|
50
51
|
var import_express = __toESM(require("express"));
|
|
51
52
|
|
|
52
|
-
// src/config.ts
|
|
53
|
-
var DEFAULT_OIDC_SCOPES = "openid email profile";
|
|
54
|
-
var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
|
|
55
|
-
var normalizeOptionalString = (value) => {
|
|
56
|
-
const trimmed = value?.trim();
|
|
57
|
-
return trimmed ? trimmed : void 0;
|
|
58
|
-
};
|
|
59
|
-
var normalizeHttpUrl = (value, optionName) => {
|
|
60
|
-
if (value === void 0) {
|
|
61
|
-
return void 0;
|
|
62
|
-
}
|
|
63
|
-
let url;
|
|
64
|
-
try {
|
|
65
|
-
url = new URL(value);
|
|
66
|
-
} catch {
|
|
67
|
-
throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
|
|
68
|
-
}
|
|
69
|
-
if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
|
|
70
|
-
throw new Error(`${optionName} must use http or https.`);
|
|
71
|
-
}
|
|
72
|
-
return url.toString();
|
|
73
|
-
};
|
|
74
|
-
var withOptionalValue = (target, key, value) => {
|
|
75
|
-
if (value !== void 0) {
|
|
76
|
-
target[key] = value;
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
function resolveOidcVaultConfig(config = {}) {
|
|
80
|
-
const issuer = normalizeHttpUrl(normalizeOptionalString(config.issuer), "config.issuer");
|
|
81
|
-
const clientId = normalizeOptionalString(config.clientId);
|
|
82
|
-
const clientSecret = normalizeOptionalString(config.clientSecret);
|
|
83
|
-
const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
|
|
84
|
-
const hasManualEndpoints = MANUAL_CONFIG_REQUIREMENTS.some(
|
|
85
|
-
(key) => key !== "issuer" && normalizeOptionalString(config[key])
|
|
86
|
-
);
|
|
87
|
-
if (!clientId) {
|
|
88
|
-
throw new Error("OIDC clientId is required.");
|
|
89
|
-
}
|
|
90
|
-
if (issuer && !hasManualEndpoints) {
|
|
91
|
-
const resolved2 = {
|
|
92
|
-
mode: "issuer",
|
|
93
|
-
issuer,
|
|
94
|
-
scopes
|
|
95
|
-
};
|
|
96
|
-
withOptionalValue(resolved2, "clientId", clientId);
|
|
97
|
-
withOptionalValue(resolved2, "clientSecret", clientSecret);
|
|
98
|
-
return resolved2;
|
|
99
|
-
}
|
|
100
|
-
const resolved = {
|
|
101
|
-
mode: "manual",
|
|
102
|
-
scopes
|
|
103
|
-
};
|
|
104
|
-
withOptionalValue(resolved, "issuer", issuer);
|
|
105
|
-
withOptionalValue(
|
|
106
|
-
resolved,
|
|
107
|
-
"authorizationEndpoint",
|
|
108
|
-
normalizeHttpUrl(normalizeOptionalString(config.authorizationEndpoint), "config.authorizationEndpoint")
|
|
109
|
-
);
|
|
110
|
-
withOptionalValue(
|
|
111
|
-
resolved,
|
|
112
|
-
"tokenEndpoint",
|
|
113
|
-
normalizeHttpUrl(normalizeOptionalString(config.tokenEndpoint), "config.tokenEndpoint")
|
|
114
|
-
);
|
|
115
|
-
withOptionalValue(
|
|
116
|
-
resolved,
|
|
117
|
-
"userInfoEndpoint",
|
|
118
|
-
normalizeHttpUrl(normalizeOptionalString(config.userInfoEndpoint), "config.userInfoEndpoint")
|
|
119
|
-
);
|
|
120
|
-
withOptionalValue(resolved, "jwksUri", normalizeHttpUrl(normalizeOptionalString(config.jwksUri), "config.jwksUri"));
|
|
121
|
-
withOptionalValue(
|
|
122
|
-
resolved,
|
|
123
|
-
"endSessionEndpoint",
|
|
124
|
-
normalizeHttpUrl(normalizeOptionalString(config.endSessionEndpoint), "config.endSessionEndpoint")
|
|
125
|
-
);
|
|
126
|
-
withOptionalValue(resolved, "clientId", clientId);
|
|
127
|
-
withOptionalValue(resolved, "clientSecret", clientSecret);
|
|
128
|
-
const missing = MANUAL_CONFIG_REQUIREMENTS.filter((key) => !resolved[key]);
|
|
129
|
-
if (missing.length > 0) {
|
|
130
|
-
throw new Error(`Missing required OIDC configuration for manual mode: ${missing.join(", ")}`);
|
|
131
|
-
}
|
|
132
|
-
return resolved;
|
|
133
|
-
}
|
|
134
|
-
function resolveOidcVaultConfigFromEnv(env = process.env) {
|
|
135
|
-
return resolveOidcVaultConfig({
|
|
136
|
-
issuer: env.OIDC_ISSUER,
|
|
137
|
-
authorizationEndpoint: env.OIDC_AUTHORIZATION_ENDPOINT,
|
|
138
|
-
tokenEndpoint: env.OIDC_TOKEN_ENDPOINT,
|
|
139
|
-
userInfoEndpoint: env.OIDC_USERINFO_ENDPOINT,
|
|
140
|
-
jwksUri: env.OIDC_JWKS_URI,
|
|
141
|
-
endSessionEndpoint: env.OIDC_END_SESSION_ENDPOINT,
|
|
142
|
-
clientId: env.OIDC_CLIENT_ID,
|
|
143
|
-
clientSecret: env.OIDC_CLIENT_SECRET,
|
|
144
|
-
scopes: env.OIDC_SCOPES
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// src/constants.ts
|
|
149
|
-
var DEFAULT_OIDC_VAULT_BASE_PATH = "/auth/oidc";
|
|
150
|
-
var DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
151
|
-
var DEFAULT_EXCHANGE_CODE_TTL_MS = 30 * 1e3;
|
|
152
|
-
var DEFAULT_SESSION_COOKIE_NAME = "oidc_vault_session";
|
|
153
|
-
var DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT = "16kb";
|
|
154
|
-
var OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT = 16;
|
|
155
|
-
var OIDC_VAULT_ROUTE_PATHS = {
|
|
156
|
-
login: "/login",
|
|
157
|
-
callback: "/callback",
|
|
158
|
-
exchange: "/exchange",
|
|
159
|
-
refresh: "/refresh",
|
|
160
|
-
logout: "/logout",
|
|
161
|
-
"backchannel-logout": "/backchannel-logout"
|
|
162
|
-
};
|
|
163
|
-
|
|
164
53
|
// src/errors.ts
|
|
165
54
|
var OidcVaultHttpError = class extends Error {
|
|
166
55
|
constructor(status, code, message, clientMessage = message) {
|
|
@@ -240,40 +129,33 @@ var getRequiredFiniteNonNegativeInteger = (value, message, code, status = 502) =
|
|
|
240
129
|
return value;
|
|
241
130
|
};
|
|
242
131
|
|
|
132
|
+
// src/fingerprint-recognition.ts
|
|
133
|
+
var import_node_crypto3 = require("crypto");
|
|
134
|
+
|
|
135
|
+
// src/constants.ts
|
|
136
|
+
var DEFAULT_OIDC_VAULT_BASE_PATH = "/auth/oidc";
|
|
137
|
+
var DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
138
|
+
var DEFAULT_EXCHANGE_CODE_TTL_MS = 30 * 1e3;
|
|
139
|
+
var DEFAULT_SESSION_COOKIE_NAME = "oidc_vault_session";
|
|
140
|
+
var DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT = "16kb";
|
|
141
|
+
var OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT = 16;
|
|
142
|
+
var OIDC_VAULT_ROUTE_PATHS = {
|
|
143
|
+
login: "/login",
|
|
144
|
+
callback: "/callback",
|
|
145
|
+
exchange: "/exchange",
|
|
146
|
+
refresh: "/refresh",
|
|
147
|
+
logout: "/logout",
|
|
148
|
+
"backchannel-logout": "/backchannel-logout"
|
|
149
|
+
};
|
|
150
|
+
|
|
243
151
|
// src/cookies.ts
|
|
244
152
|
var getSessionTransport = (options) => options.sessionTransport ?? "body";
|
|
245
153
|
var usesCookieTransport = (options) => getSessionTransport(options) === "cookie";
|
|
246
|
-
var parseCookieHeader = (headerValue) => {
|
|
247
|
-
if (!headerValue) {
|
|
248
|
-
return {};
|
|
249
|
-
}
|
|
250
|
-
return headerValue.split(";").reduce((cookies, part) => {
|
|
251
|
-
const separatorIndex = part.indexOf("=");
|
|
252
|
-
if (separatorIndex === -1) {
|
|
253
|
-
return cookies;
|
|
254
|
-
}
|
|
255
|
-
const name = part.slice(0, separatorIndex).trim();
|
|
256
|
-
const value = part.slice(separatorIndex + 1).trim();
|
|
257
|
-
if (!name) {
|
|
258
|
-
return cookies;
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
cookies[name] = decodeURIComponent(value);
|
|
262
|
-
} catch {
|
|
263
|
-
throw new OidcVaultHttpError(
|
|
264
|
-
400,
|
|
265
|
-
"OIDC_VAULT_MALFORMED_SESSION_COOKIE",
|
|
266
|
-
"OIDC vault session cookie is malformed."
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
return cookies;
|
|
270
|
-
}, {});
|
|
271
|
-
};
|
|
272
154
|
var resolveCookieOptions = (options) => {
|
|
273
155
|
const cookieOptions = options.cookie ?? {};
|
|
274
156
|
const deploymentMode = cookieOptions.deploymentMode ?? "same-origin";
|
|
275
157
|
const sameSite = cookieOptions.sameSite ?? (deploymentMode === "cross-site" ? "none" : "lax");
|
|
276
|
-
const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site");
|
|
158
|
+
const secure = cookieOptions.secure ?? (sameSite === "none" || deploymentMode === "cross-site" || isHttpsBackendOrigin(options));
|
|
277
159
|
return {
|
|
278
160
|
name: cookieOptions.name ?? DEFAULT_SESSION_COOKIE_NAME,
|
|
279
161
|
sameSite,
|
|
@@ -283,12 +165,25 @@ var resolveCookieOptions = (options) => {
|
|
|
283
165
|
httpOnly: cookieOptions.httpOnly ?? true
|
|
284
166
|
};
|
|
285
167
|
};
|
|
168
|
+
var isEffectivelySecureCookie = (cookieOptions) => cookieOptions.secure || cookieOptions.sameSite === "none";
|
|
169
|
+
var isHttpsBackendOrigin = (options) => {
|
|
170
|
+
try {
|
|
171
|
+
const raw = options.backendOrigin;
|
|
172
|
+
if (typeof raw !== "string") {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
return new URL(raw.trim()).protocol === "https:";
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
286
180
|
var COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
181
|
+
var isValidCookieName = (value) => typeof value === "string" && COOKIE_NAME_PATTERN.test(value) && !hasUnsafeCookieValueCharacters(value);
|
|
287
182
|
var COOKIE_DOMAIN_PATTERN = /^\.?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
|
288
183
|
var hasUnsafeCookieValueCharacters = (value) => {
|
|
289
184
|
for (let index = 0; index < value.length; index += 1) {
|
|
290
185
|
const code = value.charCodeAt(index);
|
|
291
|
-
if (code
|
|
186
|
+
if (code < 32 || code > 126 || value[index] === ";") {
|
|
292
187
|
return true;
|
|
293
188
|
}
|
|
294
189
|
}
|
|
@@ -299,7 +194,7 @@ var validateCookieOptions = (options) => {
|
|
|
299
194
|
return;
|
|
300
195
|
}
|
|
301
196
|
const cookieOptions = resolveCookieOptions(options);
|
|
302
|
-
if (!
|
|
197
|
+
if (!isValidCookieName(cookieOptions.name)) {
|
|
303
198
|
throw new Error("cookie.name must be a valid HTTP cookie name.");
|
|
304
199
|
}
|
|
305
200
|
if (hasUnsafeCookieValueCharacters(cookieOptions.path) || !cookieOptions.path.startsWith("/")) {
|
|
@@ -311,6 +206,22 @@ var validateCookieOptions = (options) => {
|
|
|
311
206
|
if (!cookieOptions.httpOnly) {
|
|
312
207
|
throw new Error("cookie.httpOnly must be true for cookie session transport.");
|
|
313
208
|
}
|
|
209
|
+
const effectiveSecure = isEffectivelySecureCookie(cookieOptions);
|
|
210
|
+
if (cookieOptions.name.startsWith("__Host-")) {
|
|
211
|
+
if (!effectiveSecure) {
|
|
212
|
+
throw new Error("cookie.name with the __Host- prefix must be Secure.");
|
|
213
|
+
}
|
|
214
|
+
if (cookieOptions.domain !== void 0) {
|
|
215
|
+
throw new Error("cookie.name with the __Host- prefix must not set cookie.domain.");
|
|
216
|
+
}
|
|
217
|
+
if (cookieOptions.path !== "/") {
|
|
218
|
+
throw new Error('cookie.name with the __Host- prefix must use cookie.path "/".');
|
|
219
|
+
}
|
|
220
|
+
} else if (cookieOptions.name.startsWith("__Secure-")) {
|
|
221
|
+
if (!effectiveSecure) {
|
|
222
|
+
throw new Error("cookie.name with the __Secure- prefix must be Secure.");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
314
225
|
};
|
|
315
226
|
var usesCrossSiteCookieTransport = (options) => usesCookieTransport(options) && resolveCookieOptions(options).sameSite === "none";
|
|
316
227
|
var serializeCookie = (name, value, options, overrides) => {
|
|
@@ -346,11 +257,863 @@ var clearSessionCookie = (res, options) => {
|
|
|
346
257
|
expires: /* @__PURE__ */ new Date(0)
|
|
347
258
|
})
|
|
348
259
|
);
|
|
349
|
-
};
|
|
350
|
-
var getSessionIdFromCookie = (req, options) => {
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
260
|
+
};
|
|
261
|
+
var getSessionIdFromCookie = (req, options) => {
|
|
262
|
+
const raw = parseSelectedCookieValue(req.headers.cookie, resolveCookieOptions(options).name);
|
|
263
|
+
if (raw === void 0) {
|
|
264
|
+
return void 0;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
return decodeURIComponent(raw);
|
|
268
|
+
} catch {
|
|
269
|
+
throw new OidcVaultHttpError(400, "OIDC_VAULT_MALFORMED_SESSION_COOKIE", "OIDC vault session cookie is malformed.");
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
var parseSelectedCookieValue = (headerValue, selectedName) => {
|
|
273
|
+
if (!headerValue) {
|
|
274
|
+
return void 0;
|
|
275
|
+
}
|
|
276
|
+
const segments = headerValue.split(";");
|
|
277
|
+
for (const segment of segments) {
|
|
278
|
+
const separatorIndex = segment.indexOf("=");
|
|
279
|
+
if (separatorIndex === -1) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const name = segment.slice(0, separatorIndex).trim();
|
|
283
|
+
if (!name || name !== selectedName) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
return segment.slice(separatorIndex + 1).trim();
|
|
287
|
+
}
|
|
288
|
+
return void 0;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/device-binding-policy.ts
|
|
292
|
+
var import_node_net = require("net");
|
|
293
|
+
var isPlainRecord = (value) => {
|
|
294
|
+
if (typeof value !== "object" || value === null) return false;
|
|
295
|
+
const prototype = Object.getPrototypeOf(value);
|
|
296
|
+
return prototype === Object.prototype || prototype === null;
|
|
297
|
+
};
|
|
298
|
+
var isDpopAlgorithm = (value) => value === "ES256" || value === "PS256" || value === "RS256";
|
|
299
|
+
var resolveInteger = (value, fallback, min, max, name) => {
|
|
300
|
+
const resolved = value === void 0 ? fallback : value;
|
|
301
|
+
if (typeof resolved !== "number" || !Number.isInteger(resolved) || resolved < min || resolved > max) {
|
|
302
|
+
throw new Error(`${name} must be an integer from ${min} through ${max}.`);
|
|
303
|
+
}
|
|
304
|
+
return resolved;
|
|
305
|
+
};
|
|
306
|
+
var resolveDeviceBindingOptions = (value) => {
|
|
307
|
+
if (value === void 0) return void 0;
|
|
308
|
+
if (!isPlainRecord(value)) throw new Error("deviceBinding must be a plain options object.");
|
|
309
|
+
const { mode, algorithms, proofMaxAgeSeconds, clockSkewSeconds, nonce } = value;
|
|
310
|
+
if (mode !== void 0 && mode !== "optional" && mode !== "required") {
|
|
311
|
+
throw new Error("deviceBinding.mode must be optional or required.");
|
|
312
|
+
}
|
|
313
|
+
const configuredAlgorithms = algorithms === void 0 ? ["ES256"] : algorithms;
|
|
314
|
+
if (!Array.isArray(configuredAlgorithms) || configuredAlgorithms.length === 0) {
|
|
315
|
+
throw new Error("deviceBinding.algorithms must be a nonempty allowlist of ES256, PS256, or RS256.");
|
|
316
|
+
}
|
|
317
|
+
const allowlist = Array.from(configuredAlgorithms);
|
|
318
|
+
if (!allowlist.every(isDpopAlgorithm)) {
|
|
319
|
+
throw new Error("deviceBinding.algorithms must be a nonempty allowlist of ES256, PS256, or RS256.");
|
|
320
|
+
}
|
|
321
|
+
let resolvedNonce = false;
|
|
322
|
+
if (nonce !== void 0 && nonce !== false) {
|
|
323
|
+
if (!isPlainRecord(nonce)) throw new Error("deviceBinding.nonce must be false or a plain nonce options object.");
|
|
324
|
+
const { secret, lifetimeSeconds } = nonce;
|
|
325
|
+
if (!(secret instanceof Uint8Array) || secret.byteLength < 32) {
|
|
326
|
+
throw new Error("deviceBinding.nonce.secret must be a Uint8Array of at least 32 bytes.");
|
|
327
|
+
}
|
|
328
|
+
resolvedNonce = Object.freeze({
|
|
329
|
+
// Uint8Array.from also copies Buffer/subarray inputs instead of retaining
|
|
330
|
+
// a shared view. Nonempty typed arrays cannot be Object.freeze'd; these
|
|
331
|
+
// owned bytes stay private while the plain nonce container is frozen.
|
|
332
|
+
secret: Uint8Array.from(secret),
|
|
333
|
+
lifetimeSeconds: resolveInteger(lifetimeSeconds, 60, 1, 300, "deviceBinding.nonce.lifetimeSeconds")
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return Object.freeze({
|
|
337
|
+
mode: mode ?? "optional",
|
|
338
|
+
algorithms: Object.freeze([...new Set(allowlist)]),
|
|
339
|
+
proofMaxAgeSeconds: resolveInteger(proofMaxAgeSeconds, 60, 1, 300, "deviceBinding.proofMaxAgeSeconds"),
|
|
340
|
+
clockSkewSeconds: resolveInteger(clockSkewSeconds, 5, 0, 30, "deviceBinding.clockSkewSeconds"),
|
|
341
|
+
nonce: resolvedNonce
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
var DEVICE_BINDING_STORE_CAPABILITIES = [
|
|
345
|
+
"getAuthorizationTransaction",
|
|
346
|
+
"consumeAuthorizationTransactionIfMatches",
|
|
347
|
+
"getExchangeCode",
|
|
348
|
+
"consumeExchangeCodeIfMatches",
|
|
349
|
+
"getSessionRevocationContext",
|
|
350
|
+
"reserveDpopProof"
|
|
351
|
+
];
|
|
352
|
+
function assertDeviceBindingStoreCapabilities(store, feature = "deviceBinding") {
|
|
353
|
+
for (const name of DEVICE_BINDING_STORE_CAPABILITIES) {
|
|
354
|
+
if (typeof store?.[name] !== "function") {
|
|
355
|
+
throw new Error(`${feature} requires storeProvider.${name}.`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
var resolveDeviceBindingOrigin = (value, name = "backendOrigin") => {
|
|
360
|
+
if (typeof value !== "string") throw new Error(`${name} must be a static HTTP(S) origin for deviceBinding.`);
|
|
361
|
+
const raw = value.trim();
|
|
362
|
+
let url;
|
|
363
|
+
try {
|
|
364
|
+
url = new URL(raw);
|
|
365
|
+
} catch {
|
|
366
|
+
throw new Error(`${name} must be a static HTTP(S) origin for deviceBinding.`);
|
|
367
|
+
}
|
|
368
|
+
if (!/^https?:\/\/[^/\\?#]+\/?$/i.test(raw) || /\s/.test(raw) || raw.includes("@") || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.origin === "null") {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`${name} must be a static HTTP(S) origin without userinfo, path, query, or fragment for deviceBinding.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || (0, import_node_net.isIP)(url.hostname) === 4 && url.hostname.startsWith("127.");
|
|
374
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
375
|
+
throw new Error(`${name} must use HTTPS except for loopback HTTP development when deviceBinding is enabled.`);
|
|
376
|
+
}
|
|
377
|
+
return url.origin;
|
|
378
|
+
};
|
|
379
|
+
var isCanonicalDpopJkt = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
|
|
380
|
+
var invalidBinding = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_DPOP_PROOF", diagnostic, "DPoP proof validation failed.");
|
|
381
|
+
var snapshotDpopBinding = (value) => {
|
|
382
|
+
if (value === void 0) return void 0;
|
|
383
|
+
if (!isPlainRecord(value)) throw invalidBinding("Stored device binding must be a plain dpop binding object.");
|
|
384
|
+
const { type, jkt } = value;
|
|
385
|
+
if (type !== "dpop" || !isCanonicalDpopJkt(jkt)) {
|
|
386
|
+
throw invalidBinding("Stored device binding must contain type dpop and a canonical SHA-256 JWK thumbprint.");
|
|
387
|
+
}
|
|
388
|
+
return Object.freeze({ type, jkt });
|
|
389
|
+
};
|
|
390
|
+
var assertDeviceBindingPolicy = (policy, binding) => {
|
|
391
|
+
const stored = snapshotDpopBinding(binding);
|
|
392
|
+
if (stored !== void 0 && policy === void 0) {
|
|
393
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
394
|
+
}
|
|
395
|
+
if (stored === void 0 && policy?.mode === "required") {
|
|
396
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
var resolveVerifiedDeviceBinding = (policy, binding, verified) => {
|
|
400
|
+
const stored = snapshotDpopBinding(binding);
|
|
401
|
+
assertDeviceBindingPolicy(policy, stored);
|
|
402
|
+
if (verified === void 0) {
|
|
403
|
+
if (stored !== void 0) {
|
|
404
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
405
|
+
}
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
408
|
+
const proofBinding = snapshotDpopBinding(verified);
|
|
409
|
+
const alg = verified.alg;
|
|
410
|
+
if (proofBinding === void 0 || !isDpopAlgorithm(alg) || !policy?.algorithms.includes(alg)) {
|
|
411
|
+
throw invalidBinding("Verified DPoP context must use a currently allowed asymmetric proof algorithm.");
|
|
412
|
+
}
|
|
413
|
+
if (stored === void 0) return void 0;
|
|
414
|
+
if (stored.jkt !== proofBinding.jkt) throw invalidBinding("Verified DPoP key does not match the original binding.");
|
|
415
|
+
return Object.freeze({ type: "dpop", jkt: stored.jkt, alg });
|
|
416
|
+
};
|
|
417
|
+
var withSessionDeviceBinding = (session, binding) => {
|
|
418
|
+
const restored = {};
|
|
419
|
+
for (const key of Reflect.ownKeys(session)) {
|
|
420
|
+
if (key !== "deviceBinding" && Object.prototype.propertyIsEnumerable.call(session, key))
|
|
421
|
+
Object.defineProperty(restored, key, {
|
|
422
|
+
value: Reflect.get(session, key),
|
|
423
|
+
enumerable: true,
|
|
424
|
+
configurable: true,
|
|
425
|
+
writable: true
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const stored = snapshotDpopBinding(binding);
|
|
429
|
+
if (stored !== void 0) restored.deviceBinding = stored;
|
|
430
|
+
return restored;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/dpop-proof.ts
|
|
434
|
+
var import_node_crypto2 = require("crypto");
|
|
435
|
+
var import_jose = require("jose");
|
|
436
|
+
|
|
437
|
+
// src/dpop-nonce.ts
|
|
438
|
+
var import_node_crypto = require("crypto");
|
|
439
|
+
var NONCE_VERSION = "v1";
|
|
440
|
+
var HMAC_DOMAIN = "@web-ts-toolkit/express-oidc-vault/dpop-nonce/v1\0";
|
|
441
|
+
var DPOP_NONCE_MAX_BYTES = 512;
|
|
442
|
+
var isEpochMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
443
|
+
var decodeBase64url = (value, byteLength) => {
|
|
444
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
|
|
445
|
+
const bytes = Buffer.from(value, "base64url");
|
|
446
|
+
if (bytes.toString("base64url") !== value || byteLength !== void 0 && bytes.length !== byteLength) return null;
|
|
447
|
+
return bytes;
|
|
448
|
+
};
|
|
449
|
+
var createDpopNoncePolicy = (options) => {
|
|
450
|
+
const { secret: configuredSecret, lifetimeSeconds: configuredLifetime, namespace } = options;
|
|
451
|
+
if (!(configuredSecret instanceof Uint8Array) || configuredSecret.byteLength < 32) {
|
|
452
|
+
throw new TypeError("DPoP nonce secret must be a Uint8Array of at least 32 bytes.");
|
|
453
|
+
}
|
|
454
|
+
const lifetimeSeconds = configuredLifetime === void 0 ? 60 : configuredLifetime;
|
|
455
|
+
if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > 300) {
|
|
456
|
+
throw new TypeError("DPoP nonce lifetime must be an integer from 1 through 300 seconds.");
|
|
457
|
+
}
|
|
458
|
+
if (typeof namespace !== "string" || namespace.length === 0) {
|
|
459
|
+
throw new TypeError("DPoP nonce requires a resolved protection-space namespace.");
|
|
460
|
+
}
|
|
461
|
+
const secret = Uint8Array.from(configuredSecret);
|
|
462
|
+
const namespaceDigest = (0, import_node_crypto.createHash)("sha256").update(namespace, "utf8").digest("base64url");
|
|
463
|
+
const lifetimeMs = lifetimeSeconds * 1e3;
|
|
464
|
+
const sign = (encodedPayload) => (0, import_node_crypto.createHmac)("sha256", secret).update(HMAC_DOMAIN, "utf8").update(`${NONCE_VERSION}.${encodedPayload}`, "ascii").digest();
|
|
465
|
+
return Object.freeze({
|
|
466
|
+
issue(jkt, now) {
|
|
467
|
+
const expiresAt = now + lifetimeMs;
|
|
468
|
+
if (!isCanonicalDpopJkt(jkt) || !isEpochMs(now) || !isEpochMs(expiresAt) || expiresAt <= now) {
|
|
469
|
+
throw new TypeError("DPoP nonce issuance requires a canonical key thumbprint and a safe live epoch window.");
|
|
470
|
+
}
|
|
471
|
+
const payload = JSON.stringify([(0, import_node_crypto.randomBytes)(16).toString("base64url"), now, expiresAt, namespaceDigest, jkt]);
|
|
472
|
+
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
|
473
|
+
const nonce = `${NONCE_VERSION}.${encodedPayload}.${sign(encodedPayload).toString("base64url")}`;
|
|
474
|
+
if (Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES) {
|
|
475
|
+
throw new TypeError("DPoP nonce issuance exceeded its fixed wire bound.");
|
|
476
|
+
}
|
|
477
|
+
return nonce;
|
|
478
|
+
},
|
|
479
|
+
verify(nonce, jkt, now) {
|
|
480
|
+
if (!isCanonicalDpopJkt(jkt) || !isEpochMs(now) || typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)
|
|
481
|
+
return false;
|
|
482
|
+
const [version, encodedPayload, encodedMac, extra] = nonce.split(".");
|
|
483
|
+
if (version !== NONCE_VERSION || !encodedPayload || !encodedMac || extra !== void 0) return false;
|
|
484
|
+
const mac = decodeBase64url(encodedMac, 32);
|
|
485
|
+
const bytes = decodeBase64url(encodedPayload);
|
|
486
|
+
if (!mac || !bytes || !(0, import_node_crypto.timingSafeEqual)(mac, sign(encodedPayload))) return false;
|
|
487
|
+
let payload;
|
|
488
|
+
try {
|
|
489
|
+
payload = JSON.parse(bytes.toString("utf8"));
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
if (!Array.isArray(payload) || payload.length !== 5 || !bytes.equals(Buffer.from(JSON.stringify(payload), "utf8")))
|
|
494
|
+
return false;
|
|
495
|
+
const [random, issuedAt, expiresAt, context, key] = payload;
|
|
496
|
+
if (typeof random !== "string" || !decodeBase64url(random, 16) || !isEpochMs(issuedAt) || !isEpochMs(expiresAt) || context !== namespaceDigest || key !== jkt)
|
|
497
|
+
return false;
|
|
498
|
+
const declaredLifetime = expiresAt - issuedAt;
|
|
499
|
+
return declaredLifetime >= 1e3 && declaredLifetime <= lifetimeMs && declaredLifetime % 1e3 === 0 && issuedAt <= now && now < expiresAt;
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/dpop-target.ts
|
|
505
|
+
var invalidTarget = () => new OidcVaultHttpError(
|
|
506
|
+
401,
|
|
507
|
+
"OIDC_VAULT_INVALID_DPOP_PROOF",
|
|
508
|
+
"DPoP target must be a valid pinned HTTP(S) URL/path.",
|
|
509
|
+
"DPoP proof validation failed."
|
|
510
|
+
);
|
|
511
|
+
var hasControl = (value) => {
|
|
512
|
+
for (let index = 0; index < value.length; index++) {
|
|
513
|
+
const code = value.charCodeAt(index);
|
|
514
|
+
if (code < 32 || code === 127) return true;
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
};
|
|
518
|
+
var normalizePath = (origin, path) => {
|
|
519
|
+
if (!path.startsWith("/") || /[\s\\]/.test(path) || hasControl(path) || /%(?![0-9a-f]{2})/i.test(path))
|
|
520
|
+
throw invalidTarget();
|
|
521
|
+
const decoded = path.replace(/%([0-9a-f]{2})/gi, (_, hex) => {
|
|
522
|
+
const character = String.fromCharCode(Number.parseInt(hex, 16));
|
|
523
|
+
return /^[A-Za-z0-9._~-]$/.test(character) ? character : `%${hex.toUpperCase()}`;
|
|
524
|
+
});
|
|
525
|
+
return new URL(`${origin}${decoded}`).pathname;
|
|
526
|
+
};
|
|
527
|
+
var createDpopRequestTargetResolver = (options) => {
|
|
528
|
+
const { publicOrigin, publicPathPrefix } = options;
|
|
529
|
+
const origin = resolveDeviceBindingOrigin(publicOrigin, "publicOrigin");
|
|
530
|
+
let prefix = "";
|
|
531
|
+
if (publicPathPrefix !== void 0 && publicPathPrefix !== "") {
|
|
532
|
+
if (typeof publicPathPrefix !== "string" || /[?#]/.test(publicPathPrefix)) {
|
|
533
|
+
throw new TypeError("deviceBinding.publicPathPrefix must be an absolute path prefix without query or fragment.");
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
prefix = normalizePath(origin, publicPathPrefix).replace(/\/+$/g, "");
|
|
537
|
+
} catch {
|
|
538
|
+
throw new TypeError("deviceBinding.publicPathPrefix must be a valid absolute path prefix.");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return (originalUrl) => {
|
|
542
|
+
if (typeof originalUrl !== "string") throw invalidTarget();
|
|
543
|
+
const path = originalUrl.split(/[?#]/, 1)[0];
|
|
544
|
+
if (!path.startsWith("/")) throw invalidTarget();
|
|
545
|
+
return `${origin}${normalizePath(origin, `${prefix}${path}`)}`;
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
var normalizeDpopProofTarget = (value) => {
|
|
549
|
+
if (typeof value !== "string" || /[\s\\]/.test(value) || hasControl(value) || !/^https?:\/\/[^/\\?#]+/i.test(value))
|
|
550
|
+
throw invalidTarget();
|
|
551
|
+
let url;
|
|
552
|
+
try {
|
|
553
|
+
url = new URL(value);
|
|
554
|
+
} catch {
|
|
555
|
+
throw invalidTarget();
|
|
556
|
+
}
|
|
557
|
+
const queryFree = value.split(/[?#]/, 1)[0];
|
|
558
|
+
const authority = queryFree.slice(queryFree.indexOf("://") + 3).split("/", 1)[0];
|
|
559
|
+
if (url.username || url.password || url.origin === "null" || authority.includes("@")) throw invalidTarget();
|
|
560
|
+
const authorityEnd = queryFree.indexOf("/", queryFree.indexOf("://") + 3);
|
|
561
|
+
const path = authorityEnd === -1 ? "/" : queryFree.slice(authorityEnd);
|
|
562
|
+
return `${url.origin}${normalizePath(url.origin, path)}`;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// src/dpop-proof.ts
|
|
566
|
+
var DPOP_PROOF_MAX_BYTES = 8192;
|
|
567
|
+
var DPOP_PROTECTED_HEADER_MAX_BYTES = 2048;
|
|
568
|
+
var readDpopRawHeader = (req, name) => {
|
|
569
|
+
const normalizedName = name.toLowerCase();
|
|
570
|
+
let count = 0;
|
|
571
|
+
let value;
|
|
572
|
+
for (let index = 0; index < req.rawHeaders.length; index += 2) {
|
|
573
|
+
if (req.rawHeaders[index].toLowerCase() !== normalizedName) continue;
|
|
574
|
+
if (++count === 1) value = req.rawHeaders[index + 1];
|
|
575
|
+
if (count === 2) break;
|
|
576
|
+
}
|
|
577
|
+
return Object.freeze({ count, value });
|
|
578
|
+
};
|
|
579
|
+
var invalidProof = (diagnostic, cause) => {
|
|
580
|
+
const error = new OidcVaultHttpError(
|
|
581
|
+
401,
|
|
582
|
+
"OIDC_VAULT_INVALID_DPOP_PROOF",
|
|
583
|
+
diagnostic,
|
|
584
|
+
"DPoP proof validation failed."
|
|
585
|
+
);
|
|
586
|
+
if (cause !== void 0) Object.defineProperty(error, "cause", { value: cause, configurable: true });
|
|
587
|
+
return error;
|
|
588
|
+
};
|
|
589
|
+
var extractDpopProof = (header) => {
|
|
590
|
+
if (header.count === 0) return void 0;
|
|
591
|
+
const { value } = header;
|
|
592
|
+
if (header.count !== 1 || typeof value !== "string" || value.length === 0 || value.length > DPOP_PROOF_MAX_BYTES || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
|
|
593
|
+
throw invalidProof("DPoP header must contain one bounded compact signed proof.");
|
|
594
|
+
}
|
|
595
|
+
return value;
|
|
596
|
+
};
|
|
597
|
+
var decodeBase64url2 = (value, maxBytes) => {
|
|
598
|
+
if (!value || value.length > Math.ceil(maxBytes * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
599
|
+
throw invalidProof("DPoP base64url input exceeds its bound or has invalid encoding.");
|
|
600
|
+
}
|
|
601
|
+
const bytes = Buffer.from(value, "base64url");
|
|
602
|
+
if (bytes.length > maxBytes || bytes.toString("base64url") !== value)
|
|
603
|
+
throw invalidProof("DPoP base64url input is not canonical.");
|
|
604
|
+
return bytes;
|
|
605
|
+
};
|
|
606
|
+
var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
607
|
+
var parseObject = (bytes) => {
|
|
608
|
+
try {
|
|
609
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
610
|
+
const value = JSON.parse(json);
|
|
611
|
+
if (!isPlainObject(value)) throw new TypeError("DPoP JSON must be an object.");
|
|
612
|
+
const stack = [];
|
|
613
|
+
for (let index = 0; index < json.length; index++) {
|
|
614
|
+
const character = json[index];
|
|
615
|
+
if (character === "{" || character === "[") {
|
|
616
|
+
stack.push({ keys: character === "{" ? /* @__PURE__ */ new Set() : null, expectsKey: character === "{" });
|
|
617
|
+
} else if (character === "}" || character === "]") {
|
|
618
|
+
stack.pop();
|
|
619
|
+
} else if (character === ",") {
|
|
620
|
+
const container = stack[stack.length - 1];
|
|
621
|
+
if (container?.keys) container.expectsKey = true;
|
|
622
|
+
} else if (character === '"') {
|
|
623
|
+
const start = index++;
|
|
624
|
+
for (; index < json.length; index++) {
|
|
625
|
+
if (json[index] === "\\") index++;
|
|
626
|
+
else if (json[index] === '"') break;
|
|
627
|
+
}
|
|
628
|
+
const container = stack[stack.length - 1];
|
|
629
|
+
if (container?.keys && container.expectsKey) {
|
|
630
|
+
const key = JSON.parse(json.slice(start, index + 1));
|
|
631
|
+
if (container.keys.has(key)) throw new TypeError("DPoP JSON has duplicate member names.");
|
|
632
|
+
container.keys.add(key);
|
|
633
|
+
container.expectsKey = false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return value;
|
|
638
|
+
} catch (cause) {
|
|
639
|
+
throw invalidProof("DPoP JSON encoding or object shape is invalid.", cause);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
var publicJwk = (value, alg) => {
|
|
643
|
+
if (!isPlainObject(value) || Buffer.byteLength(JSON.stringify(value), "utf8") > DPOP_PROTECTED_HEADER_MAX_BYTES) {
|
|
644
|
+
throw invalidProof("DPoP JWK must be a bounded public object.");
|
|
645
|
+
}
|
|
646
|
+
const allowed = alg === "ES256" ? ["kty", "crv", "x", "y", "alg", "use", "key_ops", "kid"] : ["kty", "n", "e", "alg", "use", "key_ops", "kid"];
|
|
647
|
+
if (Object.keys(value).some((key) => !allowed.includes(key)) || value.alg !== void 0 && value.alg !== alg || value.use !== void 0 && value.use !== "sig" || value.kid !== void 0 && typeof value.kid !== "string" || value.key_ops !== void 0 && (!Array.isArray(value.key_ops) || value.key_ops.length !== 1 || value.key_ops[0] !== "verify")) {
|
|
648
|
+
throw invalidProof("DPoP JWK contains private, remote, unsupported or incompatible key parameters.");
|
|
649
|
+
}
|
|
650
|
+
if (alg === "ES256") {
|
|
651
|
+
if (value.kty !== "EC" || value.crv !== "P-256" || typeof value.x !== "string" || typeof value.y !== "string" || decodeBase64url2(value.x, 32).length !== 32 || decodeBase64url2(value.y, 32).length !== 32) {
|
|
652
|
+
throw invalidProof("DPoP ES256 requires a public P-256 key with canonical coordinates.");
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
if (value.kty !== "RSA" || typeof value.n !== "string" || typeof value.e !== "string") {
|
|
656
|
+
throw invalidProof("DPoP RSA requires public modulus and exponent.");
|
|
657
|
+
}
|
|
658
|
+
const modulus = decodeBase64url2(value.n, 512);
|
|
659
|
+
const exponent = decodeBase64url2(value.e, 512);
|
|
660
|
+
const bits = (modulus.length - 1) * 8 + 32 - Math.clz32(modulus[0]);
|
|
661
|
+
if (modulus[0] === 0 || bits < 2048 || bits > 4096 || exponent[0] === 0 || (exponent[exponent.length - 1] & 1) === 0 || exponent.length === 1 && exponent[0] < 3 || exponent.length > modulus.length || exponent.length === modulus.length && Buffer.compare(exponent, modulus) >= 0) {
|
|
662
|
+
throw invalidProof(
|
|
663
|
+
"DPoP RSA public parameters must use a 2048\u20134096-bit modulus and a valid bounded odd exponent."
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return value;
|
|
668
|
+
};
|
|
669
|
+
var verifyDpopProof = async (input) => {
|
|
670
|
+
const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
|
|
671
|
+
const algorithms = [...proofOptions.algorithms];
|
|
672
|
+
extractDpopProof({ count: 1, value: proof });
|
|
673
|
+
const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
|
|
674
|
+
const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
|
|
675
|
+
decodeBase64url2(encodedPayload, DPOP_PROOF_MAX_BYTES);
|
|
676
|
+
const signature = decodeBase64url2(encodedSignature, 512);
|
|
677
|
+
const { alg } = header;
|
|
678
|
+
if (header.typ !== "dpop+jwt" || !isDpopAlgorithm(alg) || !algorithms.includes(alg) || ["crit", "b64", "jku", "x5u", "x5c", "x5t", "x5t#S256"].some((key) => Object.hasOwn(header, key))) {
|
|
679
|
+
throw invalidProof("DPoP protected header has an unsupported type, algorithm or key/critical extension.");
|
|
680
|
+
}
|
|
681
|
+
const jwk = publicJwk(header.jwk, alg);
|
|
682
|
+
const signatureBytes = alg === "ES256" ? 64 : decodeBase64url2(jwk.n, 512).length;
|
|
683
|
+
if (signature.length !== signatureBytes)
|
|
684
|
+
throw invalidProof("DPoP signature length does not match the allowed key profile.");
|
|
685
|
+
let payload;
|
|
686
|
+
try {
|
|
687
|
+
const key = await (0, import_jose.importJWK)(jwk, alg);
|
|
688
|
+
({ payload } = await (0, import_jose.compactVerify)(proof, key, { algorithms }));
|
|
689
|
+
} catch (cause) {
|
|
690
|
+
throw invalidProof("DPoP public-key import or signature verification failed.", cause);
|
|
691
|
+
}
|
|
692
|
+
const claims = parseObject(payload);
|
|
693
|
+
const { htm, htu, iat, jti, nonce, ath } = claims;
|
|
694
|
+
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
|
|
695
|
+
throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
|
|
696
|
+
}
|
|
697
|
+
if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== targetUrl) {
|
|
698
|
+
throw invalidProof("DPoP signed method or normalized public target does not match this request.");
|
|
699
|
+
}
|
|
700
|
+
if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
|
|
701
|
+
throw invalidProof("DPoP signed nonce exceeds its byte bound.");
|
|
702
|
+
}
|
|
703
|
+
if (accessToken !== void 0 && (typeof accessToken !== "string" || !/^[\x21-\x7e]+$/.test(accessToken) || !isCanonicalDpopJkt(ath) || ath !== (0, import_node_crypto2.createHash)("sha256").update(accessToken, "ascii").digest("base64url"))) {
|
|
704
|
+
throw invalidProof("DPoP signed access-token hash does not match the presented API token.");
|
|
705
|
+
}
|
|
706
|
+
const jkt = await (0, import_jose.calculateJwkThumbprint)(jwk, "sha256");
|
|
707
|
+
if (expectedJkt !== void 0 && (!isCanonicalDpopJkt(expectedJkt) || jkt !== expectedJkt)) {
|
|
708
|
+
throw invalidProof("DPoP verified public key does not match the original credential confirmation.");
|
|
709
|
+
}
|
|
710
|
+
return Object.freeze({
|
|
711
|
+
binding: Object.freeze({ type: "dpop", jkt, alg }),
|
|
712
|
+
iat,
|
|
713
|
+
jti,
|
|
714
|
+
...nonce === void 0 ? {} : { nonce }
|
|
715
|
+
});
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// src/fingerprint-recognition.ts
|
|
719
|
+
var FINGERPRINT_RECOGNITION_METADATA_KEY = "oidcVaultFingerprintRecognition";
|
|
720
|
+
var isPlainRecord2 = (value) => typeof value === "object" && value !== null && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
721
|
+
var COLLIDING_HEADERS = /* @__PURE__ */ new Set([
|
|
722
|
+
"authorization",
|
|
723
|
+
"proxy-authorization",
|
|
724
|
+
"www-authenticate",
|
|
725
|
+
"proxy-authenticate",
|
|
726
|
+
"authentication-info",
|
|
727
|
+
"proxy-authentication-info",
|
|
728
|
+
"dpop",
|
|
729
|
+
"dpop-nonce",
|
|
730
|
+
"cookie",
|
|
731
|
+
"set-cookie",
|
|
732
|
+
"origin",
|
|
733
|
+
"referer",
|
|
734
|
+
"host",
|
|
735
|
+
"forwarded",
|
|
736
|
+
"accept",
|
|
737
|
+
"accept-charset",
|
|
738
|
+
"accept-encoding",
|
|
739
|
+
"accept-language",
|
|
740
|
+
"connection",
|
|
741
|
+
"keep-alive",
|
|
742
|
+
"te",
|
|
743
|
+
"trailer",
|
|
744
|
+
"transfer-encoding",
|
|
745
|
+
"upgrade"
|
|
746
|
+
]);
|
|
747
|
+
var resolveFingerprintRecognitionOptions = (value) => {
|
|
748
|
+
if (value === void 0) return void 0;
|
|
749
|
+
if (!isPlainRecord2(value)) throw new Error("fingerprintRecognition must be a plain options object.");
|
|
750
|
+
const { headerName } = value;
|
|
751
|
+
const name = headerName === void 0 ? "X-Device-Fingerprint" : headerName;
|
|
752
|
+
if (!isValidCookieName(name)) throw new Error("fingerprintRecognition.headerName must be a valid HTTP field name.");
|
|
753
|
+
const normalized = name.toLowerCase();
|
|
754
|
+
if (COLLIDING_HEADERS.has(normalized) || ["content-", "x-forwarded-", "access-control-", "sec-fetch-"].some((prefix) => normalized.startsWith(prefix))) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
"fingerprintRecognition.headerName must not collide with authentication, cookie, origin, content, or transport headers."
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
return Object.freeze({ headerName: name });
|
|
760
|
+
};
|
|
761
|
+
var invalidFingerprint = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_FINGERPRINT", "Fingerprint signal is invalid.");
|
|
762
|
+
var captureFingerprintRecognition = (req, policy) => {
|
|
763
|
+
if (policy === void 0) return void 0;
|
|
764
|
+
const { count, value } = readDpopRawHeader(req, policy.headerName);
|
|
765
|
+
if (count === 0) return void 0;
|
|
766
|
+
if (count !== 1 || typeof value !== "string" || value.length === 0 || value.length > 256) throw invalidFingerprint();
|
|
767
|
+
for (let index = 0; index < value.length; index++) {
|
|
768
|
+
const byte = value.charCodeAt(index);
|
|
769
|
+
if (byte < 32 || byte > 126) throw invalidFingerprint();
|
|
770
|
+
}
|
|
771
|
+
return Object.freeze({ version: 1, hash: (0, import_node_crypto3.createHash)("sha256").update(value, "ascii").digest("base64url") });
|
|
772
|
+
};
|
|
773
|
+
var snapshotFingerprintRecognition = (metadata) => {
|
|
774
|
+
if (metadata === void 0 || !Object.hasOwn(metadata, FINGERPRINT_RECOGNITION_METADATA_KEY)) return void 0;
|
|
775
|
+
const value = metadata?.[FINGERPRINT_RECOGNITION_METADATA_KEY];
|
|
776
|
+
if (value === void 0) return void 0;
|
|
777
|
+
if (!isPlainRecord2(value)) throw new TypeError("Stored fingerprint recognition metadata is invalid.");
|
|
778
|
+
const { version, hash } = value;
|
|
779
|
+
if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "version") || !Object.hasOwn(value, "hash") || version !== 1 || !isCanonicalDpopJkt(hash)) {
|
|
780
|
+
throw new TypeError("Stored fingerprint recognition metadata is invalid.");
|
|
781
|
+
}
|
|
782
|
+
return Object.freeze({ version, hash });
|
|
783
|
+
};
|
|
784
|
+
var fingerprintRecognitionMatches = (actual, original) => actual?.hash === original?.hash;
|
|
785
|
+
var assertFingerprintRecognition = (policy, original, supplied) => {
|
|
786
|
+
if (policy !== void 0 && original !== void 0 && !fingerprintRecognitionMatches(supplied, original)) {
|
|
787
|
+
throw new OidcVaultHttpError(
|
|
788
|
+
403,
|
|
789
|
+
"OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED",
|
|
790
|
+
"Browser recognition changed; sign in again."
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
var withFingerprintRecognitionMetadata = (metadata, original) => {
|
|
795
|
+
if (metadata === void 0 && original === void 0) return void 0;
|
|
796
|
+
if (original === void 0 && metadata !== void 0 && !Object.hasOwn(metadata, FINGERPRINT_RECOGNITION_METADATA_KEY))
|
|
797
|
+
return metadata;
|
|
798
|
+
const restored = {};
|
|
799
|
+
for (const name of Object.keys(metadata ?? {})) {
|
|
800
|
+
if (name !== FINGERPRINT_RECOGNITION_METADATA_KEY)
|
|
801
|
+
Object.defineProperty(restored, name, {
|
|
802
|
+
value: Reflect.get(metadata, name),
|
|
803
|
+
enumerable: true,
|
|
804
|
+
configurable: true,
|
|
805
|
+
writable: true
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (original !== void 0) restored[FINGERPRINT_RECOGNITION_METADATA_KEY] = { version: 1, hash: original.hash };
|
|
809
|
+
return restored;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// src/lifetime-policy.ts
|
|
813
|
+
var MAX_EPOCH_MS = 864e13;
|
|
814
|
+
var isUsableEpochMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= MAX_EPOCH_MS;
|
|
815
|
+
var computeExpiresAt = (now, ttlMs, name) => {
|
|
816
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
817
|
+
throw new Error(`${name} must be a positive safe integer in milliseconds.`);
|
|
818
|
+
}
|
|
819
|
+
const expiresAt = now + ttlMs;
|
|
820
|
+
if (!isUsableEpochMs(now) || !isUsableEpochMs(expiresAt) || expiresAt <= now) {
|
|
821
|
+
throw new Error(`${name} must produce a usable epoch-millisecond expiry after now.`);
|
|
822
|
+
}
|
|
823
|
+
return expiresAt;
|
|
824
|
+
};
|
|
825
|
+
var validateLifetimeOptions = (options, now) => {
|
|
826
|
+
computeExpiresAt(
|
|
827
|
+
now,
|
|
828
|
+
options.authorizationTransactionTtlMs === void 0 ? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS : options.authorizationTransactionTtlMs,
|
|
829
|
+
"authorizationTransactionTtlMs"
|
|
830
|
+
);
|
|
831
|
+
computeExpiresAt(
|
|
832
|
+
now,
|
|
833
|
+
options.exchangeCodeTtlMs === void 0 ? DEFAULT_EXCHANGE_CODE_TTL_MS : options.exchangeCodeTtlMs,
|
|
834
|
+
"exchangeCodeTtlMs"
|
|
835
|
+
);
|
|
836
|
+
if (options.sessionTtlMs !== void 0) {
|
|
837
|
+
computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// src/transaction-cookie.ts
|
|
842
|
+
var import_node_crypto4 = require("crypto");
|
|
843
|
+
var invalidBrowserBinding = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_BROWSER_BINDING", "Login browser binding validation failed.");
|
|
844
|
+
var resolveTransactionCookieOptions = (options, backendOrigin) => {
|
|
845
|
+
const value = options.transactionCookie;
|
|
846
|
+
if (value !== void 0 && (typeof value !== "object" || value === null || ![Object.prototype, null].includes(Object.getPrototypeOf(value)))) {
|
|
847
|
+
throw new Error("transactionCookie must be a plain options object.");
|
|
848
|
+
}
|
|
849
|
+
if (value !== void 0 && Object.keys(value).some((key) => key !== "name" && key !== "sameSite")) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
"transactionCookie supports only name and sameSite; browser-binding cookie security cannot be overridden."
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
const { name, sameSite } = value ?? {};
|
|
855
|
+
const secure = new URL(backendOrigin).protocol === "https:";
|
|
856
|
+
const resolvedName = name === void 0 ? secure ? "__Host-oidc_vault_transaction" : "oidc_vault_transaction" : name;
|
|
857
|
+
if (!isValidCookieName(resolvedName)) throw new Error("transactionCookie.name must be a valid HTTP cookie name.");
|
|
858
|
+
if (sameSite !== void 0 && sameSite !== "lax" && sameSite !== "none") {
|
|
859
|
+
throw new Error("transactionCookie.sameSite must be lax or none.");
|
|
860
|
+
}
|
|
861
|
+
if (!secure && sameSite === "none") throw new Error("transactionCookie.sameSite none requires HTTPS.");
|
|
862
|
+
if (!secure && (resolvedName.startsWith("__Host-") || resolvedName.startsWith("__Secure-"))) {
|
|
863
|
+
throw new Error("transactionCookie.name with the __Host- or __Secure- prefix requires HTTPS.");
|
|
864
|
+
}
|
|
865
|
+
if ((options.deviceBinding !== void 0 || options.fingerprintRecognition !== void 0 || value !== void 0) && resolvedName === resolveCookieOptions(options).name) {
|
|
866
|
+
throw new Error("transactionCookie.name must be distinct from cookie.name.");
|
|
867
|
+
}
|
|
868
|
+
return Object.freeze({ name: resolvedName, sameSite: sameSite ?? "lax", secure, path: "/", httpOnly: true });
|
|
869
|
+
};
|
|
870
|
+
var hashSecret = (secret) => (0, import_node_crypto4.createHash)("sha256").update(secret, "ascii").digest("base64url");
|
|
871
|
+
var createTransactionBrowserBinding = () => {
|
|
872
|
+
const secret = (0, import_node_crypto4.randomBytes)(32).toString("base64url");
|
|
873
|
+
return Object.freeze({ secret, browserBindingHash: hashSecret(secret) });
|
|
874
|
+
};
|
|
875
|
+
var parseTransactionCookie = (header, selectedName) => {
|
|
876
|
+
if (header === void 0) return void 0;
|
|
877
|
+
if (typeof header !== "string") throw invalidBrowserBinding();
|
|
878
|
+
let selected;
|
|
879
|
+
for (let start = 0; start < header.length; ) {
|
|
880
|
+
const delimiter = header.indexOf(";", start);
|
|
881
|
+
const end = delimiter === -1 ? header.length : delimiter;
|
|
882
|
+
let separator = start;
|
|
883
|
+
while (separator < end && header[separator] !== "=") separator += 1;
|
|
884
|
+
const hasValue = separator < end;
|
|
885
|
+
let nameStart = start;
|
|
886
|
+
let nameEnd = hasValue ? separator : end;
|
|
887
|
+
while (nameStart < nameEnd && (header[nameStart] === " " || header[nameStart] === " ")) nameStart += 1;
|
|
888
|
+
while (nameEnd > nameStart && (header[nameEnd - 1] === " " || header[nameEnd - 1] === " ")) nameEnd -= 1;
|
|
889
|
+
if (nameEnd - nameStart === selectedName.length && header.startsWith(selectedName, nameStart)) {
|
|
890
|
+
if (selected !== void 0 || !hasValue) throw invalidBrowserBinding();
|
|
891
|
+
if (end - separator - 1 !== 43) throw invalidBrowserBinding();
|
|
892
|
+
const value = header.slice(separator + 1, end);
|
|
893
|
+
if (!isCanonicalDpopJkt(value)) throw invalidBrowserBinding();
|
|
894
|
+
selected = value;
|
|
895
|
+
}
|
|
896
|
+
start = end + 1;
|
|
897
|
+
}
|
|
898
|
+
return selected;
|
|
899
|
+
};
|
|
900
|
+
var snapshotRecordBindingMatch = (record) => {
|
|
901
|
+
const { deviceBinding, browserBindingHash } = record;
|
|
902
|
+
const binding = snapshotDpopBinding(deviceBinding);
|
|
903
|
+
if (binding !== void 0 && Object.keys(deviceBinding).length !== 2) {
|
|
904
|
+
throw new OidcVaultHttpError(
|
|
905
|
+
401,
|
|
906
|
+
"OIDC_VAULT_INVALID_DPOP_PROOF",
|
|
907
|
+
"Stored device binding contains unsupported fields.",
|
|
908
|
+
"DPoP proof validation failed."
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (browserBindingHash !== void 0 && !isCanonicalDpopJkt(browserBindingHash) || binding !== void 0 && browserBindingHash === void 0)
|
|
912
|
+
throw invalidBrowserBinding();
|
|
913
|
+
return Object.freeze({ deviceBinding: binding ?? null, browserBindingHash: browserBindingHash ?? null });
|
|
914
|
+
};
|
|
915
|
+
var recordBindingMatches = (actual, expected) => actual.deviceBinding?.jkt === expected.deviceBinding?.jkt && actual.browserBindingHash === expected.browserBindingHash;
|
|
916
|
+
var authenticateTransactionCookie = (header, cookie, match) => {
|
|
917
|
+
if (match.browserBindingHash === null) return void 0;
|
|
918
|
+
const secret = parseTransactionCookie(header, cookie.name);
|
|
919
|
+
if (secret === void 0 || hashSecret(secret) !== match.browserBindingHash) throw invalidBrowserBinding();
|
|
920
|
+
return secret;
|
|
921
|
+
};
|
|
922
|
+
var setTransactionCookie = (res, cookie, secret, expiresAt, now) => {
|
|
923
|
+
if (!isCanonicalDpopJkt(secret) || !isUsableEpochMs(expiresAt) || !isUsableEpochMs(now)) {
|
|
924
|
+
throw new TypeError("OIDC vault transaction cookie requires a canonical secret and usable deadline/clock.");
|
|
925
|
+
}
|
|
926
|
+
res.append(
|
|
927
|
+
"Set-Cookie",
|
|
928
|
+
serializeCookie(cookie.name, secret, cookie, {
|
|
929
|
+
maxAge: Math.max(0, Math.floor((expiresAt - now) / 1e3)),
|
|
930
|
+
expires: new Date(Math.floor(expiresAt / 1e3) * 1e3)
|
|
931
|
+
})
|
|
932
|
+
);
|
|
933
|
+
};
|
|
934
|
+
var clearTransactionCookie = (res, cookie) => {
|
|
935
|
+
res.append("Set-Cookie", serializeCookie(cookie.name, "", cookie, { maxAge: 0, expires: /* @__PURE__ */ new Date(0) }));
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// src/authorization-transaction.ts
|
|
939
|
+
var PROVIDER_METADATA_KEY = "oidcVaultTransactionProvider";
|
|
940
|
+
var invalidAuthorizationState = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
|
|
941
|
+
var createTransactionProviderMetadata = (config) => ({
|
|
942
|
+
[PROVIDER_METADATA_KEY]: { issuer: config.issuer, clientId: config.clientId }
|
|
943
|
+
});
|
|
944
|
+
var snapshotAuthorizationTransaction = (record, state, now) => {
|
|
945
|
+
const { state: storedState, nonce, pkceVerifier, codeChallenge, returnTo, createdAt, expiresAt, metadata } = record;
|
|
946
|
+
if (storedState !== state || typeof nonce !== "string" || typeof pkceVerifier !== "string" || typeof codeChallenge !== "string" || returnTo !== void 0 && typeof returnTo !== "string" || !isUsableEpochMs(createdAt) || !isUsableEpochMs(expiresAt) || !isUsableEpochMs(now) || expiresAt <= now) {
|
|
947
|
+
throw invalidAuthorizationState();
|
|
948
|
+
}
|
|
949
|
+
const match = snapshotRecordBindingMatch(record);
|
|
950
|
+
let fingerprintRecognition;
|
|
951
|
+
try {
|
|
952
|
+
fingerprintRecognition = snapshotFingerprintRecognition(metadata);
|
|
953
|
+
} catch {
|
|
954
|
+
throw invalidAuthorizationState();
|
|
955
|
+
}
|
|
956
|
+
if (fingerprintRecognition !== void 0 && match.browserBindingHash === null) throw invalidAuthorizationState();
|
|
957
|
+
const value = metadata?.[PROVIDER_METADATA_KEY];
|
|
958
|
+
let provider;
|
|
959
|
+
if (value !== void 0) {
|
|
960
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidAuthorizationState();
|
|
961
|
+
const { issuer, clientId } = value;
|
|
962
|
+
if (issuer !== void 0 && typeof issuer !== "string" || clientId !== void 0 && typeof clientId !== "string") {
|
|
963
|
+
throw invalidAuthorizationState();
|
|
964
|
+
}
|
|
965
|
+
provider = Object.freeze({
|
|
966
|
+
...issuer === void 0 ? {} : { issuer },
|
|
967
|
+
...clientId === void 0 ? {} : { clientId }
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
return Object.freeze({
|
|
971
|
+
state,
|
|
972
|
+
nonce,
|
|
973
|
+
pkceVerifier,
|
|
974
|
+
codeChallenge,
|
|
975
|
+
returnTo,
|
|
976
|
+
createdAt,
|
|
977
|
+
expiresAt,
|
|
978
|
+
...metadata === void 0 ? {} : {
|
|
979
|
+
metadata: Object.freeze({
|
|
980
|
+
...withFingerprintRecognitionMetadata(metadata, fingerprintRecognition),
|
|
981
|
+
...provider === void 0 ? {} : { [PROVIDER_METADATA_KEY]: provider },
|
|
982
|
+
...fingerprintRecognition === void 0 ? {} : { [FINGERPRINT_RECOGNITION_METADATA_KEY]: fingerprintRecognition }
|
|
983
|
+
})
|
|
984
|
+
},
|
|
985
|
+
...match.deviceBinding === null ? {} : { deviceBinding: match.deviceBinding },
|
|
986
|
+
...match.browserBindingHash === null ? {} : { browserBindingHash: match.browserBindingHash },
|
|
987
|
+
match,
|
|
988
|
+
...provider === void 0 ? {} : { provider },
|
|
989
|
+
...fingerprintRecognition === void 0 ? {} : { fingerprintRecognition }
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
var assertAuthorizationTransactionIdentity = (transaction, config) => {
|
|
993
|
+
if (transaction.provider?.issuer !== void 0 && transaction.provider.issuer !== config.issuer || transaction.provider?.clientId !== void 0 && transaction.provider.clientId !== config.clientId) {
|
|
994
|
+
throw invalidAuthorizationState();
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
var assertAuthorizationTransactionMatches = (actual, original) => {
|
|
998
|
+
if (actual.state !== original.state || actual.nonce !== original.nonce || actual.pkceVerifier !== original.pkceVerifier || actual.codeChallenge !== original.codeChallenge || actual.returnTo !== original.returnTo || actual.createdAt !== original.createdAt || actual.expiresAt !== original.expiresAt || actual.provider?.issuer !== original.provider?.issuer || actual.provider?.clientId !== original.provider?.clientId || !fingerprintRecognitionMatches(actual.fingerprintRecognition, original.fingerprintRecognition) || !recordBindingMatches(actual.match, original.match))
|
|
999
|
+
throw invalidAuthorizationState();
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// src/config.ts
|
|
1003
|
+
var DEFAULT_OIDC_SCOPES = "openid email profile";
|
|
1004
|
+
var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
|
|
1005
|
+
var MANUAL_CONFIG_SELECTORS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri", "userInfoEndpoint", "endSessionEndpoint"];
|
|
1006
|
+
var normalizeOptionalString = (value) => {
|
|
1007
|
+
const trimmed = value?.trim();
|
|
1008
|
+
return trimmed ? trimmed : void 0;
|
|
1009
|
+
};
|
|
1010
|
+
var normalizeHttpUrl = (value, optionName) => {
|
|
1011
|
+
if (value === void 0) {
|
|
1012
|
+
return void 0;
|
|
1013
|
+
}
|
|
1014
|
+
let url;
|
|
1015
|
+
try {
|
|
1016
|
+
url = new URL(value);
|
|
1017
|
+
} catch {
|
|
1018
|
+
throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
|
|
1019
|
+
}
|
|
1020
|
+
if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
|
|
1021
|
+
throw new Error(`${optionName} must use http or https.`);
|
|
1022
|
+
}
|
|
1023
|
+
return url.toString();
|
|
1024
|
+
};
|
|
1025
|
+
var normalizeIssuerIdentifier = (value, optionName) => {
|
|
1026
|
+
if (value === void 0) {
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
let url;
|
|
1030
|
+
try {
|
|
1031
|
+
url = new URL(value);
|
|
1032
|
+
} catch {
|
|
1033
|
+
throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
|
|
1034
|
+
}
|
|
1035
|
+
if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
|
|
1036
|
+
throw new Error(`${optionName} must use http or https.`);
|
|
1037
|
+
}
|
|
1038
|
+
if (url.username !== "" || url.password !== "") {
|
|
1039
|
+
throw new Error(`${optionName} must not include userinfo.`);
|
|
1040
|
+
}
|
|
1041
|
+
if (url.search !== "" || url.hash !== "") {
|
|
1042
|
+
throw new Error(`${optionName} must not include query or fragment.`);
|
|
1043
|
+
}
|
|
1044
|
+
return value;
|
|
1045
|
+
};
|
|
1046
|
+
var withOptionalValue = (target, key, value) => {
|
|
1047
|
+
if (value !== void 0) {
|
|
1048
|
+
target[key] = value;
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
function resolveOidcVaultConfig(config = {}) {
|
|
1052
|
+
const issuer = normalizeIssuerIdentifier(normalizeOptionalString(config.issuer), "config.issuer");
|
|
1053
|
+
const clientId = normalizeOptionalString(config.clientId);
|
|
1054
|
+
const clientSecret = normalizeOptionalString(config.clientSecret);
|
|
1055
|
+
const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
|
|
1056
|
+
const hasManualEndpoints = MANUAL_CONFIG_SELECTORS.some((key) => normalizeOptionalString(config[key]));
|
|
1057
|
+
if (!clientId) {
|
|
1058
|
+
throw new Error("OIDC clientId is required.");
|
|
1059
|
+
}
|
|
1060
|
+
if (issuer && !hasManualEndpoints) {
|
|
1061
|
+
const resolved2 = {
|
|
1062
|
+
mode: "issuer",
|
|
1063
|
+
issuer,
|
|
1064
|
+
scopes
|
|
1065
|
+
};
|
|
1066
|
+
withOptionalValue(resolved2, "clientId", clientId);
|
|
1067
|
+
withOptionalValue(resolved2, "clientSecret", clientSecret);
|
|
1068
|
+
return resolved2;
|
|
1069
|
+
}
|
|
1070
|
+
const resolved = {
|
|
1071
|
+
mode: "manual",
|
|
1072
|
+
scopes
|
|
1073
|
+
};
|
|
1074
|
+
withOptionalValue(resolved, "issuer", issuer);
|
|
1075
|
+
withOptionalValue(
|
|
1076
|
+
resolved,
|
|
1077
|
+
"authorizationEndpoint",
|
|
1078
|
+
normalizeHttpUrl(normalizeOptionalString(config.authorizationEndpoint), "config.authorizationEndpoint")
|
|
1079
|
+
);
|
|
1080
|
+
withOptionalValue(
|
|
1081
|
+
resolved,
|
|
1082
|
+
"tokenEndpoint",
|
|
1083
|
+
normalizeHttpUrl(normalizeOptionalString(config.tokenEndpoint), "config.tokenEndpoint")
|
|
1084
|
+
);
|
|
1085
|
+
withOptionalValue(
|
|
1086
|
+
resolved,
|
|
1087
|
+
"userInfoEndpoint",
|
|
1088
|
+
normalizeHttpUrl(normalizeOptionalString(config.userInfoEndpoint), "config.userInfoEndpoint")
|
|
1089
|
+
);
|
|
1090
|
+
withOptionalValue(resolved, "jwksUri", normalizeHttpUrl(normalizeOptionalString(config.jwksUri), "config.jwksUri"));
|
|
1091
|
+
withOptionalValue(
|
|
1092
|
+
resolved,
|
|
1093
|
+
"endSessionEndpoint",
|
|
1094
|
+
normalizeHttpUrl(normalizeOptionalString(config.endSessionEndpoint), "config.endSessionEndpoint")
|
|
1095
|
+
);
|
|
1096
|
+
withOptionalValue(resolved, "clientId", clientId);
|
|
1097
|
+
withOptionalValue(resolved, "clientSecret", clientSecret);
|
|
1098
|
+
const missing = MANUAL_CONFIG_REQUIREMENTS.filter((key) => !resolved[key]);
|
|
1099
|
+
if (missing.length > 0) {
|
|
1100
|
+
throw new Error(`Missing required OIDC configuration for manual mode: ${missing.join(", ")}`);
|
|
1101
|
+
}
|
|
1102
|
+
return resolved;
|
|
1103
|
+
}
|
|
1104
|
+
function resolveOidcVaultConfigFromEnv(env = process.env) {
|
|
1105
|
+
return resolveOidcVaultConfig({
|
|
1106
|
+
issuer: env.OIDC_ISSUER,
|
|
1107
|
+
authorizationEndpoint: env.OIDC_AUTHORIZATION_ENDPOINT,
|
|
1108
|
+
tokenEndpoint: env.OIDC_TOKEN_ENDPOINT,
|
|
1109
|
+
userInfoEndpoint: env.OIDC_USERINFO_ENDPOINT,
|
|
1110
|
+
jwksUri: env.OIDC_JWKS_URI,
|
|
1111
|
+
endSessionEndpoint: env.OIDC_END_SESSION_ENDPOINT,
|
|
1112
|
+
clientId: env.OIDC_CLIENT_ID,
|
|
1113
|
+
clientSecret: env.OIDC_CLIENT_SECRET,
|
|
1114
|
+
scopes: env.OIDC_SCOPES
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
354
1117
|
|
|
355
1118
|
// src/utils.ts
|
|
356
1119
|
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
@@ -434,32 +1197,70 @@ var getRequestSourceOrigin = (req) => {
|
|
|
434
1197
|
}
|
|
435
1198
|
};
|
|
436
1199
|
var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
|
|
437
|
-
|
|
438
|
-
|
|
1200
|
+
captureTrustedOriginGuard(req, options, trustedOrigins, action)();
|
|
1201
|
+
};
|
|
1202
|
+
var captureTrustedOriginGuard = (req, options, trustedOrigins, action) => {
|
|
1203
|
+
const guarded = action === "login" || action === "exchange";
|
|
1204
|
+
if (!guarded && !usesCookieTransport(options)) {
|
|
1205
|
+
return () => {
|
|
1206
|
+
};
|
|
439
1207
|
}
|
|
440
|
-
const origin = getRequestSourceOrigin(req);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1208
|
+
const origin = guarded ? getGuardedRequestSourceOrigin(req) : getRequestSourceOrigin(req);
|
|
1209
|
+
return () => {
|
|
1210
|
+
if (!origin || !trustedOrigins.has(origin)) {
|
|
1211
|
+
throw new OidcVaultHttpError(
|
|
1212
|
+
403,
|
|
1213
|
+
"OIDC_VAULT_UNTRUSTED_ORIGIN",
|
|
1214
|
+
`${action.charAt(0).toUpperCase()}${action.slice(1)} request origin is not trusted.`
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
};
|
|
1219
|
+
var getGuardedRequestSourceOrigin = (req) => {
|
|
1220
|
+
const origin = readDpopRawHeader(req, "origin");
|
|
1221
|
+
if (origin.count !== 0) return origin.count === 1 && isString(origin.value) ? origin.value : void 0;
|
|
1222
|
+
const referer = readDpopRawHeader(req, "referer");
|
|
1223
|
+
if (referer.count !== 1 || !isString(referer.value)) return void 0;
|
|
1224
|
+
if (!/^https?:\/\//i.test(referer.value) || /[\s\\]/.test(referer.value)) return void 0;
|
|
1225
|
+
try {
|
|
1226
|
+
const url = new URL(referer.value);
|
|
1227
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password ? url.origin : void 0;
|
|
1228
|
+
} catch {
|
|
1229
|
+
return void 0;
|
|
447
1230
|
}
|
|
448
1231
|
};
|
|
449
1232
|
|
|
450
1233
|
// src/provider-client.ts
|
|
451
|
-
var
|
|
1234
|
+
var import_jose2 = require("jose");
|
|
452
1235
|
var DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 5e3;
|
|
453
1236
|
var DEFAULT_DISCOVERY_CACHE_TTL_MS = 10 * 6e4;
|
|
454
1237
|
var DEFAULT_DISCOVERY_CACHE_CAPACITY = 32;
|
|
455
1238
|
var DEFAULT_JWKS_CACHE_CAPACITY = 32;
|
|
456
1239
|
var DEFAULT_PROVIDER_ERROR_BODY_LIMIT = 1024;
|
|
457
1240
|
var DEFAULT_PROVIDER_JSON_BODY_LIMIT = 1024 * 1024;
|
|
1241
|
+
var DEFAULT_JWKS_BODY_LIMIT = 1024 * 1024;
|
|
1242
|
+
var DEFAULT_JWKS_MAX_KEYS = 100;
|
|
458
1243
|
var discoveryCache = /* @__PURE__ */ new Map();
|
|
459
1244
|
var jwksCache = /* @__PURE__ */ new Map();
|
|
460
|
-
var normalizeIssuerForDiscoveryComparison = (issuer) => issuer.replace(/\/+$/g, "");
|
|
461
1245
|
var assertDiscoveredIssuerMatchesConfig = (configuredIssuer, discoveredIssuer) => {
|
|
462
|
-
|
|
1246
|
+
let discoveredUrl;
|
|
1247
|
+
try {
|
|
1248
|
+
discoveredUrl = new URL(discoveredIssuer);
|
|
1249
|
+
} catch {
|
|
1250
|
+
throw new OidcVaultHttpError(
|
|
1251
|
+
502,
|
|
1252
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1253
|
+
"OIDC discovery response issuer must be an absolute HTTP(S) URL."
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
if (discoveredUrl.protocol !== "https:" && discoveredUrl.protocol !== "http:" || discoveredUrl.origin === "null" || discoveredUrl.username !== "" || discoveredUrl.password !== "" || discoveredUrl.search !== "" || discoveredUrl.hash !== "") {
|
|
1257
|
+
throw new OidcVaultHttpError(
|
|
1258
|
+
502,
|
|
1259
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1260
|
+
"OIDC discovery response issuer must be an http(s) URL without userinfo, query, or fragment."
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
if (configuredIssuer !== discoveredIssuer) {
|
|
463
1264
|
throw new OidcVaultHttpError(
|
|
464
1265
|
502,
|
|
465
1266
|
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
@@ -487,6 +1288,20 @@ var validateDiscoveredHttpUrl = (value, fieldName) => {
|
|
|
487
1288
|
}
|
|
488
1289
|
return url.toString();
|
|
489
1290
|
};
|
|
1291
|
+
var validateOptionalDiscoveredHttpUrl = (value, fieldName) => {
|
|
1292
|
+
if (value === void 0) {
|
|
1293
|
+
return void 0;
|
|
1294
|
+
}
|
|
1295
|
+
return validateDiscoveredHttpUrl(
|
|
1296
|
+
getRequiredString(
|
|
1297
|
+
value,
|
|
1298
|
+
`OIDC discovery response ${fieldName} must be a non-empty string when present.`,
|
|
1299
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1300
|
+
502
|
|
1301
|
+
),
|
|
1302
|
+
fieldName
|
|
1303
|
+
);
|
|
1304
|
+
};
|
|
490
1305
|
var buildWellKnownUrl = (issuer) => {
|
|
491
1306
|
const normalizedIssuer = issuer.endsWith("/") ? issuer : `${issuer}/`;
|
|
492
1307
|
return new URL(".well-known/openid-configuration", normalizedIssuer);
|
|
@@ -511,23 +1326,47 @@ var evictOldestEntry = (cache, capacity) => {
|
|
|
511
1326
|
cache.delete(oldestKey);
|
|
512
1327
|
}
|
|
513
1328
|
};
|
|
1329
|
+
var isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
1330
|
+
var createProviderTimeoutError = (errorCode) => new OidcVaultHttpError(502, errorCode, "OIDC provider request timed out.");
|
|
1331
|
+
var createProviderTransportError = (errorCode, cause) => {
|
|
1332
|
+
const error = new OidcVaultHttpError(502, errorCode, "OIDC provider request failed.");
|
|
1333
|
+
Object.defineProperty(error, "cause", { value: cause, configurable: true, writable: true });
|
|
1334
|
+
return error;
|
|
1335
|
+
};
|
|
1336
|
+
var cancelBestEffort = (cancel) => {
|
|
1337
|
+
try {
|
|
1338
|
+
void cancel()?.catch(() => {
|
|
1339
|
+
});
|
|
1340
|
+
} catch {
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
514
1343
|
var fetchProvider = async (input, init, errorCode, options) => {
|
|
1344
|
+
const timeoutMs = getProviderRequestTimeoutMs(options);
|
|
515
1345
|
const controller = new AbortController();
|
|
516
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
1346
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1347
|
+
const done = () => clearTimeout(timeout);
|
|
517
1348
|
try {
|
|
518
|
-
|
|
1349
|
+
const response = await fetch(input, {
|
|
519
1350
|
...init,
|
|
520
1351
|
redirect: "manual",
|
|
521
1352
|
signal: controller.signal
|
|
522
1353
|
});
|
|
1354
|
+
return { response, signal: controller.signal, done };
|
|
523
1355
|
} catch (error) {
|
|
524
|
-
|
|
525
|
-
|
|
1356
|
+
done();
|
|
1357
|
+
if (controller.signal.aborted || isAbortError(error)) {
|
|
1358
|
+
throw createProviderTimeoutError(errorCode);
|
|
526
1359
|
}
|
|
527
|
-
throw error;
|
|
528
|
-
}
|
|
529
|
-
|
|
1360
|
+
throw createProviderTransportError(errorCode, error);
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
var throwBoundedReadTimeout = (errorCode, timeoutAsTimeoutError) => {
|
|
1364
|
+
if (timeoutAsTimeoutError) {
|
|
1365
|
+
const timeoutError = new Error("JSON Web Key Set request timed out.");
|
|
1366
|
+
timeoutError.name = "TimeoutError";
|
|
1367
|
+
throw timeoutError;
|
|
530
1368
|
}
|
|
1369
|
+
throw createProviderTimeoutError(errorCode);
|
|
531
1370
|
};
|
|
532
1371
|
var readBoundedResponseText = async (response, limit, errorCode, options = {}) => {
|
|
533
1372
|
if (!response.body) {
|
|
@@ -535,18 +1374,43 @@ var readBoundedResponseText = async (response, limit, errorCode, options = {}) =
|
|
|
535
1374
|
}
|
|
536
1375
|
const reader = response.body.getReader();
|
|
537
1376
|
const decoder = new TextDecoder();
|
|
1377
|
+
const { signal } = options;
|
|
538
1378
|
let size = 0;
|
|
539
1379
|
let text = "";
|
|
1380
|
+
let onAbort;
|
|
1381
|
+
let abortPromise;
|
|
1382
|
+
if (signal) {
|
|
1383
|
+
if (signal.aborted) {
|
|
1384
|
+
cancelBestEffort(() => reader.cancel());
|
|
1385
|
+
reader.releaseLock();
|
|
1386
|
+
throwBoundedReadTimeout(errorCode, options.timeoutAsTimeoutError === true);
|
|
1387
|
+
}
|
|
1388
|
+
abortPromise = new Promise((_resolve, reject) => {
|
|
1389
|
+
onAbort = () => {
|
|
1390
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
|
|
1391
|
+
};
|
|
1392
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1393
|
+
});
|
|
1394
|
+
abortPromise.catch(() => {
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
540
1397
|
try {
|
|
541
1398
|
for (; ; ) {
|
|
542
|
-
|
|
1399
|
+
let result;
|
|
1400
|
+
try {
|
|
1401
|
+
result = abortPromise === void 0 ? await reader.read() : await Promise.race([reader.read(), abortPromise]);
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
if (signal?.aborted === true || isAbortError(error)) {
|
|
1404
|
+
throwBoundedReadTimeout(errorCode, options.timeoutAsTimeoutError === true);
|
|
1405
|
+
}
|
|
1406
|
+
throw createProviderTransportError(options.transportErrorCode ?? errorCode, error);
|
|
1407
|
+
}
|
|
543
1408
|
if (result.done) {
|
|
544
1409
|
break;
|
|
545
1410
|
}
|
|
546
1411
|
size += result.value.byteLength;
|
|
547
1412
|
if (size > limit) {
|
|
548
|
-
|
|
549
|
-
if (options.truncate) {
|
|
1413
|
+
if (options.truncate === true) {
|
|
550
1414
|
return `${text.slice(0, limit)}...`;
|
|
551
1415
|
}
|
|
552
1416
|
throw new OidcVaultHttpError(502, errorCode, "OIDC provider response is too large.");
|
|
@@ -554,123 +1418,219 @@ var readBoundedResponseText = async (response, limit, errorCode, options = {}) =
|
|
|
554
1418
|
text += decoder.decode(result.value, { stream: true });
|
|
555
1419
|
}
|
|
556
1420
|
} finally {
|
|
1421
|
+
if (signal !== void 0 && onAbort !== void 0) {
|
|
1422
|
+
signal.removeEventListener("abort", onAbort);
|
|
1423
|
+
}
|
|
1424
|
+
cancelBestEffort(() => reader.cancel());
|
|
557
1425
|
reader.releaseLock();
|
|
558
1426
|
}
|
|
559
1427
|
return `${text}${decoder.decode()}`;
|
|
560
1428
|
};
|
|
1429
|
+
var createBoundedJwksFetch = (timeoutMs) => {
|
|
1430
|
+
void timeoutMs;
|
|
1431
|
+
return async (url, { headers, method, redirect, signal }) => {
|
|
1432
|
+
let response;
|
|
1433
|
+
try {
|
|
1434
|
+
response = await fetch(url, { method, headers, redirect, signal });
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
if (signal.aborted || isAbortError(error)) {
|
|
1437
|
+
const timeoutError = new Error("JSON Web Key Set request timed out.");
|
|
1438
|
+
timeoutError.name = "TimeoutError";
|
|
1439
|
+
throw timeoutError;
|
|
1440
|
+
}
|
|
1441
|
+
throw createProviderTransportError("OIDC_VAULT_JWKS_FAILED", error);
|
|
1442
|
+
}
|
|
1443
|
+
if (response.status !== 200) {
|
|
1444
|
+
cancelBestEffort(() => response.body?.cancel());
|
|
1445
|
+
return response;
|
|
1446
|
+
}
|
|
1447
|
+
const text = await readBoundedResponseText(response, DEFAULT_JWKS_BODY_LIMIT, "OIDC_VAULT_JWKS_FAILED", {
|
|
1448
|
+
signal,
|
|
1449
|
+
timeoutAsTimeoutError: true
|
|
1450
|
+
});
|
|
1451
|
+
try {
|
|
1452
|
+
const parsed = JSON.parse(text);
|
|
1453
|
+
if (Array.isArray(parsed.keys) && parsed.keys.length > DEFAULT_JWKS_MAX_KEYS) {
|
|
1454
|
+
throw new OidcVaultHttpError(502, "OIDC_VAULT_JWKS_FAILED", "JSON Web Key Set contains too many keys.");
|
|
1455
|
+
}
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
if (error instanceof OidcVaultHttpError) {
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return new Response(text, {
|
|
1462
|
+
status: 200,
|
|
1463
|
+
headers: { "content-type": "application/json" }
|
|
1464
|
+
});
|
|
1465
|
+
};
|
|
1466
|
+
};
|
|
561
1467
|
var resolveJwks = (jwksUri, options = {}) => {
|
|
562
|
-
|
|
1468
|
+
const timeoutMs = getProviderRequestTimeoutMs(options);
|
|
1469
|
+
const cacheKey = `${jwksUri}\0${timeoutMs}`;
|
|
1470
|
+
let jwks = jwksCache.get(cacheKey);
|
|
563
1471
|
if (!jwks) {
|
|
564
|
-
jwks = (0,
|
|
565
|
-
timeoutDuration:
|
|
566
|
-
cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS
|
|
1472
|
+
jwks = (0, import_jose2.createRemoteJWKSet)(new URL(jwksUri), {
|
|
1473
|
+
timeoutDuration: timeoutMs,
|
|
1474
|
+
cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS,
|
|
1475
|
+
[import_jose2.customFetch]: createBoundedJwksFetch(timeoutMs)
|
|
567
1476
|
});
|
|
568
|
-
jwksCache.set(
|
|
1477
|
+
jwksCache.set(cacheKey, jwks);
|
|
569
1478
|
evictOldestEntry(jwksCache, DEFAULT_JWKS_CACHE_CAPACITY);
|
|
570
1479
|
}
|
|
571
1480
|
return jwks;
|
|
572
1481
|
};
|
|
573
1482
|
var formEncodeClientCredential = (value) => new URLSearchParams([[value, ""]]).toString().slice(0, -1);
|
|
574
1483
|
var toBasicAuthorization = (clientId, clientSecret) => `Basic ${Buffer.from(`${formEncodeClientCredential(clientId)}:${formEncodeClientCredential(clientSecret)}`).toString("base64")}`;
|
|
575
|
-
var
|
|
576
|
-
|
|
1484
|
+
var isProviderJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1485
|
+
var readJsonResponse = async (response, errorCode, invalidJsonMessage = "OIDC provider returned invalid JSON.", readOptions = {}) => {
|
|
1486
|
+
const text = await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode, {
|
|
1487
|
+
signal: readOptions.signal,
|
|
1488
|
+
transportErrorCode: readOptions.transportErrorCode
|
|
1489
|
+
});
|
|
577
1490
|
if (!text) {
|
|
578
1491
|
return {};
|
|
579
1492
|
}
|
|
1493
|
+
let parsed;
|
|
580
1494
|
try {
|
|
581
|
-
|
|
1495
|
+
parsed = JSON.parse(text);
|
|
582
1496
|
} catch {
|
|
583
|
-
throw new OidcVaultHttpError(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
1497
|
+
throw new OidcVaultHttpError(502, errorCode, invalidJsonMessage, invalidJsonMessage);
|
|
1498
|
+
}
|
|
1499
|
+
if (!isProviderJsonObject(parsed)) {
|
|
1500
|
+
throw new OidcVaultHttpError(502, errorCode, invalidJsonMessage, invalidJsonMessage);
|
|
1501
|
+
}
|
|
1502
|
+
return parsed;
|
|
1503
|
+
};
|
|
1504
|
+
var drainProviderErrorBody = async (response, signal, errorCode) => {
|
|
1505
|
+
try {
|
|
1506
|
+
await readBoundedResponseText(response, DEFAULT_PROVIDER_JSON_BODY_LIMIT, errorCode, { signal });
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
if (error instanceof OidcVaultHttpError) {
|
|
1509
|
+
throw error;
|
|
1510
|
+
}
|
|
1511
|
+
throw error;
|
|
589
1512
|
}
|
|
590
1513
|
};
|
|
591
1514
|
async function discoverIssuerMetadata(issuer, options) {
|
|
1515
|
+
const callerTimeoutMs = getProviderRequestTimeoutMs(options);
|
|
1516
|
+
const policyKey = `${issuer}\0${callerTimeoutMs}`;
|
|
592
1517
|
const now = Date.now();
|
|
593
|
-
const
|
|
594
|
-
let discoveryPromise =
|
|
595
|
-
if (
|
|
596
|
-
discoveryCache.delete(
|
|
597
|
-
}
|
|
598
|
-
if (
|
|
1518
|
+
const policyCached = discoveryCache.get(policyKey);
|
|
1519
|
+
let discoveryPromise = policyCached !== void 0 && policyCached.expiresAt > now ? policyCached.promise : void 0;
|
|
1520
|
+
if (policyCached !== void 0 && discoveryPromise === void 0) {
|
|
1521
|
+
discoveryCache.delete(policyKey);
|
|
1522
|
+
}
|
|
1523
|
+
if (discoveryPromise === void 0) {
|
|
1524
|
+
const sharedCached = discoveryCache.get(issuer);
|
|
1525
|
+
const sharedPromise = sharedCached !== void 0 && sharedCached.expiresAt > now ? sharedCached.promise : void 0;
|
|
1526
|
+
if (sharedCached !== void 0 && sharedPromise === void 0) {
|
|
1527
|
+
discoveryCache.delete(issuer);
|
|
1528
|
+
}
|
|
1529
|
+
if (sharedPromise !== void 0) {
|
|
1530
|
+
return sharedPromise;
|
|
1531
|
+
}
|
|
599
1532
|
discoveryPromise = (async () => {
|
|
600
1533
|
const discoveryUrl = buildWellKnownUrl(issuer);
|
|
601
|
-
const response = await fetchProvider(
|
|
602
|
-
|
|
603
|
-
|
|
1534
|
+
const { response, signal, done } = await fetchProvider(
|
|
1535
|
+
discoveryUrl,
|
|
1536
|
+
void 0,
|
|
1537
|
+
"OIDC_VAULT_DISCOVERY_FAILED",
|
|
1538
|
+
options
|
|
1539
|
+
);
|
|
1540
|
+
try {
|
|
1541
|
+
if (!response.ok) {
|
|
1542
|
+
const errorText = await readBoundedResponseText(
|
|
1543
|
+
response,
|
|
1544
|
+
DEFAULT_PROVIDER_ERROR_BODY_LIMIT,
|
|
1545
|
+
"OIDC_VAULT_DISCOVERY_FAILED",
|
|
1546
|
+
{
|
|
1547
|
+
truncate: true,
|
|
1548
|
+
signal
|
|
1549
|
+
}
|
|
1550
|
+
);
|
|
1551
|
+
throw new OidcVaultHttpError(
|
|
1552
|
+
502,
|
|
1553
|
+
"OIDC_VAULT_DISCOVERY_FAILED",
|
|
1554
|
+
errorText || `OIDC discovery failed with status ${response.status}.`,
|
|
1555
|
+
"OIDC discovery failed."
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
const discovered = await readJsonResponse(
|
|
604
1559
|
response,
|
|
605
|
-
|
|
606
|
-
"
|
|
607
|
-
{
|
|
608
|
-
truncate: true
|
|
609
|
-
}
|
|
1560
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1561
|
+
"OIDC discovery response is malformed JSON.",
|
|
1562
|
+
{ signal, transportErrorCode: "OIDC_VAULT_DISCOVERY_FAILED" }
|
|
610
1563
|
);
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1564
|
+
const authorizationEndpoint = validateDiscoveredHttpUrl(
|
|
1565
|
+
getRequiredString(
|
|
1566
|
+
discovered.authorization_endpoint,
|
|
1567
|
+
"OIDC discovery response is missing authorization_endpoint.",
|
|
1568
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1569
|
+
502
|
|
1570
|
+
),
|
|
1571
|
+
"authorization_endpoint"
|
|
616
1572
|
);
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
),
|
|
639
|
-
"token_endpoint"
|
|
640
|
-
);
|
|
641
|
-
const jwksUri = validateDiscoveredHttpUrl(
|
|
642
|
-
getRequiredString(
|
|
643
|
-
discovered.jwks_uri,
|
|
644
|
-
"OIDC discovery response is missing jwks_uri.",
|
|
1573
|
+
const tokenEndpoint = validateDiscoveredHttpUrl(
|
|
1574
|
+
getRequiredString(
|
|
1575
|
+
discovered.token_endpoint,
|
|
1576
|
+
"OIDC discovery response is missing token_endpoint.",
|
|
1577
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1578
|
+
502
|
|
1579
|
+
),
|
|
1580
|
+
"token_endpoint"
|
|
1581
|
+
);
|
|
1582
|
+
const jwksUri = validateDiscoveredHttpUrl(
|
|
1583
|
+
getRequiredString(
|
|
1584
|
+
discovered.jwks_uri,
|
|
1585
|
+
"OIDC discovery response is missing jwks_uri.",
|
|
1586
|
+
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
1587
|
+
502
|
|
1588
|
+
),
|
|
1589
|
+
"jwks_uri"
|
|
1590
|
+
);
|
|
1591
|
+
const discoveredIssuer = getRequiredString(
|
|
1592
|
+
discovered.issuer,
|
|
1593
|
+
"OIDC discovery response is missing issuer.",
|
|
645
1594
|
"OIDC_VAULT_DISCOVERY_INVALID",
|
|
646
1595
|
502
|
|
647
|
-
)
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
};
|
|
1596
|
+
);
|
|
1597
|
+
assertDiscoveredIssuerMatchesConfig(issuer, discoveredIssuer);
|
|
1598
|
+
return {
|
|
1599
|
+
issuer: discoveredIssuer,
|
|
1600
|
+
authorizationEndpoint,
|
|
1601
|
+
tokenEndpoint,
|
|
1602
|
+
jwksUri,
|
|
1603
|
+
userInfoEndpoint: validateOptionalDiscoveredHttpUrl(discovered.userinfo_endpoint, "userinfo_endpoint"),
|
|
1604
|
+
endSessionEndpoint: validateOptionalDiscoveredHttpUrl(
|
|
1605
|
+
discovered.end_session_endpoint,
|
|
1606
|
+
"end_session_endpoint"
|
|
1607
|
+
)
|
|
1608
|
+
};
|
|
1609
|
+
} finally {
|
|
1610
|
+
done();
|
|
1611
|
+
cancelBestEffort(() => response.body?.cancel());
|
|
1612
|
+
}
|
|
665
1613
|
})();
|
|
666
|
-
|
|
1614
|
+
discoveryPromise.then(
|
|
1615
|
+
() => {
|
|
1616
|
+
const fetchedAt = Date.now();
|
|
1617
|
+
discoveryCache.set(issuer, {
|
|
1618
|
+
expiresAt: fetchedAt + DEFAULT_DISCOVERY_CACHE_TTL_MS,
|
|
1619
|
+
promise: discoveryPromise
|
|
1620
|
+
});
|
|
1621
|
+
evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
|
|
1622
|
+
},
|
|
1623
|
+
() => {
|
|
1624
|
+
}
|
|
1625
|
+
);
|
|
1626
|
+
discoveryCache.set(policyKey, {
|
|
667
1627
|
expiresAt: now + DEFAULT_DISCOVERY_CACHE_TTL_MS,
|
|
668
1628
|
promise: discoveryPromise
|
|
669
1629
|
});
|
|
670
1630
|
evictOldestEntry(discoveryCache, DEFAULT_DISCOVERY_CACHE_CAPACITY);
|
|
671
1631
|
discoveryPromise.catch(() => {
|
|
672
|
-
if (discoveryCache.get(
|
|
673
|
-
discoveryCache.delete(
|
|
1632
|
+
if (discoveryCache.get(policyKey)?.promise === discoveryPromise) {
|
|
1633
|
+
discoveryCache.delete(policyKey);
|
|
674
1634
|
}
|
|
675
1635
|
});
|
|
676
1636
|
}
|
|
@@ -721,7 +1681,7 @@ async function requestToken(metadata, params, options = {}) {
|
|
|
721
1681
|
if (metadata.clientSecret) {
|
|
722
1682
|
headers.authorization = toBasicAuthorization(clientId, metadata.clientSecret);
|
|
723
1683
|
}
|
|
724
|
-
const response = await fetchProvider(
|
|
1684
|
+
const { response, signal, done } = await fetchProvider(
|
|
725
1685
|
metadata.tokenEndpoint,
|
|
726
1686
|
{
|
|
727
1687
|
method: "POST",
|
|
@@ -731,17 +1691,33 @@ async function requestToken(metadata, params, options = {}) {
|
|
|
731
1691
|
"OIDC_VAULT_TOKEN_REQUEST_FAILED",
|
|
732
1692
|
options
|
|
733
1693
|
);
|
|
734
|
-
|
|
735
|
-
|
|
1694
|
+
try {
|
|
1695
|
+
if (!response.ok) {
|
|
1696
|
+
await drainProviderErrorBody(response, signal, "OIDC_VAULT_TOKEN_REQUEST_FAILED");
|
|
1697
|
+
throw new OidcVaultHttpError(
|
|
1698
|
+
502,
|
|
1699
|
+
"OIDC_VAULT_TOKEN_REQUEST_FAILED",
|
|
1700
|
+
"OIDC token request failed.",
|
|
1701
|
+
"OIDC token request failed."
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
const json = await readJsonResponse(response, "OIDC_VAULT_TOKEN_REQUEST_FAILED", void 0, { signal });
|
|
1705
|
+
return json;
|
|
1706
|
+
} finally {
|
|
1707
|
+
done();
|
|
1708
|
+
cancelBestEffort(() => response.body?.cancel());
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
var assertPresentTokenStringField = (tokenResponse, field) => {
|
|
1712
|
+
const value = tokenResponse[field];
|
|
1713
|
+
if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
|
|
736
1714
|
throw new OidcVaultHttpError(
|
|
737
1715
|
502,
|
|
738
|
-
"
|
|
739
|
-
|
|
740
|
-
"OIDC token request failed."
|
|
1716
|
+
"OIDC_VAULT_INVALID_TOKEN_RESPONSE",
|
|
1717
|
+
`OIDC token response ${field} must be a non-empty string when present.`
|
|
741
1718
|
);
|
|
742
1719
|
}
|
|
743
|
-
|
|
744
|
-
}
|
|
1720
|
+
};
|
|
745
1721
|
function validateTokenResponse(tokenResponse) {
|
|
746
1722
|
if (typeof tokenResponse.token_type !== "string" || tokenResponse.token_type.toLowerCase() !== "bearer") {
|
|
747
1723
|
throw new OidcVaultHttpError(
|
|
@@ -757,12 +1733,38 @@ function validateTokenResponse(tokenResponse) {
|
|
|
757
1733
|
"OIDC_VAULT_INVALID_TOKEN_RESPONSE"
|
|
758
1734
|
);
|
|
759
1735
|
}
|
|
1736
|
+
assertPresentTokenStringField(tokenResponse, "access_token");
|
|
1737
|
+
assertPresentTokenStringField(tokenResponse, "id_token");
|
|
1738
|
+
assertPresentTokenStringField(tokenResponse, "refresh_token");
|
|
1739
|
+
if (tokenResponse.scope !== void 0 && typeof tokenResponse.scope !== "string") {
|
|
1740
|
+
throw new OidcVaultHttpError(
|
|
1741
|
+
502,
|
|
1742
|
+
"OIDC_VAULT_INVALID_TOKEN_RESPONSE",
|
|
1743
|
+
"OIDC token response scope must be a string when present."
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
function validateCallbackTokenResponse(tokenResponse) {
|
|
1748
|
+
validateTokenResponse(tokenResponse);
|
|
1749
|
+
if (typeof tokenResponse.id_token !== "string" || tokenResponse.id_token.length === 0) {
|
|
1750
|
+
throw new OidcVaultHttpError(502, "OIDC_VAULT_MISSING_ID_TOKEN", "OIDC token response is missing id_token.");
|
|
1751
|
+
}
|
|
1752
|
+
if (typeof tokenResponse.refresh_token !== "string" || tokenResponse.refresh_token.length === 0) {
|
|
1753
|
+
throw new OidcVaultHttpError(
|
|
1754
|
+
502,
|
|
1755
|
+
"OIDC_VAULT_MISSING_REFRESH_TOKEN",
|
|
1756
|
+
"OIDC token response is missing refresh_token."
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
function validateRefreshTokenResponse(tokenResponse) {
|
|
1761
|
+
validateTokenResponse(tokenResponse);
|
|
760
1762
|
}
|
|
761
1763
|
async function fetchUserInfo(metadata, accessToken, options = {}) {
|
|
762
1764
|
if (!metadata.userInfoEndpoint) {
|
|
763
1765
|
return {};
|
|
764
1766
|
}
|
|
765
|
-
const response = await fetchProvider(
|
|
1767
|
+
const { response, signal, done } = await fetchProvider(
|
|
766
1768
|
metadata.userInfoEndpoint,
|
|
767
1769
|
{
|
|
768
1770
|
headers: {
|
|
@@ -772,20 +1774,261 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
|
|
|
772
1774
|
"OIDC_VAULT_USERINFO_FAILED",
|
|
773
1775
|
options
|
|
774
1776
|
);
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1777
|
+
try {
|
|
1778
|
+
if (!response.ok) {
|
|
1779
|
+
await drainProviderErrorBody(response, signal, "OIDC_VAULT_USERINFO_FAILED");
|
|
1780
|
+
throw new OidcVaultHttpError(
|
|
1781
|
+
502,
|
|
1782
|
+
"OIDC_VAULT_USERINFO_FAILED",
|
|
1783
|
+
"OIDC userinfo request failed.",
|
|
1784
|
+
"OIDC userinfo request failed."
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
const json = await readJsonResponse(response, "OIDC_VAULT_USERINFO_FAILED", void 0, { signal });
|
|
1788
|
+
return json;
|
|
1789
|
+
} finally {
|
|
1790
|
+
done();
|
|
1791
|
+
cancelBestEffort(() => response.body?.cancel());
|
|
783
1792
|
}
|
|
784
|
-
return json;
|
|
785
1793
|
}
|
|
786
1794
|
|
|
1795
|
+
// src/token-issuance.ts
|
|
1796
|
+
var import_jose3 = require("jose");
|
|
1797
|
+
|
|
1798
|
+
// src/vault-session.ts
|
|
1799
|
+
var invalidVaultSession = () => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
|
|
1800
|
+
var invalidExchangeCode = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
|
|
1801
|
+
var isPlainObject2 = (value) => typeof value === "object" && value !== null && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
1802
|
+
var copyPortableData = (value, omitRecognition = false) => {
|
|
1803
|
+
const copies = /* @__PURE__ */ new Map();
|
|
1804
|
+
const pending = [];
|
|
1805
|
+
const copy = (item) => {
|
|
1806
|
+
if (!Array.isArray(item) && !isPlainObject2(item)) return item;
|
|
1807
|
+
const existing = copies.get(item);
|
|
1808
|
+
if (existing) return existing;
|
|
1809
|
+
const target = Array.isArray(item) ? new Array(item.length) : Object.create(Object.getPrototypeOf(item));
|
|
1810
|
+
copies.set(item, target);
|
|
1811
|
+
pending.push({ source: item, target });
|
|
1812
|
+
return target;
|
|
1813
|
+
};
|
|
1814
|
+
const result = copy(value);
|
|
1815
|
+
for (let index = 0; index < pending.length; index++) {
|
|
1816
|
+
const { source, target } = pending[index];
|
|
1817
|
+
for (const key of Object.keys(source)) {
|
|
1818
|
+
if (omitRecognition && key === FINGERPRINT_RECOGNITION_METADATA_KEY) continue;
|
|
1819
|
+
Object.defineProperty(target, key, {
|
|
1820
|
+
value: copy(Reflect.get(source, key)),
|
|
1821
|
+
enumerable: true,
|
|
1822
|
+
configurable: true,
|
|
1823
|
+
writable: true
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return result;
|
|
1828
|
+
};
|
|
1829
|
+
var copyVaultSession = (session, omitRecognition = false) => copyPortableData(session, omitRecognition);
|
|
1830
|
+
var copyVaultUserProfile = (user) => copyPortableData(user, true);
|
|
1831
|
+
var snapshotProvider = (value) => {
|
|
1832
|
+
if (value === void 0) return void 0;
|
|
1833
|
+
if (!isPlainObject2(value)) throw invalidVaultSession();
|
|
1834
|
+
const { issuer, clientId } = value;
|
|
1835
|
+
if (issuer !== void 0 && typeof issuer !== "string" || clientId !== void 0 && typeof clientId !== "string") {
|
|
1836
|
+
throw invalidVaultSession();
|
|
1837
|
+
}
|
|
1838
|
+
return Object.freeze({
|
|
1839
|
+
...issuer === void 0 ? {} : { issuer },
|
|
1840
|
+
...clientId === void 0 ? {} : { clientId }
|
|
1841
|
+
});
|
|
1842
|
+
};
|
|
1843
|
+
var assertVaultSessionIdentity = (session, config) => {
|
|
1844
|
+
if (session.provider?.issuer !== void 0 && session.provider.issuer !== config.issuer || session.provider?.clientId !== void 0 && session.provider.clientId !== config.clientId)
|
|
1845
|
+
throw invalidVaultSession();
|
|
1846
|
+
};
|
|
1847
|
+
var snapshotVaultSession = (record, expectedSessionId, now) => {
|
|
1848
|
+
const {
|
|
1849
|
+
sessionId,
|
|
1850
|
+
logicalSessionId,
|
|
1851
|
+
subject,
|
|
1852
|
+
providerSessionId,
|
|
1853
|
+
provider: rawProvider,
|
|
1854
|
+
deviceBinding: rawBinding,
|
|
1855
|
+
refreshToken,
|
|
1856
|
+
idToken,
|
|
1857
|
+
accessToken,
|
|
1858
|
+
scope,
|
|
1859
|
+
expiresAt,
|
|
1860
|
+
createdAt,
|
|
1861
|
+
updatedAt,
|
|
1862
|
+
user,
|
|
1863
|
+
metadata
|
|
1864
|
+
} = record;
|
|
1865
|
+
if (sessionId !== expectedSessionId || typeof sessionId !== "string" || !sessionId || typeof subject !== "string" || !subject || logicalSessionId !== void 0 && (typeof logicalSessionId !== "string" || !logicalSessionId) || providerSessionId !== void 0 && typeof providerSessionId !== "string" || typeof refreshToken !== "string" || typeof idToken !== "string" || accessToken !== void 0 && typeof accessToken !== "string" || scope !== void 0 && typeof scope !== "string" || !isUsableEpochMs(createdAt) || !isUsableEpochMs(updatedAt) || !isUsableEpochMs(now) || expiresAt !== void 0 && (!isUsableEpochMs(expiresAt) || expiresAt <= now))
|
|
1866
|
+
throw invalidVaultSession();
|
|
1867
|
+
const provider = snapshotProvider(rawProvider);
|
|
1868
|
+
const binding = snapshotDpopBinding(rawBinding);
|
|
1869
|
+
if (binding !== void 0 && Object.keys(rawBinding).length !== 2) throw invalidVaultSession();
|
|
1870
|
+
return Object.freeze({
|
|
1871
|
+
sessionId,
|
|
1872
|
+
logicalSessionId,
|
|
1873
|
+
subject,
|
|
1874
|
+
providerSessionId,
|
|
1875
|
+
provider,
|
|
1876
|
+
...binding === void 0 ? {} : { deviceBinding: binding },
|
|
1877
|
+
refreshToken,
|
|
1878
|
+
idToken,
|
|
1879
|
+
accessToken,
|
|
1880
|
+
scope,
|
|
1881
|
+
expiresAt,
|
|
1882
|
+
createdAt,
|
|
1883
|
+
updatedAt,
|
|
1884
|
+
...user === void 0 ? {} : { user: copyPortableData(user) },
|
|
1885
|
+
...metadata === void 0 ? {} : { metadata: copyPortableData(metadata) }
|
|
1886
|
+
});
|
|
1887
|
+
};
|
|
1888
|
+
var assertVaultSessionMatches = (actual, original) => {
|
|
1889
|
+
if (actual.sessionId !== original.sessionId || actual.logicalSessionId !== original.logicalSessionId || actual.subject !== original.subject || actual.providerSessionId !== original.providerSessionId || actual.provider?.issuer !== original.provider?.issuer || actual.provider?.clientId !== original.provider?.clientId || actual.deviceBinding?.jkt !== original.deviceBinding?.jkt || actual.expiresAt !== original.expiresAt || actual.createdAt !== original.createdAt || actual.updatedAt !== original.updatedAt || actual.refreshToken !== original.refreshToken || actual.idToken !== original.idToken || actual.accessToken !== original.accessToken || actual.scope !== original.scope)
|
|
1890
|
+
throw invalidVaultSession();
|
|
1891
|
+
};
|
|
1892
|
+
var snapshotVaultExchangeCode = (record, expectedCode, now) => {
|
|
1893
|
+
const { code, sessionId, returnTo, createdAt, expiresAt } = record;
|
|
1894
|
+
if (code !== expectedCode || typeof sessionId !== "string" || !sessionId || returnTo !== void 0 && typeof returnTo !== "string" || !isUsableEpochMs(createdAt) || !isUsableEpochMs(expiresAt) || !isUsableEpochMs(now) || expiresAt <= now)
|
|
1895
|
+
throw invalidExchangeCode();
|
|
1896
|
+
const match = snapshotRecordBindingMatch(record);
|
|
1897
|
+
return Object.freeze({
|
|
1898
|
+
code,
|
|
1899
|
+
sessionId,
|
|
1900
|
+
returnTo,
|
|
1901
|
+
createdAt,
|
|
1902
|
+
expiresAt,
|
|
1903
|
+
match,
|
|
1904
|
+
...match.deviceBinding === null ? {} : { deviceBinding: match.deviceBinding },
|
|
1905
|
+
...match.browserBindingHash === null ? {} : { browserBindingHash: match.browserBindingHash }
|
|
1906
|
+
});
|
|
1907
|
+
};
|
|
1908
|
+
var assertVaultExchangeCodeMatches = (actual, original) => {
|
|
1909
|
+
if (actual.code !== original.code || actual.sessionId !== original.sessionId || actual.returnTo !== original.returnTo || actual.createdAt !== original.createdAt || actual.expiresAt !== original.expiresAt || !recordBindingMatches(actual.match, original.match))
|
|
1910
|
+
throw invalidExchangeCode();
|
|
1911
|
+
};
|
|
1912
|
+
var assertVaultExchangeSessionBinding = (code, session) => {
|
|
1913
|
+
if (code.sessionId !== session.sessionId || code.deviceBinding?.jkt !== session.deviceBinding?.jkt)
|
|
1914
|
+
throw invalidVaultSession();
|
|
1915
|
+
};
|
|
1916
|
+
var snapshotVaultRevocationContext = (context) => {
|
|
1917
|
+
const { logicalSessionId, provider, deviceBinding } = context;
|
|
1918
|
+
if (typeof logicalSessionId !== "string" || !logicalSessionId) throw invalidVaultSession();
|
|
1919
|
+
const identity = snapshotProvider(provider);
|
|
1920
|
+
const binding = snapshotDpopBinding(deviceBinding);
|
|
1921
|
+
if (binding !== void 0 && Object.keys(deviceBinding).length !== 2) throw invalidVaultSession();
|
|
1922
|
+
return Object.freeze({
|
|
1923
|
+
logicalSessionId,
|
|
1924
|
+
...identity === void 0 ? {} : { provider: identity },
|
|
1925
|
+
...binding === void 0 ? {} : { deviceBinding: binding }
|
|
1926
|
+
});
|
|
1927
|
+
};
|
|
1928
|
+
var assertVaultRevocationContextMatches = (actual, original) => {
|
|
1929
|
+
if (actual.logicalSessionId !== original.logicalSessionId || actual.provider?.issuer !== original.provider?.issuer || actual.provider?.clientId !== original.provider?.clientId || actual.deviceBinding?.jkt !== original.deviceBinding?.jkt) {
|
|
1930
|
+
throw invalidVaultSession();
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
// src/token-issuance.ts
|
|
1935
|
+
var createExchangeResponse = (options, session, issuedToken) => ({
|
|
1936
|
+
sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
|
|
1937
|
+
user: copyVaultUserProfile(session.user),
|
|
1938
|
+
accessToken: issuedToken.accessToken,
|
|
1939
|
+
expiresIn: issuedToken.expiresIn,
|
|
1940
|
+
tokenType: issuedToken.tokenType
|
|
1941
|
+
});
|
|
1942
|
+
var isCompactSignedJwt = (token) => {
|
|
1943
|
+
const parts = token.split(".");
|
|
1944
|
+
return parts.length === 3 && parts.every(
|
|
1945
|
+
(part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
|
|
1946
|
+
);
|
|
1947
|
+
};
|
|
1948
|
+
var assertIssuedTokenConfirmation = (accessToken, jkt) => {
|
|
1949
|
+
if (!isCompactSignedJwt(accessToken)) {
|
|
1950
|
+
throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
|
|
1951
|
+
}
|
|
1952
|
+
let header;
|
|
1953
|
+
let claims;
|
|
1954
|
+
try {
|
|
1955
|
+
header = (0, import_jose3.decodeProtectedHeader)(accessToken);
|
|
1956
|
+
claims = (0, import_jose3.decodeJwt)(accessToken);
|
|
1957
|
+
} catch {
|
|
1958
|
+
throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
|
|
1959
|
+
}
|
|
1960
|
+
if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false) {
|
|
1961
|
+
throw new TypeError("tokenIssuer.issue accessToken must use a signed JWT algorithm for bound issuance.");
|
|
1962
|
+
}
|
|
1963
|
+
const cnf = claims.cnf;
|
|
1964
|
+
if (typeof cnf !== "object" || cnf === null || Array.isArray(cnf) || cnf.jkt !== jkt) {
|
|
1965
|
+
throw new TypeError("tokenIssuer.issue accessToken cnf.jkt must match the verified device binding.");
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
1969
|
+
const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
|
|
1970
|
+
if (!options.tokenIssuer) return {};
|
|
1971
|
+
const logicalSessionId = session.logicalSessionId ?? session.sessionId;
|
|
1972
|
+
try {
|
|
1973
|
+
const issuerSession = withSessionDeviceBinding(copyVaultSession(session, true), binding);
|
|
1974
|
+
const result = await options.tokenIssuer.issue({
|
|
1975
|
+
req,
|
|
1976
|
+
res,
|
|
1977
|
+
session: issuerSession,
|
|
1978
|
+
...binding === void 0 ? {} : { deviceBinding: Object.freeze({ ...binding }) }
|
|
1979
|
+
});
|
|
1980
|
+
if (typeof result !== "object" || result === null || Array.isArray(result)) {
|
|
1981
|
+
throw new TypeError("tokenIssuer.issue must return a token result object.");
|
|
1982
|
+
}
|
|
1983
|
+
const { accessToken, expiresIn, tokenType } = result;
|
|
1984
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) {
|
|
1985
|
+
throw new TypeError("tokenIssuer.issue accessToken must be a nonempty string.");
|
|
1986
|
+
}
|
|
1987
|
+
if (typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn < 0) {
|
|
1988
|
+
throw new TypeError("tokenIssuer.issue expiresIn must be a finite nonnegative safe integer.");
|
|
1989
|
+
}
|
|
1990
|
+
if (tokenType !== void 0 && tokenType !== "Bearer" && tokenType !== "DPoP") {
|
|
1991
|
+
throw new TypeError("tokenIssuer.issue tokenType must be the exact Bearer or DPoP literal when provided.");
|
|
1992
|
+
}
|
|
1993
|
+
if (binding === void 0) {
|
|
1994
|
+
if (tokenType !== void 0 && tokenType !== "Bearer") {
|
|
1995
|
+
throw new TypeError("tokenIssuer.issue tokenType must be Bearer when provided for unbound issuance.");
|
|
1996
|
+
}
|
|
1997
|
+
} else {
|
|
1998
|
+
if (tokenType !== "DPoP") throw new TypeError("tokenIssuer.issue tokenType must be DPoP for bound issuance.");
|
|
1999
|
+
assertIssuedTokenConfirmation(accessToken, binding.jkt);
|
|
2000
|
+
}
|
|
2001
|
+
return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
|
|
2002
|
+
} catch (error) {
|
|
2003
|
+
await options.storeProvider.deleteSessionsByLogicalSessionId({ logicalSessionId });
|
|
2004
|
+
if (usesCookieTransport(options)) clearSessionCookie(res, options);
|
|
2005
|
+
throw error;
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
// src/token-validation.ts
|
|
2010
|
+
var import_jose4 = require("jose");
|
|
2011
|
+
|
|
2012
|
+
// src/access-token-confirmation.ts
|
|
2013
|
+
var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
|
|
2014
|
+
var snapshotJkt = (value) => {
|
|
2015
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2016
|
+
throw invalidAccessToken("Access token confirmation must be a plain jkt object.");
|
|
2017
|
+
}
|
|
2018
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2019
|
+
const keys = Reflect.ownKeys(value);
|
|
2020
|
+
if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
|
|
2021
|
+
throw invalidAccessToken("Access token confirmation contains unsupported fields.");
|
|
2022
|
+
}
|
|
2023
|
+
const { jkt } = value;
|
|
2024
|
+
if (!isCanonicalDpopJkt(jkt))
|
|
2025
|
+
throw invalidAccessToken("Access token confirmation is not a canonical SHA-256 thumbprint.");
|
|
2026
|
+
return Object.freeze({ jkt });
|
|
2027
|
+
};
|
|
2028
|
+
var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
|
|
2029
|
+
var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
|
|
2030
|
+
|
|
787
2031
|
// src/token-validation.ts
|
|
788
|
-
var import_jose2 = require("jose");
|
|
789
2032
|
var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
|
|
790
2033
|
var validateBackchannelLogoutTokenTyp = (typ) => {
|
|
791
2034
|
if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
|
|
@@ -811,6 +2054,9 @@ var defaultJwtClaimsMapper = (claims) => {
|
|
|
811
2054
|
};
|
|
812
2055
|
};
|
|
813
2056
|
var assertUserInfoSubject = (userInfo, subject) => {
|
|
2057
|
+
if (typeof userInfo !== "object" || userInfo === null || Array.isArray(userInfo)) {
|
|
2058
|
+
throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is invalid.");
|
|
2059
|
+
}
|
|
814
2060
|
if (!isString(userInfo.sub)) {
|
|
815
2061
|
throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_USERINFO", "OIDC userinfo response is missing sub.");
|
|
816
2062
|
}
|
|
@@ -823,6 +2069,12 @@ var mergeUserProfile = (sub, idTokenClaims, userInfo) => ({
|
|
|
823
2069
|
...userInfo ?? {},
|
|
824
2070
|
sub
|
|
825
2071
|
});
|
|
2072
|
+
var composeRefreshedUserProfile = (sub, currentUser, freshIdClaims, freshUserInfo) => {
|
|
2073
|
+
if (freshIdClaims === void 0) {
|
|
2074
|
+
return mergeUserProfile(sub, { ...currentUser ?? { sub } }, freshUserInfo);
|
|
2075
|
+
}
|
|
2076
|
+
return mergeUserProfile(sub, { ...freshIdClaims }, freshUserInfo);
|
|
2077
|
+
};
|
|
826
2078
|
async function verifyIdToken(metadata, idToken, nonce, options = {}) {
|
|
827
2079
|
if (!metadata.jwksUri) {
|
|
828
2080
|
throw new OidcVaultHttpError(500, "OIDC_VAULT_MISSING_JWKS_URI", "OIDC jwksUri is required to validate id_token.");
|
|
@@ -835,7 +2087,7 @@ async function verifyIdToken(metadata, idToken, nonce, options = {}) {
|
|
|
835
2087
|
);
|
|
836
2088
|
}
|
|
837
2089
|
const jwks = resolveJwks(metadata.jwksUri, options);
|
|
838
|
-
const result = await (0,
|
|
2090
|
+
const result = await (0, import_jose4.jwtVerify)(idToken, jwks, {
|
|
839
2091
|
audience: metadata.clientId,
|
|
840
2092
|
issuer: metadata.issuer
|
|
841
2093
|
});
|
|
@@ -879,7 +2131,7 @@ async function verifyBackchannelLogoutToken(metadata, logoutToken, options = {})
|
|
|
879
2131
|
);
|
|
880
2132
|
}
|
|
881
2133
|
const jwks = resolveJwks(metadata.jwksUri, options);
|
|
882
|
-
const result = await (0,
|
|
2134
|
+
const result = await (0, import_jose4.jwtVerify)(logoutToken, jwks, {
|
|
883
2135
|
audience: metadata.clientId,
|
|
884
2136
|
issuer: metadata.issuer
|
|
885
2137
|
});
|
|
@@ -924,17 +2176,35 @@ async function verifyBackchannelLogoutToken(metadata, logoutToken, options = {})
|
|
|
924
2176
|
return claims;
|
|
925
2177
|
}
|
|
926
2178
|
function createOidcVaultJwtAccessTokenValidator(options) {
|
|
927
|
-
|
|
2179
|
+
const {
|
|
2180
|
+
key: configuredKey,
|
|
2181
|
+
issuer,
|
|
2182
|
+
audience: configuredAudience,
|
|
2183
|
+
algorithms: configuredAlgorithms,
|
|
2184
|
+
mapClaims
|
|
2185
|
+
} = options;
|
|
2186
|
+
const key = configuredKey instanceof Uint8Array ? Uint8Array.from(configuredKey) : "kty" in configuredKey ? { ...configuredKey, ...configuredKey.key_ops ? { key_ops: [...configuredKey.key_ops] } : {} } : configuredKey;
|
|
2187
|
+
const audience = Array.isArray(configuredAudience) ? [...configuredAudience] : configuredAudience;
|
|
2188
|
+
const algorithms = configuredAlgorithms === void 0 ? void 0 : [...configuredAlgorithms];
|
|
2189
|
+
const validateJwt = async (token) => {
|
|
2190
|
+
const result = await (0, import_jose4.jwtVerify)(token, key, { issuer, audience, algorithms });
|
|
2191
|
+
const claims = result.payload;
|
|
2192
|
+
const confirmation = snapshotVerifiedJwtConfirmation(claims);
|
|
2193
|
+
const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
|
|
2194
|
+
const { subject, sessionId, scope, claims: mappedClaims } = mapped;
|
|
2195
|
+
return { subject, sessionId, scope, claims: mappedClaims, confirmation };
|
|
2196
|
+
};
|
|
2197
|
+
return Object.freeze({
|
|
928
2198
|
async validate(token) {
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return
|
|
2199
|
+
const { confirmation, ...mapped } = await validateJwt(token);
|
|
2200
|
+
return confirmation === null ? mapped : { ...mapped, confirmation };
|
|
2201
|
+
},
|
|
2202
|
+
async validateWithRequest({
|
|
2203
|
+
token
|
|
2204
|
+
}) {
|
|
2205
|
+
return validateJwt(token);
|
|
936
2206
|
}
|
|
937
|
-
};
|
|
2207
|
+
});
|
|
938
2208
|
}
|
|
939
2209
|
|
|
940
2210
|
// src/types.ts
|
|
@@ -944,6 +2214,235 @@ var OidcVaultStoreConflictError = class extends Error {
|
|
|
944
2214
|
this.name = "OidcVaultStoreConflictError";
|
|
945
2215
|
}
|
|
946
2216
|
};
|
|
2217
|
+
var OidcVaultDpopReplayCapacityError = class extends Error {
|
|
2218
|
+
constructor(message = "OIDC vault DPoP replay store capacity is exhausted.") {
|
|
2219
|
+
super(message);
|
|
2220
|
+
this.name = "OidcVaultDpopReplayCapacityError";
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
|
|
2224
|
+
// src/dpop-replay.ts
|
|
2225
|
+
var import_node_crypto5 = require("crypto");
|
|
2226
|
+
var isPrintableIdentifier = (value) => typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value);
|
|
2227
|
+
var normalizeNamespaceBasePath = (origin, value) => {
|
|
2228
|
+
if (value !== void 0 && typeof value !== "string")
|
|
2229
|
+
throw new TypeError("DPoP vault basePath must be a static path.");
|
|
2230
|
+
const mounted = !value || value === "/" ? DEFAULT_OIDC_VAULT_BASE_PATH : `/${value.replace(/^\/+|\/+$/g, "")}`;
|
|
2231
|
+
if (/[\s\\?#]/.test(mounted) || /%(?![0-9a-f]{2})/i.test(mounted)) {
|
|
2232
|
+
throw new TypeError("DPoP vault basePath must be a static path without query, fragment or malformed escapes.");
|
|
2233
|
+
}
|
|
2234
|
+
const path = mounted.replace(/%([0-9a-f]{2})/gi, (_, hex) => {
|
|
2235
|
+
const character = String.fromCharCode(Number.parseInt(hex, 16));
|
|
2236
|
+
return /^[A-Za-z0-9._~-]$/.test(character) ? character : `%${hex.toUpperCase()}`;
|
|
2237
|
+
});
|
|
2238
|
+
const normalized = new URL(`${origin}${path}`).pathname;
|
|
2239
|
+
return normalized.replace(/\/+$/g, "") || "/";
|
|
2240
|
+
};
|
|
2241
|
+
var resolveDpopEffectiveNamespace = (space) => {
|
|
2242
|
+
if (space?.type === "vault") {
|
|
2243
|
+
const { backendOrigin, basePath, issuer, clientId } = space;
|
|
2244
|
+
const origin = resolveDeviceBindingOrigin(backendOrigin);
|
|
2245
|
+
if (typeof issuer !== "string" || issuer.length === 0 || typeof clientId !== "string" || clientId.length === 0) {
|
|
2246
|
+
throw new TypeError("DPoP vault namespace requires resolved issuer and clientId.");
|
|
2247
|
+
}
|
|
2248
|
+
return Object.freeze(["vault", origin, normalizeNamespaceBasePath(origin, basePath), issuer, clientId]);
|
|
2249
|
+
}
|
|
2250
|
+
if (space?.type === "api") {
|
|
2251
|
+
const { publicOrigin, replayNamespace } = space;
|
|
2252
|
+
if (!isPrintableIdentifier(replayNamespace)) {
|
|
2253
|
+
throw new TypeError("DPoP replayNamespace must contain 1 through 128 printable ASCII bytes.");
|
|
2254
|
+
}
|
|
2255
|
+
return Object.freeze(["api", resolveDeviceBindingOrigin(publicOrigin, "publicOrigin"), replayNamespace]);
|
|
2256
|
+
}
|
|
2257
|
+
throw new TypeError("DPoP replay policy requires a vault or API protection space.");
|
|
2258
|
+
};
|
|
2259
|
+
var createDpopReplayKey = (effectiveNamespace, jkt, jti) => {
|
|
2260
|
+
if (!isCanonicalDpopJkt(jkt) || !isPrintableIdentifier(jti)) {
|
|
2261
|
+
throw new TypeError("DPoP replay key requires a canonical thumbprint and a bounded printable JTI.");
|
|
2262
|
+
}
|
|
2263
|
+
return `dpop:v1:${(0, import_node_crypto5.createHash)("sha256").update(JSON.stringify([effectiveNamespace, jkt, jti]), "utf8").digest("base64url")}`;
|
|
2264
|
+
};
|
|
2265
|
+
var getDpopReplayExpiresAt = (input) => {
|
|
2266
|
+
const { iat, now, proofMaxAgeSeconds: age, clockSkewSeconds: skew } = input;
|
|
2267
|
+
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || !Number.isSafeInteger(now) || now < 0 || !Number.isInteger(age) || age < 1 || age > 300 || !Number.isInteger(skew) || skew < 0 || skew > 30)
|
|
2268
|
+
return null;
|
|
2269
|
+
const issuedAt = iat * 1e3;
|
|
2270
|
+
const expiresAt = (iat + age + skew) * 1e3;
|
|
2271
|
+
if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt) || issuedAt - now > skew * 1e3 || expiresAt <= now || expiresAt - now > 36e4)
|
|
2272
|
+
return null;
|
|
2273
|
+
return expiresAt;
|
|
2274
|
+
};
|
|
2275
|
+
var invalidProof2 = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_DPOP_PROOF", diagnostic, "DPoP proof validation failed.");
|
|
2276
|
+
var replayUnavailable = (cause) => {
|
|
2277
|
+
const error = new OidcVaultHttpError(
|
|
2278
|
+
503,
|
|
2279
|
+
"OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE",
|
|
2280
|
+
"DPoP replay admission failed.",
|
|
2281
|
+
"DPoP replay protection is unavailable."
|
|
2282
|
+
);
|
|
2283
|
+
Object.defineProperty(error, "cause", { value: cause, configurable: true });
|
|
2284
|
+
return error;
|
|
2285
|
+
};
|
|
2286
|
+
var createDpopReplayPolicy = (options) => {
|
|
2287
|
+
const { protectionSpace, proofOptions, replayStore, now: configuredNow } = options;
|
|
2288
|
+
const policy = resolveDeviceBindingOptions(proofOptions);
|
|
2289
|
+
if (!policy) throw new TypeError("DPoP replay policy requires enabled proof options.");
|
|
2290
|
+
if (typeof replayStore?.reserveDpopProof !== "function")
|
|
2291
|
+
throw new TypeError("DPoP replay policy requires a replay store.");
|
|
2292
|
+
if (configuredNow !== void 0 && typeof configuredNow !== "function")
|
|
2293
|
+
throw new TypeError("DPoP replay clock must be callable.");
|
|
2294
|
+
const now = configuredNow ?? Date.now;
|
|
2295
|
+
const effectiveNamespace = resolveDpopEffectiveNamespace(protectionSpace);
|
|
2296
|
+
const noncePolicy = policy.nonce === false ? void 0 : createDpopNoncePolicy({
|
|
2297
|
+
...policy.nonce,
|
|
2298
|
+
namespace: JSON.stringify(effectiveNamespace)
|
|
2299
|
+
});
|
|
2300
|
+
return Object.freeze({
|
|
2301
|
+
effectiveNamespace,
|
|
2302
|
+
async verifyAndReserve(verifyBeforeReplay) {
|
|
2303
|
+
const verified = await verifyBeforeReplay();
|
|
2304
|
+
if (typeof verified !== "object" || verified === null || Array.isArray(verified)) {
|
|
2305
|
+
throw invalidProof2("DPoP verifier did not return a proof verification result.");
|
|
2306
|
+
}
|
|
2307
|
+
const { binding: verifiedBinding, iat, jti, nonce } = verified;
|
|
2308
|
+
if (typeof verifiedBinding !== "object" || verifiedBinding === null || Array.isArray(verifiedBinding)) {
|
|
2309
|
+
throw invalidProof2("DPoP verifier did not return a verified key binding.");
|
|
2310
|
+
}
|
|
2311
|
+
const { type, jkt, alg } = verifiedBinding;
|
|
2312
|
+
if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti)) {
|
|
2313
|
+
throw invalidProof2("DPoP verified key/algorithm or signed JTI shape is invalid.");
|
|
2314
|
+
}
|
|
2315
|
+
const binding = Object.freeze({ type, jkt, alg });
|
|
2316
|
+
let checkedNow;
|
|
2317
|
+
try {
|
|
2318
|
+
checkedNow = now();
|
|
2319
|
+
if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
|
|
2320
|
+
} catch (error) {
|
|
2321
|
+
throw replayUnavailable(error);
|
|
2322
|
+
}
|
|
2323
|
+
const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
|
|
2324
|
+
if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
|
|
2325
|
+
if (noncePolicy) {
|
|
2326
|
+
if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
|
|
2327
|
+
return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
|
|
2328
|
+
}
|
|
2329
|
+
} else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
|
|
2330
|
+
throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
|
|
2331
|
+
}
|
|
2332
|
+
let reserved;
|
|
2333
|
+
try {
|
|
2334
|
+
reserved = await replayStore.reserveDpopProof({
|
|
2335
|
+
replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
|
|
2336
|
+
expiresAt
|
|
2337
|
+
});
|
|
2338
|
+
if (typeof reserved !== "boolean")
|
|
2339
|
+
throw new TypeError("DPoP replay store returned a non-boolean admission result.");
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
throw replayUnavailable(error);
|
|
2342
|
+
}
|
|
2343
|
+
if (!reserved) throw invalidProof2("DPoP replay reservation was duplicate or no longer live.");
|
|
2344
|
+
return Object.freeze({ type: "accepted", binding, expiresAt });
|
|
2345
|
+
}
|
|
2346
|
+
});
|
|
2347
|
+
};
|
|
2348
|
+
var challenge = (error, algorithms) => {
|
|
2349
|
+
if (algorithms.length === 0 || !algorithms.every(isDpopAlgorithm))
|
|
2350
|
+
throw new TypeError("DPoP challenge requires a resolved algorithm allowlist.");
|
|
2351
|
+
return `DPoP error="${error}", algs="${[...new Set(algorithms)].join(" ")}"`;
|
|
2352
|
+
};
|
|
2353
|
+
var toDpopReplayErrorResponse = (error, algorithms) => {
|
|
2354
|
+
const { status, code, message } = toErrorPayload(error);
|
|
2355
|
+
return Object.freeze({
|
|
2356
|
+
status,
|
|
2357
|
+
body: Object.freeze({ code, message }),
|
|
2358
|
+
headers: Object.freeze({
|
|
2359
|
+
"Cache-Control": "no-store",
|
|
2360
|
+
...status === 401 && (code === "OIDC_VAULT_INVALID_DPOP_PROOF" || code === "OIDC_VAULT_DPOP_REQUIRED") ? { "WWW-Authenticate": challenge("invalid_dpop_proof", algorithms) } : {}
|
|
2361
|
+
})
|
|
2362
|
+
});
|
|
2363
|
+
};
|
|
2364
|
+
var toDpopNonceChallengeResponse = (result, endpoint, algorithms) => {
|
|
2365
|
+
const nonce = result?.nonce;
|
|
2366
|
+
if (result?.type !== "nonce-challenge" || typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || !/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/.test(nonce))
|
|
2367
|
+
throw new TypeError("DPoP nonce response requires a bounded server-issued challenge.");
|
|
2368
|
+
if (endpoint !== "vault-post" && endpoint !== "api")
|
|
2369
|
+
throw new TypeError("DPoP nonce response requires an API or vault POST endpoint.");
|
|
2370
|
+
return Object.freeze({
|
|
2371
|
+
status: endpoint === "api" ? 401 : 400,
|
|
2372
|
+
body: Object.freeze({ code: "OIDC_VAULT_USE_DPOP_NONCE", message: "A fresh DPoP nonce is required." }),
|
|
2373
|
+
headers: Object.freeze({
|
|
2374
|
+
"Cache-Control": "no-store",
|
|
2375
|
+
"DPoP-Nonce": nonce,
|
|
2376
|
+
...endpoint === "api" ? { "WWW-Authenticate": challenge("use_dpop_nonce", algorithms) } : {}
|
|
2377
|
+
})
|
|
2378
|
+
});
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
// src/vault-route-proof.ts
|
|
2382
|
+
var captureVaultRouteProof = (req) => Object.freeze({
|
|
2383
|
+
header: readDpopRawHeader(req, "dpop"),
|
|
2384
|
+
method: req.method,
|
|
2385
|
+
originalUrl: req.originalUrl
|
|
2386
|
+
});
|
|
2387
|
+
var VaultDpopNonceChallengeError = class extends OidcVaultHttpError {
|
|
2388
|
+
constructor(response) {
|
|
2389
|
+
super(400, "OIDC_VAULT_USE_DPOP_NONCE", "A fresh DPoP nonce is required.");
|
|
2390
|
+
this.response = response;
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) => {
|
|
2394
|
+
const policy = options.deviceBinding;
|
|
2395
|
+
if (policy === void 0) return void 0;
|
|
2396
|
+
assertDeviceBindingStoreCapabilities(options.storeProvider);
|
|
2397
|
+
const targetFor = createDpopRequestTargetResolver({ publicOrigin: backendOrigin });
|
|
2398
|
+
const replay = createDpopReplayPolicy({
|
|
2399
|
+
// The config resolver requires both identifiers in manual/discovery modes.
|
|
2400
|
+
protectionSpace: { type: "vault", backendOrigin, basePath, issuer: config.issuer, clientId: config.clientId },
|
|
2401
|
+
proofOptions: policy,
|
|
2402
|
+
replayStore: options.storeProvider,
|
|
2403
|
+
now: options.now
|
|
2404
|
+
});
|
|
2405
|
+
const admit = async (request, proof, expectedJkt) => {
|
|
2406
|
+
const targetUrl = targetFor(request.originalUrl);
|
|
2407
|
+
const result = await replay.verifyAndReserve(
|
|
2408
|
+
() => verifyDpopProof({
|
|
2409
|
+
proof,
|
|
2410
|
+
method: request.method,
|
|
2411
|
+
targetUrl,
|
|
2412
|
+
proofOptions: policy,
|
|
2413
|
+
expectedJkt
|
|
2414
|
+
})
|
|
2415
|
+
);
|
|
2416
|
+
if (result.type === "nonce-challenge") {
|
|
2417
|
+
throw new VaultDpopNonceChallengeError(toDpopNonceChallengeResponse(result, "vault-post", policy.algorithms));
|
|
2418
|
+
}
|
|
2419
|
+
return result.binding;
|
|
2420
|
+
};
|
|
2421
|
+
return Object.freeze({
|
|
2422
|
+
async initiate(request) {
|
|
2423
|
+
const proof = extractDpopProof(request.header);
|
|
2424
|
+
if (proof === void 0) {
|
|
2425
|
+
assertDeviceBindingPolicy(policy, void 0);
|
|
2426
|
+
return void 0;
|
|
2427
|
+
}
|
|
2428
|
+
return admit(request, proof);
|
|
2429
|
+
},
|
|
2430
|
+
async verify(request, originalBinding) {
|
|
2431
|
+
const binding = snapshotDpopBinding(originalBinding);
|
|
2432
|
+
assertDeviceBindingPolicy(policy, binding);
|
|
2433
|
+
const proof = extractDpopProof(request.header);
|
|
2434
|
+
const verified = proof === void 0 ? void 0 : await admit(request, proof, binding?.jkt);
|
|
2435
|
+
return resolveVerifiedDeviceBinding(policy, binding, verified);
|
|
2436
|
+
}
|
|
2437
|
+
});
|
|
2438
|
+
};
|
|
2439
|
+
var toVaultRouteErrorResponse = (error, policy) => error instanceof VaultDpopNonceChallengeError ? error.response : toDpopReplayErrorResponse(error, policy?.algorithms ?? ["ES256"]);
|
|
2440
|
+
var sendVaultRoutePolicyResponse = (res, response) => {
|
|
2441
|
+
res.removeHeader("WWW-Authenticate");
|
|
2442
|
+
res.removeHeader("DPoP-Nonce");
|
|
2443
|
+
for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
|
|
2444
|
+
res.status(response.status).json(response.body);
|
|
2445
|
+
};
|
|
947
2446
|
|
|
948
2447
|
// src/access-token-middleware.ts
|
|
949
2448
|
var extractBearerToken = (authorizationHeader) => {
|
|
@@ -960,36 +2459,212 @@ var extractBearerToken = (authorizationHeader) => {
|
|
|
960
2459
|
}
|
|
961
2460
|
return token;
|
|
962
2461
|
};
|
|
963
|
-
var
|
|
964
|
-
|
|
2462
|
+
var notifyMiddlewareError = async (onError, context) => {
|
|
2463
|
+
try {
|
|
2464
|
+
await onError?.(context);
|
|
2465
|
+
} catch {
|
|
2466
|
+
}
|
|
2467
|
+
};
|
|
2468
|
+
var schemeOf = (value) => {
|
|
2469
|
+
const scheme = value?.trim().split(/\s+/, 1)[0]?.toLowerCase();
|
|
2470
|
+
return scheme === "bearer" ? "Bearer" : scheme === "dpop" ? "DPoP" : void 0;
|
|
2471
|
+
};
|
|
2472
|
+
var mappedFields = (result) => {
|
|
2473
|
+
if (typeof result !== "object" || result === null || Array.isArray(result))
|
|
2474
|
+
throw invalidAccessToken("Access token validator result is invalid.");
|
|
2475
|
+
const { subject, sessionId, scope, claims } = result;
|
|
2476
|
+
if (typeof subject !== "string" || subject.length === 0)
|
|
2477
|
+
throw invalidAccessToken("Access token validator result is missing subject.");
|
|
2478
|
+
return { subject, sessionId, scope, claims };
|
|
965
2479
|
};
|
|
966
2480
|
function createOidcVaultAccessTokenMiddleware(options) {
|
|
2481
|
+
const { validator, deviceBinding: configuredBinding, onAuthContext, onError } = options;
|
|
2482
|
+
const policy = resolveDeviceBindingOptions(configuredBinding);
|
|
2483
|
+
const validate = validator.validate.bind(validator);
|
|
2484
|
+
const requestAware = policy ? validator.validateWithRequest : void 0;
|
|
2485
|
+
if (policy && typeof requestAware !== "function")
|
|
2486
|
+
throw new TypeError("deviceBinding requires validator.validateWithRequest.");
|
|
2487
|
+
const validateWithRequest = requestAware?.bind(validator);
|
|
2488
|
+
const apiOptions = policy ? (() => {
|
|
2489
|
+
const { publicOrigin, publicPathPrefix, replayNamespace, replayStore, now } = configuredBinding;
|
|
2490
|
+
return {
|
|
2491
|
+
publicOrigin: resolveDeviceBindingOrigin(publicOrigin, "publicOrigin"),
|
|
2492
|
+
publicPathPrefix,
|
|
2493
|
+
replayNamespace,
|
|
2494
|
+
replayStore,
|
|
2495
|
+
now
|
|
2496
|
+
};
|
|
2497
|
+
})() : void 0;
|
|
2498
|
+
const targetFor = apiOptions ? createDpopRequestTargetResolver(apiOptions) : void 0;
|
|
2499
|
+
const replay = policy ? createDpopReplayPolicy({
|
|
2500
|
+
protectionSpace: {
|
|
2501
|
+
type: "api",
|
|
2502
|
+
publicOrigin: apiOptions.publicOrigin,
|
|
2503
|
+
replayNamespace: apiOptions.replayNamespace
|
|
2504
|
+
},
|
|
2505
|
+
proofOptions: policy,
|
|
2506
|
+
replayStore: apiOptions.replayStore,
|
|
2507
|
+
now: apiOptions.now
|
|
2508
|
+
}) : void 0;
|
|
2509
|
+
const algorithms = policy?.algorithms ?? ["ES256"];
|
|
2510
|
+
const algs = algorithms.join(" ");
|
|
2511
|
+
const dpopChallenge = (error) => `DPoP${error ? ` error="${error}",` : ""} algs="${algs}"`;
|
|
2512
|
+
const proofChallenge = dpopChallenge("invalid_dpop_proof");
|
|
967
2513
|
return async (req, res, next) => {
|
|
2514
|
+
let token;
|
|
2515
|
+
let scheme;
|
|
2516
|
+
let nonceResponse;
|
|
2517
|
+
const authenticatedRequest = req;
|
|
2518
|
+
delete authenticatedRequest.auth;
|
|
2519
|
+
if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
|
|
2520
|
+
const prepareError = (error, hookFailure = false) => {
|
|
2521
|
+
const response = nonceResponse ?? (error instanceof OidcVaultHttpError ? toDpopReplayErrorResponse(error, algorithms) : {
|
|
2522
|
+
status: hookFailure ? 500 : 401,
|
|
2523
|
+
body: hookFailure ? { code: "OIDC_VAULT_AUTH_CONTEXT_FAILED", message: "Auth context hook failed." } : { code: "OIDC_VAULT_INVALID_ACCESS_TOKEN", message: "Access token validation failed." },
|
|
2524
|
+
headers: { "Cache-Control": "no-store" }
|
|
2525
|
+
});
|
|
2526
|
+
const headers = { ...response.headers };
|
|
2527
|
+
if (response.status === 401 && headers["WWW-Authenticate"] === void 0) {
|
|
2528
|
+
const code = response.body.code;
|
|
2529
|
+
const challenge2 = code === "OIDC_VAULT_DPOP_REQUIRED" || code === "OIDC_VAULT_INVALID_DPOP_PROOF" ? proofChallenge : !policy ? "Bearer" : code === "OIDC_VAULT_MISSING_ACCESS_TOKEN" ? policy.mode === "required" ? dpopChallenge() : `Bearer, ${dpopChallenge()}` : scheme === "DPoP" || !scheme && policy.mode === "required" ? dpopChallenge("invalid_token") : 'Bearer error="invalid_token"';
|
|
2530
|
+
headers["WWW-Authenticate"] = challenge2;
|
|
2531
|
+
}
|
|
2532
|
+
return Object.freeze({
|
|
2533
|
+
status: response.status,
|
|
2534
|
+
body: Object.freeze({ ...response.body }),
|
|
2535
|
+
headers: Object.freeze(headers)
|
|
2536
|
+
});
|
|
2537
|
+
};
|
|
2538
|
+
const sendError = (response) => {
|
|
2539
|
+
res.removeHeader("WWW-Authenticate");
|
|
2540
|
+
res.removeHeader("DPoP-Nonce");
|
|
2541
|
+
for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
|
|
2542
|
+
res.status(response.status).json(response.body);
|
|
2543
|
+
};
|
|
968
2544
|
try {
|
|
969
|
-
|
|
970
|
-
|
|
2545
|
+
let result;
|
|
2546
|
+
let confirmation;
|
|
2547
|
+
let deviceBinding;
|
|
2548
|
+
if (!policy) {
|
|
2549
|
+
token = extractBearerToken(req.get("authorization"));
|
|
2550
|
+
scheme = "Bearer";
|
|
2551
|
+
result = await validate(token);
|
|
2552
|
+
delete authenticatedRequest.auth;
|
|
2553
|
+
const reported = result?.confirmation;
|
|
2554
|
+
confirmation = reported === void 0 ? void 0 : snapshotAccessTokenConfirmation(reported);
|
|
2555
|
+
result = mappedFields(result);
|
|
2556
|
+
delete authenticatedRequest.auth;
|
|
2557
|
+
if (confirmation)
|
|
2558
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
2559
|
+
} else {
|
|
2560
|
+
const authorization = readDpopRawHeader(req, "authorization");
|
|
2561
|
+
scheme = schemeOf(authorization.value);
|
|
2562
|
+
if (authorization.count === 0 || authorization.count === 1 && !authorization.value?.trim()) {
|
|
2563
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_ACCESS_TOKEN", "Missing access token.");
|
|
2564
|
+
}
|
|
2565
|
+
const credential = authorization.value?.trim().split(/\s+/);
|
|
2566
|
+
if (authorization.count !== 1 || !scheme || credential?.length !== 2 || !credential[1] || !/^[\x21-\x7e]+$/.test(credential[1]) || credential[1].includes(",")) {
|
|
2567
|
+
throw new OidcVaultHttpError(
|
|
2568
|
+
401,
|
|
2569
|
+
"OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
|
|
2570
|
+
"Authorization header must use one Bearer or DPoP credential."
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
token = credential[1];
|
|
2574
|
+
const proofHeader = readDpopRawHeader(req, "dpop");
|
|
2575
|
+
const { method, originalUrl } = req;
|
|
2576
|
+
result = await validateWithRequest(Object.freeze({ token, scheme, req }));
|
|
2577
|
+
delete authenticatedRequest.auth;
|
|
2578
|
+
confirmation = snapshotAccessTokenConfirmation(result?.confirmation);
|
|
2579
|
+
result = mappedFields(result);
|
|
2580
|
+
delete authenticatedRequest.auth;
|
|
2581
|
+
if (confirmation && scheme !== "DPoP")
|
|
2582
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
2583
|
+
if (!confirmation && scheme === "DPoP")
|
|
2584
|
+
throw invalidAccessToken("DPoP presentation of a verified unbound token is invalid.");
|
|
2585
|
+
if (!confirmation && policy.mode === "required") {
|
|
2586
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
|
|
2587
|
+
}
|
|
2588
|
+
const proof = extractDpopProof(proofHeader);
|
|
2589
|
+
if (!proof && confirmation)
|
|
2590
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
2591
|
+
if (proof) {
|
|
2592
|
+
const targetUrl = targetFor(originalUrl);
|
|
2593
|
+
const admission = await replay.verifyAndReserve(
|
|
2594
|
+
() => verifyDpopProof({
|
|
2595
|
+
proof,
|
|
2596
|
+
method,
|
|
2597
|
+
targetUrl,
|
|
2598
|
+
proofOptions: policy,
|
|
2599
|
+
accessToken: token,
|
|
2600
|
+
expectedJkt: confirmation?.jkt
|
|
2601
|
+
})
|
|
2602
|
+
);
|
|
2603
|
+
if (admission.type === "nonce-challenge") {
|
|
2604
|
+
nonceResponse = toDpopNonceChallengeResponse(admission, "api", policy.algorithms);
|
|
2605
|
+
throw new OidcVaultHttpError(401, "OIDC_VAULT_USE_DPOP_NONCE", "A fresh DPoP nonce is required.");
|
|
2606
|
+
}
|
|
2607
|
+
if (confirmation) deviceBinding = admission.binding;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
const authenticatedToken = token;
|
|
971
2611
|
const auth = {
|
|
972
|
-
|
|
973
|
-
|
|
2612
|
+
...result,
|
|
2613
|
+
token: authenticatedToken,
|
|
2614
|
+
...confirmation === void 0 ? {} : { confirmation },
|
|
2615
|
+
...deviceBinding ? { deviceBinding } : {}
|
|
2616
|
+
};
|
|
2617
|
+
const restoreAuth = () => {
|
|
2618
|
+
const { subject, sessionId, scope, claims } = auth;
|
|
2619
|
+
return {
|
|
2620
|
+
subject,
|
|
2621
|
+
sessionId,
|
|
2622
|
+
scope,
|
|
2623
|
+
claims,
|
|
2624
|
+
token: authenticatedToken,
|
|
2625
|
+
...confirmation === void 0 ? {} : { confirmation },
|
|
2626
|
+
...deviceBinding ? { deviceBinding } : {}
|
|
2627
|
+
};
|
|
974
2628
|
};
|
|
975
|
-
const authenticatedRequest = req;
|
|
976
2629
|
authenticatedRequest.auth = auth;
|
|
977
|
-
|
|
2630
|
+
try {
|
|
2631
|
+
await onAuthContext?.({ req: authenticatedRequest, res, auth });
|
|
2632
|
+
authenticatedRequest.auth = restoreAuth();
|
|
2633
|
+
} catch (hookError) {
|
|
2634
|
+
const response = prepareError(hookError, true);
|
|
2635
|
+
delete authenticatedRequest.auth;
|
|
2636
|
+
let errorAuth;
|
|
2637
|
+
try {
|
|
2638
|
+
errorAuth = restoreAuth();
|
|
2639
|
+
} catch {
|
|
2640
|
+
errorAuth = {
|
|
2641
|
+
...result,
|
|
2642
|
+
token: authenticatedToken,
|
|
2643
|
+
...confirmation === void 0 ? {} : { confirmation },
|
|
2644
|
+
...deviceBinding ? { deviceBinding } : {}
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
await notifyMiddlewareError(onError, { error: hookError, req, res, token, auth: errorAuth });
|
|
2648
|
+
delete authenticatedRequest.auth;
|
|
2649
|
+
if (res.headersSent) {
|
|
2650
|
+
next(hookError);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
sendError(response);
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
|
|
978
2657
|
next();
|
|
979
2658
|
} catch (error) {
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
2659
|
+
const response = prepareError(error);
|
|
2660
|
+
delete authenticatedRequest.auth;
|
|
2661
|
+
await notifyMiddlewareError(onError, { error, req, res, ...token ? { token } : {} });
|
|
2662
|
+
delete authenticatedRequest.auth;
|
|
2663
|
+
if (res.headersSent) {
|
|
2664
|
+
next(error);
|
|
986
2665
|
return;
|
|
987
2666
|
}
|
|
988
|
-
|
|
989
|
-
res.status(401).json({
|
|
990
|
-
code: "OIDC_VAULT_INVALID_ACCESS_TOKEN",
|
|
991
|
-
message: "Access token validation failed."
|
|
992
|
-
});
|
|
2667
|
+
sendError(response);
|
|
993
2668
|
}
|
|
994
2669
|
};
|
|
995
2670
|
}
|
|
@@ -1002,26 +2677,60 @@ function normalizeOidcVaultBasePath(value) {
|
|
|
1002
2677
|
return `/${value.replace(/^\/+|\/+$/g, "")}`;
|
|
1003
2678
|
}
|
|
1004
2679
|
var getNow = (options) => (options.now ?? Date.now)();
|
|
1005
|
-
var
|
|
1006
|
-
var
|
|
1007
|
-
|
|
2680
|
+
var CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store";
|
|
2681
|
+
var applyCredentialResponseCachePolicy = (res) => {
|
|
2682
|
+
res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL);
|
|
2683
|
+
};
|
|
2684
|
+
var createOpaqueId = (prefix) => `${prefix}_${(0, import_node_crypto6.randomBytes)(16).toString("base64url")}`;
|
|
2685
|
+
var createPkceVerifier = () => (0, import_node_crypto6.randomBytes)(32).toString("base64url");
|
|
2686
|
+
var createPkceChallenge = (verifier) => (0, import_node_crypto6.createHash)("sha256").update(verifier).digest("base64url");
|
|
1008
2687
|
var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
|
|
1009
2688
|
var validateOidcVaultOptions = (options) => {
|
|
1010
|
-
const
|
|
2689
|
+
const {
|
|
2690
|
+
deviceBinding: configuredDeviceBinding,
|
|
2691
|
+
fingerprintRecognition: configuredFingerprintRecognition,
|
|
2692
|
+
transactionCookie: configuredTransactionCookie,
|
|
2693
|
+
...otherOptions
|
|
2694
|
+
} = options;
|
|
2695
|
+
const cookie = otherOptions.cookie ? { ...otherOptions.cookie } : void 0;
|
|
2696
|
+
const cookieOptions = { ...otherOptions, cookie };
|
|
2697
|
+
validateLifetimeOptions(options, getNow(options));
|
|
2698
|
+
const deviceBinding = resolveDeviceBindingOptions(configuredDeviceBinding);
|
|
2699
|
+
const fingerprintRecognition = resolveFingerprintRecognitionOptions(configuredFingerprintRecognition);
|
|
2700
|
+
const backendOrigin = deviceBinding === void 0 ? resolveBackendOrigin(options) : resolveDeviceBindingOrigin(options.backendOrigin);
|
|
2701
|
+
if (deviceBinding !== void 0) assertDeviceBindingStoreCapabilities(options.storeProvider);
|
|
2702
|
+
if (fingerprintRecognition !== void 0)
|
|
2703
|
+
assertDeviceBindingStoreCapabilities(options.storeProvider, "fingerprintRecognition");
|
|
1011
2704
|
const frontendRedirectUri = resolveFrontendRedirectUri(options);
|
|
1012
2705
|
validatePostLogoutRedirectUri(options);
|
|
1013
|
-
validateCookieOptions(
|
|
1014
|
-
const
|
|
2706
|
+
validateCookieOptions(cookieOptions);
|
|
2707
|
+
const transactionCookie = resolveTransactionCookieOptions(
|
|
2708
|
+
{
|
|
2709
|
+
...cookieOptions,
|
|
2710
|
+
deviceBinding: configuredDeviceBinding,
|
|
2711
|
+
fingerprintRecognition,
|
|
2712
|
+
transactionCookie: configuredTransactionCookie
|
|
2713
|
+
},
|
|
2714
|
+
backendOrigin
|
|
2715
|
+
);
|
|
2716
|
+
const config = resolveOidcVaultConfig(options.config ? { ...options.config } : void 0);
|
|
1015
2717
|
const configuredTrustedOrigins = resolveTrustedOrigins(options);
|
|
1016
|
-
if (usesCrossSiteCookieTransport(
|
|
2718
|
+
if (usesCrossSiteCookieTransport(cookieOptions) && configuredTrustedOrigins.size === 0) {
|
|
1017
2719
|
throw new Error("trustedOrigins is required when using cross-site cookie transport.");
|
|
1018
2720
|
}
|
|
1019
2721
|
const trustedOrigins = new Set(configuredTrustedOrigins);
|
|
1020
2722
|
trustedOrigins.add(backendOrigin);
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
2723
|
+
const resolvedOptions = {
|
|
2724
|
+
...otherOptions,
|
|
2725
|
+
deviceBinding,
|
|
2726
|
+
fingerprintRecognition,
|
|
2727
|
+
transactionCookie,
|
|
2728
|
+
...frontendRedirectUri !== void 0 ? { frontendRedirectUri } : {},
|
|
2729
|
+
...options.trustedOrigins ? { trustedOrigins: [...options.trustedOrigins] } : {},
|
|
2730
|
+
...cookie === void 0 ? {} : { cookie },
|
|
2731
|
+
...options.config ? { config: { ...options.config } } : {}
|
|
2732
|
+
};
|
|
2733
|
+
return { backendOrigin, config, trustedOrigins, resolvedOptions };
|
|
1025
2734
|
};
|
|
1026
2735
|
var isStoreConflictError = (error) => error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
|
|
1027
2736
|
var getSessionIdFromRequest = (req, options, action) => {
|
|
@@ -1043,11 +2752,6 @@ var getSessionIdFromRequest = (req, options, action) => {
|
|
|
1043
2752
|
"OIDC_VAULT_MISSING_SESSION_ID"
|
|
1044
2753
|
);
|
|
1045
2754
|
};
|
|
1046
|
-
var createExchangeResponse = (options, session, issuedToken) => ({
|
|
1047
|
-
sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
|
|
1048
|
-
user: session.user,
|
|
1049
|
-
...issuedToken
|
|
1050
|
-
});
|
|
1051
2755
|
var appendQueryParam = (url, name, value) => {
|
|
1052
2756
|
if (value) {
|
|
1053
2757
|
url.searchParams.set(name, value);
|
|
@@ -1079,9 +2783,12 @@ var getLogoutTokenFromRequest = (req) => {
|
|
|
1079
2783
|
"OIDC_VAULT_MISSING_LOGOUT_TOKEN"
|
|
1080
2784
|
);
|
|
1081
2785
|
};
|
|
2786
|
+
var buildBackchannelLogoutReplayKey = (input) => {
|
|
2787
|
+
const encode = (value) => Buffer.from(value, "utf8").toString("base64url");
|
|
2788
|
+
return `v2:${encode(input.issuer ?? "")}:${encode(input.clientId ?? "")}:${encode(input.jti)}`;
|
|
2789
|
+
};
|
|
1082
2790
|
var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
|
|
1083
|
-
var
|
|
1084
|
-
const rawReturnTo = req.query.returnTo;
|
|
2791
|
+
var resolveLoginReturnTo = (rawReturnTo, options) => {
|
|
1085
2792
|
if (!isString(rawReturnTo)) {
|
|
1086
2793
|
return void 0;
|
|
1087
2794
|
}
|
|
@@ -1109,17 +2816,12 @@ var resolveReturnTo = (req, options) => {
|
|
|
1109
2816
|
}
|
|
1110
2817
|
return resolvedUrl.toString();
|
|
1111
2818
|
};
|
|
2819
|
+
var resolveReturnTo = (req, options) => resolveLoginReturnTo(req.query.returnTo, options);
|
|
1112
2820
|
var appendCodeToRedirectUri = (redirectUri, code) => {
|
|
1113
2821
|
const url = new URL(redirectUri);
|
|
1114
2822
|
url.searchParams.set("code", code);
|
|
1115
2823
|
return url.toString();
|
|
1116
2824
|
};
|
|
1117
|
-
var withIssuedToken = async (req, res, options, session) => {
|
|
1118
|
-
if (!options.tokenIssuer) {
|
|
1119
|
-
return {};
|
|
1120
|
-
}
|
|
1121
|
-
return options.tokenIssuer.issue({ req, res, session });
|
|
1122
|
-
};
|
|
1123
2825
|
var resolveFrontendRedirectUri2 = (transaction, options) => {
|
|
1124
2826
|
const redirectUri = transaction.returnTo ?? options.frontendRedirectUri;
|
|
1125
2827
|
if (!redirectUri) {
|
|
@@ -1155,6 +2857,7 @@ async function callPostCommitHook(route, options, hook, req, res, session, metad
|
|
|
1155
2857
|
}
|
|
1156
2858
|
}
|
|
1157
2859
|
async function handleRouteError(route, req, res, next, options, error) {
|
|
2860
|
+
const response = toVaultRouteErrorResponse(error, options.deviceBinding);
|
|
1158
2861
|
try {
|
|
1159
2862
|
await options.hooks?.onError?.({
|
|
1160
2863
|
...createHookContext(route, req, res),
|
|
@@ -1166,11 +2869,7 @@ async function handleRouteError(route, req, res, next, options, error) {
|
|
|
1166
2869
|
next(error);
|
|
1167
2870
|
return;
|
|
1168
2871
|
}
|
|
1169
|
-
|
|
1170
|
-
res.status(payload.status).json({
|
|
1171
|
-
code: payload.code,
|
|
1172
|
-
message: payload.message
|
|
1173
|
-
});
|
|
2872
|
+
sendVaultRoutePolicyResponse(res, response);
|
|
1174
2873
|
}
|
|
1175
2874
|
function createAsyncHandler(route, options, handler) {
|
|
1176
2875
|
return async (req, res, next) => {
|
|
@@ -1187,6 +2886,7 @@ function createBodyParserErrorHandler() {
|
|
|
1187
2886
|
next(error);
|
|
1188
2887
|
return;
|
|
1189
2888
|
}
|
|
2889
|
+
applyCredentialResponseCachePolicy(res);
|
|
1190
2890
|
const payload = toBodyParserErrorPayload(error);
|
|
1191
2891
|
res.status(payload.status).json({
|
|
1192
2892
|
code: payload.code,
|
|
@@ -1195,6 +2895,7 @@ function createBodyParserErrorHandler() {
|
|
|
1195
2895
|
};
|
|
1196
2896
|
}
|
|
1197
2897
|
var createLoginHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("login", options, async (req, res) => {
|
|
2898
|
+
assertDeviceBindingPolicy(options.deviceBinding, void 0);
|
|
1198
2899
|
const metadata = await resolveProviderMetadata(config, options);
|
|
1199
2900
|
await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: metadata });
|
|
1200
2901
|
const now = getNow(options);
|
|
@@ -1206,7 +2907,11 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
|
|
|
1206
2907
|
codeChallenge: createPkceChallenge(pkceVerifier),
|
|
1207
2908
|
returnTo: resolveReturnTo(req, options),
|
|
1208
2909
|
createdAt: now,
|
|
1209
|
-
expiresAt:
|
|
2910
|
+
expiresAt: computeExpiresAt(
|
|
2911
|
+
now,
|
|
2912
|
+
options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS,
|
|
2913
|
+
"authorizationTransactionTtlMs"
|
|
2914
|
+
)
|
|
1210
2915
|
};
|
|
1211
2916
|
await options.storeProvider.createAuthorizationTransaction(transaction);
|
|
1212
2917
|
const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(backendOrigin, basePath));
|
|
@@ -1216,140 +2921,330 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
|
|
|
1216
2921
|
});
|
|
1217
2922
|
res.redirect(302, authorizationUrl);
|
|
1218
2923
|
});
|
|
1219
|
-
var
|
|
1220
|
-
|
|
1221
|
-
|
|
2924
|
+
var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrigin, basePath, proofVerifier) => createAsyncHandler("login", options, async (req, res) => {
|
|
2925
|
+
const capturedProof = captureVaultRouteProof(req);
|
|
2926
|
+
const fingerprintRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
|
|
2927
|
+
assertLoginInitiationRequest(req, options, trustedOrigins);
|
|
2928
|
+
const body = getBody(req);
|
|
2929
|
+
if (Array.isArray(req.body) || body.returnTo !== void 0 && !isString(body.returnTo)) {
|
|
2930
|
+
throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_REQUEST_BODY", "Login initiation request body is invalid.");
|
|
2931
|
+
}
|
|
2932
|
+
const returnTo = resolveLoginReturnTo(body.returnTo, options);
|
|
2933
|
+
if (proofVerifier === void 0 && capturedProof.header.count !== 0) {
|
|
2934
|
+
throw new OidcVaultHttpError(
|
|
2935
|
+
401,
|
|
2936
|
+
"OIDC_VAULT_INVALID_DPOP_PROOF",
|
|
2937
|
+
"DPoP login initiation is not enabled.",
|
|
2938
|
+
"DPoP proof validation failed."
|
|
2939
|
+
);
|
|
1222
2940
|
}
|
|
1223
|
-
const
|
|
2941
|
+
const verifiedBinding = await proofVerifier?.initiate(capturedProof);
|
|
2942
|
+
const binding = snapshotDpopBinding(verifiedBinding);
|
|
2943
|
+
const metadata = await resolveProviderMetadata(config, options);
|
|
2944
|
+
await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: { ...metadata } });
|
|
2945
|
+
const now = getNow(options);
|
|
2946
|
+
const browserBinding = createTransactionBrowserBinding();
|
|
2947
|
+
const pkceVerifier = createPkceVerifier();
|
|
2948
|
+
const transaction = {
|
|
2949
|
+
state: createOpaqueId("state"),
|
|
2950
|
+
nonce: createOpaqueId("nonce"),
|
|
2951
|
+
pkceVerifier,
|
|
2952
|
+
codeChallenge: createPkceChallenge(pkceVerifier),
|
|
2953
|
+
returnTo,
|
|
2954
|
+
...binding === void 0 ? {} : { deviceBinding: binding },
|
|
2955
|
+
browserBindingHash: browserBinding.browserBindingHash,
|
|
2956
|
+
createdAt: now,
|
|
2957
|
+
expiresAt: computeExpiresAt(
|
|
2958
|
+
now,
|
|
2959
|
+
options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS,
|
|
2960
|
+
"authorizationTransactionTtlMs"
|
|
2961
|
+
),
|
|
2962
|
+
metadata: Object.freeze({
|
|
2963
|
+
...createTransactionProviderMetadata(config),
|
|
2964
|
+
...fingerprintRecognition === void 0 ? {} : { [FINGERPRINT_RECOGNITION_METADATA_KEY]: fingerprintRecognition }
|
|
2965
|
+
})
|
|
2966
|
+
};
|
|
2967
|
+
const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(backendOrigin, basePath));
|
|
2968
|
+
const { state, expiresAt } = transaction;
|
|
2969
|
+
await options.storeProvider.createAuthorizationTransaction(transaction);
|
|
2970
|
+
await callHook("login", options.hooks?.onAuthorizationUrl, req, res, void 0, { authorizationUrl, state });
|
|
2971
|
+
setTransactionCookie(res, options.transactionCookie, browserBinding.secret, expiresAt, getNow(options));
|
|
2972
|
+
applyCredentialResponseCachePolicy(res);
|
|
2973
|
+
res.status(200).json({ authorizationUrl });
|
|
2974
|
+
});
|
|
2975
|
+
var createCallbackHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("callback", options, async (req, res) => {
|
|
2976
|
+
const providerError = isString(req.query.error) ? req.query.error : void 0;
|
|
2977
|
+
const code = providerError === void 0 ? getRequiredString(
|
|
1224
2978
|
req.query.code,
|
|
1225
2979
|
"OIDC callback is missing the authorization code.",
|
|
1226
2980
|
"OIDC_VAULT_MISSING_CODE"
|
|
1227
|
-
);
|
|
2981
|
+
) : void 0;
|
|
1228
2982
|
const state = getRequiredString(req.query.state, "OIDC callback is missing state.", "OIDC_VAULT_MISSING_STATE");
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
2983
|
+
const cookieHeader = req.headers.cookie;
|
|
2984
|
+
const store = options.storeProvider;
|
|
2985
|
+
const preflight = typeof store.getAuthorizationTransaction === "function" ? await store.getAuthorizationTransaction(state) : await store.consumeAuthorizationTransaction(state);
|
|
2986
|
+
if (!preflight) throw invalidAuthorizationState();
|
|
2987
|
+
const transaction = snapshotAuthorizationTransaction(preflight, state, getNow(options));
|
|
2988
|
+
assertAuthorizationTransactionIdentity(transaction, config);
|
|
2989
|
+
assertDeviceBindingPolicy(options.deviceBinding, transaction.deviceBinding);
|
|
2990
|
+
const cookie = options.transactionCookie;
|
|
2991
|
+
const cookieSecret = authenticateTransactionCookie(cookieHeader, cookie, transaction.match);
|
|
2992
|
+
const frontendDestination = providerError === void 0 ? resolveFrontendRedirectUri2(transaction, options) : void 0;
|
|
2993
|
+
if (typeof store.getAuthorizationTransaction === "function") {
|
|
2994
|
+
let consumed;
|
|
2995
|
+
if (typeof store.consumeAuthorizationTransactionIfMatches === "function") {
|
|
2996
|
+
consumed = await store.consumeAuthorizationTransactionIfMatches({ state, match: transaction.match });
|
|
2997
|
+
} else {
|
|
2998
|
+
if (transaction.browserBindingHash !== void 0) {
|
|
2999
|
+
throw new TypeError("Guarded callbacks require storeProvider.consumeAuthorizationTransactionIfMatches.");
|
|
3000
|
+
}
|
|
3001
|
+
consumed = await store.consumeAuthorizationTransaction(state);
|
|
3002
|
+
}
|
|
3003
|
+
if (!consumed) throw invalidAuthorizationState();
|
|
3004
|
+
const returned = snapshotAuthorizationTransaction(consumed, state, getNow(options));
|
|
3005
|
+
assertAuthorizationTransactionIdentity(returned, config);
|
|
3006
|
+
assertDeviceBindingPolicy(options.deviceBinding, returned.deviceBinding);
|
|
3007
|
+
assertAuthorizationTransactionMatches(returned, transaction);
|
|
1247
3008
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
3009
|
+
try {
|
|
3010
|
+
if (providerError !== void 0) {
|
|
3011
|
+
throw new OidcVaultHttpError(400, "OIDC_VAULT_CALLBACK_ERROR", providerError, "OIDC callback failed.");
|
|
3012
|
+
}
|
|
3013
|
+
const metadata = await resolveProviderMetadata(config, options);
|
|
3014
|
+
const tokenResponse = await requestToken(
|
|
3015
|
+
metadata,
|
|
3016
|
+
{
|
|
3017
|
+
grant_type: "authorization_code",
|
|
3018
|
+
code,
|
|
3019
|
+
code_verifier: transaction.pkceVerifier,
|
|
3020
|
+
redirect_uri: getCallbackUri(backendOrigin, basePath)
|
|
3021
|
+
},
|
|
3022
|
+
options
|
|
1253
3023
|
);
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
3024
|
+
validateCallbackTokenResponse(tokenResponse);
|
|
3025
|
+
await callHook("callback", options.hooks?.onCallbackTokens, req, res, void 0, {
|
|
3026
|
+
hasAccessToken: Boolean(tokenResponse.access_token),
|
|
3027
|
+
hasRefreshToken: true,
|
|
3028
|
+
hasIdToken: true
|
|
3029
|
+
});
|
|
3030
|
+
const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce, options);
|
|
3031
|
+
const subject = getRequiredString(
|
|
3032
|
+
claims.sub,
|
|
3033
|
+
"OIDC id_token is missing sub.",
|
|
3034
|
+
"OIDC_VAULT_INVALID_ID_TOKEN",
|
|
3035
|
+
502
|
|
3036
|
+
);
|
|
3037
|
+
const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
|
|
3038
|
+
if (userInfo !== void 0) {
|
|
3039
|
+
assertUserInfoSubject(userInfo, subject);
|
|
3040
|
+
await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
|
|
3041
|
+
}
|
|
3042
|
+
const now = getNow(options);
|
|
3043
|
+
const sessionExpiresAt = options.sessionTtlMs === void 0 ? void 0 : computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
|
|
3044
|
+
const exchangeExpiresAt = computeExpiresAt(
|
|
3045
|
+
now,
|
|
3046
|
+
options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS,
|
|
3047
|
+
"exchangeCodeTtlMs"
|
|
3048
|
+
);
|
|
3049
|
+
const sessionId = createOpaqueId("sess");
|
|
3050
|
+
const provider = Object.freeze({
|
|
1277
3051
|
issuer: metadata.issuer ?? (typeof claims.iss === "string" ? claims.iss : void 0),
|
|
1278
3052
|
clientId: metadata.clientId
|
|
1279
|
-
},
|
|
1280
|
-
refreshToken: tokenResponse.refresh_token,
|
|
1281
|
-
idToken: tokenResponse.id_token,
|
|
1282
|
-
accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : void 0,
|
|
1283
|
-
scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
|
|
1284
|
-
createdAt: now,
|
|
1285
|
-
updatedAt: now,
|
|
1286
|
-
user: mergeUserProfile(subject, claims, userInfo),
|
|
1287
|
-
metadata: {
|
|
1288
|
-
tokenType: tokenResponse.token_type
|
|
1289
|
-
}
|
|
1290
|
-
};
|
|
1291
|
-
await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
|
|
1292
|
-
const createdSession = await options.storeProvider.createSession(session);
|
|
1293
|
-
const exchangeCode = createOpaqueId("code");
|
|
1294
|
-
try {
|
|
1295
|
-
await options.storeProvider.createExchangeCode({
|
|
1296
|
-
code: exchangeCode,
|
|
1297
|
-
sessionId: createdSession.sessionId,
|
|
1298
|
-
returnTo: transaction.returnTo,
|
|
1299
|
-
createdAt: now,
|
|
1300
|
-
expiresAt: now + (options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS)
|
|
1301
3053
|
});
|
|
3054
|
+
const sessionBinding = transaction.deviceBinding;
|
|
3055
|
+
const recognition = transaction.fingerprintRecognition;
|
|
3056
|
+
const session = {
|
|
3057
|
+
sessionId,
|
|
3058
|
+
logicalSessionId: sessionId,
|
|
3059
|
+
subject,
|
|
3060
|
+
providerSessionId: typeof claims.sid === "string" ? claims.sid : void 0,
|
|
3061
|
+
provider: { ...provider },
|
|
3062
|
+
...sessionBinding === void 0 ? {} : { deviceBinding: { ...sessionBinding } },
|
|
3063
|
+
refreshToken: tokenResponse.refresh_token,
|
|
3064
|
+
idToken: tokenResponse.id_token,
|
|
3065
|
+
accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : void 0,
|
|
3066
|
+
scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
|
|
3067
|
+
createdAt: now,
|
|
3068
|
+
updatedAt: now,
|
|
3069
|
+
...sessionExpiresAt === void 0 ? {} : { expiresAt: sessionExpiresAt },
|
|
3070
|
+
user: copyVaultUserProfile(mergeUserProfile(subject, claims, userInfo)),
|
|
3071
|
+
metadata: withFingerprintRecognitionMetadata({ tokenType: tokenResponse.token_type }, recognition)
|
|
3072
|
+
};
|
|
3073
|
+
await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
|
|
3074
|
+
if (sessionExpiresAt !== void 0) {
|
|
3075
|
+
session.expiresAt = isUsableEpochMs(session.expiresAt) ? Math.min(session.expiresAt, sessionExpiresAt) : sessionExpiresAt;
|
|
3076
|
+
}
|
|
3077
|
+
const guarded = transaction.browserBindingHash !== void 0;
|
|
3078
|
+
const restoredBinding = guarded ? withSessionDeviceBinding(
|
|
3079
|
+
{
|
|
3080
|
+
...copySessionApplicationFields(session),
|
|
3081
|
+
sessionId,
|
|
3082
|
+
logicalSessionId: sessionId,
|
|
3083
|
+
provider: { ...provider }
|
|
3084
|
+
},
|
|
3085
|
+
sessionBinding
|
|
3086
|
+
) : withSessionDeviceBinding(session, sessionBinding);
|
|
3087
|
+
const restored = {
|
|
3088
|
+
...restoredBinding,
|
|
3089
|
+
user: copyVaultUserProfile(restoredBinding.user),
|
|
3090
|
+
metadata: withFingerprintRecognitionMetadata(restoredBinding.metadata, recognition)
|
|
3091
|
+
};
|
|
3092
|
+
const createdSession = await store.createSession(restored);
|
|
3093
|
+
const createdSessionId = guarded ? sessionId : createdSession.sessionId;
|
|
3094
|
+
const logicalSessionId = guarded ? sessionId : createdSession.logicalSessionId ?? createdSessionId;
|
|
3095
|
+
const exchangeCode = createOpaqueId("code");
|
|
3096
|
+
try {
|
|
3097
|
+
const returnedBinding = snapshotDpopBinding(createdSession.deviceBinding);
|
|
3098
|
+
if (returnedBinding?.jkt !== sessionBinding?.jkt) throw invalidAuthorizationState();
|
|
3099
|
+
if (!fingerprintRecognitionMatches(snapshotFingerprintRecognition(createdSession.metadata), recognition))
|
|
3100
|
+
throw invalidAuthorizationState();
|
|
3101
|
+
if (guarded && (createdSession.sessionId !== sessionId || createdSession.logicalSessionId !== sessionId || createdSession.provider?.issuer !== provider.issuer || createdSession.provider?.clientId !== provider.clientId)) {
|
|
3102
|
+
throw invalidAuthorizationState();
|
|
3103
|
+
}
|
|
3104
|
+
await store.createExchangeCode({
|
|
3105
|
+
code: exchangeCode,
|
|
3106
|
+
sessionId: createdSessionId,
|
|
3107
|
+
returnTo: transaction.returnTo,
|
|
3108
|
+
...sessionBinding === void 0 ? {} : { deviceBinding: sessionBinding },
|
|
3109
|
+
...transaction.browserBindingHash === void 0 ? {} : { browserBindingHash: transaction.browserBindingHash },
|
|
3110
|
+
createdAt: now,
|
|
3111
|
+
expiresAt: exchangeExpiresAt
|
|
3112
|
+
});
|
|
3113
|
+
} catch (error) {
|
|
3114
|
+
await store.deleteSessionsByLogicalSessionId({ logicalSessionId });
|
|
3115
|
+
throw error;
|
|
3116
|
+
}
|
|
3117
|
+
await callPostCommitHook(
|
|
3118
|
+
"callback",
|
|
3119
|
+
options,
|
|
3120
|
+
options.hooks?.onSessionCreated,
|
|
3121
|
+
req,
|
|
3122
|
+
res,
|
|
3123
|
+
withSessionDeviceBinding(copyVaultSession(createdSession), sessionBinding),
|
|
3124
|
+
{ subject }
|
|
3125
|
+
);
|
|
3126
|
+
if (cookieSecret !== void 0) {
|
|
3127
|
+
setTransactionCookie(res, cookie, cookieSecret, exchangeExpiresAt, getNow(options));
|
|
3128
|
+
}
|
|
3129
|
+
applyCredentialResponseCachePolicy(res);
|
|
3130
|
+
res.redirect(302, appendCodeToRedirectUri(frontendDestination, exchangeCode));
|
|
1302
3131
|
} catch (error) {
|
|
1303
|
-
|
|
1304
|
-
logicalSessionId: createdSession.logicalSessionId ?? createdSession.sessionId
|
|
1305
|
-
});
|
|
3132
|
+
if (cookieSecret !== void 0) clearTransactionCookie(res, cookie);
|
|
1306
3133
|
throw error;
|
|
1307
3134
|
}
|
|
1308
|
-
await callPostCommitHook("callback", options, options.hooks?.onSessionCreated, req, res, createdSession, {
|
|
1309
|
-
subject
|
|
1310
|
-
});
|
|
1311
|
-
res.redirect(302, appendCodeToRedirectUri(resolveFrontendRedirectUri2(transaction, options), exchangeCode));
|
|
1312
3135
|
});
|
|
1313
|
-
var
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
3136
|
+
var assertLoginInitiationRequest = (req, options, trustedOrigins) => {
|
|
3137
|
+
assertTrustedOrigin(req, options, trustedOrigins, "login");
|
|
3138
|
+
if (!req.is("application/json")) {
|
|
3139
|
+
throw new OidcVaultHttpError(
|
|
3140
|
+
415,
|
|
3141
|
+
"OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE",
|
|
3142
|
+
"Login initiation requires a JSON request body."
|
|
3143
|
+
);
|
|
1319
3144
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
3145
|
+
};
|
|
3146
|
+
var copySessionApplicationFields = (session) => {
|
|
3147
|
+
const result = {};
|
|
3148
|
+
for (const key of Object.keys(session)) {
|
|
3149
|
+
if (["deviceBinding", "browserBindingHash", "sessionId", "logicalSessionId", "provider"].includes(key)) continue;
|
|
3150
|
+
Object.defineProperty(result, key, {
|
|
3151
|
+
value: Reflect.get(session, key),
|
|
3152
|
+
enumerable: true,
|
|
3153
|
+
configurable: true,
|
|
3154
|
+
writable: true
|
|
3155
|
+
});
|
|
1323
3156
|
}
|
|
1324
|
-
|
|
3157
|
+
return result;
|
|
3158
|
+
};
|
|
3159
|
+
var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("exchange", options, async (req, res) => {
|
|
3160
|
+
const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
|
|
3161
|
+
const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
|
|
3162
|
+
const assertExchangeOrigin = captureTrustedOriginGuard(req, options, trustedOrigins, "exchange");
|
|
3163
|
+
const cookieHeader = req.headers.cookie;
|
|
3164
|
+
const body = getBody(req);
|
|
3165
|
+
const code = getRequiredString(body.code, "Exchange request is missing code.", "OIDC_VAULT_MISSING_EXCHANGE_CODE");
|
|
3166
|
+
const store = options.storeProvider;
|
|
3167
|
+
const canPreflight = typeof store.getExchangeCode === "function";
|
|
3168
|
+
if ((proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) && (!canPreflight || typeof store.consumeExchangeCodeIfMatches !== "function")) {
|
|
3169
|
+
throw new TypeError("Device-bound exchange requires guarded store reads and atomic consumption.");
|
|
3170
|
+
}
|
|
3171
|
+
const rawCode = canPreflight ? await store.getExchangeCode(code) : await store.consumeExchangeCode(code);
|
|
3172
|
+
if (!rawCode) throw invalidExchangeCode();
|
|
3173
|
+
const record = snapshotVaultExchangeCode(rawCode, code, getNow(options));
|
|
3174
|
+
const guarded = record.browserBindingHash !== void 0;
|
|
3175
|
+
if (guarded) assertExchangeOrigin();
|
|
3176
|
+
assertDeviceBindingPolicy(options.deviceBinding, record.deviceBinding);
|
|
3177
|
+
const rawSession = await store.getSession(record.sessionId);
|
|
3178
|
+
if (!rawSession) throw invalidVaultSession();
|
|
3179
|
+
const session = snapshotVaultSession(rawSession, record.sessionId, getNow(options));
|
|
3180
|
+
assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
|
|
3181
|
+
assertVaultExchangeSessionBinding(record, session);
|
|
3182
|
+
const protectedExchange = proofVerifier !== void 0 || options.fingerprintRecognition !== void 0 || guarded;
|
|
3183
|
+
if (protectedExchange && typeof store.consumeExchangeCodeIfMatches !== "function") {
|
|
3184
|
+
throw new TypeError("Guarded exchange requires storeProvider.consumeExchangeCodeIfMatches.");
|
|
3185
|
+
}
|
|
3186
|
+
if (protectedExchange) assertVaultSessionIdentity(session, config);
|
|
3187
|
+
const cookieSecret = authenticateTransactionCookie(cookieHeader, options.transactionCookie, record.match);
|
|
3188
|
+
const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(session.metadata);
|
|
3189
|
+
assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
|
|
3190
|
+
const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, session.deviceBinding);
|
|
3191
|
+
if (canPreflight) {
|
|
3192
|
+
const consumed = protectedExchange ? await store.consumeExchangeCodeIfMatches({
|
|
3193
|
+
code,
|
|
3194
|
+
expectedSessionId: record.sessionId,
|
|
3195
|
+
match: record.match
|
|
3196
|
+
}) : await store.consumeExchangeCode(code);
|
|
3197
|
+
if (!consumed) throw invalidExchangeCode();
|
|
3198
|
+
const returned = snapshotVaultExchangeCode(consumed, code, getNow(options));
|
|
3199
|
+
assertVaultExchangeCodeMatches(returned, record);
|
|
3200
|
+
const returnedSession = await store.getSession(record.sessionId);
|
|
3201
|
+
if (!returnedSession) throw invalidVaultSession();
|
|
3202
|
+
const checkedSession = snapshotVaultSession(returnedSession, record.sessionId, getNow(options));
|
|
3203
|
+
assertVaultSessionIdentity(checkedSession, config);
|
|
3204
|
+
assertVaultExchangeSessionBinding(returned, checkedSession);
|
|
3205
|
+
assertVaultSessionMatches(checkedSession, session);
|
|
3206
|
+
if (options.fingerprintRecognition !== void 0 && !fingerprintRecognitionMatches(snapshotFingerprintRecognition(checkedSession.metadata), recognition))
|
|
3207
|
+
throw invalidVaultSession();
|
|
3208
|
+
}
|
|
3209
|
+
assertVaultSessionIdentity(session, config);
|
|
1325
3210
|
try {
|
|
1326
|
-
issuedToken = await withIssuedToken(req, res, options, session);
|
|
3211
|
+
const issuedToken = await withIssuedToken(req, res, options, session, verifiedBinding);
|
|
3212
|
+
if (usesCookieTransport(options)) setSessionCookie(res, options, session.sessionId);
|
|
3213
|
+
if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
|
|
3214
|
+
applyCredentialResponseCachePolicy(res);
|
|
3215
|
+
res.status(200).json(createExchangeResponse(options, session, issuedToken));
|
|
1327
3216
|
} catch (error) {
|
|
1328
|
-
|
|
1329
|
-
logicalSessionId: session.logicalSessionId ?? session.sessionId
|
|
1330
|
-
});
|
|
1331
|
-
if (usesCookieTransport(options)) {
|
|
1332
|
-
clearSessionCookie(res, options);
|
|
1333
|
-
}
|
|
3217
|
+
if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
|
|
1334
3218
|
throw error;
|
|
1335
3219
|
}
|
|
1336
|
-
if (usesCookieTransport(options)) {
|
|
1337
|
-
setSessionCookie(res, options, session.sessionId);
|
|
1338
|
-
}
|
|
1339
|
-
const response = createExchangeResponse(options, session, issuedToken);
|
|
1340
|
-
res.status(200).json(response);
|
|
1341
3220
|
});
|
|
1342
|
-
var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandler("refresh", options, async (req, res) => {
|
|
3221
|
+
var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("refresh", options, async (req, res) => {
|
|
3222
|
+
const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
|
|
1343
3223
|
assertTrustedOrigin(req, options, trustedOrigins, "refresh");
|
|
3224
|
+
const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
|
|
1344
3225
|
const sessionId = getSessionIdFromRequest(req, options, "refresh");
|
|
1345
|
-
const
|
|
1346
|
-
if (!
|
|
3226
|
+
const currentRecord = await options.storeProvider.getSession(sessionId);
|
|
3227
|
+
if (!currentRecord) {
|
|
1347
3228
|
if (usesCookieTransport(options)) {
|
|
1348
3229
|
clearSessionCookie(res, options);
|
|
1349
3230
|
}
|
|
1350
3231
|
throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
|
|
1351
3232
|
}
|
|
3233
|
+
const currentSession = snapshotVaultSession(currentRecord, sessionId, getNow(options));
|
|
3234
|
+
assertVaultSessionIdentity(currentSession, config);
|
|
3235
|
+
assertDeviceBindingPolicy(options.deviceBinding, currentSession.deviceBinding);
|
|
3236
|
+
const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(currentSession.metadata);
|
|
3237
|
+
assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
|
|
3238
|
+
const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, currentSession.deviceBinding);
|
|
1352
3239
|
const metadata = await resolveProviderMetadata(config, options);
|
|
3240
|
+
if (proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) {
|
|
3241
|
+
const latest = await options.storeProvider.getSession(sessionId);
|
|
3242
|
+
if (!latest) throw invalidVaultSession();
|
|
3243
|
+
const latestSession = snapshotVaultSession(latest, sessionId, getNow(options));
|
|
3244
|
+
assertVaultSessionMatches(latestSession, currentSession);
|
|
3245
|
+
if (options.fingerprintRecognition !== void 0 && !fingerprintRecognitionMatches(snapshotFingerprintRecognition(latestSession.metadata), recognition))
|
|
3246
|
+
throw invalidVaultSession();
|
|
3247
|
+
}
|
|
1353
3248
|
const tokenResponse = await requestToken(
|
|
1354
3249
|
metadata,
|
|
1355
3250
|
{
|
|
@@ -1358,39 +3253,40 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
|
|
|
1358
3253
|
},
|
|
1359
3254
|
options
|
|
1360
3255
|
);
|
|
1361
|
-
|
|
3256
|
+
validateRefreshTokenResponse(tokenResponse);
|
|
1362
3257
|
const newIdToken = isString(tokenResponse.id_token) ? tokenResponse.id_token : void 0;
|
|
1363
3258
|
const idToken = newIdToken ?? currentSession.idToken;
|
|
1364
|
-
const
|
|
1365
|
-
const subject =
|
|
3259
|
+
const freshClaims = newIdToken ? await verifyIdToken(metadata, newIdToken, void 0, options) : void 0;
|
|
3260
|
+
const subject = freshClaims ? getRequiredString(freshClaims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502) : currentSession.subject;
|
|
1366
3261
|
if (newIdToken && subject !== currentSession.subject) {
|
|
1367
3262
|
throw new OidcVaultHttpError(502, "OIDC_VAULT_INVALID_ID_TOKEN", "OIDC refreshed id_token sub changed.");
|
|
1368
3263
|
}
|
|
1369
3264
|
const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
|
|
1370
|
-
if (userInfo) {
|
|
3265
|
+
if (userInfo !== void 0) {
|
|
1371
3266
|
assertUserInfoSubject(userInfo, subject);
|
|
1372
3267
|
}
|
|
1373
3268
|
const now = getNow(options);
|
|
1374
3269
|
const logicalSessionId = currentSession.logicalSessionId ?? currentSession.sessionId;
|
|
1375
3270
|
const nextSession = {
|
|
1376
|
-
...currentSession,
|
|
3271
|
+
...copyVaultSession(currentSession),
|
|
1377
3272
|
sessionId: createOpaqueId("sess"),
|
|
1378
3273
|
logicalSessionId,
|
|
1379
3274
|
subject,
|
|
1380
|
-
providerSessionId: typeof
|
|
3275
|
+
providerSessionId: freshClaims && typeof freshClaims.sid === "string" ? freshClaims.sid : currentSession.providerSessionId,
|
|
1381
3276
|
refreshToken: isString(tokenResponse.refresh_token) ? tokenResponse.refresh_token : currentSession.refreshToken,
|
|
1382
3277
|
idToken,
|
|
1383
3278
|
accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : currentSession.accessToken,
|
|
1384
3279
|
scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : currentSession.scope,
|
|
1385
3280
|
expiresAt: currentSession.expiresAt,
|
|
1386
3281
|
updatedAt: now,
|
|
1387
|
-
user:
|
|
3282
|
+
user: copyVaultUserProfile(composeRefreshedUserProfile(subject, currentSession.user, freshClaims, userInfo))
|
|
1388
3283
|
};
|
|
1389
|
-
|
|
3284
|
+
const expectedSession = snapshotVaultSession(nextSession, nextSession.sessionId, getNow(options));
|
|
3285
|
+
let rotatedRecord;
|
|
1390
3286
|
try {
|
|
1391
|
-
|
|
3287
|
+
rotatedRecord = await options.storeProvider.rotateSession({
|
|
1392
3288
|
sessionId: currentSession.sessionId,
|
|
1393
|
-
nextSession
|
|
3289
|
+
nextSession: copyVaultSession(expectedSession)
|
|
1394
3290
|
});
|
|
1395
3291
|
} catch (error) {
|
|
1396
3292
|
if (isStoreConflictError(error)) {
|
|
@@ -1401,51 +3297,127 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
|
|
|
1401
3297
|
}
|
|
1402
3298
|
throw error;
|
|
1403
3299
|
}
|
|
1404
|
-
let issuedToken;
|
|
1405
3300
|
try {
|
|
1406
|
-
|
|
3301
|
+
const returnedSession = snapshotVaultSession(rotatedRecord, expectedSession.sessionId, getNow(options));
|
|
3302
|
+
assertVaultSessionIdentity(returnedSession, config);
|
|
3303
|
+
assertVaultSessionMatches(returnedSession, expectedSession);
|
|
3304
|
+
if (options.fingerprintRecognition !== void 0 && !fingerprintRecognitionMatches(snapshotFingerprintRecognition(returnedSession.metadata), recognition))
|
|
3305
|
+
throw invalidVaultSession();
|
|
1407
3306
|
} catch (error) {
|
|
1408
|
-
await options.storeProvider.deleteSessionsByLogicalSessionId({
|
|
1409
|
-
|
|
1410
|
-
});
|
|
1411
|
-
if (usesCookieTransport(options)) {
|
|
1412
|
-
clearSessionCookie(res, options);
|
|
1413
|
-
}
|
|
3307
|
+
await options.storeProvider.deleteSessionsByLogicalSessionId({ logicalSessionId });
|
|
3308
|
+
if (usesCookieTransport(options)) clearSessionCookie(res, options);
|
|
1414
3309
|
throw error;
|
|
1415
3310
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
3311
|
+
const rotatedSession = expectedSession;
|
|
3312
|
+
const issuedToken = await withIssuedToken(req, res, options, rotatedSession, verifiedBinding);
|
|
3313
|
+
await callPostCommitHook(
|
|
3314
|
+
"refresh",
|
|
3315
|
+
options,
|
|
3316
|
+
options.hooks?.onSessionRefreshed,
|
|
3317
|
+
req,
|
|
3318
|
+
res,
|
|
3319
|
+
copyVaultSession(rotatedSession),
|
|
3320
|
+
{
|
|
3321
|
+
previousSessionId: currentSession.sessionId
|
|
3322
|
+
}
|
|
3323
|
+
);
|
|
1419
3324
|
if (usesCookieTransport(options)) {
|
|
1420
3325
|
setSessionCookie(res, options, rotatedSession.sessionId);
|
|
1421
3326
|
}
|
|
1422
3327
|
const response = createExchangeResponse(options, rotatedSession, issuedToken);
|
|
3328
|
+
applyCredentialResponseCachePolicy(res);
|
|
1423
3329
|
res.status(200).json(response);
|
|
1424
3330
|
});
|
|
1425
|
-
var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandler("logout", options, async (req, res) => {
|
|
3331
|
+
var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("logout", options, async (req, res) => {
|
|
1426
3332
|
assertTrustedOrigin(req, options, trustedOrigins, "logout");
|
|
3333
|
+
const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
|
|
1427
3334
|
const body = getBody(req);
|
|
1428
3335
|
const sessionId = getSessionIdFromRequest(req, options, "logout");
|
|
1429
3336
|
const redirect = body.redirect === true;
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
3337
|
+
const store = options.storeProvider;
|
|
3338
|
+
const hasContext = typeof store.getSessionRevocationContext === "function";
|
|
3339
|
+
if (proofVerifier !== void 0 && !hasContext) {
|
|
3340
|
+
throw new TypeError("Device-bound logout requires storeProvider.getSessionRevocationContext.");
|
|
3341
|
+
}
|
|
3342
|
+
const rawContext = hasContext ? await store.getSessionRevocationContext(sessionId) : void 0;
|
|
3343
|
+
const context = rawContext ? snapshotVaultRevocationContext(rawContext) : void 0;
|
|
3344
|
+
const rawSession = await store.getSession(sessionId);
|
|
3345
|
+
const session = rawSession ? snapshotVaultSession(rawSession, sessionId, getNow(options)) : void 0;
|
|
3346
|
+
const finishLocalLogout = () => {
|
|
3347
|
+
if (usesCookieTransport(options)) clearSessionCookie(res, options);
|
|
3348
|
+
applyCredentialResponseCachePolicy(res);
|
|
1436
3349
|
res.status(200).json({ loggedOut: true });
|
|
3350
|
+
};
|
|
3351
|
+
if (hasContext && context === void 0) {
|
|
3352
|
+
if (session !== void 0) {
|
|
3353
|
+
assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
|
|
3354
|
+
throw invalidVaultSession();
|
|
3355
|
+
}
|
|
3356
|
+
finishLocalLogout();
|
|
1437
3357
|
return;
|
|
1438
3358
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
3359
|
+
const authority = context ?? (session === void 0 ? void 0 : snapshotVaultRevocationContext({
|
|
3360
|
+
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
3361
|
+
provider: session.provider,
|
|
3362
|
+
deviceBinding: session.deviceBinding
|
|
3363
|
+
}));
|
|
3364
|
+
if (authority === void 0) {
|
|
3365
|
+
await store.deleteSession(sessionId);
|
|
3366
|
+
finishLocalLogout();
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
assertVaultSessionIdentity(authority, config);
|
|
3370
|
+
assertDeviceBindingPolicy(options.deviceBinding, authority.deviceBinding);
|
|
3371
|
+
if (session !== void 0)
|
|
3372
|
+
assertVaultRevocationContextMatches(
|
|
3373
|
+
snapshotVaultRevocationContext({
|
|
3374
|
+
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
3375
|
+
provider: session.provider,
|
|
3376
|
+
deviceBinding: session.deviceBinding
|
|
3377
|
+
}),
|
|
3378
|
+
authority
|
|
3379
|
+
);
|
|
3380
|
+
if (proofVerifier !== void 0) await proofVerifier.verify(capturedProof, authority.deviceBinding);
|
|
3381
|
+
if (hasContext) {
|
|
3382
|
+
const latest = await store.getSessionRevocationContext(sessionId);
|
|
3383
|
+
if (!latest) {
|
|
3384
|
+
finishLocalLogout();
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3387
|
+
assertVaultRevocationContextMatches(snapshotVaultRevocationContext(latest), authority);
|
|
3388
|
+
}
|
|
3389
|
+
if (session !== void 0)
|
|
3390
|
+
await callHook("logout", options.hooks?.onBeforeLogout, req, res, copyVaultSession(session));
|
|
3391
|
+
await store.deleteSessionsByLogicalSessionId({ logicalSessionId: authority.logicalSessionId });
|
|
1443
3392
|
if (usesCookieTransport(options)) {
|
|
1444
3393
|
clearSessionCookie(res, options);
|
|
1445
3394
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
3395
|
+
if (session === void 0) {
|
|
3396
|
+
applyCredentialResponseCachePolicy(res);
|
|
3397
|
+
res.status(200).json({ loggedOut: true });
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
if (!redirect) {
|
|
3401
|
+
await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
|
|
3402
|
+
applyCredentialResponseCachePolicy(res);
|
|
3403
|
+
res.status(200).json({ loggedOut: true });
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
let upstreamLogoutUrl;
|
|
3407
|
+
try {
|
|
3408
|
+
const metadata = await resolveProviderMetadata(config, options);
|
|
3409
|
+
upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
|
|
3410
|
+
} catch (error) {
|
|
3411
|
+
try {
|
|
3412
|
+
await options.hooks?.onError?.({
|
|
3413
|
+
...createHookContext("logout", req, res, copyVaultSession(session)),
|
|
3414
|
+
error
|
|
3415
|
+
});
|
|
3416
|
+
} catch {
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
|
|
3420
|
+
applyCredentialResponseCachePolicy(res);
|
|
1449
3421
|
if (redirect && upstreamLogoutUrl) {
|
|
1450
3422
|
res.redirect(302, upstreamLogoutUrl);
|
|
1451
3423
|
return;
|
|
@@ -1458,26 +3430,40 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
|
|
|
1458
3430
|
const metadata = await resolveProviderMetadata(config, options);
|
|
1459
3431
|
const logoutToken = getLogoutTokenFromRequest(req);
|
|
1460
3432
|
const claims = await verifyBackchannelLogoutToken(metadata, logoutToken, options);
|
|
3433
|
+
const replayKey = buildBackchannelLogoutReplayKey({
|
|
3434
|
+
issuer: metadata.issuer,
|
|
3435
|
+
clientId: metadata.clientId,
|
|
3436
|
+
jti: claims.jti
|
|
3437
|
+
});
|
|
1461
3438
|
const firstUse = await options.storeProvider.consumeBackchannelLogoutTokenJti({
|
|
1462
|
-
jti:
|
|
3439
|
+
jti: replayKey,
|
|
1463
3440
|
expiresAt: claims.exp * 1e3
|
|
1464
3441
|
});
|
|
1465
|
-
|
|
1466
|
-
res.status(200).json({
|
|
1467
|
-
loggedOut: true,
|
|
1468
|
-
revokedSessions: 0
|
|
1469
|
-
});
|
|
1470
|
-
return;
|
|
1471
|
-
}
|
|
1472
|
-
const revokedSessions = isString(claims.sid) ? await options.storeProvider.deleteSessionsByProviderSessionId({
|
|
3442
|
+
const revokeMatchingSessions = () => isString(claims.sid) ? options.storeProvider.deleteSessionsByProviderSessionId({
|
|
1473
3443
|
providerSessionId: claims.sid,
|
|
1474
3444
|
issuer: metadata.issuer,
|
|
1475
3445
|
clientId: metadata.clientId
|
|
1476
|
-
}) :
|
|
3446
|
+
}) : options.storeProvider.deleteSessionsBySubject({
|
|
1477
3447
|
subject: String(claims.sub),
|
|
1478
3448
|
issuer: metadata.issuer,
|
|
1479
3449
|
clientId: metadata.clientId
|
|
1480
3450
|
});
|
|
3451
|
+
if (!firstUse) {
|
|
3452
|
+
const catchUpRevokedSessions = await revokeMatchingSessions();
|
|
3453
|
+
if (catchUpRevokedSessions > 0) {
|
|
3454
|
+
await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
|
|
3455
|
+
providerSessionId: claims.sid,
|
|
3456
|
+
subject: claims.sub,
|
|
3457
|
+
revokedSessions: catchUpRevokedSessions
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
res.status(200).json({
|
|
3461
|
+
loggedOut: true,
|
|
3462
|
+
revokedSessions: catchUpRevokedSessions
|
|
3463
|
+
});
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
const revokedSessions = await revokeMatchingSessions();
|
|
1481
3467
|
await callPostCommitHook("backchannel-logout", options, options.hooks?.onLogout, req, res, void 0, {
|
|
1482
3468
|
providerSessionId: claims.sid,
|
|
1483
3469
|
subject: claims.sub,
|
|
@@ -1489,19 +3475,39 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
|
|
|
1489
3475
|
});
|
|
1490
3476
|
});
|
|
1491
3477
|
function registerRoutes(router, options, config, trustedOrigins, backendOrigin, basePath) {
|
|
3478
|
+
const proofVerifier = createVaultRouteProofVerifier(options, config, backendOrigin, basePath);
|
|
1492
3479
|
router.get(OIDC_VAULT_ROUTE_PATHS.login, createLoginHandler(options, config, backendOrigin, basePath));
|
|
3480
|
+
if (proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) {
|
|
3481
|
+
router.post(
|
|
3482
|
+
OIDC_VAULT_ROUTE_PATHS.login,
|
|
3483
|
+
createLoginInitiationHandler(options, config, trustedOrigins, backendOrigin, basePath, proofVerifier)
|
|
3484
|
+
);
|
|
3485
|
+
}
|
|
1493
3486
|
router.get(OIDC_VAULT_ROUTE_PATHS.callback, createCallbackHandler(options, config, backendOrigin, basePath));
|
|
1494
|
-
router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options));
|
|
1495
|
-
router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins));
|
|
1496
|
-
router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins));
|
|
3487
|
+
router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config, trustedOrigins, proofVerifier));
|
|
3488
|
+
router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins, proofVerifier));
|
|
3489
|
+
router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins, proofVerifier));
|
|
1497
3490
|
router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
|
|
1498
3491
|
}
|
|
1499
3492
|
function createOidcVaultMiddleware(options) {
|
|
1500
|
-
const { backendOrigin, config, trustedOrigins } = validateOidcVaultOptions(options);
|
|
3493
|
+
const { backendOrigin, config, trustedOrigins, resolvedOptions } = validateOidcVaultOptions(options);
|
|
1501
3494
|
const rootRouter = import_express.default.Router();
|
|
1502
3495
|
const baseRouter = import_express.default.Router();
|
|
1503
|
-
const basePath = normalizeOidcVaultBasePath(
|
|
1504
|
-
const requestBodyLimit =
|
|
3496
|
+
const basePath = normalizeOidcVaultBasePath(resolvedOptions.basePath);
|
|
3497
|
+
const requestBodyLimit = resolvedOptions.requestBodyLimit ?? DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT;
|
|
3498
|
+
baseRouter.use((_req, res, next) => {
|
|
3499
|
+
applyCredentialResponseCachePolicy(res);
|
|
3500
|
+
next();
|
|
3501
|
+
});
|
|
3502
|
+
if (resolvedOptions.deviceBinding !== void 0 || resolvedOptions.fingerprintRecognition !== void 0) {
|
|
3503
|
+
baseRouter.post(
|
|
3504
|
+
OIDC_VAULT_ROUTE_PATHS.login,
|
|
3505
|
+
createAsyncHandler("login", resolvedOptions, async (req, res, next) => {
|
|
3506
|
+
assertLoginInitiationRequest(req, resolvedOptions, trustedOrigins);
|
|
3507
|
+
next();
|
|
3508
|
+
})
|
|
3509
|
+
);
|
|
3510
|
+
}
|
|
1505
3511
|
baseRouter.use(import_express.default.json({ limit: requestBodyLimit }));
|
|
1506
3512
|
baseRouter.use(
|
|
1507
3513
|
import_express.default.urlencoded({
|
|
@@ -1511,7 +3517,7 @@ function createOidcVaultMiddleware(options) {
|
|
|
1511
3517
|
})
|
|
1512
3518
|
);
|
|
1513
3519
|
baseRouter.use(createBodyParserErrorHandler());
|
|
1514
|
-
registerRoutes(baseRouter,
|
|
3520
|
+
registerRoutes(baseRouter, resolvedOptions, config, trustedOrigins, backendOrigin, basePath);
|
|
1515
3521
|
rootRouter.use(basePath, baseRouter);
|
|
1516
3522
|
return rootRouter;
|
|
1517
3523
|
}
|
|
@@ -1524,6 +3530,7 @@ function createOidcVaultMiddleware(options) {
|
|
|
1524
3530
|
DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
|
|
1525
3531
|
OIDC_VAULT_ROUTE_PATHS,
|
|
1526
3532
|
OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
|
|
3533
|
+
OidcVaultDpopReplayCapacityError,
|
|
1527
3534
|
OidcVaultStoreConflictError,
|
|
1528
3535
|
createOidcVaultAccessTokenMiddleware,
|
|
1529
3536
|
createOidcVaultJwtAccessTokenValidator,
|