@web-ts-toolkit/express-oidc-vault 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -37,9 +37,14 @@ __export(index_exports, {
37
37
  DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT: () => DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
38
38
  OIDC_VAULT_ROUTE_PATHS: () => OIDC_VAULT_ROUTE_PATHS,
39
39
  OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT: () => OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
40
+ OidcVaultAuthDeniedError: () => OidcVaultAuthDeniedError,
40
41
  OidcVaultDpopReplayCapacityError: () => OidcVaultDpopReplayCapacityError,
42
+ OidcVaultInvalidAccessTokenError: () => OidcVaultInvalidAccessTokenError,
43
+ OidcVaultLogoutNavigationCapacityError: () => OidcVaultLogoutNavigationCapacityError,
41
44
  OidcVaultStoreConflictError: () => OidcVaultStoreConflictError,
42
45
  createOidcVaultAccessTokenMiddleware: () => createOidcVaultAccessTokenMiddleware,
46
+ createOidcVaultAuthenticator: () => createOidcVaultAuthenticator,
47
+ createOidcVaultJwtAccessTokenIssuer: () => createOidcVaultJwtAccessTokenIssuer,
43
48
  createOidcVaultJwtAccessTokenValidator: () => createOidcVaultJwtAccessTokenValidator,
44
49
  createOidcVaultMiddleware: () => createOidcVaultMiddleware,
45
50
  normalizeOidcVaultBasePath: () => normalizeOidcVaultBasePath,
@@ -47,7 +52,7 @@ __export(index_exports, {
47
52
  resolveOidcVaultConfigFromEnv: () => resolveOidcVaultConfigFromEnv
48
53
  });
49
54
  module.exports = __toCommonJS(index_exports);
50
- var import_node_crypto6 = require("crypto");
55
+ var import_node_crypto9 = require("crypto");
51
56
  var import_express = __toESM(require("express"));
52
57
 
53
58
  // src/errors.ts
@@ -145,6 +150,7 @@ var OIDC_VAULT_ROUTE_PATHS = {
145
150
  exchange: "/exchange",
146
151
  refresh: "/refresh",
147
152
  logout: "/logout",
153
+ "logout-navigation": "/logout/navigation",
148
154
  "backchannel-logout": "/backchannel-logout"
149
155
  };
150
156
 
@@ -618,10 +624,11 @@ var invalidProof = (diagnostic, cause) => {
618
624
  if (cause !== void 0) Object.defineProperty(error, "cause", { value: cause, configurable: true });
619
625
  return error;
620
626
  };
621
- var extractDpopProof = (header) => {
627
+ var extractDpopProof = (header, diagnostic) => {
622
628
  if (header.count === 0) return void 0;
623
629
  const { value } = header;
624
630
  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)) {
631
+ diagnostic?.emit("presentation", "rejected", "malformed-proof");
625
632
  throw invalidProof("DPoP header must contain one bounded compact signed proof.");
626
633
  }
627
634
  return value;
@@ -699,58 +706,105 @@ var publicJwk = (value, alg) => {
699
706
  return value;
700
707
  };
701
708
  var verifyDpopProof = async (input) => {
702
- const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
709
+ const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken, diagnostic } = input;
703
710
  const algorithms = [...proofOptions.algorithms];
704
711
  const { ignoreTargetFailure } = proofOptions;
705
- extractDpopProof({ count: 1, value: proof });
706
- const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
707
- const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
708
- decodeBase64url2(encodedPayload, DPOP_PROOF_MAX_BYTES);
709
- const signature = decodeBase64url2(encodedSignature, 512);
710
- const { alg } = header;
711
- if (header.typ !== "dpop+jwt" || !isDpopAlgorithm(alg) || !algorithms.includes(alg) || ["crit", "b64", "jku", "x5u", "x5c", "x5t", "x5t#S256"].some((key) => Object.hasOwn(header, key))) {
712
- throw invalidProof("DPoP protected header has an unsupported type, algorithm or key/critical extension.");
713
- }
714
- const jwk = publicJwk(header.jwk, alg);
715
- const signatureBytes = alg === "ES256" ? 64 : decodeBase64url2(jwk.n, 512).length;
716
- if (signature.length !== signatureBytes)
717
- throw invalidProof("DPoP signature length does not match the allowed key profile.");
718
- let payload;
719
- try {
720
- const key = await (0, import_jose.importJWK)(jwk, alg);
721
- ({ payload } = await (0, import_jose.compactVerify)(proof, key, { algorithms }));
722
- } catch (cause) {
723
- throw invalidProof("DPoP public-key import or signature verification failed.", cause);
724
- }
725
- const claims = parseObject(payload);
726
- const { htm, htu, iat, jti, nonce, ath } = claims;
727
- if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
728
- throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
729
- }
712
+ let stage = "header";
713
+ let reason = "malformed-proof";
730
714
  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.");
715
+ extractDpopProof({ count: 1, value: proof }, diagnostic);
716
+ const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
717
+ const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
718
+ decodeBase64url2(encodedPayload, DPOP_PROOF_MAX_BYTES);
719
+ const signature = decodeBase64url2(encodedSignature, 512);
720
+ const { alg } = header;
721
+ reason = "unsupported-header";
722
+ if (header.typ !== "dpop+jwt" || !isDpopAlgorithm(alg) || !algorithms.includes(alg) || ["crit", "b64", "jku", "x5u", "x5c", "x5t", "x5t#S256"].some((key) => Object.hasOwn(header, key))) {
723
+ throw invalidProof("DPoP protected header has an unsupported type, algorithm or key/critical extension.");
724
+ }
725
+ diagnostic?.emit("header", "accepted", "check-passed");
726
+ stage = "key";
727
+ reason = "invalid-key";
728
+ const jwk = publicJwk(header.jwk, alg);
729
+ stage = "signature";
730
+ reason = "invalid-signature";
731
+ const signatureBytes = alg === "ES256" ? 64 : decodeBase64url2(jwk.n, 512).length;
732
+ if (signature.length !== signatureBytes)
733
+ throw invalidProof("DPoP signature length does not match the allowed key profile.");
734
+ let payload;
735
+ try {
736
+ stage = "key";
737
+ reason = "invalid-key";
738
+ const key = await (0, import_jose.importJWK)(jwk, alg);
739
+ diagnostic?.emit("key", "accepted", "check-passed");
740
+ stage = "signature";
741
+ reason = "invalid-signature";
742
+ ({ payload } = await (0, import_jose.compactVerify)(proof, key, { algorithms }));
743
+ } catch (cause) {
744
+ throw invalidProof("DPoP public-key import or signature verification failed.", cause);
745
+ }
746
+ diagnostic?.mark({ signature: "verified" });
747
+ diagnostic?.emit("signature", "accepted", "check-passed");
748
+ stage = "claims";
749
+ reason = "invalid-claims";
750
+ const claims = parseObject(payload);
751
+ const { htm, htu, iat, jti, nonce, ath } = claims;
752
+ if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
753
+ throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
754
+ }
755
+ diagnostic?.signedTime(iat);
756
+ diagnostic?.emit("claims", "accepted", "check-passed");
757
+ stage = "target";
758
+ reason = "target-mismatch";
759
+ try {
760
+ const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
761
+ if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
762
+ throw invalidProof("DPoP signed method or normalized public target does not match this request.");
763
+ }
764
+ diagnostic?.mark({ target: "matched" });
765
+ diagnostic?.emit("target", "accepted", "check-passed");
766
+ } catch (error) {
767
+ if (ignoreTargetFailure) {
768
+ diagnostic?.mark({ target: "ignored" });
769
+ diagnostic?.emit("target", "ignored", reason);
770
+ }
771
+ handleDpopCheckFailure(ignoreTargetFailure, "target", error);
772
+ }
773
+ stage = "nonce";
774
+ reason = "invalid-nonce";
775
+ if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
776
+ throw invalidProof("DPoP signed nonce exceeds its byte bound.");
734
777
  }
778
+ stage = "token-hash";
779
+ reason = "token-hash-mismatch";
780
+ if (accessToken !== void 0 && (typeof accessToken !== "string" || !/^[\x21-\x7e]+$/.test(accessToken) || !isCanonicalDpopJkt(ath) || ath !== (0, import_node_crypto2.createHash)("sha256").update(accessToken, "ascii").digest("base64url"))) {
781
+ throw invalidProof("DPoP signed access-token hash does not match the presented API token.");
782
+ }
783
+ diagnostic?.mark({ tokenHash: accessToken === void 0 ? "not-applicable" : "matched" });
784
+ if (accessToken !== void 0) diagnostic?.emit("token-hash", "accepted", "check-passed");
785
+ stage = "binding";
786
+ reason = "key-mismatch";
787
+ const jkt = await (0, import_jose.calculateJwkThumbprint)(jwk, "sha256");
788
+ if (expectedJkt !== void 0 && (!isCanonicalDpopJkt(expectedJkt) || jkt !== expectedJkt)) {
789
+ throw invalidProof("DPoP verified public key does not match the original credential confirmation.");
790
+ }
791
+ diagnostic?.mark({ credentialBinding: expectedJkt === void 0 ? "not-applicable" : "matched" });
792
+ if (expectedJkt !== void 0) diagnostic?.emit("binding", "accepted", "check-passed");
793
+ return Object.freeze({
794
+ binding: Object.freeze({ type: "dpop", jkt, alg }),
795
+ iat,
796
+ jti,
797
+ ...nonce === void 0 ? {} : { nonce }
798
+ });
735
799
  } catch (error) {
736
- handleDpopCheckFailure(ignoreTargetFailure, "target", error);
737
- }
738
- if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
739
- throw invalidProof("DPoP signed nonce exceeds its byte bound.");
740
- }
741
- if (accessToken !== void 0 && (typeof accessToken !== "string" || !/^[\x21-\x7e]+$/.test(accessToken) || !isCanonicalDpopJkt(ath) || ath !== (0, import_node_crypto2.createHash)("sha256").update(accessToken, "ascii").digest("base64url"))) {
742
- throw invalidProof("DPoP signed access-token hash does not match the presented API token.");
743
- }
744
- const jkt = await (0, import_jose.calculateJwkThumbprint)(jwk, "sha256");
745
- if (expectedJkt !== void 0 && (!isCanonicalDpopJkt(expectedJkt) || jkt !== expectedJkt)) {
746
- throw invalidProof("DPoP verified public key does not match the original credential confirmation.");
800
+ if (stage === "signature") diagnostic?.mark({ signature: "failed" });
801
+ else if (stage === "target") diagnostic?.mark({ target: "failed" });
802
+ else if (stage === "token-hash") diagnostic?.mark({ tokenHash: "failed" });
803
+ else if (stage === "binding") diagnostic?.mark({ credentialBinding: "failed" });
804
+ else if (stage === "nonce") diagnostic?.mark({ nonce: "failed" });
805
+ diagnostic?.emit(stage, "rejected", reason);
806
+ throw error;
747
807
  }
748
- return Object.freeze({
749
- binding: Object.freeze({ type: "dpop", jkt, alg }),
750
- iat,
751
- jti,
752
- ...nonce === void 0 ? {} : { nonce }
753
- });
754
808
  };
755
809
 
756
810
  // src/fingerprint-recognition.ts
@@ -813,11 +867,11 @@ var snapshotFingerprintRecognition = (metadata) => {
813
867
  const value = metadata?.[FINGERPRINT_RECOGNITION_METADATA_KEY];
814
868
  if (value === void 0) return void 0;
815
869
  if (!isPlainRecord2(value)) throw new TypeError("Stored fingerprint recognition metadata is invalid.");
816
- const { version, hash } = value;
817
- if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "version") || !Object.hasOwn(value, "hash") || version !== 1 || !isCanonicalDpopJkt(hash)) {
870
+ const { version, hash: hash2 } = value;
871
+ if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "version") || !Object.hasOwn(value, "hash") || version !== 1 || !isCanonicalDpopJkt(hash2)) {
818
872
  throw new TypeError("Stored fingerprint recognition metadata is invalid.");
819
873
  }
820
- return Object.freeze({ version, hash });
874
+ return Object.freeze({ version, hash: hash2 });
821
875
  };
