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