@web-ts-toolkit/express-oidc-vault 0.50.0 → 0.52.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 +210 -340
- package/index.d.mts +45 -65
- package/index.d.ts +45 -65
- package/index.js +114 -48
- package/index.mjs +114 -48
- package/package.json +11 -1
- package/wire.d.mts +76 -0
- package/wire.d.ts +76 -0
- package/wire.js +18 -0
- package/wire.mjs +0 -0
package/index.js
CHANGED
|
@@ -290,6 +290,26 @@ var parseSelectedCookieValue = (headerValue, selectedName) => {
|
|
|
290
290
|
|
|
291
291
|
// src/device-binding-policy.ts
|
|
292
292
|
var import_node_net = require("net");
|
|
293
|
+
|
|
294
|
+
// src/dpop-failure-policy.ts
|
|
295
|
+
var CHECKS = {
|
|
296
|
+
target: { step: 6, option: "ignoreTargetFailure", env: "OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE" },
|
|
297
|
+
freshness: { step: 7, option: "ignoreFreshnessFailure", env: "OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE" },
|
|
298
|
+
replay: { step: 8, option: "ignoreReplayFailure", env: "OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE" }
|
|
299
|
+
};
|
|
300
|
+
var resolveDpopFailureOption = (value, check) => {
|
|
301
|
+
const { option, env } = CHECKS[check];
|
|
302
|
+
if (value === void 0) return process.env[env] === "true";
|
|
303
|
+
if (typeof value !== "boolean") throw new TypeError(`deviceBinding.${option} must be a boolean.`);
|
|
304
|
+
return value;
|
|
305
|
+
};
|
|
306
|
+
var handleDpopCheckFailure = (ignore, check, error) => {
|
|
307
|
+
if (!ignore) throw error;
|
|
308
|
+
const { step, option, env } = CHECKS[check];
|
|
309
|
+
console.warn(`[express-oidc-vault] DPoP step ${step} (${check}) failed; continuing (${option}=true; ${env}).`, error);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// src/device-binding-policy.ts
|
|
293
313
|
var isPlainRecord = (value) => {
|
|
294
314
|
if (typeof value !== "object" || value === null) return false;
|
|
295
315
|
const prototype = Object.getPrototypeOf(value);
|
|
@@ -306,7 +326,16 @@ var resolveInteger = (value, fallback, min, max, name) => {
|
|
|
306
326
|
var resolveDeviceBindingOptions = (value) => {
|
|
307
327
|
if (value === void 0) return void 0;
|
|
308
328
|
if (!isPlainRecord(value)) throw new Error("deviceBinding must be a plain options object.");
|
|
309
|
-
const {
|
|
329
|
+
const {
|
|
330
|
+
mode,
|
|
331
|
+
algorithms,
|
|
332
|
+
proofMaxAgeSeconds,
|
|
333
|
+
clockSkewSeconds,
|
|
334
|
+
nonce,
|
|
335
|
+
ignoreTargetFailure,
|
|
336
|
+
ignoreFreshnessFailure,
|
|
337
|
+
ignoreReplayFailure
|
|
338
|
+
} = value;
|
|
310
339
|
if (mode !== void 0 && mode !== "optional" && mode !== "required") {
|
|
311
340
|
throw new Error("deviceBinding.mode must be optional or required.");
|
|
312
341
|
}
|
|
@@ -338,7 +367,10 @@ var resolveDeviceBindingOptions = (value) => {
|
|
|
338
367
|
algorithms: Object.freeze([...new Set(allowlist)]),
|
|
339
368
|
proofMaxAgeSeconds: resolveInteger(proofMaxAgeSeconds, 60, 1, 300, "deviceBinding.proofMaxAgeSeconds"),
|
|
340
369
|
clockSkewSeconds: resolveInteger(clockSkewSeconds, 5, 0, 30, "deviceBinding.clockSkewSeconds"),
|
|
341
|
-
nonce: resolvedNonce
|
|
370
|
+
nonce: resolvedNonce,
|
|
371
|
+
ignoreTargetFailure: resolveDpopFailureOption(ignoreTargetFailure, "target"),
|
|
372
|
+
ignoreFreshnessFailure: resolveDpopFailureOption(ignoreFreshnessFailure, "freshness"),
|
|
373
|
+
ignoreReplayFailure: resolveDpopFailureOption(ignoreReplayFailure, "replay")
|
|
342
374
|
});
|
|
343
375
|
};
|
|
344
376
|
var DEVICE_BINDING_STORE_CAPABILITIES = [
|
|
@@ -669,6 +701,7 @@ var publicJwk = (value, alg) => {
|
|
|
669
701
|
var verifyDpopProof = async (input) => {
|
|
670
702
|
const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
|
|
671
703
|
const algorithms = [...proofOptions.algorithms];
|
|
704
|
+
const { ignoreTargetFailure } = proofOptions;
|
|
672
705
|
extractDpopProof({ count: 1, value: proof });
|
|
673
706
|
const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
|
|
674
707
|
const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
|
|
@@ -694,8 +727,13 @@ var verifyDpopProof = async (input) => {
|
|
|
694
727
|
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
|
|
695
728
|
throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
|
|
696
729
|
}
|
|
697
|
-
|
|
698
|
-
|
|
730
|
+
try {
|
|
731
|
+
const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
|
|
732
|
+
if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
|
|
733
|
+
throw invalidProof("DPoP signed method or normalized public target does not match this request.");
|
|
734
|
+
}
|
|
735
|
+
} catch (error) {
|
|
736
|
+
handleDpopCheckFailure(ignoreTargetFailure, "target", error);
|
|
699
737
|
}
|
|
700
738
|
if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
|
|
701
739
|
throw invalidProof("DPoP signed nonce exceeds its byte bound.");
|
|
@@ -1795,6 +1833,35 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
|
|
|
1795
1833
|
// src/token-issuance.ts
|
|
1796
1834
|
var import_jose3 = require("jose");
|
|
1797
1835
|
|
|
1836
|
+
// src/access-token-confirmation.ts
|
|
1837
|
+
var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
|
|
1838
|
+
var snapshotJkt = (value, invalid = invalidAccessToken) => {
|
|
1839
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1840
|
+
throw invalid("Access token confirmation must be a plain jkt object.");
|
|
1841
|
+
}
|
|
1842
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1843
|
+
const keys = Reflect.ownKeys(value);
|
|
1844
|
+
if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
|
|
1845
|
+
throw invalid("Access token confirmation contains unsupported fields.");
|
|
1846
|
+
}
|
|
1847
|
+
const { jkt } = value;
|
|
1848
|
+
if (!isCanonicalDpopJkt(jkt)) throw invalid("Access token confirmation is not a canonical SHA-256 thumbprint.");
|
|
1849
|
+
return Object.freeze({ jkt });
|
|
1850
|
+
};
|
|
1851
|
+
var isCompactSignedJwt = (token) => {
|
|
1852
|
+
const parts = token.split(".");
|
|
1853
|
+
return parts.length === 3 && parts.every(
|
|
1854
|
+
(part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
|
|
1855
|
+
);
|
|
1856
|
+
};
|
|
1857
|
+
var readJwtExpirationMs = (exp) => {
|
|
1858
|
+
if (typeof exp !== "number" || !Number.isSafeInteger(exp) || exp < 0) return void 0;
|
|
1859
|
+
const milliseconds = exp * 1e3;
|
|
1860
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : void 0;
|
|
1861
|
+
};
|
|
1862
|
+
var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
|
|
1863
|
+
var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
|
|
1864
|
+
|
|
1798
1865
|
// src/vault-session.ts
|
|
1799
1866
|
var invalidVaultSession = () => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
|
|
1800
1867
|
var invalidExchangeCode = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
|
|
@@ -1939,13 +2006,7 @@ var createExchangeResponse = (options, session, issuedToken) => ({
|
|
|
1939
2006
|
expiresIn: issuedToken.expiresIn,
|
|
1940
2007
|
tokenType: issuedToken.tokenType
|
|
1941
2008
|
});
|
|
1942
|
-
var
|
|
1943
|
-
const parts = token.split(".");
|
|
1944
|
-
return parts.length === 3 && parts.every(
|
|
1945
|
-
(part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
|
|
1946
|
-
);
|
|
1947
|
-
};
|
|
1948
|
-
var assertIssuedTokenConfirmation = (accessToken, jkt) => {
|
|
2009
|
+
var assertIssuedBoundJwt = (accessToken, jkt) => {
|
|
1949
2010
|
if (!isCompactSignedJwt(accessToken)) {
|
|
1950
2011
|
throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
|
|
1951
2012
|
}
|
|
@@ -1960,10 +2021,16 @@ var assertIssuedTokenConfirmation = (accessToken, jkt) => {
|
|
|
1960
2021
|
if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false) {
|
|
1961
2022
|
throw new TypeError("tokenIssuer.issue accessToken must use a signed JWT algorithm for bound issuance.");
|
|
1962
2023
|
}
|
|
1963
|
-
const
|
|
1964
|
-
|
|
2024
|
+
const confirmation = snapshotJkt(
|
|
2025
|
+
claims.cnf,
|
|
2026
|
+
(diagnostic) => new TypeError(`tokenIssuer.issue accessToken cnf.jkt is invalid. ${diagnostic}`)
|
|
2027
|
+
);
|
|
2028
|
+
if (confirmation.jkt !== jkt) {
|
|
1965
2029
|
throw new TypeError("tokenIssuer.issue accessToken cnf.jkt must match the verified device binding.");
|
|
1966
2030
|
}
|
|
2031
|
+
if (readJwtExpirationMs(claims.exp) === void 0) {
|
|
2032
|
+
throw new TypeError("tokenIssuer.issue accessToken exp must be a nonnegative safe integer with safe milliseconds.");
|
|
2033
|
+
}
|
|
1967
2034
|
};
|
|
1968
2035
|
var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
1969
2036
|
const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
|
|
@@ -1996,7 +2063,10 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
|
1996
2063
|
}
|
|
1997
2064
|
} else {
|
|
1998
2065
|
if (tokenType !== "DPoP") throw new TypeError("tokenIssuer.issue tokenType must be DPoP for bound issuance.");
|
|
1999
|
-
|
|
2066
|
+
if (!Number.isSafeInteger(expiresIn * 1e3)) {
|
|
2067
|
+
throw new TypeError("tokenIssuer.issue expiresIn must have safe milliseconds for bound issuance.");
|
|
2068
|
+
}
|
|
2069
|
+
assertIssuedBoundJwt(accessToken, binding.jkt);
|
|
2000
2070
|
}
|
|
2001
2071
|
return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
|
|
2002
2072
|
} catch (error) {
|
|
@@ -2008,27 +2078,6 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
|
2008
2078
|
|
|
2009
2079
|
// src/token-validation.ts
|
|
2010
2080
|
var import_jose4 = require("jose");
|
|
2011
|
-
|
|
2012
|
-
// src/access-token-confirmation.ts
|
|
2013
|
-
var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
|
|
2014
|
-
var snapshotJkt = (value) => {
|
|
2015
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2016
|
-
throw invalidAccessToken("Access token confirmation must be a plain jkt object.");
|
|
2017
|
-
}
|
|
2018
|
-
const prototype = Object.getPrototypeOf(value);
|
|
2019
|
-
const keys = Reflect.ownKeys(value);
|
|
2020
|
-
if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
|
|
2021
|
-
throw invalidAccessToken("Access token confirmation contains unsupported fields.");
|
|
2022
|
-
}
|
|
2023
|
-
const { jkt } = value;
|
|
2024
|
-
if (!isCanonicalDpopJkt(jkt))
|
|
2025
|
-
throw invalidAccessToken("Access token confirmation is not a canonical SHA-256 thumbprint.");
|
|
2026
|
-
return Object.freeze({ jkt });
|
|
2027
|
-
};
|
|
2028
|
-
var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
|
|
2029
|
-
var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
|
|
2030
|
-
|
|
2031
|
-
// src/token-validation.ts
|
|
2032
2081
|
var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
|
|
2033
2082
|
var validateBackchannelLogoutTokenTyp = (typ) => {
|
|
2034
2083
|
if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
|
|
@@ -2190,6 +2239,14 @@ function createOidcVaultJwtAccessTokenValidator(options) {
|
|
|
2190
2239
|
const result = await (0, import_jose4.jwtVerify)(token, key, { issuer, audience, algorithms });
|
|
2191
2240
|
const claims = result.payload;
|
|
2192
2241
|
const confirmation = snapshotVerifiedJwtConfirmation(claims);
|
|
2242
|
+
if (confirmation !== null) {
|
|
2243
|
+
if (!isCompactSignedJwt(token)) {
|
|
2244
|
+
throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
|
|
2245
|
+
}
|
|
2246
|
+
if (readJwtExpirationMs(claims.exp) === void 0) {
|
|
2247
|
+
throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2193
2250
|
const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
|
|
2194
2251
|
const { subject, sessionId, scope, claims: mappedClaims } = mapped;
|
|
2195
2252
|
return { subject, sessionId, scope, claims: mappedClaims, confirmation };
|
|
@@ -2309,8 +2366,8 @@ var createDpopReplayPolicy = (options) => {
|
|
|
2309
2366
|
throw invalidProof2("DPoP verifier did not return a verified key binding.");
|
|
2310
2367
|
}
|
|
2311
2368
|
const { type, jkt, alg } = verifiedBinding;
|
|
2312
|
-
if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti)) {
|
|
2313
|
-
throw invalidProof2("DPoP verified key/algorithm or signed JTI shape is invalid.");
|
|
2369
|
+
if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
|
|
2370
|
+
throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
|
|
2314
2371
|
}
|
|
2315
2372
|
const binding = Object.freeze({ type, jkt, alg });
|
|
2316
2373
|
let checkedNow;
|
|
@@ -2318,10 +2375,20 @@ var createDpopReplayPolicy = (options) => {
|
|
|
2318
2375
|
checkedNow = now();
|
|
2319
2376
|
if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
|
|
2320
2377
|
} catch (error) {
|
|
2321
|
-
|
|
2378
|
+
handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
|
|
2379
|
+
checkedNow = Date.now();
|
|
2380
|
+
if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
|
|
2381
|
+
throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
|
|
2382
|
+
}
|
|
2383
|
+
let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
|
|
2384
|
+
if (expiresAt === null) {
|
|
2385
|
+
handleDpopCheckFailure(
|
|
2386
|
+
policy.ignoreFreshnessFailure,
|
|
2387
|
+
"freshness",
|
|
2388
|
+
invalidProof2("DPoP signed iat is outside the bounded replay window.")
|
|
2389
|
+
);
|
|
2390
|
+
expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
|
|
2322
2391
|
}
|
|
2323
|
-
const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
|
|
2324
|
-
if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
|
|
2325
2392
|
if (noncePolicy) {
|
|
2326
2393
|
if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
|
|
2327
2394
|
return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
|
|
@@ -2329,18 +2396,19 @@ var createDpopReplayPolicy = (options) => {
|
|
|
2329
2396
|
} else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
|
|
2330
2397
|
throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
|
|
2331
2398
|
}
|
|
2332
|
-
let
|
|
2399
|
+
let reservationError;
|
|
2333
2400
|
try {
|
|
2334
|
-
reserved = await replayStore.reserveDpopProof({
|
|
2401
|
+
const reserved = await replayStore.reserveDpopProof({
|
|
2335
2402
|
replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
|
|
2336
2403
|
expiresAt
|
|
2337
2404
|
});
|
|
2338
2405
|
if (typeof reserved !== "boolean")
|
|
2339
2406
|
throw new TypeError("DPoP replay store returned a non-boolean admission result.");
|
|
2407
|
+
if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
|
|
2340
2408
|
} catch (error) {
|
|
2341
|
-
|
|
2409
|
+
reservationError = replayUnavailable(error);
|
|
2342
2410
|
}
|
|
2343
|
-
if (
|
|
2411
|
+
if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
|
|
2344
2412
|
return Object.freeze({ type: "accepted", binding, expiresAt });
|
|
2345
2413
|
}
|
|
2346
2414
|
});
|
|
@@ -2403,12 +2471,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
|
|
|
2403
2471
|
now: options.now
|
|
2404
2472
|
});
|
|
2405
2473
|
const admit = async (request, proof, expectedJkt) => {
|
|
2406
|
-
const targetUrl = targetFor(request.originalUrl);
|
|
2407
2474
|
const result = await replay.verifyAndReserve(
|
|
2408
2475
|
() => verifyDpopProof({
|
|
2409
2476
|
proof,
|
|
2410
2477
|
method: request.method,
|
|
2411
|
-
targetUrl,
|
|
2478
|
+
targetUrl: () => targetFor(request.originalUrl),
|
|
2412
2479
|
proofOptions: policy,
|
|
2413
2480
|
expectedJkt
|
|
2414
2481
|
})
|
|
@@ -2589,12 +2656,11 @@ function createOidcVaultAccessTokenMiddleware(options) {
|
|
|
2589
2656
|
if (!proof && confirmation)
|
|
2590
2657
|
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
2591
2658
|
if (proof) {
|
|
2592
|
-
const targetUrl = targetFor(originalUrl);
|
|
2593
2659
|
const admission = await replay.verifyAndReserve(
|
|
2594
2660
|
() => verifyDpopProof({
|
|
2595
2661
|
proof,
|
|
2596
2662
|
method,
|
|
2597
|
-
targetUrl,
|
|
2663
|
+
targetUrl: () => targetFor(originalUrl),
|
|
2598
2664
|
proofOptions: policy,
|
|
2599
2665
|
accessToken: token,
|
|
2600
2666
|
expectedJkt: confirmation?.jkt
|
package/index.mjs
CHANGED
|
@@ -242,6 +242,26 @@ var parseSelectedCookieValue = (headerValue, selectedName) => {
|
|
|
242
242
|
|
|
243
243
|
// src/device-binding-policy.ts
|
|
244
244
|
import { isIP } from "net";
|
|
245
|
+
|
|
246
|
+
// src/dpop-failure-policy.ts
|
|
247
|
+
var CHECKS = {
|
|
248
|
+
target: { step: 6, option: "ignoreTargetFailure", env: "OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE" },
|
|
249
|
+
freshness: { step: 7, option: "ignoreFreshnessFailure", env: "OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE" },
|
|
250
|
+
replay: { step: 8, option: "ignoreReplayFailure", env: "OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE" }
|
|
251
|
+
};
|
|
252
|
+
var resolveDpopFailureOption = (value, check) => {
|
|
253
|
+
const { option, env } = CHECKS[check];
|
|
254
|
+
if (value === void 0) return process.env[env] === "true";
|
|
255
|
+
if (typeof value !== "boolean") throw new TypeError(`deviceBinding.${option} must be a boolean.`);
|
|
256
|
+
return value;
|
|
257
|
+
};
|
|
258
|
+
var handleDpopCheckFailure = (ignore, check, error) => {
|
|
259
|
+
if (!ignore) throw error;
|
|
260
|
+
const { step, option, env } = CHECKS[check];
|
|
261
|
+
console.warn(`[express-oidc-vault] DPoP step ${step} (${check}) failed; continuing (${option}=true; ${env}).`, error);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// src/device-binding-policy.ts
|
|
245
265
|
var isPlainRecord = (value) => {
|
|
246
266
|
if (typeof value !== "object" || value === null) return false;
|
|
247
267
|
const prototype = Object.getPrototypeOf(value);
|
|
@@ -258,7 +278,16 @@ var resolveInteger = (value, fallback, min, max, name) => {
|
|
|
258
278
|
var resolveDeviceBindingOptions = (value) => {
|
|
259
279
|
if (value === void 0) return void 0;
|
|
260
280
|
if (!isPlainRecord(value)) throw new Error("deviceBinding must be a plain options object.");
|
|
261
|
-
const {
|
|
281
|
+
const {
|
|
282
|
+
mode,
|
|
283
|
+
algorithms,
|
|
284
|
+
proofMaxAgeSeconds,
|
|
285
|
+
clockSkewSeconds,
|
|
286
|
+
nonce,
|
|
287
|
+
ignoreTargetFailure,
|
|
288
|
+
ignoreFreshnessFailure,
|
|
289
|
+
ignoreReplayFailure
|
|
290
|
+
} = value;
|
|
262
291
|
if (mode !== void 0 && mode !== "optional" && mode !== "required") {
|
|
263
292
|
throw new Error("deviceBinding.mode must be optional or required.");
|
|
264
293
|
}
|
|
@@ -290,7 +319,10 @@ var resolveDeviceBindingOptions = (value) => {
|
|
|
290
319
|
algorithms: Object.freeze([...new Set(allowlist)]),
|
|
291
320
|
proofMaxAgeSeconds: resolveInteger(proofMaxAgeSeconds, 60, 1, 300, "deviceBinding.proofMaxAgeSeconds"),
|
|
292
321
|
clockSkewSeconds: resolveInteger(clockSkewSeconds, 5, 0, 30, "deviceBinding.clockSkewSeconds"),
|
|
293
|
-
nonce: resolvedNonce
|
|
322
|
+
nonce: resolvedNonce,
|
|
323
|
+
ignoreTargetFailure: resolveDpopFailureOption(ignoreTargetFailure, "target"),
|
|
324
|
+
ignoreFreshnessFailure: resolveDpopFailureOption(ignoreFreshnessFailure, "freshness"),
|
|
325
|
+
ignoreReplayFailure: resolveDpopFailureOption(ignoreReplayFailure, "replay")
|
|
294
326
|
});
|
|
295
327
|
};
|
|
296
328
|
var DEVICE_BINDING_STORE_CAPABILITIES = [
|
|
@@ -621,6 +653,7 @@ var publicJwk = (value, alg) => {
|
|
|
621
653
|
var verifyDpopProof = async (input) => {
|
|
622
654
|
const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
|
|
623
655
|
const algorithms = [...proofOptions.algorithms];
|
|
656
|
+
const { ignoreTargetFailure } = proofOptions;
|
|
624
657
|
extractDpopProof({ count: 1, value: proof });
|
|
625
658
|
const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
|
|
626
659
|
const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
|
|
@@ -646,8 +679,13 @@ var verifyDpopProof = async (input) => {
|
|
|
646
679
|
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
|
|
647
680
|
throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
|
|
648
681
|
}
|
|
649
|
-
|
|
650
|
-
|
|
682
|
+
try {
|
|
683
|
+
const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
|
|
684
|
+
if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
|
|
685
|
+
throw invalidProof("DPoP signed method or normalized public target does not match this request.");
|
|
686
|
+
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
handleDpopCheckFailure(ignoreTargetFailure, "target", error);
|
|
651
689
|
}
|
|
652
690
|
if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
|
|
653
691
|
throw invalidProof("DPoP signed nonce exceeds its byte bound.");
|
|
@@ -1747,6 +1785,35 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
|
|
|
1747
1785
|
// src/token-issuance.ts
|
|
1748
1786
|
import { decodeJwt, decodeProtectedHeader } from "jose";
|
|
1749
1787
|
|
|
1788
|
+
// src/access-token-confirmation.ts
|
|
1789
|
+
var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
|
|
1790
|
+
var snapshotJkt = (value, invalid = invalidAccessToken) => {
|
|
1791
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1792
|
+
throw invalid("Access token confirmation must be a plain jkt object.");
|
|
1793
|
+
}
|
|
1794
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1795
|
+
const keys = Reflect.ownKeys(value);
|
|
1796
|
+
if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
|
|
1797
|
+
throw invalid("Access token confirmation contains unsupported fields.");
|
|
1798
|
+
}
|
|
1799
|
+
const { jkt } = value;
|
|
1800
|
+
if (!isCanonicalDpopJkt(jkt)) throw invalid("Access token confirmation is not a canonical SHA-256 thumbprint.");
|
|
1801
|
+
return Object.freeze({ jkt });
|
|
1802
|
+
};
|
|
1803
|
+
var isCompactSignedJwt = (token) => {
|
|
1804
|
+
const parts = token.split(".");
|
|
1805
|
+
return parts.length === 3 && parts.every(
|
|
1806
|
+
(part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
|
|
1807
|
+
);
|
|
1808
|
+
};
|
|
1809
|
+
var readJwtExpirationMs = (exp) => {
|
|
1810
|
+
if (typeof exp !== "number" || !Number.isSafeInteger(exp) || exp < 0) return void 0;
|
|
1811
|
+
const milliseconds = exp * 1e3;
|
|
1812
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : void 0;
|
|
1813
|
+
};
|
|
1814
|
+
var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
|
|
1815
|
+
var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
|
|
1816
|
+
|
|
1750
1817
|
// src/vault-session.ts
|
|
1751
1818
|
var invalidVaultSession = () => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
|
|
1752
1819
|
var invalidExchangeCode = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
|
|
@@ -1891,13 +1958,7 @@ var createExchangeResponse = (options, session, issuedToken) => ({
|
|
|
1891
1958
|
expiresIn: issuedToken.expiresIn,
|
|
1892
1959
|
tokenType: issuedToken.tokenType
|
|
1893
1960
|
});
|
|
1894
|
-
var
|
|
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) => {
|
|
1961
|
+
var assertIssuedBoundJwt = (accessToken, jkt) => {
|
|
1901
1962
|
if (!isCompactSignedJwt(accessToken)) {
|
|
1902
1963
|
throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
|
|
1903
1964
|
}
|
|
@@ -1912,10 +1973,16 @@ var assertIssuedTokenConfirmation = (accessToken, jkt) => {
|
|
|
1912
1973
|
if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false) {
|
|
1913
1974
|
throw new TypeError("tokenIssuer.issue accessToken must use a signed JWT algorithm for bound issuance.");
|
|
1914
1975
|
}
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1976
|
+
const confirmation = snapshotJkt(
|
|
1977
|
+
claims.cnf,
|
|
1978
|
+
(diagnostic) => new TypeError(`tokenIssuer.issue accessToken cnf.jkt is invalid. ${diagnostic}`)
|
|
1979
|
+
);
|
|
1980
|
+
if (confirmation.jkt !== jkt) {
|
|
1917
1981
|
throw new TypeError("tokenIssuer.issue accessToken cnf.jkt must match the verified device binding.");
|
|
1918
1982
|
}
|
|
1983
|
+
if (readJwtExpirationMs(claims.exp) === void 0) {
|
|
1984
|
+
throw new TypeError("tokenIssuer.issue accessToken exp must be a nonnegative safe integer with safe milliseconds.");
|
|
1985
|
+
}
|
|
1919
1986
|
};
|
|
1920
1987
|
var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
1921
1988
|
const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
|
|
@@ -1948,7 +2015,10 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
|
1948
2015
|
}
|
|
1949
2016
|
} else {
|
|
1950
2017
|
if (tokenType !== "DPoP") throw new TypeError("tokenIssuer.issue tokenType must be DPoP for bound issuance.");
|
|
1951
|
-
|
|
2018
|
+
if (!Number.isSafeInteger(expiresIn * 1e3)) {
|
|
2019
|
+
throw new TypeError("tokenIssuer.issue expiresIn must have safe milliseconds for bound issuance.");
|
|
2020
|
+
}
|
|
2021
|
+
assertIssuedBoundJwt(accessToken, binding.jkt);
|
|
1952
2022
|
}
|
|
1953
2023
|
return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
|
|
1954
2024
|
} catch (error) {
|
|
@@ -1960,27 +2030,6 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
|
|
|
1960
2030
|
|
|
1961
2031
|
// src/token-validation.ts
|
|
1962
2032
|
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
|
|
1984
2033
|
var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
|
|
1985
2034
|
var validateBackchannelLogoutTokenTyp = (typ) => {
|
|
1986
2035
|
if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
|
|
@@ -2142,6 +2191,14 @@ function createOidcVaultJwtAccessTokenValidator(options) {
|
|
|
2142
2191
|
const result = await jwtVerify(token, key, { issuer, audience, algorithms });
|
|
2143
2192
|
const claims = result.payload;
|
|
2144
2193
|
const confirmation = snapshotVerifiedJwtConfirmation(claims);
|
|
2194
|
+
if (confirmation !== null) {
|
|
2195
|
+
if (!isCompactSignedJwt(token)) {
|
|
2196
|
+
throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
|
|
2197
|
+
}
|
|
2198
|
+
if (readJwtExpirationMs(claims.exp) === void 0) {
|
|
2199
|
+
throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2145
2202
|
const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
|
|
2146
2203
|
const { subject, sessionId, scope, claims: mappedClaims } = mapped;
|
|
2147
2204
|
return { subject, sessionId, scope, claims: mappedClaims, confirmation };
|
|
@@ -2261,8 +2318,8 @@ var createDpopReplayPolicy = (options) => {
|
|
|
2261
2318
|
throw invalidProof2("DPoP verifier did not return a verified key binding.");
|
|
2262
2319
|
}
|
|
2263
2320
|
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.");
|
|
2321
|
+
if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
|
|
2322
|
+
throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
|
|
2266
2323
|
}
|
|
2267
2324
|
const binding = Object.freeze({ type, jkt, alg });
|
|
2268
2325
|
let checkedNow;
|
|
@@ -2270,10 +2327,20 @@ var createDpopReplayPolicy = (options) => {
|
|
|
2270
2327
|
checkedNow = now();
|
|
2271
2328
|
if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
|
|
2272
2329
|
} catch (error) {
|
|
2273
|
-
|
|
2330
|
+
handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
|
|
2331
|
+
checkedNow = Date.now();
|
|
2332
|
+
if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
|
|
2333
|
+
throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
|
|
2334
|
+
}
|
|
2335
|
+
let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
|
|
2336
|
+
if (expiresAt === null) {
|
|
2337
|
+
handleDpopCheckFailure(
|
|
2338
|
+
policy.ignoreFreshnessFailure,
|
|
2339
|
+
"freshness",
|
|
2340
|
+
invalidProof2("DPoP signed iat is outside the bounded replay window.")
|
|
2341
|
+
);
|
|
2342
|
+
expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
|
|
2274
2343
|
}
|
|
2275
|
-
const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
|
|
2276
|
-
if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
|
|
2277
2344
|
if (noncePolicy) {
|
|
2278
2345
|
if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
|
|
2279
2346
|
return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
|
|
@@ -2281,18 +2348,19 @@ var createDpopReplayPolicy = (options) => {
|
|
|
2281
2348
|
} else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
|
|
2282
2349
|
throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
|
|
2283
2350
|
}
|
|
2284
|
-
let
|
|
2351
|
+
let reservationError;
|
|
2285
2352
|
try {
|
|
2286
|
-
reserved = await replayStore.reserveDpopProof({
|
|
2353
|
+
const reserved = await replayStore.reserveDpopProof({
|
|
2287
2354
|
replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
|
|
2288
2355
|
expiresAt
|
|
2289
2356
|
});
|
|
2290
2357
|
if (typeof reserved !== "boolean")
|
|
2291
2358
|
throw new TypeError("DPoP replay store returned a non-boolean admission result.");
|
|
2359
|
+
if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
|
|
2292
2360
|
} catch (error) {
|
|
2293
|
-
|
|
2361
|
+
reservationError = replayUnavailable(error);
|
|
2294
2362
|
}
|
|
2295
|
-
if (
|
|
2363
|
+
if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
|
|
2296
2364
|
return Object.freeze({ type: "accepted", binding, expiresAt });
|
|
2297
2365
|
}
|
|
2298
2366
|
});
|
|
@@ -2355,12 +2423,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
|
|
|
2355
2423
|
now: options.now
|
|
2356
2424
|
});
|
|
2357
2425
|
const admit = async (request, proof, expectedJkt) => {
|
|
2358
|
-
const targetUrl = targetFor(request.originalUrl);
|
|
2359
2426
|
const result = await replay.verifyAndReserve(
|
|
2360
2427
|
() => verifyDpopProof({
|
|
2361
2428
|
proof,
|
|
2362
2429
|
method: request.method,
|
|
2363
|
-
targetUrl,
|
|
2430
|
+
targetUrl: () => targetFor(request.originalUrl),
|
|
2364
2431
|
proofOptions: policy,
|
|
2365
2432
|
expectedJkt
|
|
2366
2433
|
})
|
|
@@ -2541,12 +2608,11 @@ function createOidcVaultAccessTokenMiddleware(options) {
|
|
|
2541
2608
|
if (!proof && confirmation)
|
|
2542
2609
|
throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
|
|
2543
2610
|
if (proof) {
|
|
2544
|
-
const targetUrl = targetFor(originalUrl);
|
|
2545
2611
|
const admission = await replay.verifyAndReserve(
|
|
2546
2612
|
() => verifyDpopProof({
|
|
2547
2613
|
proof,
|
|
2548
2614
|
method,
|
|
2549
|
-
targetUrl,
|
|
2615
|
+
targetUrl: () => targetFor(originalUrl),
|
|
2550
2616
|
proofOptions: policy,
|
|
2551
2617
|
accessToken: token,
|
|
2552
2618
|
expectedJkt: confirmation?.jkt
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/express-oidc-vault",
|
|
3
3
|
"description": "Express OIDC sessions with body or cookie transport, optional DPoP-bound local JWTs, and pluggable vault stores",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.52.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"express",
|
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
"import": "./index.mjs",
|
|
27
27
|
"require": "./index.js",
|
|
28
28
|
"default": "./index.js"
|
|
29
|
+
},
|
|
30
|
+
"./wire": {
|
|
31
|
+
"types": {
|
|
32
|
+
"import": "./wire.d.mts",
|
|
33
|
+
"require": "./wire.d.ts",
|
|
34
|
+
"default": "./wire.d.ts"
|
|
35
|
+
},
|
|
36
|
+
"import": "./wire.mjs",
|
|
37
|
+
"require": "./wire.js",
|
|
38
|
+
"default": "./wire.js"
|
|
29
39
|
}
|
|
30
40
|
},
|
|
31
41
|
"engines": {
|