822
876
  var fingerprintRecognitionMatches = (actual, original) => actual?.hash === original?.hash;
823
877
  var assertFingerprintRecognition = (policy, original, supplied) => {
@@ -1153,6 +1207,249 @@ function resolveOidcVaultConfigFromEnv(env = process.env) {
1153
1207
  });
1154
1208
  }
1155
1209
 
1210
+ // src/diagnostics.ts
1211
+ var import_node_crypto5 = require("crypto");
1212
+ var createDiagnosticDispatcher = (observer) => {
1213
+ let pending = 0;
1214
+ let invoking = false;
1215
+ return (event) => {
1216
+ if (!observer || invoking || pending >= 32) return;
1217
+ invoking = true;
1218
+ try {
1219
+ const result = observer(event);
1220
+ if (result !== void 0) {
1221
+ pending++;
1222
+ const release = () => {
1223
+ pending--;
1224
+ };
1225
+ void Promise.resolve(result).then(release, release);
1226
+ }
1227
+ } catch {
1228
+ } finally {
1229
+ invoking = false;
1230
+ }
1231
+ };
1232
+ };
1233
+ var reference = (purpose, value) => typeof value === "string" && value.length > 0 && value.length <= 1024 ? (0, import_node_crypto5.createHash)("sha256").update(`${purpose}\0${value}`, "utf8").digest("base64url") : void 0;
1234
+ var diagnosticCorrelation = (association, subject, sessionId) => {
1235
+ const subjectRef = reference("subject", subject);
1236
+ const sessionRef = reference("session", sessionId);
1237
+ return Object.freeze({ association, ...subjectRef ? { subjectRef } : {}, ...sessionRef ? { sessionRef } : {} });
1238
+ };
1239
+ var DpopDiagnostic = class {
1240
+ constructor(operation, dispatch) {
1241
+ this.operation = operation;
1242
+ this.dispatch = dispatch;
1243
+ this.verification = {
1244
+ signature: "unchecked",
1245
+ target: "unchecked",
1246
+ tokenHash: "unchecked",
1247
+ credentialBinding: "unchecked",
1248
+ freshness: "unchecked",
1249
+ nonce: "unchecked",
1250
+ replay: "unchecked"
1251
+ };
1252
+ this.terminal = false;
1253
+ }
1254
+ associate(correlation) {
1255
+ this.correlation = correlation;
1256
+ }
1257
+ mark(update) {
1258
+ this.verification = { ...this.verification, ...update };
1259
+ }
1260
+ signedTime(iatSeconds) {
1261
+ this.timing = Object.freeze({ provenance: "signature-verified", iatSeconds });
1262
+ }
1263
+ checkTime(checkedAtMs, clock) {
1264
+ if (!this.timing) return;
1265
+ const issuedAt = this.timing.iatSeconds * 1e3;
1266
+ const age = checkedAtMs - issuedAt;
1267
+ this.timing = Object.freeze({
1268
+ ...this.timing,
1269
+ clock,
1270
+ checkedAtMs,
1271
+ ...Number.isSafeInteger(issuedAt) && Number.isSafeInteger(age) ? { observedAgeMs: age } : {}
1272
+ });
1273
+ }
1274
+ emit(stage, outcome, reason) {
1275
+ if (this.terminal) return;
1276
+ if (outcome === "rejected" || outcome === "challenged" || stage === "proof" || stage === "presentation" && outcome === "ignored")
1277
+ this.terminal = true;
1278
+ this.dispatch(
1279
+ Object.freeze({
1280
+ operation: this.operation,
1281
+ stage,
1282
+ outcome,
1283
+ reason,
1284
+ verification: Object.freeze({ ...this.verification }),
1285
+ ...this.timing ? { timing: this.timing } : {},
1286
+ ...this.correlation ? { correlation: this.correlation } : {}
1287
+ })
1288
+ );
1289
+ }
1290
+ accept() {
1291
+ if (this.correlation && this.hasAuthenticatedProof()) {
1292
+ this.correlation = Object.freeze({ ...this.correlation, association: "authenticated" });
1293
+ }
1294
+ this.emit("proof", "accepted", "proof-accepted");
1295
+ }
1296
+ /** Policy acceptance with an ignored security check is not strict identity evidence. */
1297
+ hasAuthenticatedProof() {
1298
+ const v = this.verification;
1299
+ return v.signature === "verified" && v.target === "matched" && v.credentialBinding === "matched" && (v.tokenHash === "matched" || v.tokenHash === "not-applicable") && v.freshness === "passed" && (v.nonce === "passed" || v.nonce === "disabled") && v.replay === "reserved";
1300
+ }
1301
+ };
1302
+ var diagnosticPreflightReason = (code) => {
1303
+ switch (code) {
1304
+ case "OIDC_VAULT_DPOP_REQUIRED":
1305
+ return "proof-required";
1306
+ case "OIDC_VAULT_DEVICE_BINDING_REQUIRED":
1307
+ return "binding-required";
1308
+ case "OIDC_VAULT_INVALID_SESSION":
1309
+ return "session-mismatch";
1310
+ case "OIDC_VAULT_INVALID_EXCHANGE_CODE":
1311
+ return "session-unavailable";
1312
+ case "OIDC_VAULT_INVALID_ORIGIN":
1313
+ case "OIDC_VAULT_UNTRUSTED_ORIGIN":
1314
+ return "origin-rejected";
1315
+ case "OIDC_VAULT_INVALID_BROWSER_BINDING":
1316
+ return "browser-binding-rejected";
1317
+ case "OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED":
1318
+ return "recognition-rejected";
1319
+ default:
1320
+ return "preflight-failed";
1321
+ }
1322
+ };
1323
+ var createDpopDiagnosticFactory = (observer) => {
1324
+ const dispatch = createDiagnosticDispatcher(observer);
1325
+ return (operation) => new DpopDiagnostic(operation, dispatch);
1326
+ };
1327
+
1328
+ // src/logout-navigation.ts
1329
+ var import_node_crypto6 = require("crypto");
1330
+ var import_node_net2 = require("net");
1331
+
1332
+ // src/types.ts
1333
+ var OidcVaultStoreConflictError = class extends Error {
1334
+ constructor(message = "OIDC vault store operation conflicted with concurrent state changes.") {
1335
+ super(message);
1336
+ this.name = "OidcVaultStoreConflictError";
1337
+ }
1338
+ };
1339
+ var OidcVaultDpopReplayCapacityError = class extends Error {
1340
+ constructor(message = "OIDC vault DPoP replay store capacity is exhausted.") {
1341
+ super(message);
1342
+ this.name = "OidcVaultDpopReplayCapacityError";
1343
+ }
1344
+ };
1345
+ var OidcVaultLogoutNavigationCapacityError = class extends Error {
1346
+ constructor(message = "OIDC vault logout navigation ticket store capacity is exhausted.") {
1347
+ super(message);
1348
+ this.name = "OidcVaultLogoutNavigationCapacityError";
1349
+ }
1350
+ };
1351
+
1352
+ // src/logout-navigation.ts
1353
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
1354
+ var TICKET = /^lnt_[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
1355
+ var hash = (value) => (0, import_node_crypto6.createHash)("sha256").update(value, "utf8").digest("base64url");
1356
+ var plainRecord = (value) => typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
1357
+ var destination = (value, name, maxBytes, secure) => {
1358
+ const invalid = () => new TypeError(`${name} must be a bounded absolute HTTP(S) URL without unsafe characters, userinfo or fragment.`);
1359
+ if (typeof value !== "string" || !/^https?:\/\//i.test(value) || /^https?:\/\/[^/?#]*@/i.test(value) || UNSAFE_URL.test(value) || value.includes("#") || Buffer.byteLength(value, "utf8") > maxBytes)
1360
+ throw invalid();
1361
+ let url;
1362
+ try {
1363
+ url = new URL(value);
1364
+ } catch {
1365
+ throw invalid();
1366
+ }
1367
+ const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || (0, import_node_net2.isIP)(url.hostname) === 4 && url.hostname.startsWith("127.");
1368
+ if (url.protocol !== "https:" && url.protocol !== "http:" || secure && url.protocol === "http:" && !loopback || url.username || url.password || url.hash || Buffer.byteLength(url.href, "utf8") > maxBytes)
1369
+ throw invalid();
1370
+ return url;
1371
+ };
1372
+ var resolveLogoutNavigationOptions = (value, input) => {
1373
+ if (value === void 0) return void 0;
1374
+ if (!plainRecord(value)) throw new TypeError("logoutNavigation must be a plain options object.");
1375
+ if (!input.deviceBindingConfigured) throw new TypeError("logoutNavigation requires deviceBinding.");
1376
+ for (const name of ["createLogoutNavigationTicket", "consumeLogoutNavigationTicket"]) {
1377
+ if (typeof input.store[name] !== "function")
1378
+ throw new TypeError(`logoutNavigation requires storeProvider.${name}.`);
1379
+ }
1380
+ const { fallbackRedirectUri, ticketTtlMs = 6e4, namespace = "default" } = value;
1381
+ if (!Number.isInteger(ticketTtlMs) || ticketTtlMs < 1e3 || ticketTtlMs > 12e4)
1382
+ throw new TypeError("logoutNavigation.ticketTtlMs must be an integer from 1000 through 120000.");
1383
+ computeExpiresAt(input.now, ticketTtlMs, "logoutNavigation.ticketTtlMs");
1384
+ if (typeof namespace !== "string" || !/^[\x21-\x7e]{1,128}$/.test(namespace))
1385
+ throw new TypeError("logoutNavigation.namespace must be a static printable ASCII label of 1 through 128 bytes.");
1386
+ const path = `${input.basePath}${OIDC_VAULT_ROUTE_PATHS["logout-navigation"]}`;
1387
+ const fallback = destination(fallbackRedirectUri, "logoutNavigation.fallbackRedirectUri", 2048, true);
1388
+ if (fallback.origin !== input.backendOrigin && (input.frontendRedirectUri === void 0 || fallback.origin !== new URL(input.frontendRedirectUri).origin) || // Express's default mount/route matching is case-insensitive and accepts
1389
+ // a trailing slash. Those aliases are continuation loops as well.
1390
+ fallback.origin === input.backendOrigin && fallback.pathname.replace(/\/$/, "").toLowerCase() === path.toLowerCase())
1391
+ throw new TypeError(
1392
+ "logoutNavigation.fallbackRedirectUri must stay on the backend/frontend origin and cannot loop to logout navigation."
1393
+ );
1394
+ const postLogout = input.postLogoutRedirectUri === void 0 ? void 0 : destination(input.postLogoutRedirectUri, "logoutNavigation.postLogoutRedirectUri", 2048, true).href;
1395
+ return Object.freeze({
1396
+ fallbackRedirectUri: fallback.href,
1397
+ ticketTtlMs,
1398
+ namespace: "logout-navigation:v1:" + hash(
1399
+ JSON.stringify([
1400
+ "oidc-vault-logout-navigation",
1401
+ 1,
1402
+ input.backendOrigin,
1403
+ input.basePath,
1404
+ input.config.issuer,
1405
+ input.config.clientId,
1406
+ namespace,
1407
+ fallback.href,
1408
+ postLogout ?? null
1409
+ ])
1410
+ ),
1411
+ navigationUrl: `${input.backendOrigin}${path}`,
1412
+ ...postLogout === void 0 ? {} : { postLogoutRedirectUri: postLogout }
1413
+ });
1414
+ };
1415
+ var validateUpstreamLogoutUrl = (value) => destination(value, "Logout navigation upstream destination", 16384, false).href;
1416
+ var persistLogoutNavigation = async (policy, store, upstreamLogoutUrl, now) => {
1417
+ const expiresAt = computeExpiresAt(now, policy.ticketTtlMs, "logoutNavigation.ticketTtlMs");
1418
+ for (let attempt = 0; attempt < 3; attempt++) {
1419
+ const ticket = `lnt_${(0, import_node_crypto6.randomBytes)(32).toString("base64url")}`;
1420
+ try {
1421
+ await store.createLogoutNavigationTicket({
1422
+ namespace: policy.namespace,
1423
+ ticketHash: hash(ticket),
1424
+ upstreamLogoutUrl,
1425
+ createdAt: now,
1426
+ expiresAt
1427
+ });
1428
+ return `${policy.navigationUrl}?ticket=${ticket}`;
1429
+ } catch (error) {
1430
+ const conflict = error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
1431
+ if (!conflict || attempt === 2) throw error;
1432
+ }
1433
+ }
1434
+ throw new Error("Logout navigation ticket attempts exhausted.");
1435
+ };
1436
+ var navigationTicketHash = (originalUrl) => {
1437
+ const index = originalUrl.indexOf("?");
1438
+ if (index < 0) return void 0;
1439
+ const query = originalUrl.slice(index + 1);
1440
+ if (!query.startsWith("ticket=")) return void 0;
1441
+ const ticket = query.slice("ticket=".length);
1442
+ return TICKET.test(ticket) ? hash(ticket) : void 0;
1443
+ };
1444
+ var consumedLogoutDestination = (value, namespace, ticketHash, now) => {
1445
+ if (!plainRecord(value) || Object.keys(value).length !== 5)
1446
+ throw new TypeError("Logout navigation consume returned an invalid record.");
1447
+ const { namespace: returnedNamespace, ticketHash: returnedHash, upstreamLogoutUrl, createdAt, expiresAt } = value;
1448
+ if (returnedNamespace !== namespace || returnedHash !== ticketHash || !isUsableEpochMs(now) || !isUsableEpochMs(createdAt) || !isUsableEpochMs(expiresAt) || expiresAt - createdAt < 1e3 || expiresAt - createdAt > 12e4 || expiresAt <= now || expiresAt - now > 12e4)
1449
+ throw new TypeError("Logout navigation consume returned invalid identity or lifetime.");
1450
+ return validateUpstreamLogoutUrl(upstreamLogoutUrl);
1451
+ };
1452
+
1156
1453
  // src/utils.ts
1157
1454
  var isRecord = (value) => typeof value === "object" && value !== null;
1158
1455
  var isString = (value) => typeof value === "string" && value.length > 0;
@@ -1237,8 +1534,8 @@ var getRequestSourceOrigin = (req) => {
1237
1534
  var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
1238
1535
  captureTrustedOriginGuard(req, options, trustedOrigins, action)();
1239
1536
  };
1240
- var captureTrustedOriginGuard = (req, options, trustedOrigins, action) => {
1241
- const guarded = action === "login" || action === "exchange";
1537
+ var captureTrustedOriginGuard = (req, options, trustedOrigins, action, strict = false) => {
1538
+ const guarded = strict || action === "login" || action === "exchange";
1242
1539
  if (!guarded && !usesCookieTransport(options)) {
1243
1540
  return () => {
1244
1541
  };
@@ -2006,6 +2303,28 @@ var createExchangeResponse = (options, session, issuedToken) => ({
2006
2303
  expiresIn: issuedToken.expiresIn,
2007
2304
  tokenType: issuedToken.tokenType
2008
2305
  });
2306
+ var notifyTokenOutcomes = (operation, session, issuedToken, notify, diagnostic) => {
2307
+ const deviceBound = session.deviceBinding !== void 0;
2308
+ const correlation = diagnosticCorrelation(
2309
+ deviceBound && !diagnostic?.hasAuthenticatedProof() ? "candidate-session" : "authenticated",
2310
+ session.subject,
2311
+ session.logicalSessionId ?? session.sessionId
2312
+ );
2313
+ const tokenIssued = issuedToken.accessToken !== void 0;
2314
+ if (tokenIssued)
2315
+ notify(
2316
+ Object.freeze({
2317
+ kind: "token-issued",
2318
+ operation,
2319
+ correlation,
2320
+ deviceBound,
2321
+ expiresIn: issuedToken.expiresIn,
2322
+ ...issuedToken.tokenType === void 0 ? {} : { tokenType: issuedToken.tokenType }
2323
+ })
2324
+ );
2325
+ if (operation === "exchange")
2326
+ notify(Object.freeze({ kind: "exchange-succeeded", operation, correlation, deviceBound, tokenIssued }));
2327
+ };
2009
2328
  var assertIssuedBoundJwt = (accessToken, jkt) => {
2010
2329
  if (!isCompactSignedJwt(accessToken)) {
2011
2330
  throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
@@ -2032,13 +2351,14 @@ var assertIssuedBoundJwt = (accessToken, jkt) => {
2032
2351
  throw new TypeError("tokenIssuer.issue accessToken exp must be a nonnegative safe integer with safe milliseconds.");
2033
2352
  }
2034
2353
  };
2035
- var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
2354
+ var withIssuedToken = async (req, res, options, session, operation, verifiedBinding) => {
2036
2355
  const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
2037
2356
  if (!options.tokenIssuer) return {};
2038
2357
  const logicalSessionId = session.logicalSessionId ?? session.sessionId;
2039
2358
  try {
2040
2359
  const issuerSession = withSessionDeviceBinding(copyVaultSession(session, true), binding);
2041
2360
  const result = await options.tokenIssuer.issue({
2361
+ operation,
2042
2362
  req,
2043
2363
  res,
2044
2364
  session: issuerSession,
@@ -2078,6 +2398,31 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
2078
2398
 
2079
2399
  // src/token-validation.ts
2080
2400
  var import_jose4 = require("jose");
2401
+
2402
+ // src/access-token-errors.ts
2403
+ var OidcVaultInvalidAccessTokenError = class extends OidcVaultHttpError {
2404
+ constructor(message = "Access token rejected.") {
2405
+ super(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", message, "Access token validation failed.");
2406
+ this.name = "OidcVaultInvalidAccessTokenError";
2407
+ }
2408
+ };
2409
+ var OidcVaultAuthDeniedError = class extends OidcVaultHttpError {
2410
+ constructor(message = "Authentication denied.", status = 403) {
2411
+ if (status !== 401 && status !== 403) throw new TypeError("Auth denial status must be 401 or 403.");
2412
+ super(status, "OIDC_VAULT_AUTH_DENIED", message);
2413
+ this.name = "OidcVaultAuthDeniedError";
2414
+ }
2415
+ };
2416
+ var credentialOutcomes = /* @__PURE__ */ new WeakMap();
2417
+ var markAccessTokenRejected = (error) => {
2418
+ if (typeof error === "object" && error !== null) credentialOutcomes.set(error, true);
2419
+ };
2420
+ var markAccessTokenValidationFailure = (error) => {
2421
+ if (typeof error === "object" && error !== null) credentialOutcomes.set(error, false);
2422
+ };
2423
+ var isAccessTokenRejected = (error) => typeof error === "object" && error !== null && (credentialOutcomes.get(error) ?? error instanceof OidcVaultInvalidAccessTokenError);
2424
+
2425
+ // src/token-validation.ts
2081
2426
  var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
2082
2427
  var validateBackchannelLogoutTokenTyp = (typ) => {
2083
2428
  if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
@@ -2236,20 +2581,36 @@ function createOidcVaultJwtAccessTokenValidator(options) {
2236
2581
  const audience = Array.isArray(configuredAudience) ? [...configuredAudience] : configuredAudience;
2237
2582
  const algorithms = configuredAlgorithms === void 0 ? void 0 : [...configuredAlgorithms];
2238
2583
  const validateJwt = async (token) => {
2239
- const result = await (0, import_jose4.jwtVerify)(token, key, { issuer, audience, algorithms });
2240
- const claims = result.payload;
2241
- const confirmation = snapshotVerifiedJwtConfirmation(claims);
2242
- if (confirmation !== null) {
2243
- if (!isCompactSignedJwt(token)) {
2244
- throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
2584
+ const result = await (0, import_jose4.jwtVerify)(token, key, { issuer, audience, algorithms }).catch((error) => {
2585
+ if (error instanceof import_jose4.errors.JWTExpired || error instanceof import_jose4.errors.JWTClaimValidationFailed || error instanceof import_jose4.errors.JWTInvalid || error instanceof import_jose4.errors.JWSInvalid || error instanceof import_jose4.errors.JWSSignatureVerificationFailed || error instanceof import_jose4.errors.JOSEAlgNotAllowed) {
2586
+ markAccessTokenRejected(error);
2245
2587
  }
2246
- if (readJwtExpirationMs(claims.exp) === void 0) {
2247
- throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
2588
+ throw error;
2589
+ });
2590
+ const claims = result.payload;
2591
+ let confirmation;
2592
+ try {
2593
+ confirmation = snapshotVerifiedJwtConfirmation(claims);
2594
+ if (confirmation !== null) {
2595
+ if (!isCompactSignedJwt(token)) {
2596
+ throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
2597
+ }
2598
+ if (readJwtExpirationMs(claims.exp) === void 0) {
2599
+ throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
2600
+ }
2248
2601
  }
2602
+ if (!mapClaims) return { ...defaultJwtClaimsMapper(claims), confirmation };
2603
+ } catch (error) {
2604
+ markAccessTokenRejected(error);
2605
+ throw error;
2606
+ }
2607
+ try {
2608
+ const { subject, sessionId, scope, claims: mappedClaims } = mapClaims(claims);
2609
+ return { subject, sessionId, scope, claims: mappedClaims, confirmation };
2610
+ } catch (error) {
2611
+ markAccessTokenValidationFailure(error);
2612
+ throw error;
2249
2613
  }
2250
- const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
2251
- const { subject, sessionId, scope, claims: mappedClaims } = mapped;
2252
- return { subject, sessionId, scope, claims: mappedClaims, confirmation };
2253
2614
  };
2254
2615
  return Object.freeze({
2255
2616
  async validate(token) {
@@ -2264,22 +2625,8 @@ function createOidcVaultJwtAccessTokenValidator(options) {
2264
2625
  });
2265
2626
  }
2266
2627
 
2267
- // src/types.ts
2268
- var OidcVaultStoreConflictError = class extends Error {
2269
- constructor(message = "OIDC vault store operation conflicted with concurrent state changes.") {
2270
- super(message);
2271
- this.name = "OidcVaultStoreConflictError";
2272
- }
2273
- };
2274
- var OidcVaultDpopReplayCapacityError = class extends Error {
2275
- constructor(message = "OIDC vault DPoP replay store capacity is exhausted.") {
2276
- super(message);
2277
- this.name = "OidcVaultDpopReplayCapacityError";
2278
- }
2279
- };
2280
-
2281
2628
  // src/dpop-replay.ts
2282
- var import_node_crypto5 = require("crypto");
2629
+ var import_node_crypto7 = require("crypto");
2283
2630
  var isPrintableIdentifier = (value) => typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value);
2284
2631
  var normalizeNamespaceBasePath = (origin, value) => {
2285
2632
  if (value !== void 0 && typeof value !== "string")
@@ -2317,7 +2664,7 @@ var createDpopReplayKey = (effectiveNamespace, jkt, jti) => {
2317
2664
  if (!isCanonicalDpopJkt(jkt) || !isPrintableIdentifier(jti)) {
2318
2665
  throw new TypeError("DPoP replay key requires a canonical thumbprint and a bounded printable JTI.");
2319
2666
  }
2320
- return `dpop:v1:${(0, import_node_crypto5.createHash)("sha256").update(JSON.stringify([effectiveNamespace, jkt, jti]), "utf8").digest("base64url")}`;
2667
+ return `dpop:v1:${(0, import_node_crypto7.createHash)("sha256").update(JSON.stringify([effectiveNamespace, jkt, jti]), "utf8").digest("base64url")}`;
2321
2668
  };
2322
2669
  var getDpopReplayExpiresAt = (input) => {
2323
2670
  const { iat, now, proofMaxAgeSeconds: age, clockSkewSeconds: skew } = input;
@@ -2356,47 +2703,85 @@ var createDpopReplayPolicy = (options) => {
2356
2703
  });
2357
2704
  return Object.freeze({
2358
2705
  effectiveNamespace,
2359
- async verifyAndReserve(verifyBeforeReplay) {
2706
+ async verifyAndReserve(verifyBeforeReplay, diagnostic) {
2360
2707
  const verified = await verifyBeforeReplay();
2361
2708
  if (typeof verified !== "object" || verified === null || Array.isArray(verified)) {
2709
+ diagnostic?.emit("claims", "rejected", "invalid-claims");
2362
2710
  throw invalidProof2("DPoP verifier did not return a proof verification result.");
2363
2711
  }
2364
2712
  const { binding: verifiedBinding, iat, jti, nonce } = verified;
2365
2713
  if (typeof verifiedBinding !== "object" || verifiedBinding === null || Array.isArray(verifiedBinding)) {
2714
+ diagnostic?.emit("binding", "rejected", "invalid-key");
2366
2715
  throw invalidProof2("DPoP verifier did not return a verified key binding.");
2367
2716
  }
2368
2717
  const { type, jkt, alg } = verifiedBinding;
2369
2718
  if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
2719
+ diagnostic?.emit("claims", "rejected", "invalid-claims");
2370
2720
  throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
2371
2721
  }
2372
2722
  const binding = Object.freeze({ type, jkt, alg });
2373
2723
  let checkedNow;
2724
+ let clockSource = configuredNow ? "configured" : "system";
2374
2725
  try {
2375
2726
  checkedNow = now();
2376
2727
  if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2377
2728
  } catch (error) {
2729
+ diagnostic?.mark({ freshness: policy.ignoreFreshnessFailure ? "ignored" : "failed" });
2730
+ diagnostic?.emit("freshness", policy.ignoreFreshnessFailure ? "ignored" : "rejected", "clock-unavailable");
2378
2731
  handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
2379
2732
  checkedNow = Date.now();
2380
- if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
2733
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) {
2734
+ diagnostic?.mark({ freshness: "failed" });
2735
+ diagnostic?.emit("freshness", "rejected", "clock-unavailable");
2381
2736
  throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
2737
+ }
2738
+ clockSource = "fallback";
2382
2739
  }
2740
+ diagnostic?.checkTime(checkedNow, clockSource);
2383
2741
  let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2384
2742
  if (expiresAt === null) {
2743
+ diagnostic?.mark({ freshness: policy.ignoreFreshnessFailure ? "ignored" : "failed" });
2744
+ diagnostic?.emit(
2745
+ "freshness",
2746
+ policy.ignoreFreshnessFailure ? "ignored" : "rejected",
2747
+ !Number.isSafeInteger(iat * 1e3) || !Number.isSafeInteger((iat + policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3) ? "invalid-time" : iat * 1e3 - checkedNow > policy.clockSkewSeconds * 1e3 ? "proof-in-future" : "proof-too-old"
2748
+ );
2385
2749
  handleDpopCheckFailure(
2386
2750
  policy.ignoreFreshnessFailure,
2387
2751
  "freshness",
2388
2752
  invalidProof2("DPoP signed iat is outside the bounded replay window.")
2389
2753
  );
2390
2754
  expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
2755
+ } else {
2756
+ diagnostic?.mark({ freshness: clockSource === "fallback" ? "ignored" : "passed" });
2757
+ diagnostic?.emit("freshness", "accepted", "check-passed");
2391
2758
  }
2392
2759
  if (noncePolicy) {
2393
2760
  if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2394
- return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
2761
+ let challengeNonce;
2762
+ try {
2763
+ challengeNonce = noncePolicy.issue(jkt, checkedNow);
2764
+ } catch (error) {
2765
+ diagnostic?.mark({ nonce: "failed" });
2766
+ diagnostic?.emit("nonce", "rejected", "nonce-unavailable");
2767
+ throw error;
2768
+ }
2769
+ diagnostic?.mark({ nonce: "challenged" });
2770
+ diagnostic?.emit("nonce", "challenged", nonce === void 0 ? "nonce-required" : "nonce-invalid");
2771
+ return Object.freeze({ type: "nonce-challenge", nonce: challengeNonce });
2395
2772
  }
2773
+ diagnostic?.mark({ nonce: "passed" });
2774
+ diagnostic?.emit("nonce", "accepted", "check-passed");
2396
2775
  } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2776
+ diagnostic?.mark({ nonce: "failed" });
2777
+ diagnostic?.emit("nonce", "rejected", "invalid-nonce");
2397
2778
  throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2779
+ } else {
2780
+ diagnostic?.mark({ nonce: "disabled" });
2781
+ diagnostic?.emit("nonce", "ignored", "nonce-disabled");
2398
2782
  }
2399
2783
  let reservationError;
2784
+ let unavailable = false;
2400
2785
  try {
2401
2786
  const reserved = await replayStore.reserveDpopProof({
2402
2787
  replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
@@ -2407,8 +2792,21 @@ var createDpopReplayPolicy = (options) => {
2407
2792
  if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2408
2793
  } catch (error) {
2409
2794
  reservationError = replayUnavailable(error);
2795
+ unavailable = true;
2410
2796
  }
2411
- if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2797
+ if (reservationError) {
2798
+ diagnostic?.mark({ replay: policy.ignoreReplayFailure ? "ignored" : "failed" });
2799
+ diagnostic?.emit(
2800
+ "replay",
2801
+ policy.ignoreReplayFailure ? "ignored" : "rejected",
2802
+ unavailable ? "replay-unavailable" : "replay-detected"
2803
+ );
2804
+ handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2805
+ } else {
2806
+ diagnostic?.mark({ replay: "reserved" });
2807
+ diagnostic?.emit("replay", "accepted", "check-passed");
2808
+ }
2809
+ diagnostic?.accept();
2412
2810
  return Object.freeze({ type: "accepted", binding, expiresAt });
2413
2811
  }
2414
2812
  });
@@ -2447,7 +2845,8 @@ var toDpopNonceChallengeResponse = (result, endpoint, algorithms) => {
2447
2845
  };
2448
2846
 
2449
2847
  // src/vault-route-proof.ts
2450
- var captureVaultRouteProof = (req) => Object.freeze({
2848
+ var captureVaultRouteProof = (req, diagnostic) => Object.freeze({
2849
+ diagnostic,
2451
2850
  header: readDpopRawHeader(req, "dpop"),
2452
2851
  method: req.method,
2453
2852
  originalUrl: req.originalUrl
@@ -2471,14 +2870,17 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2471
2870
  now: options.now
2472
2871
  });
2473
2872
  const admit = async (request, proof, expectedJkt) => {
2873
+ const { diagnostic } = request;
2474
2874
  const result = await replay.verifyAndReserve(
2475
2875
  () => verifyDpopProof({
2476
2876
  proof,
2477
2877
  method: request.method,
2478
2878
  targetUrl: () => targetFor(request.originalUrl),
2479
2879
  proofOptions: policy,
2480
- expectedJkt
2481
- })
2880
+ expectedJkt,
2881
+ diagnostic
2882
+ }),
2883
+ diagnostic
2482
2884
  );
2483
2885
  if (result.type === "nonce-challenge") {
2484
2886
  throw new VaultDpopNonceChallengeError(toDpopNonceChallengeResponse(result, "vault-post", policy.algorithms));
@@ -2487,9 +2889,10 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2487
2889
  };
2488
2890
  return Object.freeze({
2489
2891
  async initiate(request) {
2490
- const proof = extractDpopProof(request.header);
2892
+ const proof = extractDpopProof(request.header, request.diagnostic);
2491
2893
  if (proof === void 0) {
2492
2894
  assertDeviceBindingPolicy(policy, void 0);
2895
+ request.diagnostic?.emit("presentation", "ignored", "proof-absent");
2493
2896
  return void 0;
2494
2897
  }
2495
2898
  return admit(request, proof);
@@ -2497,9 +2900,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2497
2900
  async verify(request, originalBinding) {
2498
2901
  const binding = snapshotDpopBinding(originalBinding);
2499
2902
  assertDeviceBindingPolicy(policy, binding);
2500
- const proof = extractDpopProof(request.header);
2903
+ const proof = extractDpopProof(request.header, request.diagnostic);
2501
2904
  const verified = proof === void 0 ? void 0 : await admit(request, proof, binding?.jkt);
2502
- return resolveVerifiedDeviceBinding(policy, binding, verified);
2905
+ const resolved = resolveVerifiedDeviceBinding(policy, binding, verified);
2906
+ if (proof === void 0) request.diagnostic?.emit("presentation", "ignored", "proof-absent");
2907
+ return resolved;
2503
2908
  }
2504
2909
  });
2505
2910
  };
@@ -2517,7 +2922,7 @@ var extractBearerToken = (authorizationHeader) => {
2517
2922
  throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_BEARER_TOKEN", "Missing bearer token.");
2518
2923
  }
2519
2924
  const [scheme, token, extra] = authorizationHeader.trim().split(/\s+/);
2520
- if (scheme?.toLowerCase() !== "bearer" || !token || extra) {
2925
+ if (scheme?.toLowerCase() !== "bearer" || !token || extra || !/^[\x21-\x7e]+$/.test(token) || token.includes(",")) {
2521
2926
  throw new OidcVaultHttpError(
2522
2927
  401,
2523
2928
  "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
@@ -2544,8 +2949,40 @@ var mappedFields = (result) => {
2544
2949
  throw invalidAccessToken("Access token validator result is missing subject.");
2545
2950
  return { subject, sessionId, scope, claims };
2546
2951
  };
2952
+ var evidence = /* @__PURE__ */ new WeakMap();
2953
+ var authFields = ["subject", "sessionId", "scope", "claims", "token", "confirmation", "deviceBinding"];
2954
+ var credentialRejectionCodes = /* @__PURE__ */ new Set([
2955
+ "OIDC_VAULT_MISSING_BEARER_TOKEN",
2956
+ "OIDC_VAULT_MISSING_ACCESS_TOKEN",
2957
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
2958
+ "OIDC_VAULT_INVALID_ACCESS_TOKEN",
2959
+ "OIDC_VAULT_DPOP_REQUIRED",
2960
+ "OIDC_VAULT_DEVICE_BINDING_REQUIRED",
2961
+ "OIDC_VAULT_INVALID_DPOP_PROOF"
2962
+ ]);
2963
+ var clearChallenge = (res) => {
2964
+ res.removeHeader("WWW-Authenticate");
2965
+ res.removeHeader("DPoP-Nonce");
2966
+ res.setHeader("Cache-Control", "no-store");
2967
+ };
2968
+ var sendError = (res, response) => {
2969
+ clearChallenge(res);
2970
+ for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
2971
+ res.status(response.status).json(response.body);
2972
+ };
2547
2973
  function createOidcVaultAccessTokenMiddleware(options) {
2548
- const { validator, deviceBinding: configuredBinding, onAuthContext, onError } = options;
2974
+ return createOidcVaultAuthenticator(options).authenticate;
2975
+ }
2976
+ function createOidcVaultAuthenticator(options) {
2977
+ const { validator, deviceBinding: configuredBinding, onAuthContext, onError, onDpopDiagnostic } = options;
2978
+ const createDiagnostic = createDpopDiagnosticFactory(onDpopDiagnostic);
2979
+ const { authentication = "required", invalidCredentials = "reject" } = options;
2980
+ if (authentication !== "required" && authentication !== "optional")
2981
+ throw new TypeError("authentication must be required or optional.");
2982
+ if (invalidCredentials !== "reject" && invalidCredentials !== "anonymous")
2983
+ throw new TypeError("invalidCredentials must be reject or anonymous.");
2984
+ if (invalidCredentials === "anonymous" && authentication !== "optional")
2985
+ throw new TypeError("Anonymous invalid credentials require optional authentication.");
2549
2986
  const policy = resolveDeviceBindingOptions(configuredBinding);
2550
2987
  const validate = validator.validate.bind(validator);
2551
2988
  const requestAware = policy ? validator.validateWithRequest : void 0;
@@ -2577,56 +3014,134 @@ function createOidcVaultAccessTokenMiddleware(options) {
2577
3014
  const algs = algorithms.join(" ");
2578
3015
  const dpopChallenge = (error) => `DPoP${error ? ` error="${error}",` : ""} algs="${algs}"`;
2579
3016
  const proofChallenge = dpopChallenge("invalid_dpop_proof");
2580
- return async (req, res, next) => {
3017
+ const owner = Object.freeze({});
3018
+ const prepareError = (error, scheme, nonceResponse, hookFailure = false) => {
3019
+ const response = nonceResponse ?? (error instanceof OidcVaultHttpError ? toDpopReplayErrorResponse(error, algorithms) : {
3020
+ status: hookFailure ? 500 : 401,
3021
+ body: hookFailure ? { code: "OIDC_VAULT_AUTH_CONTEXT_FAILED", message: "Auth context hook failed." } : { code: "OIDC_VAULT_INVALID_ACCESS_TOKEN", message: "Access token validation failed." },
3022
+ headers: { "Cache-Control": "no-store" }
3023
+ });
3024
+ const headers = { ...response.headers };
3025
+ if (response.status === 401 && headers["WWW-Authenticate"] === void 0) {
3026
+ const code = response.body.code;
3027
+ const challenge2 = code === "OIDC_VAULT_DPOP_REQUIRED" || code === "OIDC_VAULT_INVALID_DPOP_PROOF" ? proofChallenge : !policy ? "Bearer" : code === "OIDC_VAULT_MISSING_ACCESS_TOKEN" || code === "OIDC_VAULT_AUTHENTICATION_REQUIRED" ? policy.mode === "required" ? dpopChallenge() : `Bearer, ${dpopChallenge()}` : scheme === "DPoP" || !scheme && policy.mode === "required" ? dpopChallenge("invalid_token") : 'Bearer error="invalid_token"';
3028
+ headers["WWW-Authenticate"] = challenge2;
3029
+ }
3030
+ return Object.freeze({
3031
+ status: response.status,
3032
+ body: Object.freeze({ ...response.body }),
3033
+ headers: Object.freeze(headers)
3034
+ });
3035
+ };
3036
+ const reentryError = () => new OidcVaultHttpError(500, "OIDC_VAULT_AUTHENTICATION_REENTRY", "Authentication is already running or completed.");
3037
+ const rejectGuard = async (req, res, next) => {
3038
+ const state = evidence.get(req);
3039
+ const error = state?.status === "pending" ? reentryError() : new OidcVaultHttpError(401, "OIDC_VAULT_AUTHENTICATION_REQUIRED", "Authentication is required.");
3040
+ if (state) state.status = "failed";
3041
+ else evidence.set(req, { owner, res, status: "failed" });
3042
+ delete req.auth;
3043
+ const response = prepareError(error);
3044
+ await notifyMiddlewareError(onError, { error, req, res });
3045
+ delete req.auth;
3046
+ if (res.headersSent) next(error);
3047
+ else sendError(res, response);
3048
+ };
3049
+ const guard = (bound) => async (req, res, next) => {
3050
+ const state = evidence.get(req);
3051
+ const current = Object.getOwnPropertyDescriptor(req, "auth");
3052
+ const intact = state?.auth && state.snapshot && current && "value" in current && current.value === state.auth && authFields.every((key) => {
3053
+ const field = Object.getOwnPropertyDescriptor(state.auth, key);
3054
+ return Object.hasOwn(state.snapshot, key) ? field && "value" in field && field.value === state.snapshot[key] : field === void 0;
3055
+ });
3056
+ if (state?.status !== "authenticated" || state.owner !== owner || state.res !== res || !intact) {
3057
+ await rejectGuard(req, res, next);
3058
+ return;
3059
+ }
3060
+ if (bound && !state.snapshot.deviceBinding) {
3061
+ const error = new OidcVaultHttpError(
3062
+ 401,
3063
+ "OIDC_VAULT_DEVICE_BINDING_REQUIRED",
3064
+ "A device-bound login is required."
3065
+ );
3066
+ state.status = "failed";
3067
+ delete req.auth;
3068
+ const response = prepareError(error);
3069
+ await notifyMiddlewareError(onError, { error, req, res });
3070
+ delete req.auth;
3071
+ if (res.headersSent) next(error);
3072
+ else sendError(res, response);
3073
+ return;
3074
+ }
3075
+ if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
3076
+ next();
3077
+ };
3078
+ const authenticate = async (req, res, next) => {
3079
+ if (evidence.has(req)) {
3080
+ const state2 = evidence.get(req);
3081
+ state2.status = "failed";
3082
+ delete req.auth;
3083
+ const error = reentryError();
3084
+ const response = prepareError(error);
3085
+ await notifyMiddlewareError(onError, { error, req, res });
3086
+ delete req.auth;
3087
+ if (res.headersSent) next(error);
3088
+ else sendError(res, response);
3089
+ return;
3090
+ }
3091
+ const state = { owner, res, status: "pending" };
3092
+ const diagnostic = createDiagnostic("api");
3093
+ evidence.set(req, state);
2581
3094
  let token;
2582
3095
  let scheme;
2583
3096
  let nonceResponse;
3097
+ let stage = "credential";
2584
3098
  const authenticatedRequest = req;
2585
3099
  delete authenticatedRequest.auth;
2586
3100
  if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
2587
- const prepareError = (error, hookFailure = false) => {
2588
- const response = nonceResponse ?? (error instanceof OidcVaultHttpError ? toDpopReplayErrorResponse(error, algorithms) : {
2589
- status: hookFailure ? 500 : 401,
2590
- body: hookFailure ? { code: "OIDC_VAULT_AUTH_CONTEXT_FAILED", message: "Auth context hook failed." } : { code: "OIDC_VAULT_INVALID_ACCESS_TOKEN", message: "Access token validation failed." },
2591
- headers: { "Cache-Control": "no-store" }
2592
- });
2593
- const headers = { ...response.headers };
2594
- if (response.status === 401 && headers["WWW-Authenticate"] === void 0) {
2595
- const code = response.body.code;
2596
- 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"';
2597
- headers["WWW-Authenticate"] = challenge2;
2598
- }
2599
- return Object.freeze({
2600
- status: response.status,
2601
- body: Object.freeze({ ...response.body }),
2602
- headers: Object.freeze(headers)
2603
- });
2604
- };
2605
- const sendError = (response) => {
2606
- res.removeHeader("WWW-Authenticate");
2607
- res.removeHeader("DPoP-Nonce");
2608
- for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
2609
- res.status(response.status).json(response.body);
3101
+ const stillPending = () => {
3102
+ if (state.status === "pending") return true;
3103
+ delete authenticatedRequest.auth;
3104
+ return false;
2610
3105
  };
2611
3106
  try {
3107
+ const authorization = readDpopRawHeader(req, "authorization");
3108
+ const proofHeader = readDpopRawHeader(req, "dpop");
3109
+ if (authentication === "optional" && authorization.count === 0 && proofHeader.count === 0) {
3110
+ diagnostic.emit("presentation", "ignored", policy ? "proof-absent" : "policy-disabled");
3111
+ state.status = "missing";
3112
+ clearChallenge(res);
3113
+ next();
3114
+ return;
3115
+ }
2612
3116
  let result;
2613
3117
  let confirmation;
2614
3118
  let deviceBinding;
2615
3119
  if (!policy) {
3120
+ if (authorization.count > 1 || authorization.count === 1 && !authorization.value?.trim())
3121
+ throw new OidcVaultHttpError(
3122
+ 401,
3123
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
3124
+ "Authorization header must use the Bearer scheme."
3125
+ );
2616
3126
  token = extractBearerToken(req.get("authorization"));
2617
3127
  scheme = "Bearer";
3128
+ stage = "validator";
2618
3129
  result = await validate(token);
3130
+ if (!stillPending()) return;
2619
3131
  delete authenticatedRequest.auth;
3132
+ stage = "result";
2620
3133
  const reported = result?.confirmation;
2621
3134
  confirmation = reported === void 0 ? void 0 : snapshotAccessTokenConfirmation(reported);
2622
3135
  result = mappedFields(result);
3136
+ diagnostic.associate(diagnosticCorrelation("verified-token", result.subject, result.sessionId));
2623
3137
  delete authenticatedRequest.auth;
3138
+ stage = "proof";
2624
3139
  if (confirmation)
2625
3140
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
3141
+ diagnostic.emit("presentation", "ignored", "policy-disabled");
2626
3142
  } else {
2627
- const authorization = readDpopRawHeader(req, "authorization");
2628
3143
  scheme = schemeOf(authorization.value);
2629
- if (authorization.count === 0 || authorization.count === 1 && !authorization.value?.trim()) {
3144
+ if (authorization.count === 0) {
2630
3145
  throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_ACCESS_TOKEN", "Missing access token.");
2631
3146
  }
2632
3147
  const credential = authorization.value?.trim().split(/\s+/);
@@ -2638,13 +3153,17 @@ function createOidcVaultAccessTokenMiddleware(options) {
2638
3153
  );
2639
3154
  }
2640
3155
  token = credential[1];
2641
- const proofHeader = readDpopRawHeader(req, "dpop");
2642
3156
  const { method, originalUrl } = req;
3157
+ stage = "validator";
2643
3158
  result = await validateWithRequest(Object.freeze({ token, scheme, req }));
3159
+ if (!stillPending()) return;
2644
3160
  delete authenticatedRequest.auth;
3161
+ stage = "result";
2645
3162
  confirmation = snapshotAccessTokenConfirmation(result?.confirmation);
2646
3163
  result = mappedFields(result);
3164
+ diagnostic.associate(diagnosticCorrelation("verified-token", result.subject, result.sessionId));
2647
3165
  delete authenticatedRequest.auth;
3166
+ stage = "proof";
2648
3167
  if (confirmation && scheme !== "DPoP")
2649
3168
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2650
3169
  if (!confirmation && scheme === "DPoP")
@@ -2652,7 +3171,7 @@ function createOidcVaultAccessTokenMiddleware(options) {
2652
3171
  if (!confirmation && policy.mode === "required") {
2653
3172
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
2654
3173
  }
2655
- const proof = extractDpopProof(proofHeader);
3174
+ const proof = extractDpopProof(proofHeader, diagnostic);
2656
3175
  if (!proof && confirmation)
2657
3176
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2658
3177
  if (proof) {
@@ -2663,15 +3182,18 @@ function createOidcVaultAccessTokenMiddleware(options) {
2663
3182
  targetUrl: () => targetFor(originalUrl),
2664
3183
  proofOptions: policy,
2665
3184
  accessToken: token,
2666
- expectedJkt: confirmation?.jkt
2667
- })
3185
+ expectedJkt: confirmation?.jkt,
3186
+ diagnostic
3187
+ }),
3188
+ diagnostic
2668
3189
  );
3190
+ if (!stillPending()) return;
2669
3191
  if (admission.type === "nonce-challenge") {
2670
3192
  nonceResponse = toDpopNonceChallengeResponse(admission, "api", policy.algorithms);
2671
3193
  throw new OidcVaultHttpError(401, "OIDC_VAULT_USE_DPOP_NONCE", "A fresh DPoP nonce is required.");
2672
3194
  }
2673
3195
  if (confirmation) deviceBinding = admission.binding;
2674
- }
3196
+ } else diagnostic.emit("presentation", "ignored", "proof-absent");
2675
3197
  }
2676
3198
  const authenticatedToken = token;
2677
3199
  const auth = {
@@ -2695,9 +3217,12 @@ function createOidcVaultAccessTokenMiddleware(options) {
2695
3217
  authenticatedRequest.auth = auth;
2696
3218
  try {
2697
3219
  await onAuthContext?.({ req: authenticatedRequest, res, auth });
3220
+ if (!stillPending()) return;
2698
3221
  authenticatedRequest.auth = restoreAuth();
2699
3222
  } catch (hookError) {
2700
- const response = prepareError(hookError, true);
3223
+ if (!stillPending()) return;
3224
+ state.status = "failed";
3225
+ const response = prepareError(hookError, scheme, void 0, true);
2701
3226
  delete authenticatedRequest.auth;
2702
3227
  let errorAuth;
2703
3228
  try {
@@ -2716,13 +3241,25 @@ function createOidcVaultAccessTokenMiddleware(options) {
2716
3241
  next(hookError);
2717
3242
  return;
2718
3243
  }
2719
- sendError(response);
3244
+ sendError(res, response);
2720
3245
  return;
2721
3246
  }
3247
+ state.auth = authenticatedRequest.auth;
3248
+ state.snapshot = { ...authenticatedRequest.auth };
3249
+ state.status = "authenticated";
2722
3250
  if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
2723
3251
  next();
2724
3252
  } catch (error) {
2725
- const response = prepareError(error);
3253
+ if (!stillPending()) return;
3254
+ diagnostic.emit(
3255
+ "preflight",
3256
+ "rejected",
3257
+ stage === "proof" ? diagnosticPreflightReason(error instanceof OidcVaultHttpError ? error.code : void 0) : "credential-rejected"
3258
+ );
3259
+ const response = prepareError(error, scheme, nonceResponse);
3260
+ const rejected = stage === "validator" ? isAccessTokenRejected(error) : (stage === "credential" || stage === "proof") && error instanceof OidcVaultHttpError && credentialRejectionCodes.has(error.code);
3261
+ const anonymous = authentication === "optional" && invalidCredentials === "anonymous" && rejected && !nonceResponse;
3262
+ state.status = anonymous ? "invalid" : nonceResponse ? "challenged" : "failed";
2726
3263
  delete authenticatedRequest.auth;
2727
3264
  await notifyMiddlewareError(onError, { error, req, res, ...token ? { token } : {} });
2728
3265
  delete authenticatedRequest.auth;
@@ -2730,9 +3267,139 @@ function createOidcVaultAccessTokenMiddleware(options) {
2730
3267
  next(error);
2731
3268
  return;
2732
3269
  }
2733
- sendError(response);
3270
+ if (anonymous && state.status === "invalid") {
3271
+ clearChallenge(res);
3272
+ res.status(200);
3273
+ next();
3274
+ } else sendError(res, response);
2734
3275
  }
2735
3276
  };
3277
+ return Object.freeze({ authenticate, requireAuth: guard(false), requireDeviceBinding: guard(true) });
3278
+ }
3279
+
3280
+ // src/jwt-token-issuer.ts
3281
+ var import_node_crypto8 = require("crypto");
3282
+ var import_node_util = require("util");
3283
+ var import_jose5 = require("jose");
3284
+ var ALGORITHMS = /* @__PURE__ */ new Set([
3285
+ "HS256",
3286
+ "HS384",
3287
+ "HS512",
3288
+ "RS256",
3289
+ "RS384",
3290
+ "RS512",
3291
+ "PS256",
3292
+ "PS384",
3293
+ "PS512",
3294
+ "ES256",
3295
+ "ES384",
3296
+ "ES512",
3297
+ "EdDSA"
3298
+ ]);
3299
+ var RESERVED = ["iss", "aud", "iat", "exp", "sub", "cnf"];
3300
+ var CURVES = { ES256: "prime256v1", ES384: "secp384r1", ES512: "secp521r1" };
3301
+ var requireString = (value, name) => {
3302
+ if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`${name} must be a nonblank string.`);
3303
+ return value;
3304
+ };
3305
+ var signingKey = (configured, algorithm) => {
3306
+ const symmetric = algorithm.startsWith("HS");
3307
+ let key;
3308
+ let cryptoKey;
3309
+ if (configured instanceof Uint8Array) {
3310
+ key = (0, import_node_crypto8.createSecretKey)(Uint8Array.from(configured));
3311
+ } else if (configured instanceof import_node_crypto8.KeyObject) {
3312
+ key = configured;
3313
+ } else if (import_node_util.types.isCryptoKey(configured)) {
3314
+ cryptoKey = configured;
3315
+ const expectedName = symmetric ? "HMAC" : algorithm.startsWith("RS") ? "RSASSA-PKCS1-v1_5" : algorithm.startsWith("PS") ? "RSA-PSS" : algorithm.startsWith("ES") ? "ECDSA" : "Ed25519";
3316
+ const parameters = configured.algorithm;
3317
+ if (!configured.usages.includes("sign") || parameters.name !== expectedName || (symmetric || algorithm.startsWith("RS") || algorithm.startsWith("PS")) && parameters.hash?.name !== `SHA-${algorithm.slice(2)}`)
3318
+ throw new TypeError("JWT signing CryptoKey algorithm/hash/usages must match the signing algorithm.");
3319
+ key = import_node_crypto8.KeyObject.from(configured);
3320
+ } else {
3321
+ if (!isRecord(configured)) throw new TypeError("JWT signing key must be a JOSE key.");
3322
+ const jwk = { ...configured, ...configured.key_ops ? { key_ops: [...configured.key_ops] } : {} };
3323
+ if (jwk.alg !== void 0 && jwk.alg !== algorithm || jwk.use !== void 0 && jwk.use !== "sig" || jwk.key_ops !== void 0 && (!Array.isArray(jwk.key_ops) || !jwk.key_ops.includes("sign")))
3324
+ throw new TypeError("JWT signing JWK alg/use/key_ops must permit the signing algorithm.");
3325
+ if (jwk.kty === "oct") {
3326
+ if (typeof jwk.k !== "string" || !/^[A-Za-z0-9_-]+$/.test(jwk.k) || Buffer.from(jwk.k, "base64url").toString("base64url") !== jwk.k)
3327
+ throw new TypeError("JWT signing JWK k must be canonical base64url secret bytes.");
3328
+ key = (0, import_node_crypto8.createSecretKey)(Buffer.from(jwk.k, "base64url"));
3329
+ } else {
3330
+ key = (0, import_node_crypto8.createPrivateKey)({ key: jwk, format: "jwk" });
3331
+ }
3332
+ }
3333
+ const bits = Number(algorithm.slice(2));
3334
+ const curve = CURVES[algorithm];
3335
+ const compatible = symmetric ? key.type === "secret" && key.symmetricKeySize * 8 >= bits : key.type === "private" && (curve ? key.asymmetricKeyType === "ec" && key.asymmetricKeyDetails?.namedCurve === curve : algorithm === "EdDSA" ? key.asymmetricKeyType === "ed25519" : key.asymmetricKeyType === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) >= 2048);
3336
+ if (!compatible) throw new TypeError("JWT signing key type/size/curve must match the signing algorithm.");
3337
+ return cryptoKey ?? key;
3338
+ };
3339
+ var tokenTimes = (now, lifetimeSeconds) => {
3340
+ const milliseconds = now();
3341
+ const lifetimeMs = lifetimeSeconds * 1e3;
3342
+ const iat = Math.floor(milliseconds / 1e3);
3343
+ const exp = iat + lifetimeSeconds;
3344
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || !Number.isSafeInteger(lifetimeMs) || !Number.isSafeInteger(milliseconds + lifetimeMs) || !Number.isSafeInteger(exp) || !Number.isSafeInteger(exp * 1e3))
3345
+ throw new TypeError("JWT clock/lifetime must produce safe nonnegative integer milliseconds and expiry.");
3346
+ return { iat, exp };
3347
+ };
3348
+ function assertApplicationClaims(claims) {
3349
+ if (!isRecord(claims) || Array.isArray(claims)) throw new TypeError("JWT mapClaims must return a claims object.");
3350
+ for (const field of RESERVED) {
3351
+ if (field in claims) throw new TypeError(`JWT mapClaims must not supply reserved claim ${field}.`);
3352
+ }
3353
+ }
3354
+ function createOidcVaultJwtAccessTokenIssuer(options) {
3355
+ const {
3356
+ algorithm,
3357
+ issuer: configuredIssuer,
3358
+ audience: configuredAudience,
3359
+ lifetimeSeconds = 300,
3360
+ now = Date.now,
3361
+ mapSubject,
3362
+ mapClaims
3363
+ } = options;
3364
+ if (!ALGORITHMS.has(algorithm)) throw new TypeError("JWT signing algorithm is unsupported.");
3365
+ const issuer = requireString(configuredIssuer, "JWT issuer");
3366
+ const audience = Array.isArray(configuredAudience) ? [...configuredAudience] : configuredAudience;
3367
+ if (Array.isArray(audience)) {
3368
+ if (audience.length === 0) throw new TypeError("JWT audience must not be empty.");
3369
+ for (const value of audience) requireString(value, "JWT audience");
3370
+ } else requireString(audience, "JWT audience");
3371
+ if (!Number.isSafeInteger(lifetimeSeconds) || lifetimeSeconds < 0)
3372
+ throw new TypeError("JWT lifetimeSeconds must be a nonnegative safe integer.");
3373
+ if (typeof now !== "function" || mapSubject !== void 0 && typeof mapSubject !== "function" || mapClaims !== void 0 && typeof mapClaims !== "function")
3374
+ throw new TypeError("JWT clock and mappers must be functions.");
3375
+ const key = signingKey(options.key, algorithm);
3376
+ tokenTimes(now, lifetimeSeconds);
3377
+ return Object.freeze({
3378
+ async issue(input) {
3379
+ const { deviceBinding, session } = input;
3380
+ const { subject: defaultSubject, scope } = session;
3381
+ if (scope !== void 0 && typeof scope !== "string") throw new TypeError("JWT session scope must be a string.");
3382
+ if (deviceBinding !== void 0 && deviceBinding.type !== "dpop")
3383
+ throw new TypeError("JWT deviceBinding must be verified DPoP context.");
3384
+ const cnf = deviceBinding === void 0 ? void 0 : snapshotJkt({ jkt: deviceBinding.jkt }, (message) => new TypeError(message));
3385
+ const subject = requireString(mapSubject ? await mapSubject(input) : defaultSubject, "JWT subject");
3386
+ const mapped = mapClaims ? await mapClaims(input) : {};
3387
+ assertApplicationClaims(mapped);
3388
+ const claims = JSON.parse(JSON.stringify({ ...scope === void 0 ? {} : { scope }, ...mapped }));
3389
+ assertApplicationClaims(claims);
3390
+ const { iat, exp } = tokenTimes(now, lifetimeSeconds);
3391
+ const accessToken = await new import_jose5.SignJWT({
3392
+ ...claims,
3393
+ iss: issuer,
3394
+ aud: audience,
3395
+ sub: subject,
3396
+ iat,
3397
+ exp,
3398
+ ...cnf === void 0 ? {} : { cnf }
3399
+ }).setProtectedHeader({ alg: algorithm, typ: "JWT" }).sign(key);
3400
+ return { accessToken, expiresIn: lifetimeSeconds, tokenType: cnf === void 0 ? "Bearer" : "DPoP" };
3401
+ }
3402
+ });
2736
3403
  }
2737
3404
 
2738
3405
  // src/index.ts
@@ -2747,24 +3414,30 @@ var CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store";
2747
3414
  var applyCredentialResponseCachePolicy = (res) => {
2748
3415
  res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL);
2749
3416
  };
2750
- var createOpaqueId = (prefix) => `${prefix}_${(0, import_node_crypto6.randomBytes)(16).toString("base64url")}`;
2751
- var createPkceVerifier = () => (0, import_node_crypto6.randomBytes)(32).toString("base64url");
2752
- var createPkceChallenge = (verifier) => (0, import_node_crypto6.createHash)("sha256").update(verifier).digest("base64url");
3417
+ var createOpaqueId = (prefix) => `${prefix}_${(0, import_node_crypto9.randomBytes)(16).toString("base64url")}`;
3418
+ var createPkceVerifier = () => (0, import_node_crypto9.randomBytes)(32).toString("base64url");
3419
+ var createPkceChallenge = (verifier) => (0, import_node_crypto9.createHash)("sha256").update(verifier).digest("base64url");
2753
3420
  var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
2754
3421
  var validateOidcVaultOptions = (options) => {
2755
3422
  const {
2756
3423
  deviceBinding: configuredDeviceBinding,
2757
3424
  fingerprintRecognition: configuredFingerprintRecognition,
2758
3425
  transactionCookie: configuredTransactionCookie,
3426
+ logoutNavigation: configuredLogoutNavigation,
2759
3427
  ...otherOptions
2760
3428
  } = options;
2761
3429
  const cookie = otherOptions.cookie ? { ...otherOptions.cookie } : void 0;
2762
3430
  const cookieOptions = { ...otherOptions, cookie };
2763
- validateLifetimeOptions(options, getNow(options));
3431
+ const now = getNow(options);
3432
+ validateLifetimeOptions(options, now);
2764
3433
  const deviceBinding = resolveDeviceBindingOptions(configuredDeviceBinding);
2765
3434
  const fingerprintRecognition = resolveFingerprintRecognitionOptions(configuredFingerprintRecognition);
2766
3435
  const backendOrigin = deviceBinding === void 0 ? resolveBackendOrigin(options) : resolveDeviceBindingOrigin(options.backendOrigin);
2767
- if (deviceBinding !== void 0) assertDeviceBindingStoreCapabilities(options.storeProvider);
3436
+ if (deviceBinding !== void 0)
3437
+ assertDeviceBindingStoreCapabilities(
3438
+ options.storeProvider,
3439
+ configuredLogoutNavigation === void 0 ? "deviceBinding" : "logoutNavigation"
3440
+ );
2768
3441
  if (fingerprintRecognition !== void 0)
2769
3442
  assertDeviceBindingStoreCapabilities(options.storeProvider, "fingerprintRecognition");
2770
3443
  const frontendRedirectUri = resolveFrontendRedirectUri(options);
@@ -2780,6 +3453,16 @@ var validateOidcVaultOptions = (options) => {
2780
3453
  backendOrigin
2781
3454
  );
2782
3455
  const config = resolveOidcVaultConfig(options.config ? { ...options.config } : void 0);
3456
+ const logoutNavigation = resolveLogoutNavigationOptions(configuredLogoutNavigation, {
3457
+ backendOrigin,
3458
+ basePath: normalizeOidcVaultBasePath(otherOptions.basePath),
3459
+ config,
3460
+ frontendRedirectUri,
3461
+ postLogoutRedirectUri: otherOptions.postLogoutRedirectUri,
3462
+ deviceBindingConfigured: deviceBinding !== void 0,
3463
+ store: otherOptions.storeProvider,
3464
+ now
3465
+ });
2783
3466
  const configuredTrustedOrigins = resolveTrustedOrigins(options);
2784
3467
  if (usesCrossSiteCookieTransport(cookieOptions) && configuredTrustedOrigins.size === 0) {
2785
3468
  throw new Error("trustedOrigins is required when using cross-site cookie transport.");
@@ -2791,6 +3474,8 @@ var validateOidcVaultOptions = (options) => {
2791
3474
  deviceBinding,
2792
3475
  fingerprintRecognition,
2793
3476
  transactionCookie,
3477
+ logoutNavigation,
3478
+ ...logoutNavigation === void 0 ? {} : { postLogoutRedirectUri: logoutNavigation.postLogoutRedirectUri },
2794
3479
  ...frontendRedirectUri !== void 0 ? { frontendRedirectUri } : {},
2795
3480
  ...options.trustedOrigins ? { trustedOrigins: [...options.trustedOrigins] } : {},
2796
3481
  ...cookie === void 0 ? {} : { cookie },
@@ -2938,10 +3623,18 @@ async function handleRouteError(route, req, res, next, options, error) {
2938
3623
  sendVaultRoutePolicyResponse(res, response);
2939
3624
  }
2940
3625
  function createAsyncHandler(route, options, handler) {
3626
+ const createDiagnostic = createDpopDiagnosticFactory(options.onDpopDiagnostic);
3627
+ const notify = createDiagnosticDispatcher(options.onTokenOutcome);
2941
3628
  return async (req, res, next) => {
3629
+ const diagnostic = req.method === "POST" && route !== "callback" && route !== "backchannel-logout" ? createDiagnostic(route) : void 0;
2942
3630
  try {
2943
- await handler(req, res, next);
3631
+ await handler(req, res, next, diagnostic, notify);
2944
3632
  } catch (error) {
3633
+ diagnostic?.emit(
3634
+ "preflight",
3635
+ "rejected",
3636
+ diagnosticPreflightReason(error instanceof OidcVaultHttpError ? error.code : void 0)
3637
+ );
2945
3638
  await handleRouteError(route, req, res, next, options, error);
2946
3639
  }
2947
3640
  };
@@ -2987,8 +3680,8 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
2987
3680
  });
2988
3681
  res.redirect(302, authorizationUrl);
2989
3682
  });
2990
- var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrigin, basePath, proofVerifier) => createAsyncHandler("login", options, async (req, res) => {
2991
- const capturedProof = captureVaultRouteProof(req);
3683
+ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrigin, basePath, proofVerifier) => createAsyncHandler("login", options, async (req, res, _next, diagnostic) => {
3684
+ const capturedProof = captureVaultRouteProof(req, diagnostic);
2992
3685
  const fingerprintRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
2993
3686
  assertLoginInitiationRequest(req, options, trustedOrigins);
2994
3687
  const body = getBody(req);
@@ -2997,6 +3690,7 @@ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrig
2997
3690
  }
2998
3691
  const returnTo = resolveLoginReturnTo(body.returnTo, options);
2999
3692
  if (proofVerifier === void 0 && capturedProof.header.count !== 0) {
3693
+ diagnostic?.emit("presentation", "rejected", "policy-disabled");
3000
3694
  throw new OidcVaultHttpError(
3001
3695
  401,
3002
3696
  "OIDC_VAULT_INVALID_DPOP_PROOF",
@@ -3005,6 +3699,7 @@ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrig
3005
3699
  );
3006
3700
  }
3007
3701
  const verifiedBinding = await proofVerifier?.initiate(capturedProof);
3702
+ if (proofVerifier === void 0) diagnostic?.emit("presentation", "ignored", "policy-disabled");
3008
3703
  const binding = snapshotDpopBinding(verifiedBinding);
3009
3704
  const metadata = await resolveProviderMetadata(config, options);
3010
3705
  await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: { ...metadata } });
@@ -3222,8 +3917,8 @@ var copySessionApplicationFields = (session) => {
3222
3917
  }
3223
3918
  return result;
3224
3919
  };
3225
- var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("exchange", options, async (req, res) => {
3226
- const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3920
+ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("exchange", options, async (req, res, _next, diagnostic, notify) => {
3921
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req, diagnostic);
3227
3922
  const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
3228
3923
  const assertExchangeOrigin = captureTrustedOriginGuard(req, options, trustedOrigins, "exchange");
3229
3924
  const cookieHeader = req.headers.cookie;
@@ -3235,14 +3930,24 @@ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) =>
3235
3930
  throw new TypeError("Device-bound exchange requires guarded store reads and atomic consumption.");
3236
3931
  }
3237
3932
  const rawCode = canPreflight ? await store.getExchangeCode(code) : await store.consumeExchangeCode(code);
3238
- if (!rawCode) throw invalidExchangeCode();
3933
+ if (!rawCode) {
3934
+ diagnostic?.emit("preflight", "rejected", "session-unavailable");
3935
+ throw invalidExchangeCode();
3936
+ }
3239
3937
  const record = snapshotVaultExchangeCode(rawCode, code, getNow(options));
3938
+ diagnostic?.associate(diagnosticCorrelation("candidate-session", void 0, record.sessionId));
3240
3939
  const guarded = record.browserBindingHash !== void 0;
3241
3940
  if (guarded) assertExchangeOrigin();
3242
3941
  assertDeviceBindingPolicy(options.deviceBinding, record.deviceBinding);
3243
3942
  const rawSession = await store.getSession(record.sessionId);
3244
- if (!rawSession) throw invalidVaultSession();
3943
+ if (!rawSession) {
3944
+ diagnostic?.emit("preflight", "rejected", "session-unavailable");
3945
+ throw invalidVaultSession();
3946
+ }
3245
3947
  const session = snapshotVaultSession(rawSession, record.sessionId, getNow(options));
3948
+ diagnostic?.associate(
3949
+ diagnosticCorrelation("candidate-session", session.subject, session.logicalSessionId ?? session.sessionId)
3950
+ );
3246
3951
  assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
3247
3952
  assertVaultExchangeSessionBinding(record, session);
3248
3953
  const protectedExchange = proofVerifier !== void 0 || options.fingerprintRecognition !== void 0 || guarded;
@@ -3254,6 +3959,7 @@ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) =>
3254
3959
  const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(session.metadata);
3255
3960
  assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
3256
3961
  const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, session.deviceBinding);
3962
+ if (proofVerifier === void 0) diagnostic?.emit("presentation", "ignored", "policy-disabled");
3257
3963
  if (canPreflight) {
3258
3964
  const consumed = protectedExchange ? await store.consumeExchangeCodeIfMatches({
3259
3965
  code,
@@ -3274,34 +3980,45 @@ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) =>
3274
3980
  }
3275
3981
  assertVaultSessionIdentity(session, config);
3276
3982
  try {
3277
- const issuedToken = await withIssuedToken(req, res, options, session, verifiedBinding);
3983
+ const issuedToken = await withIssuedToken(req, res, options, session, "exchange", verifiedBinding);
3278
3984
  if (usesCookieTransport(options)) setSessionCookie(res, options, session.sessionId);
3279
3985
  if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
3280
3986
  applyCredentialResponseCachePolicy(res);
3281
- res.status(200).json(createExchangeResponse(options, session, issuedToken));
3987
+ const response = createExchangeResponse(options, session, issuedToken);
3988
+ notifyTokenOutcomes("exchange", session, issuedToken, notify, diagnostic);
3989
+ res.status(200).json(response);
3282
3990
  } catch (error) {
3283
3991
  if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
3284
3992
  throw error;
3285
3993
  }
3286
3994
  });
3287
- var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("refresh", options, async (req, res) => {
3995
+ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("refresh", options, async (req, res, _next, diagnostic, notify) => {
3288
3996
  const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
3289
3997
  assertTrustedOrigin(req, options, trustedOrigins, "refresh");
3290
- const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3998
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req, diagnostic);
3291
3999
  const sessionId = getSessionIdFromRequest(req, options, "refresh");
3292
4000
  const currentRecord = await options.storeProvider.getSession(sessionId);
3293
4001
  if (!currentRecord) {
4002
+ diagnostic?.emit("preflight", "rejected", "session-unavailable");
3294
4003
  if (usesCookieTransport(options)) {
3295
4004
  clearSessionCookie(res, options);
3296
4005
  }
3297
4006
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
3298
4007
  }
3299
4008
  const currentSession = snapshotVaultSession(currentRecord, sessionId, getNow(options));
4009
+ diagnostic?.associate(
4010
+ diagnosticCorrelation(
4011
+ "candidate-session",
4012
+ currentSession.subject,
4013
+ currentSession.logicalSessionId ?? currentSession.sessionId
4014
+ )
4015
+ );
3300
4016
  assertVaultSessionIdentity(currentSession, config);
3301
4017
  assertDeviceBindingPolicy(options.deviceBinding, currentSession.deviceBinding);
3302
4018
  const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(currentSession.metadata);
3303
4019
  assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
3304
4020
  const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, currentSession.deviceBinding);
4021
+ if (proofVerifier === void 0) diagnostic?.emit("presentation", "ignored", "policy-disabled");
3305
4022
  const metadata = await resolveProviderMetadata(config, options);
3306
4023
  if (proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) {
3307
4024
  const latest = await options.storeProvider.getSession(sessionId);
@@ -3375,7 +4092,7 @@ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => c
3375
4092
  throw error;
3376
4093
  }
3377
4094
  const rotatedSession = expectedSession;
3378
- const issuedToken = await withIssuedToken(req, res, options, rotatedSession, verifiedBinding);
4095
+ const issuedToken = await withIssuedToken(req, res, options, rotatedSession, "refresh", verifiedBinding);
3379
4096
  await callPostCommitHook(
3380
4097
  "refresh",
3381
4098
  options,
@@ -3392,14 +4109,21 @@ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => c
3392
4109
  }
3393
4110
  const response = createExchangeResponse(options, rotatedSession, issuedToken);
3394
4111
  applyCredentialResponseCachePolicy(res);
4112
+ notifyTokenOutcomes("refresh", rotatedSession, issuedToken, notify, diagnostic);
3395
4113
  res.status(200).json(response);
3396
4114
  });
3397
- var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("logout", options, async (req, res) => {
3398
- assertTrustedOrigin(req, options, trustedOrigins, "logout");
3399
- const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3400
- const body = getBody(req);
4115
+ var notifyLogoutError = async (options, req, res, error, session) => {
4116
+ try {
4117
+ await options.hooks?.onError?.({
4118
+ ...createHookContext("logout", req, res, session === void 0 ? void 0 : copyVaultSession(session)),
4119
+ error
4120
+ });
4121
+ } catch {
4122
+ }
4123
+ };
4124
+ var performLogout = async (options, config, proofVerifier, req, res, diagnostic, redirect, navigation) => {
4125
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req, diagnostic);
3401
4126
  const sessionId = getSessionIdFromRequest(req, options, "logout");
3402
- const redirect = body.redirect === true;
3403
4127
  const store = options.storeProvider;
3404
4128
  const hasContext = typeof store.getSessionRevocationContext === "function";
3405
4129
  if (proofVerifier !== void 0 && !hasContext) {
@@ -3409,18 +4133,29 @@ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => cr
3409
4133
  const context = rawContext ? snapshotVaultRevocationContext(rawContext) : void 0;
3410
4134
  const rawSession = await store.getSession(sessionId);
3411
4135
  const session = rawSession ? snapshotVaultSession(rawSession, sessionId, getNow(options)) : void 0;
4136
+ if (session || context)
4137
+ diagnostic?.associate(
4138
+ diagnosticCorrelation(
4139
+ "candidate-session",
4140
+ session?.subject,
4141
+ context?.logicalSessionId ?? session?.logicalSessionId ?? session?.sessionId
4142
+ )
4143
+ );
3412
4144
  const finishLocalLogout = () => {
4145
+ diagnostic?.emit(
4146
+ "presentation",
4147
+ "ignored",
4148
+ options.deviceBinding === void 0 ? "policy-disabled" : "target-absent"
4149
+ );
3413
4150
  if (usesCookieTransport(options)) clearSessionCookie(res, options);
3414
- applyCredentialResponseCachePolicy(res);
3415
- res.status(200).json({ loggedOut: true });
4151
+ return {};
3416
4152
  };
3417
4153
  if (hasContext && context === void 0) {
3418
4154
  if (session !== void 0) {
3419
4155
  assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
3420
4156
  throw invalidVaultSession();
3421
4157
  }
3422
- finishLocalLogout();
3423
- return;
4158
+ return finishLocalLogout();
3424
4159
  }
3425
4160
  const authority = context ?? (session === void 0 ? void 0 : snapshotVaultRevocationContext({
3426
4161
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
@@ -3429,11 +4164,12 @@ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => cr
3429
4164
  }));
3430
4165
  if (authority === void 0) {
3431
4166
  await store.deleteSession(sessionId);
3432
- finishLocalLogout();
3433
- return;
4167
+ return finishLocalLogout();
3434
4168
  }
3435
4169
  assertVaultSessionIdentity(authority, config);
3436
4170
  assertDeviceBindingPolicy(options.deviceBinding, authority.deviceBinding);
4171
+ if (navigation !== void 0 && authority.deviceBinding === void 0)
4172
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
3437
4173
  if (session !== void 0)
3438
4174
  assertVaultRevocationContextMatches(
3439
4175
  snapshotVaultRevocationContext({
@@ -3444,54 +4180,95 @@ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => cr
3444
4180
  authority
3445
4181
  );
3446
4182
  if (proofVerifier !== void 0) await proofVerifier.verify(capturedProof, authority.deviceBinding);
4183
+ else diagnostic?.emit("presentation", "ignored", "policy-disabled");
3447
4184
  if (hasContext) {
3448
4185
  const latest = await store.getSessionRevocationContext(sessionId);
3449
4186
  if (!latest) {
3450
- finishLocalLogout();
3451
- return;
4187
+ return finishLocalLogout();
3452
4188
  }
3453
4189
  assertVaultRevocationContextMatches(snapshotVaultRevocationContext(latest), authority);
3454
4190
  }
3455
4191
  if (session !== void 0)
3456
4192
  await callHook("logout", options.hooks?.onBeforeLogout, req, res, copyVaultSession(session));
3457
4193
  await store.deleteSessionsByLogicalSessionId({ logicalSessionId: authority.logicalSessionId });
3458
- if (usesCookieTransport(options)) {
3459
- clearSessionCookie(res, options);
4194
+ try {
4195
+ if (usesCookieTransport(options)) clearSessionCookie(res, options);
4196
+ if (session === void 0 || !redirect && navigation === void 0) return {};
4197
+ if (navigation !== void 0 && !isString(session.idToken)) return {};
4198
+ try {
4199
+ const metadata = await resolveProviderMetadata(config, options);
4200
+ if (!metadata.endSessionEndpoint) return {};
4201
+ const endpoint = navigation === void 0 ? metadata.endSessionEndpoint : validateUpstreamLogoutUrl(metadata.endSessionEndpoint);
4202
+ const builtUrl = buildLogoutUrl(endpoint, session.idToken, options.postLogoutRedirectUri);
4203
+ if (navigation === void 0) return { upstreamLogoutUrl: builtUrl };
4204
+ const upstreamLogoutUrl = validateUpstreamLogoutUrl(builtUrl);
4205
+ const navigationUrl = await persistLogoutNavigation(navigation, store, upstreamLogoutUrl, getNow(options));
4206
+ return { navigationUrl };
4207
+ } catch (error) {
4208
+ await notifyLogoutError(options, req, res, error, session);
4209
+ return {};
4210
+ }
4211
+ } finally {
4212
+ if (session !== void 0)
4213
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3460
4214
  }
3461
- if (session === void 0) {
3462
- applyCredentialResponseCachePolicy(res);
3463
- res.status(200).json({ loggedOut: true });
4215
+ };
4216
+ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier, navigation) => createAsyncHandler("logout", options, async (req, res, _next, diagnostic) => {
4217
+ if (navigation === void 0) assertTrustedOrigin(req, options, trustedOrigins, "logout");
4218
+ else {
4219
+ assertLogoutNavigationRequest(req, options, trustedOrigins);
4220
+ const body = req.body;
4221
+ if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).some((key) => key !== "sessionId") || Object.hasOwn(body, "sessionId") && typeof body.sessionId !== "string")
4222
+ throw new OidcVaultHttpError(
4223
+ 400,
4224
+ "OIDC_VAULT_INVALID_REQUEST_BODY",
4225
+ "Logout navigation request body is invalid."
4226
+ );
4227
+ }
4228
+ const redirect = navigation === void 0 && getBody(req).redirect === true;
4229
+ const result = await performLogout(options, config, proofVerifier, req, res, diagnostic, redirect, navigation);
4230
+ applyCredentialResponseCachePolicy(res);
4231
+ if (navigation !== void 0) {
4232
+ res.status(200).json({
4233
+ loggedOut: true,
4234
+ navigationUrl: result.navigationUrl ?? navigation.navigationUrl
4235
+ });
3464
4236
  return;
3465
4237
  }
3466
- if (!redirect) {
3467
- await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3468
- applyCredentialResponseCachePolicy(res);
3469
- res.status(200).json({ loggedOut: true });
4238
+ if (result.upstreamLogoutUrl) {
4239
+ res.redirect(302, result.upstreamLogoutUrl);
3470
4240
  return;
3471
4241
  }
3472
- let upstreamLogoutUrl;
3473
- try {
3474
- const metadata = await resolveProviderMetadata(config, options);
3475
- upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
3476
- } catch (error) {
4242
+ res.status(200).json({ loggedOut: true });
4243
+ });
4244
+ var assertLogoutNavigationRequest = (req, options, trustedOrigins) => {
4245
+ captureTrustedOriginGuard(req, options, trustedOrigins, "logout", true)();
4246
+ if (!req.is("application/json"))
4247
+ throw new OidcVaultHttpError(
4248
+ 415,
4249
+ "OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE",
4250
+ "Logout navigation requires a JSON request body."
4251
+ );
4252
+ };
4253
+ var createLogoutNavigationGetHandler = (options) => async (req, res) => {
4254
+ const policy = options.logoutNavigation;
4255
+ let location = policy.fallbackRedirectUri;
4256
+ const ticketHash = navigationTicketHash(req.originalUrl);
4257
+ if (ticketHash !== void 0) {
3477
4258
  try {
3478
- await options.hooks?.onError?.({
3479
- ...createHookContext("logout", req, res, copyVaultSession(session)),
3480
- error
4259
+ const record = await options.storeProvider.consumeLogoutNavigationTicket({
4260
+ namespace: policy.namespace,
4261
+ ticketHash
3481
4262
  });
3482
- } catch {
4263
+ if (record !== null)
4264
+ location = consumedLogoutDestination(record, policy.namespace, ticketHash, getNow(options));
4265
+ } catch (error) {
4266
+ await notifyLogoutError(options, req, res, error);
3483
4267
  }
3484
4268
  }
3485
- await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3486
- applyCredentialResponseCachePolicy(res);
3487
- if (redirect && upstreamLogoutUrl) {
3488
- res.redirect(302, upstreamLogoutUrl);
3489
- return;
3490
- }
3491
- res.status(200).json({
3492
- loggedOut: true
3493
- });
3494
- });
4269
+ res.status(302).setHeader("Location", location);
4270
+ res.end();
4271
+ };
3495
4272
  var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("backchannel-logout", options, async (req, res) => {
3496
4273
  const metadata = await resolveProviderMetadata(config, options);
3497
4274
  const logoutToken = getLogoutTokenFromRequest(req);
@@ -3553,6 +4330,10 @@ function registerRoutes(router, options, config, trustedOrigins, backendOrigin,
3553
4330
  router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config, trustedOrigins, proofVerifier));
3554
4331
  router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins, proofVerifier));
3555
4332
  router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins, proofVerifier));
4333
+ if (options.logoutNavigation !== void 0) {
4334
+ const path = OIDC_VAULT_ROUTE_PATHS["logout-navigation"];
4335
+ router.post(path, createLogoutHandler(options, config, trustedOrigins, proofVerifier, options.logoutNavigation));
4336
+ }
3556
4337
  router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
3557
4338
  }
3558
4339
  function createOidcVaultMiddleware(options) {
@@ -3565,6 +4346,26 @@ function createOidcVaultMiddleware(options) {
3565
4346
  applyCredentialResponseCachePolicy(res);
3566
4347
  next();
3567
4348
  });
4349
+ if (resolvedOptions.logoutNavigation !== void 0) {
4350
+ const path = OIDC_VAULT_ROUTE_PATHS["logout-navigation"];
4351
+ baseRouter.all(path, (_req, res, next) => {
4352
+ res.setHeader("Referrer-Policy", "no-referrer");
4353
+ next();
4354
+ });
4355
+ baseRouter.head(path, (_req, res) => {
4356
+ res.setHeader("Allow", "GET, POST");
4357
+ res.status(405).end();
4358
+ });
4359
+ baseRouter.get(path, createLogoutNavigationGetHandler(resolvedOptions));
4360
+ baseRouter.post(
4361
+ path,
4362
+ createAsyncHandler("logout", resolvedOptions, async (req, _res, next) => {
4363
+ assertLogoutNavigationRequest(req, resolvedOptions, trustedOrigins);
4364
+ next();
4365
+ })
4366
+ );
4367
+ baseRouter.post(path, import_express.default.json({ limit: requestBodyLimit, strict: false }));
4368
+ }
3568
4369
  if (resolvedOptions.deviceBinding !== void 0 || resolvedOptions.fingerprintRecognition !== void 0) {
3569
4370
  baseRouter.post(
3570
4371
  OIDC_VAULT_ROUTE_PATHS.login,
@@ -3596,9 +4397,14 @@ function createOidcVaultMiddleware(options) {
3596
4397
  DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
3597
4398
  OIDC_VAULT_ROUTE_PATHS,
3598
4399
  OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
4400
+ OidcVaultAuthDeniedError,
3599
4401
  OidcVaultDpopReplayCapacityError,
4402
+ OidcVaultInvalidAccessTokenError,
4403
+ OidcVaultLogoutNavigationCapacityError,
3600
4404
  OidcVaultStoreConflictError,
3601
4405
  createOidcVaultAccessTokenMiddleware,
4406
+ createOidcVaultAuthenticator,
4407
+ createOidcVaultJwtAccessTokenIssuer,
3602
4408
  createOidcVaultJwtAccessTokenValidator,
3603
4409
  createOidcVaultMiddleware,
3604
4410
  normalizeOidcVaultBasePath,