@web-ts-toolkit/express-oidc-vault 0.53.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.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { createHash as createHash6, randomBytes as randomBytes3 } from "crypto";
2
+ import { createHash as createHash8, randomBytes as randomBytes4 } from "crypto";
3
3
  import express from "express";
4
4
 
5
5
  // src/errors.ts
@@ -97,6 +97,7 @@ var OIDC_VAULT_ROUTE_PATHS = {
97
97
  exchange: "/exchange",
98
98
  refresh: "/refresh",
99
99
  logout: "/logout",
100
+ "logout-navigation": "/logout/navigation",
100
101
  "backchannel-logout": "/backchannel-logout"
101
102
  };
102
103
 
@@ -570,10 +571,11 @@ var invalidProof = (diagnostic, cause) => {
570
571
  if (cause !== void 0) Object.defineProperty(error, "cause", { value: cause, configurable: true });
571
572
  return error;
572
573
  };
573
- var extractDpopProof = (header) => {
574
+ var extractDpopProof = (header, diagnostic) => {
574
575
  if (header.count === 0) return void 0;
575
576
  const { value } = header;
576
577
  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)) {
578
+ diagnostic?.emit("presentation", "rejected", "malformed-proof");
577
579
  throw invalidProof("DPoP header must contain one bounded compact signed proof.");
578
580
  }
579
581
  return value;
@@ -651,58 +653,105 @@ var publicJwk = (value, alg) => {
651
653
  return value;
652
654
  };
653
655
  var verifyDpopProof = async (input) => {
654
- const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
656
+ const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken, diagnostic } = input;
655
657
  const algorithms = [...proofOptions.algorithms];
656
658
  const { ignoreTargetFailure } = proofOptions;
657
- extractDpopProof({ count: 1, value: proof });
658
- const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
659
- const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
660
- decodeBase64url2(encodedPayload, DPOP_PROOF_MAX_BYTES);
661
- const signature = decodeBase64url2(encodedSignature, 512);
662
- const { alg } = header;
663
- if (header.typ !== "dpop+jwt" || !isDpopAlgorithm(alg) || !algorithms.includes(alg) || ["crit", "b64", "jku", "x5u", "x5c", "x5t", "x5t#S256"].some((key) => Object.hasOwn(header, key))) {
664
- throw invalidProof("DPoP protected header has an unsupported type, algorithm or key/critical extension.");
665
- }
666
- const jwk = publicJwk(header.jwk, alg);
667
- const signatureBytes = alg === "ES256" ? 64 : decodeBase64url2(jwk.n, 512).length;
668
- if (signature.length !== signatureBytes)
669
- throw invalidProof("DPoP signature length does not match the allowed key profile.");
670
- let payload;
671
- try {
672
- const key = await importJWK(jwk, alg);
673
- ({ payload } = await compactVerify(proof, key, { algorithms }));
674
- } catch (cause) {
675
- throw invalidProof("DPoP public-key import or signature verification failed.", cause);
676
- }
677
- const claims = parseObject(payload);
678
- const { htm, htu, iat, jti, nonce, ath } = claims;
679
- if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
680
- throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
681
- }
659
+ let stage = "header";
660
+ let reason = "malformed-proof";
682
661
  try {
683
- const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
684
- if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
685
- throw invalidProof("DPoP signed method or normalized public target does not match this request.");
662
+ extractDpopProof({ count: 1, value: proof }, diagnostic);
663
+ const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
664
+ const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
665
+ decodeBase64url2(encodedPayload, DPOP_PROOF_MAX_BYTES);
666
+ const signature = decodeBase64url2(encodedSignature, 512);
667
+ const { alg } = header;
668
+ reason = "unsupported-header";
669
+ if (header.typ !== "dpop+jwt" || !isDpopAlgorithm(alg) || !algorithms.includes(alg) || ["crit", "b64", "jku", "x5u", "x5c", "x5t", "x5t#S256"].some((key) => Object.hasOwn(header, key))) {
670
+ throw invalidProof("DPoP protected header has an unsupported type, algorithm or key/critical extension.");
671
+ }
672
+ diagnostic?.emit("header", "accepted", "check-passed");
673
+ stage = "key";
674
+ reason = "invalid-key";
675
+ const jwk = publicJwk(header.jwk, alg);
676
+ stage = "signature";
677
+ reason = "invalid-signature";
678
+ const signatureBytes = alg === "ES256" ? 64 : decodeBase64url2(jwk.n, 512).length;
679
+ if (signature.length !== signatureBytes)
680
+ throw invalidProof("DPoP signature length does not match the allowed key profile.");
681
+ let payload;
682
+ try {
683
+ stage = "key";
684
+ reason = "invalid-key";
685
+ const key = await importJWK(jwk, alg);
686
+ diagnostic?.emit("key", "accepted", "check-passed");
687
+ stage = "signature";
688
+ reason = "invalid-signature";
689
+ ({ payload } = await compactVerify(proof, key, { algorithms }));
690
+ } catch (cause) {
691
+ throw invalidProof("DPoP public-key import or signature verification failed.", cause);
692
+ }
693
+ diagnostic?.mark({ signature: "verified" });
694
+ diagnostic?.emit("signature", "accepted", "check-passed");
695
+ stage = "claims";
696
+ reason = "invalid-claims";
697
+ const claims = parseObject(payload);
698
+ const { htm, htu, iat, jti, nonce, ath } = claims;
699
+ if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
700
+ throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
701
+ }
702
+ diagnostic?.signedTime(iat);
703
+ diagnostic?.emit("claims", "accepted", "check-passed");
704
+ stage = "target";
705
+ reason = "target-mismatch";
706
+ try {
707
+ const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
708
+ if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
709
+ throw invalidProof("DPoP signed method or normalized public target does not match this request.");
710
+ }
711
+ diagnostic?.mark({ target: "matched" });
712
+ diagnostic?.emit("target", "accepted", "check-passed");
713
+ } catch (error) {
714
+ if (ignoreTargetFailure) {
715
+ diagnostic?.mark({ target: "ignored" });
716
+ diagnostic?.emit("target", "ignored", reason);
717
+ }
718
+ handleDpopCheckFailure(ignoreTargetFailure, "target", error);
719
+ }
720
+ stage = "nonce";
721
+ reason = "invalid-nonce";
722
+ if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
723
+ throw invalidProof("DPoP signed nonce exceeds its byte bound.");
686
724
  }
725
+ stage = "token-hash";
726
+ reason = "token-hash-mismatch";
727
+ if (accessToken !== void 0 && (typeof accessToken !== "string" || !/^[\x21-\x7e]+$/.test(accessToken) || !isCanonicalDpopJkt(ath) || ath !== createHash2("sha256").update(accessToken, "ascii").digest("base64url"))) {
728
+ throw invalidProof("DPoP signed access-token hash does not match the presented API token.");
729
+ }
730
+ diagnostic?.mark({ tokenHash: accessToken === void 0 ? "not-applicable" : "matched" });
731
+ if (accessToken !== void 0) diagnostic?.emit("token-hash", "accepted", "check-passed");
732
+ stage = "binding";
733
+ reason = "key-mismatch";
734
+ const jkt = await calculateJwkThumbprint(jwk, "sha256");
735
+ if (expectedJkt !== void 0 && (!isCanonicalDpopJkt(expectedJkt) || jkt !== expectedJkt)) {
736
+ throw invalidProof("DPoP verified public key does not match the original credential confirmation.");
737
+ }
738
+ diagnostic?.mark({ credentialBinding: expectedJkt === void 0 ? "not-applicable" : "matched" });
739
+ if (expectedJkt !== void 0) diagnostic?.emit("binding", "accepted", "check-passed");
740
+ return Object.freeze({
741
+ binding: Object.freeze({ type: "dpop", jkt, alg }),
742
+ iat,
743
+ jti,
744
+ ...nonce === void 0 ? {} : { nonce }
745
+ });
687
746
  } catch (error) {
688
- handleDpopCheckFailure(ignoreTargetFailure, "target", error);
689
- }
690
- if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
691
- throw invalidProof("DPoP signed nonce exceeds its byte bound.");
692
- }
693
- if (accessToken !== void 0 && (typeof accessToken !== "string" || !/^[\x21-\x7e]+$/.test(accessToken) || !isCanonicalDpopJkt(ath) || ath !== createHash2("sha256").update(accessToken, "ascii").digest("base64url"))) {
694
- throw invalidProof("DPoP signed access-token hash does not match the presented API token.");
695
- }
696
- const jkt = await calculateJwkThumbprint(jwk, "sha256");
697
- if (expectedJkt !== void 0 && (!isCanonicalDpopJkt(expectedJkt) || jkt !== expectedJkt)) {
698
- throw invalidProof("DPoP verified public key does not match the original credential confirmation.");
747
+ if (stage === "signature") diagnostic?.mark({ signature: "failed" });
748
+ else if (stage === "target") diagnostic?.mark({ target: "failed" });
749
+ else if (stage === "token-hash") diagnostic?.mark({ tokenHash: "failed" });
750
+ else if (stage === "binding") diagnostic?.mark({ credentialBinding: "failed" });
751
+ else if (stage === "nonce") diagnostic?.mark({ nonce: "failed" });
752
+ diagnostic?.emit(stage, "rejected", reason);
753
+ throw error;
699
754
  }
700
- return Object.freeze({
701
- binding: Object.freeze({ type: "dpop", jkt, alg }),
702
- iat,
703
- jti,
704
- ...nonce === void 0 ? {} : { nonce }
705
- });
706
755
  };
707
756
 
708
757
  // src/fingerprint-recognition.ts
@@ -765,11 +814,11 @@ var snapshotFingerprintRecognition = (metadata) => {
765
814
  const value = metadata?.[FINGERPRINT_RECOGNITION_METADATA_KEY];
766
815
  if (value === void 0) return void 0;
767
816
  if (!isPlainRecord2(value)) throw new TypeError("Stored fingerprint recognition metadata is invalid.");
768
- const { version, hash } = value;
769
- if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "version") || !Object.hasOwn(value, "hash") || version !== 1 || !isCanonicalDpopJkt(hash)) {
817
+ const { version, hash: hash2 } = value;
818
+ if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "version") || !Object.hasOwn(value, "hash") || version !== 1 || !isCanonicalDpopJkt(hash2)) {
770
819
  throw new TypeError("Stored fingerprint recognition metadata is invalid.");
771
820
  }
772
- return Object.freeze({ version, hash });
821
+ return Object.freeze({ version, hash: hash2 });
773
822
  };
774
823
  var fingerprintRecognitionMatches = (actual, original) => actual?.hash === original?.hash;
775
824
  var assertFingerprintRecognition = (policy, original, supplied) => {
@@ -1105,6 +1154,249 @@ function resolveOidcVaultConfigFromEnv(env = process.env) {
1105
1154
  });
1106
1155
  }
1107
1156
 
1157
+ // src/diagnostics.ts
1158
+ import { createHash as createHash5 } from "crypto";
1159
+ var createDiagnosticDispatcher = (observer) => {
1160
+ let pending = 0;
1161
+ let invoking = false;
1162
+ return (event) => {
1163
+ if (!observer || invoking || pending >= 32) return;
1164
+ invoking = true;
1165
+ try {
1166
+ const result = observer(event);
1167
+ if (result !== void 0) {
1168
+ pending++;
1169
+ const release = () => {
1170
+ pending--;
1171
+ };
1172
+ void Promise.resolve(result).then(release, release);
1173
+ }
1174
+ } catch {
1175
+ } finally {
1176
+ invoking = false;
1177
+ }
1178
+ };
1179
+ };
1180
+ var reference = (purpose, value) => typeof value === "string" && value.length > 0 && value.length <= 1024 ? createHash5("sha256").update(`${purpose}\0${value}`, "utf8").digest("base64url") : void 0;
1181
+ var diagnosticCorrelation = (association, subject, sessionId) => {
1182
+ const subjectRef = reference("subject", subject);
1183
+ const sessionRef = reference("session", sessionId);
1184
+ return Object.freeze({ association, ...subjectRef ? { subjectRef } : {}, ...sessionRef ? { sessionRef } : {} });
1185
+ };
1186
+ var DpopDiagnostic = class {
1187
+ constructor(operation, dispatch) {
1188
+ this.operation = operation;
1189
+ this.dispatch = dispatch;
1190
+ this.verification = {
1191
+ signature: "unchecked",
1192
+ target: "unchecked",
1193
+ tokenHash: "unchecked",
1194
+ credentialBinding: "unchecked",
1195
+ freshness: "unchecked",
1196
+ nonce: "unchecked",
1197
+ replay: "unchecked"
1198
+ };
1199
+ this.terminal = false;
1200
+ }
1201
+ associate(correlation) {
1202
+ this.correlation = correlation;
1203
+ }
1204
+ mark(update) {
1205
+ this.verification = { ...this.verification, ...update };
1206
+ }
1207
+ signedTime(iatSeconds) {
1208
+ this.timing = Object.freeze({ provenance: "signature-verified", iatSeconds });
1209
+ }
1210
+ checkTime(checkedAtMs, clock) {
1211
+ if (!this.timing) return;
1212
+ const issuedAt = this.timing.iatSeconds * 1e3;
1213
+ const age = checkedAtMs - issuedAt;
1214
+ this.timing = Object.freeze({
1215
+ ...this.timing,
1216
+ clock,
1217
+ checkedAtMs,
1218
+ ...Number.isSafeInteger(issuedAt) && Number.isSafeInteger(age) ? { observedAgeMs: age } : {}
1219
+ });
1220
+ }
1221
+ emit(stage, outcome, reason) {
1222
+ if (this.terminal) return;
1223
+ if (outcome === "rejected" || outcome === "challenged" || stage === "proof" || stage === "presentation" && outcome === "ignored")
1224
+ this.terminal = true;
1225
+ this.dispatch(
1226
+ Object.freeze({
1227
+ operation: this.operation,
1228
+ stage,
1229
+ outcome,
1230
+ reason,
1231
+ verification: Object.freeze({ ...this.verification }),
1232
+ ...this.timing ? { timing: this.timing } : {},
1233
+ ...this.correlation ? { correlation: this.correlation } : {}
1234
+ })
1235
+ );
1236
+ }
1237
+ accept() {
1238
+ if (this.correlation && this.hasAuthenticatedProof()) {
1239
+ this.correlation = Object.freeze({ ...this.correlation, association: "authenticated" });
1240
+ }
1241
+ this.emit("proof", "accepted", "proof-accepted");
1242
+ }
1243
+ /** Policy acceptance with an ignored security check is not strict identity evidence. */
1244
+ hasAuthenticatedProof() {
1245
+ const v = this.verification;
1246
+ 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";
1247
+ }
1248
+ };
1249
+ var diagnosticPreflightReason = (code) => {
1250
+ switch (code) {
1251
+ case "OIDC_VAULT_DPOP_REQUIRED":
1252
+ return "proof-required";
1253
+ case "OIDC_VAULT_DEVICE_BINDING_REQUIRED":
1254
+ return "binding-required";
1255
+ case "OIDC_VAULT_INVALID_SESSION":
1256
+ return "session-mismatch";
1257
+ case "OIDC_VAULT_INVALID_EXCHANGE_CODE":
1258
+ return "session-unavailable";
1259
+ case "OIDC_VAULT_INVALID_ORIGIN":
1260
+ case "OIDC_VAULT_UNTRUSTED_ORIGIN":
1261
+ return "origin-rejected";
1262
+ case "OIDC_VAULT_INVALID_BROWSER_BINDING":
1263
+ return "browser-binding-rejected";
1264
+ case "OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED":
1265
+ return "recognition-rejected";
1266
+ default:
1267
+ return "preflight-failed";
1268
+ }
1269
+ };
1270
+ var createDpopDiagnosticFactory = (observer) => {
1271
+ const dispatch = createDiagnosticDispatcher(observer);
1272
+ return (operation) => new DpopDiagnostic(operation, dispatch);
1273
+ };
1274
+
1275
+ // src/logout-navigation.ts
1276
+ import { createHash as createHash6, randomBytes as randomBytes3 } from "crypto";
1277
+ import { isIP as isIP2 } from "net";
1278
+
1279
+ // src/types.ts
1280
+ var OidcVaultStoreConflictError = class extends Error {
1281
+ constructor(message = "OIDC vault store operation conflicted with concurrent state changes.") {
1282
+ super(message);
1283
+ this.name = "OidcVaultStoreConflictError";
1284
+ }
1285
+ };
1286
+ var OidcVaultDpopReplayCapacityError = class extends Error {
1287
+ constructor(message = "OIDC vault DPoP replay store capacity is exhausted.") {
1288
+ super(message);
1289
+ this.name = "OidcVaultDpopReplayCapacityError";
1290
+ }
1291
+ };
1292
+ var OidcVaultLogoutNavigationCapacityError = class extends Error {
1293
+ constructor(message = "OIDC vault logout navigation ticket store capacity is exhausted.") {
1294
+ super(message);
1295
+ this.name = "OidcVaultLogoutNavigationCapacityError";
1296
+ }
1297
+ };
1298
+
1299
+ // src/logout-navigation.ts
1300
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
1301
+ var TICKET = /^lnt_[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
1302
+ var hash = (value) => createHash6("sha256").update(value, "utf8").digest("base64url");
1303
+ var plainRecord = (value) => typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
1304
+ var destination = (value, name, maxBytes, secure) => {
1305
+ const invalid = () => new TypeError(`${name} must be a bounded absolute HTTP(S) URL without unsafe characters, userinfo or fragment.`);
1306
+ if (typeof value !== "string" || !/^https?:\/\//i.test(value) || /^https?:\/\/[^/?#]*@/i.test(value) || UNSAFE_URL.test(value) || value.includes("#") || Buffer.byteLength(value, "utf8") > maxBytes)
1307
+ throw invalid();
1308
+ let url;
1309
+ try {
1310
+ url = new URL(value);
1311
+ } catch {
1312
+ throw invalid();
1313
+ }
1314
+ const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || isIP2(url.hostname) === 4 && url.hostname.startsWith("127.");
1315
+ if (url.protocol !== "https:" && url.protocol !== "http:" || secure && url.protocol === "http:" && !loopback || url.username || url.password || url.hash || Buffer.byteLength(url.href, "utf8") > maxBytes)
1316
+ throw invalid();
1317
+ return url;
1318
+ };
1319
+ var resolveLogoutNavigationOptions = (value, input) => {
1320
+ if (value === void 0) return void 0;
1321
+ if (!plainRecord(value)) throw new TypeError("logoutNavigation must be a plain options object.");
1322
+ if (!input.deviceBindingConfigured) throw new TypeError("logoutNavigation requires deviceBinding.");
1323
+ for (const name of ["createLogoutNavigationTicket", "consumeLogoutNavigationTicket"]) {
1324
+ if (typeof input.store[name] !== "function")
1325
+ throw new TypeError(`logoutNavigation requires storeProvider.${name}.`);
1326
+ }
1327
+ const { fallbackRedirectUri, ticketTtlMs = 6e4, namespace = "default" } = value;
1328
+ if (!Number.isInteger(ticketTtlMs) || ticketTtlMs < 1e3 || ticketTtlMs > 12e4)
1329
+ throw new TypeError("logoutNavigation.ticketTtlMs must be an integer from 1000 through 120000.");
1330
+ computeExpiresAt(input.now, ticketTtlMs, "logoutNavigation.ticketTtlMs");
1331
+ if (typeof namespace !== "string" || !/^[\x21-\x7e]{1,128}$/.test(namespace))
1332
+ throw new TypeError("logoutNavigation.namespace must be a static printable ASCII label of 1 through 128 bytes.");
1333
+ const path = `${input.basePath}${OIDC_VAULT_ROUTE_PATHS["logout-navigation"]}`;
1334
+ const fallback = destination(fallbackRedirectUri, "logoutNavigation.fallbackRedirectUri", 2048, true);
1335
+ 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
1336
+ // a trailing slash. Those aliases are continuation loops as well.
1337
+ fallback.origin === input.backendOrigin && fallback.pathname.replace(/\/$/, "").toLowerCase() === path.toLowerCase())
1338
+ throw new TypeError(
1339
+ "logoutNavigation.fallbackRedirectUri must stay on the backend/frontend origin and cannot loop to logout navigation."
1340
+ );
1341
+ const postLogout = input.postLogoutRedirectUri === void 0 ? void 0 : destination(input.postLogoutRedirectUri, "logoutNavigation.postLogoutRedirectUri", 2048, true).href;
1342
+ return Object.freeze({
1343
+ fallbackRedirectUri: fallback.href,
1344
+ ticketTtlMs,
1345
+ namespace: "logout-navigation:v1:" + hash(
1346
+ JSON.stringify([
1347
+ "oidc-vault-logout-navigation",
1348
+ 1,
1349
+ input.backendOrigin,
1350
+ input.basePath,
1351
+ input.config.issuer,
1352
+ input.config.clientId,
1353
+ namespace,
1354
+ fallback.href,
1355
+ postLogout ?? null
1356
+ ])
1357
+ ),
1358
+ navigationUrl: `${input.backendOrigin}${path}`,
1359
+ ...postLogout === void 0 ? {} : { postLogoutRedirectUri: postLogout }
1360
+ });
1361
+ };
1362
+ var validateUpstreamLogoutUrl = (value) => destination(value, "Logout navigation upstream destination", 16384, false).href;
1363
+ var persistLogoutNavigation = async (policy, store, upstreamLogoutUrl, now) => {
1364
+ const expiresAt = computeExpiresAt(now, policy.ticketTtlMs, "logoutNavigation.ticketTtlMs");
1365
+ for (let attempt = 0; attempt < 3; attempt++) {
1366
+ const ticket = `lnt_${randomBytes3(32).toString("base64url")}`;
1367
+ try {
1368
+ await store.createLogoutNavigationTicket({
1369
+ namespace: policy.namespace,
1370
+ ticketHash: hash(ticket),
1371
+ upstreamLogoutUrl,
1372
+ createdAt: now,
1373
+ expiresAt
1374
+ });
1375
+ return `${policy.navigationUrl}?ticket=${ticket}`;
1376
+ } catch (error) {
1377
+ const conflict = error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
1378
+ if (!conflict || attempt === 2) throw error;
1379
+ }
1380
+ }
1381
+ throw new Error("Logout navigation ticket attempts exhausted.");
1382
+ };
1383
+ var navigationTicketHash = (originalUrl) => {
1384
+ const index = originalUrl.indexOf("?");
1385
+ if (index < 0) return void 0;
1386
+ const query = originalUrl.slice(index + 1);
1387
+ if (!query.startsWith("ticket=")) return void 0;
1388
+ const ticket = query.slice("ticket=".length);
1389
+ return TICKET.test(ticket) ? hash(ticket) : void 0;
1390
+ };
1391
+ var consumedLogoutDestination = (value, namespace, ticketHash, now) => {
1392
+ if (!plainRecord(value) || Object.keys(value).length !== 5)
1393
+ throw new TypeError("Logout navigation consume returned an invalid record.");
1394
+ const { namespace: returnedNamespace, ticketHash: returnedHash, upstreamLogoutUrl, createdAt, expiresAt } = value;
1395
+ if (returnedNamespace !== namespace || returnedHash !== ticketHash || !isUsableEpochMs(now) || !isUsableEpochMs(createdAt) || !isUsableEpochMs(expiresAt) || expiresAt - createdAt < 1e3 || expiresAt - createdAt > 12e4 || expiresAt <= now || expiresAt - now > 12e4)
1396
+ throw new TypeError("Logout navigation consume returned invalid identity or lifetime.");
1397
+ return validateUpstreamLogoutUrl(upstreamLogoutUrl);
1398
+ };
1399
+
1108
1400
  // src/utils.ts
1109
1401
  var isRecord = (value) => typeof value === "object" && value !== null;
1110
1402
  var isString = (value) => typeof value === "string" && value.length > 0;
@@ -1189,8 +1481,8 @@ var getRequestSourceOrigin = (req) => {
1189
1481
  var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
1190
1482
  captureTrustedOriginGuard(req, options, trustedOrigins, action)();
1191
1483
  };
1192
- var captureTrustedOriginGuard = (req, options, trustedOrigins, action) => {
1193
- const guarded = action === "login" || action === "exchange";
1484
+ var captureTrustedOriginGuard = (req, options, trustedOrigins, action, strict = false) => {
1485
+ const guarded = strict || action === "login" || action === "exchange";
1194
1486
  if (!guarded && !usesCookieTransport(options)) {
1195
1487
  return () => {
1196
1488
  };
@@ -1958,6 +2250,28 @@ var createExchangeResponse = (options, session, issuedToken) => ({
1958
2250
  expiresIn: issuedToken.expiresIn,
1959
2251
  tokenType: issuedToken.tokenType
1960
2252
  });
2253
+ var notifyTokenOutcomes = (operation, session, issuedToken, notify, diagnostic) => {
2254
+ const deviceBound = session.deviceBinding !== void 0;
2255
+ const correlation = diagnosticCorrelation(
2256
+ deviceBound && !diagnostic?.hasAuthenticatedProof() ? "candidate-session" : "authenticated",
2257
+ session.subject,
2258
+ session.logicalSessionId ?? session.sessionId
2259
+ );
2260
+ const tokenIssued = issuedToken.accessToken !== void 0;
2261
+ if (tokenIssued)
2262
+ notify(
2263
+ Object.freeze({
2264
+ kind: "token-issued",
2265
+ operation,
2266
+ correlation,
2267
+ deviceBound,
2268
+ expiresIn: issuedToken.expiresIn,
2269
+ ...issuedToken.tokenType === void 0 ? {} : { tokenType: issuedToken.tokenType }
2270
+ })
2271
+ );
2272
+ if (operation === "exchange")
2273
+ notify(Object.freeze({ kind: "exchange-succeeded", operation, correlation, deviceBound, tokenIssued }));
2274
+ };
1961
2275
  var assertIssuedBoundJwt = (accessToken, jkt) => {
1962
2276
  if (!isCompactSignedJwt(accessToken)) {
1963
2277
  throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
@@ -1984,13 +2298,14 @@ var assertIssuedBoundJwt = (accessToken, jkt) => {
1984
2298
  throw new TypeError("tokenIssuer.issue accessToken exp must be a nonnegative safe integer with safe milliseconds.");
1985
2299
  }
1986
2300
  };
1987
- var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
2301
+ var withIssuedToken = async (req, res, options, session, operation, verifiedBinding) => {
1988
2302
  const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
1989
2303
  if (!options.tokenIssuer) return {};
1990
2304
  const logicalSessionId = session.logicalSessionId ?? session.sessionId;
1991
2305
  try {
1992
2306
  const issuerSession = withSessionDeviceBinding(copyVaultSession(session, true), binding);
1993
2307
  const result = await options.tokenIssuer.issue({
2308
+ operation,
1994
2309
  req,
1995
2310
  res,
1996
2311
  session: issuerSession,
@@ -2029,7 +2344,32 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
2029
2344
  };
2030
2345
 
2031
2346
  // src/token-validation.ts
2032
- import { jwtVerify } from "jose";
2347
+ import { errors as joseErrors, jwtVerify } from "jose";
2348
+
2349
+ // src/access-token-errors.ts
2350
+ var OidcVaultInvalidAccessTokenError = class extends OidcVaultHttpError {
2351
+ constructor(message = "Access token rejected.") {
2352
+ super(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", message, "Access token validation failed.");
2353
+ this.name = "OidcVaultInvalidAccessTokenError";
2354
+ }
2355
+ };
2356
+ var OidcVaultAuthDeniedError = class extends OidcVaultHttpError {
2357
+ constructor(message = "Authentication denied.", status = 403) {
2358
+ if (status !== 401 && status !== 403) throw new TypeError("Auth denial status must be 401 or 403.");
2359
+ super(status, "OIDC_VAULT_AUTH_DENIED", message);
2360
+ this.name = "OidcVaultAuthDeniedError";
2361
+ }
2362
+ };
2363
+ var credentialOutcomes = /* @__PURE__ */ new WeakMap();
2364
+ var markAccessTokenRejected = (error) => {
2365
+ if (typeof error === "object" && error !== null) credentialOutcomes.set(error, true);
2366
+ };
2367
+ var markAccessTokenValidationFailure = (error) => {
2368
+ if (typeof error === "object" && error !== null) credentialOutcomes.set(error, false);
2369
+ };
2370
+ var isAccessTokenRejected = (error) => typeof error === "object" && error !== null && (credentialOutcomes.get(error) ?? error instanceof OidcVaultInvalidAccessTokenError);
2371
+
2372
+ // src/token-validation.ts
2033
2373
  var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
2034
2374
  var validateBackchannelLogoutTokenTyp = (typ) => {
2035
2375
  if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
@@ -2188,20 +2528,36 @@ function createOidcVaultJwtAccessTokenValidator(options) {
2188
2528
  const audience = Array.isArray(configuredAudience) ? [...configuredAudience] : configuredAudience;
2189
2529
  const algorithms = configuredAlgorithms === void 0 ? void 0 : [...configuredAlgorithms];
2190
2530
  const validateJwt = async (token) => {
2191
- const result = await jwtVerify(token, key, { issuer, audience, algorithms });
2192
- const claims = result.payload;
2193
- const confirmation = snapshotVerifiedJwtConfirmation(claims);
2194
- if (confirmation !== null) {
2195
- if (!isCompactSignedJwt(token)) {
2196
- throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
2531
+ const result = await jwtVerify(token, key, { issuer, audience, algorithms }).catch((error) => {
2532
+ if (error instanceof joseErrors.JWTExpired || error instanceof joseErrors.JWTClaimValidationFailed || error instanceof joseErrors.JWTInvalid || error instanceof joseErrors.JWSInvalid || error instanceof joseErrors.JWSSignatureVerificationFailed || error instanceof joseErrors.JOSEAlgNotAllowed) {
2533
+ markAccessTokenRejected(error);
2197
2534
  }
2198
- if (readJwtExpirationMs(claims.exp) === void 0) {
2199
- throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
2535
+ throw error;
2536
+ });
2537
+ const claims = result.payload;
2538
+ let confirmation;
2539
+ try {
2540
+ confirmation = snapshotVerifiedJwtConfirmation(claims);
2541
+ if (confirmation !== null) {
2542
+ if (!isCompactSignedJwt(token)) {
2543
+ throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
2544
+ }
2545
+ if (readJwtExpirationMs(claims.exp) === void 0) {
2546
+ throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
2547
+ }
2200
2548
  }
2549
+ if (!mapClaims) return { ...defaultJwtClaimsMapper(claims), confirmation };
2550
+ } catch (error) {
2551
+ markAccessTokenRejected(error);
2552
+ throw error;
2553
+ }
2554
+ try {
2555
+ const { subject, sessionId, scope, claims: mappedClaims } = mapClaims(claims);
2556
+ return { subject, sessionId, scope, claims: mappedClaims, confirmation };
2557
+ } catch (error) {
2558
+ markAccessTokenValidationFailure(error);
2559
+ throw error;
2201
2560
  }
2202
- const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
2203
- const { subject, sessionId, scope, claims: mappedClaims } = mapped;
2204
- return { subject, sessionId, scope, claims: mappedClaims, confirmation };
2205
2561
  };
2206
2562
  return Object.freeze({
2207
2563
  async validate(token) {
@@ -2216,22 +2572,8 @@ function createOidcVaultJwtAccessTokenValidator(options) {
2216
2572
  });
2217
2573
  }
2218
2574
 
2219
- // src/types.ts
2220
- var OidcVaultStoreConflictError = class extends Error {
2221
- constructor(message = "OIDC vault store operation conflicted with concurrent state changes.") {
2222
- super(message);
2223
- this.name = "OidcVaultStoreConflictError";
2224
- }
2225
- };
2226
- var OidcVaultDpopReplayCapacityError = class extends Error {
2227
- constructor(message = "OIDC vault DPoP replay store capacity is exhausted.") {
2228
- super(message);
2229
- this.name = "OidcVaultDpopReplayCapacityError";
2230
- }
2231
- };
2232
-
2233
2575
  // src/dpop-replay.ts
2234
- import { createHash as createHash5 } from "crypto";
2576
+ import { createHash as createHash7 } from "crypto";
2235
2577
  var isPrintableIdentifier = (value) => typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value);
2236
2578
  var normalizeNamespaceBasePath = (origin, value) => {
2237
2579
  if (value !== void 0 && typeof value !== "string")
@@ -2269,7 +2611,7 @@ var createDpopReplayKey = (effectiveNamespace, jkt, jti) => {
2269
2611
  if (!isCanonicalDpopJkt(jkt) || !isPrintableIdentifier(jti)) {
2270
2612
  throw new TypeError("DPoP replay key requires a canonical thumbprint and a bounded printable JTI.");
2271
2613
  }
2272
- return `dpop:v1:${createHash5("sha256").update(JSON.stringify([effectiveNamespace, jkt, jti]), "utf8").digest("base64url")}`;
2614
+ return `dpop:v1:${createHash7("sha256").update(JSON.stringify([effectiveNamespace, jkt, jti]), "utf8").digest("base64url")}`;
2273
2615
  };
2274
2616
  var getDpopReplayExpiresAt = (input) => {
2275
2617
  const { iat, now, proofMaxAgeSeconds: age, clockSkewSeconds: skew } = input;
@@ -2308,47 +2650,85 @@ var createDpopReplayPolicy = (options) => {
2308
2650
  });
2309
2651
  return Object.freeze({
2310
2652
  effectiveNamespace,
2311
- async verifyAndReserve(verifyBeforeReplay) {
2653
+ async verifyAndReserve(verifyBeforeReplay, diagnostic) {
2312
2654
  const verified = await verifyBeforeReplay();
2313
2655
  if (typeof verified !== "object" || verified === null || Array.isArray(verified)) {
2656
+ diagnostic?.emit("claims", "rejected", "invalid-claims");
2314
2657
  throw invalidProof2("DPoP verifier did not return a proof verification result.");
2315
2658
  }
2316
2659
  const { binding: verifiedBinding, iat, jti, nonce } = verified;
2317
2660
  if (typeof verifiedBinding !== "object" || verifiedBinding === null || Array.isArray(verifiedBinding)) {
2661
+ diagnostic?.emit("binding", "rejected", "invalid-key");
2318
2662
  throw invalidProof2("DPoP verifier did not return a verified key binding.");
2319
2663
  }
2320
2664
  const { type, jkt, alg } = verifiedBinding;
2321
2665
  if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
2666
+ diagnostic?.emit("claims", "rejected", "invalid-claims");
2322
2667
  throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
2323
2668
  }
2324
2669
  const binding = Object.freeze({ type, jkt, alg });
2325
2670
  let checkedNow;
2671
+ let clockSource = configuredNow ? "configured" : "system";
2326
2672
  try {
2327
2673
  checkedNow = now();
2328
2674
  if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2329
2675
  } catch (error) {
2676
+ diagnostic?.mark({ freshness: policy.ignoreFreshnessFailure ? "ignored" : "failed" });
2677
+ diagnostic?.emit("freshness", policy.ignoreFreshnessFailure ? "ignored" : "rejected", "clock-unavailable");
2330
2678
  handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
2331
2679
  checkedNow = Date.now();
2332
- if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
2680
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) {
2681
+ diagnostic?.mark({ freshness: "failed" });
2682
+ diagnostic?.emit("freshness", "rejected", "clock-unavailable");
2333
2683
  throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
2684
+ }
2685
+ clockSource = "fallback";
2334
2686
  }
2687
+ diagnostic?.checkTime(checkedNow, clockSource);
2335
2688
  let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2336
2689
  if (expiresAt === null) {
2690
+ diagnostic?.mark({ freshness: policy.ignoreFreshnessFailure ? "ignored" : "failed" });
2691
+ diagnostic?.emit(
2692
+ "freshness",
2693
+ policy.ignoreFreshnessFailure ? "ignored" : "rejected",
2694
+ !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"
2695
+ );
2337
2696
  handleDpopCheckFailure(
2338
2697
  policy.ignoreFreshnessFailure,
2339
2698
  "freshness",
2340
2699
  invalidProof2("DPoP signed iat is outside the bounded replay window.")
2341
2700
  );
2342
2701
  expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
2702
+ } else {
2703
+ diagnostic?.mark({ freshness: clockSource === "fallback" ? "ignored" : "passed" });
2704
+ diagnostic?.emit("freshness", "accepted", "check-passed");
2343
2705
  }
2344
2706
  if (noncePolicy) {
2345
2707
  if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2346
- return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
2708
+ let challengeNonce;
2709
+ try {
2710
+ challengeNonce = noncePolicy.issue(jkt, checkedNow);
2711
+ } catch (error) {
2712
+ diagnostic?.mark({ nonce: "failed" });
2713
+ diagnostic?.emit("nonce", "rejected", "nonce-unavailable");
2714
+ throw error;
2715
+ }
2716
+ diagnostic?.mark({ nonce: "challenged" });
2717
+ diagnostic?.emit("nonce", "challenged", nonce === void 0 ? "nonce-required" : "nonce-invalid");
2718
+ return Object.freeze({ type: "nonce-challenge", nonce: challengeNonce });
2347
2719
  }
2720
+ diagnostic?.mark({ nonce: "passed" });
2721
+ diagnostic?.emit("nonce", "accepted", "check-passed");
2348
2722
  } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2723
+ diagnostic?.mark({ nonce: "failed" });
2724
+ diagnostic?.emit("nonce", "rejected", "invalid-nonce");
2349
2725
  throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2726
+ } else {
2727
+ diagnostic?.mark({ nonce: "disabled" });
2728
+ diagnostic?.emit("nonce", "ignored", "nonce-disabled");
2350
2729
  }
2351
2730
  let reservationError;
2731
+ let unavailable = false;
2352
2732
  try {
2353
2733
  const reserved = await replayStore.reserveDpopProof({
2354
2734
  replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
@@ -2359,8 +2739,21 @@ var createDpopReplayPolicy = (options) => {
2359
2739
  if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2360
2740
  } catch (error) {
2361
2741
  reservationError = replayUnavailable(error);
2742
+ unavailable = true;
2362
2743
  }
2363
- if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2744
+ if (reservationError) {
2745
+ diagnostic?.mark({ replay: policy.ignoreReplayFailure ? "ignored" : "failed" });
2746
+ diagnostic?.emit(
2747
+ "replay",
2748
+ policy.ignoreReplayFailure ? "ignored" : "rejected",
2749
+ unavailable ? "replay-unavailable" : "replay-detected"
2750
+ );
2751
+ handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2752
+ } else {
2753
+ diagnostic?.mark({ replay: "reserved" });
2754
+ diagnostic?.emit("replay", "accepted", "check-passed");
2755
+ }
2756
+ diagnostic?.accept();
2364
2757
  return Object.freeze({ type: "accepted", binding, expiresAt });
2365
2758
  }
2366
2759
  });
@@ -2399,7 +2792,8 @@ var toDpopNonceChallengeResponse = (result, endpoint, algorithms) => {
2399
2792
  };
2400
2793
 
2401
2794
  // src/vault-route-proof.ts
2402
- var captureVaultRouteProof = (req) => Object.freeze({
2795
+ var captureVaultRouteProof = (req, diagnostic) => Object.freeze({
2796
+ diagnostic,
2403
2797
  header: readDpopRawHeader(req, "dpop"),
2404
2798
  method: req.method,
2405
2799
  originalUrl: req.originalUrl
@@ -2423,14 +2817,17 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2423
2817
  now: options.now
2424
2818
  });
2425
2819
  const admit = async (request, proof, expectedJkt) => {
2820
+ const { diagnostic } = request;
2426
2821
  const result = await replay.verifyAndReserve(
2427
2822
  () => verifyDpopProof({
2428
2823
  proof,
2429
2824
  method: request.method,
2430
2825
  targetUrl: () => targetFor(request.originalUrl),
2431
2826
  proofOptions: policy,
2432
- expectedJkt
2433
- })
2827
+ expectedJkt,
2828
+ diagnostic
2829
+ }),
2830
+ diagnostic
2434
2831
  );
2435
2832
  if (result.type === "nonce-challenge") {
2436
2833
  throw new VaultDpopNonceChallengeError(toDpopNonceChallengeResponse(result, "vault-post", policy.algorithms));
@@ -2439,9 +2836,10 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2439
2836
  };
2440
2837
  return Object.freeze({
2441
2838
  async initiate(request) {
2442
- const proof = extractDpopProof(request.header);
2839
+ const proof = extractDpopProof(request.header, request.diagnostic);
2443
2840
  if (proof === void 0) {
2444
2841
  assertDeviceBindingPolicy(policy, void 0);
2842
+ request.diagnostic?.emit("presentation", "ignored", "proof-absent");
2445
2843
  return void 0;
2446
2844
  }
2447
2845
  return admit(request, proof);
@@ -2449,9 +2847,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2449
2847
  async verify(request, originalBinding) {
2450
2848
  const binding = snapshotDpopBinding(originalBinding);
2451
2849
  assertDeviceBindingPolicy(policy, binding);
2452
- const proof = extractDpopProof(request.header);
2850
+ const proof = extractDpopProof(request.header, request.diagnostic);
2453
2851
  const verified = proof === void 0 ? void 0 : await admit(request, proof, binding?.jkt);
2454
- return resolveVerifiedDeviceBinding(policy, binding, verified);
2852
+ const resolved = resolveVerifiedDeviceBinding(policy, binding, verified);
2853
+ if (proof === void 0) request.diagnostic?.emit("presentation", "ignored", "proof-absent");
2854
+ return resolved;
2455
2855
  }
2456
2856
  });
2457
2857
  };
@@ -2469,7 +2869,7 @@ var extractBearerToken = (authorizationHeader) => {
2469
2869
  throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_BEARER_TOKEN", "Missing bearer token.");
2470
2870
  }
2471
2871
  const [scheme, token, extra] = authorizationHeader.trim().split(/\s+/);
2472
- if (scheme?.toLowerCase() !== "bearer" || !token || extra) {
2872
+ if (scheme?.toLowerCase() !== "bearer" || !token || extra || !/^[\x21-\x7e]+$/.test(token) || token.includes(",")) {
2473
2873
  throw new OidcVaultHttpError(
2474
2874
  401,
2475
2875
  "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
@@ -2496,8 +2896,40 @@ var mappedFields = (result) => {
2496
2896
  throw invalidAccessToken("Access token validator result is missing subject.");
2497
2897
  return { subject, sessionId, scope, claims };
2498
2898
  };
2899
+ var evidence = /* @__PURE__ */ new WeakMap();
2900
+ var authFields = ["subject", "sessionId", "scope", "claims", "token", "confirmation", "deviceBinding"];
2901
+ var credentialRejectionCodes = /* @__PURE__ */ new Set([
2902
+ "OIDC_VAULT_MISSING_BEARER_TOKEN",
2903
+ "OIDC_VAULT_MISSING_ACCESS_TOKEN",
2904
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
2905
+ "OIDC_VAULT_INVALID_ACCESS_TOKEN",
2906
+ "OIDC_VAULT_DPOP_REQUIRED",
2907
+ "OIDC_VAULT_DEVICE_BINDING_REQUIRED",
2908
+ "OIDC_VAULT_INVALID_DPOP_PROOF"
2909
+ ]);
2910
+ var clearChallenge = (res) => {
2911
+ res.removeHeader("WWW-Authenticate");
2912
+ res.removeHeader("DPoP-Nonce");
2913
+ res.setHeader("Cache-Control", "no-store");
2914
+ };
2915
+ var sendError = (res, response) => {
2916
+ clearChallenge(res);
2917
+ for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
2918
+ res.status(response.status).json(response.body);
2919
+ };
2499
2920
  function createOidcVaultAccessTokenMiddleware(options) {
2500
- const { validator, deviceBinding: configuredBinding, onAuthContext, onError } = options;
2921
+ return createOidcVaultAuthenticator(options).authenticate;
2922
+ }
2923
+ function createOidcVaultAuthenticator(options) {
2924
+ const { validator, deviceBinding: configuredBinding, onAuthContext, onError, onDpopDiagnostic } = options;
2925
+ const createDiagnostic = createDpopDiagnosticFactory(onDpopDiagnostic);
2926
+ const { authentication = "required", invalidCredentials = "reject" } = options;
2927
+ if (authentication !== "required" && authentication !== "optional")
2928
+ throw new TypeError("authentication must be required or optional.");
2929
+ if (invalidCredentials !== "reject" && invalidCredentials !== "anonymous")
2930
+ throw new TypeError("invalidCredentials must be reject or anonymous.");
2931
+ if (invalidCredentials === "anonymous" && authentication !== "optional")
2932
+ throw new TypeError("Anonymous invalid credentials require optional authentication.");
2501
2933
  const policy = resolveDeviceBindingOptions(configuredBinding);
2502
2934
  const validate = validator.validate.bind(validator);
2503
2935
  const requestAware = policy ? validator.validateWithRequest : void 0;
@@ -2529,56 +2961,134 @@ function createOidcVaultAccessTokenMiddleware(options) {
2529
2961
  const algs = algorithms.join(" ");
2530
2962
  const dpopChallenge = (error) => `DPoP${error ? ` error="${error}",` : ""} algs="${algs}"`;
2531
2963
  const proofChallenge = dpopChallenge("invalid_dpop_proof");
2532
- return async (req, res, next) => {
2964
+ const owner = Object.freeze({});
2965
+ const prepareError = (error, scheme, nonceResponse, hookFailure = false) => {
2966
+ const response = nonceResponse ?? (error instanceof OidcVaultHttpError ? toDpopReplayErrorResponse(error, algorithms) : {
2967
+ status: hookFailure ? 500 : 401,
2968
+ body: hookFailure ? { code: "OIDC_VAULT_AUTH_CONTEXT_FAILED", message: "Auth context hook failed." } : { code: "OIDC_VAULT_INVALID_ACCESS_TOKEN", message: "Access token validation failed." },
2969
+ headers: { "Cache-Control": "no-store" }
2970
+ });
2971
+ const headers = { ...response.headers };
2972
+ if (response.status === 401 && headers["WWW-Authenticate"] === void 0) {
2973
+ const code = response.body.code;
2974
+ 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"';
2975
+ headers["WWW-Authenticate"] = challenge2;
2976
+ }
2977
+ return Object.freeze({
2978
+ status: response.status,
2979
+ body: Object.freeze({ ...response.body }),
2980
+ headers: Object.freeze(headers)
2981
+ });
2982
+ };
2983
+ const reentryError = () => new OidcVaultHttpError(500, "OIDC_VAULT_AUTHENTICATION_REENTRY", "Authentication is already running or completed.");
2984
+ const rejectGuard = async (req, res, next) => {
2985
+ const state = evidence.get(req);
2986
+ const error = state?.status === "pending" ? reentryError() : new OidcVaultHttpError(401, "OIDC_VAULT_AUTHENTICATION_REQUIRED", "Authentication is required.");
2987
+ if (state) state.status = "failed";
2988
+ else evidence.set(req, { owner, res, status: "failed" });
2989
+ delete req.auth;
2990
+ const response = prepareError(error);
2991
+ await notifyMiddlewareError(onError, { error, req, res });
2992
+ delete req.auth;
2993
+ if (res.headersSent) next(error);
2994
+ else sendError(res, response);
2995
+ };
2996
+ const guard = (bound) => async (req, res, next) => {
2997
+ const state = evidence.get(req);
2998
+ const current = Object.getOwnPropertyDescriptor(req, "auth");
2999
+ const intact = state?.auth && state.snapshot && current && "value" in current && current.value === state.auth && authFields.every((key) => {
3000
+ const field = Object.getOwnPropertyDescriptor(state.auth, key);
3001
+ return Object.hasOwn(state.snapshot, key) ? field && "value" in field && field.value === state.snapshot[key] : field === void 0;
3002
+ });
3003
+ if (state?.status !== "authenticated" || state.owner !== owner || state.res !== res || !intact) {
3004
+ await rejectGuard(req, res, next);
3005
+ return;
3006
+ }
3007
+ if (bound && !state.snapshot.deviceBinding) {
3008
+ const error = new OidcVaultHttpError(
3009
+ 401,
3010
+ "OIDC_VAULT_DEVICE_BINDING_REQUIRED",
3011
+ "A device-bound login is required."
3012
+ );
3013
+ state.status = "failed";
3014
+ delete req.auth;
3015
+ const response = prepareError(error);
3016
+ await notifyMiddlewareError(onError, { error, req, res });
3017
+ delete req.auth;
3018
+ if (res.headersSent) next(error);
3019
+ else sendError(res, response);
3020
+ return;
3021
+ }
3022
+ if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
3023
+ next();
3024
+ };
3025
+ const authenticate = async (req, res, next) => {
3026
+ if (evidence.has(req)) {
3027
+ const state2 = evidence.get(req);
3028
+ state2.status = "failed";
3029
+ delete req.auth;
3030
+ const error = reentryError();
3031
+ const response = prepareError(error);
3032
+ await notifyMiddlewareError(onError, { error, req, res });
3033
+ delete req.auth;
3034
+ if (res.headersSent) next(error);
3035
+ else sendError(res, response);
3036
+ return;
3037
+ }
3038
+ const state = { owner, res, status: "pending" };
3039
+ const diagnostic = createDiagnostic("api");
3040
+ evidence.set(req, state);
2533
3041
  let token;
2534
3042
  let scheme;
2535
3043
  let nonceResponse;
3044
+ let stage = "credential";
2536
3045
  const authenticatedRequest = req;
2537
3046
  delete authenticatedRequest.auth;
2538
3047
  if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
2539
- const prepareError = (error, hookFailure = false) => {
2540
- const response = nonceResponse ?? (error instanceof OidcVaultHttpError ? toDpopReplayErrorResponse(error, algorithms) : {
2541
- status: hookFailure ? 500 : 401,
2542
- body: hookFailure ? { code: "OIDC_VAULT_AUTH_CONTEXT_FAILED", message: "Auth context hook failed." } : { code: "OIDC_VAULT_INVALID_ACCESS_TOKEN", message: "Access token validation failed." },
2543
- headers: { "Cache-Control": "no-store" }
2544
- });
2545
- const headers = { ...response.headers };
2546
- if (response.status === 401 && headers["WWW-Authenticate"] === void 0) {
2547
- const code = response.body.code;
2548
- 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"';
2549
- headers["WWW-Authenticate"] = challenge2;
2550
- }
2551
- return Object.freeze({
2552
- status: response.status,
2553
- body: Object.freeze({ ...response.body }),
2554
- headers: Object.freeze(headers)
2555
- });
2556
- };
2557
- const sendError = (response) => {
2558
- res.removeHeader("WWW-Authenticate");
2559
- res.removeHeader("DPoP-Nonce");
2560
- for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
2561
- res.status(response.status).json(response.body);
3048
+ const stillPending = () => {
3049
+ if (state.status === "pending") return true;
3050
+ delete authenticatedRequest.auth;
3051
+ return false;
2562
3052
  };
2563
3053
  try {
3054
+ const authorization = readDpopRawHeader(req, "authorization");
3055
+ const proofHeader = readDpopRawHeader(req, "dpop");
3056
+ if (authentication === "optional" && authorization.count === 0 && proofHeader.count === 0) {
3057
+ diagnostic.emit("presentation", "ignored", policy ? "proof-absent" : "policy-disabled");
3058
+ state.status = "missing";
3059
+ clearChallenge(res);
3060
+ next();
3061
+ return;
3062
+ }
2564
3063
  let result;
2565
3064
  let confirmation;
2566
3065
  let deviceBinding;
2567
3066
  if (!policy) {
3067
+ if (authorization.count > 1 || authorization.count === 1 && !authorization.value?.trim())
3068
+ throw new OidcVaultHttpError(
3069
+ 401,
3070
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
3071
+ "Authorization header must use the Bearer scheme."
3072
+ );
2568
3073
  token = extractBearerToken(req.get("authorization"));
2569
3074
  scheme = "Bearer";
3075
+ stage = "validator";
2570
3076
  result = await validate(token);
3077
+ if (!stillPending()) return;
2571
3078
  delete authenticatedRequest.auth;
3079
+ stage = "result";
2572
3080
  const reported = result?.confirmation;
2573
3081
  confirmation = reported === void 0 ? void 0 : snapshotAccessTokenConfirmation(reported);
2574
3082
  result = mappedFields(result);
3083
+ diagnostic.associate(diagnosticCorrelation("verified-token", result.subject, result.sessionId));
2575
3084
  delete authenticatedRequest.auth;
3085
+ stage = "proof";
2576
3086
  if (confirmation)
2577
3087
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
3088
+ diagnostic.emit("presentation", "ignored", "policy-disabled");
2578
3089
  } else {
2579
- const authorization = readDpopRawHeader(req, "authorization");
2580
3090
  scheme = schemeOf(authorization.value);
2581
- if (authorization.count === 0 || authorization.count === 1 && !authorization.value?.trim()) {
3091
+ if (authorization.count === 0) {
2582
3092
  throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_ACCESS_TOKEN", "Missing access token.");
2583
3093
  }
2584
3094
  const credential = authorization.value?.trim().split(/\s+/);
@@ -2590,13 +3100,17 @@ function createOidcVaultAccessTokenMiddleware(options) {
2590
3100
  );
2591
3101
  }
2592
3102
  token = credential[1];
2593
- const proofHeader = readDpopRawHeader(req, "dpop");
2594
3103
  const { method, originalUrl } = req;
3104
+ stage = "validator";
2595
3105
  result = await validateWithRequest(Object.freeze({ token, scheme, req }));
3106
+ if (!stillPending()) return;
2596
3107
  delete authenticatedRequest.auth;
3108
+ stage = "result";
2597
3109
  confirmation = snapshotAccessTokenConfirmation(result?.confirmation);
2598
3110
  result = mappedFields(result);
3111
+ diagnostic.associate(diagnosticCorrelation("verified-token", result.subject, result.sessionId));
2599
3112
  delete authenticatedRequest.auth;
3113
+ stage = "proof";
2600
3114
  if (confirmation && scheme !== "DPoP")
2601
3115
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2602
3116
  if (!confirmation && scheme === "DPoP")
@@ -2604,7 +3118,7 @@ function createOidcVaultAccessTokenMiddleware(options) {
2604
3118
  if (!confirmation && policy.mode === "required") {
2605
3119
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
2606
3120
  }
2607
- const proof = extractDpopProof(proofHeader);
3121
+ const proof = extractDpopProof(proofHeader, diagnostic);
2608
3122
  if (!proof && confirmation)
2609
3123
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2610
3124
  if (proof) {
@@ -2615,15 +3129,18 @@ function createOidcVaultAccessTokenMiddleware(options) {
2615
3129
  targetUrl: () => targetFor(originalUrl),
2616
3130
  proofOptions: policy,
2617
3131
  accessToken: token,
2618
- expectedJkt: confirmation?.jkt
2619
- })
3132
+ expectedJkt: confirmation?.jkt,
3133
+ diagnostic
3134
+ }),
3135
+ diagnostic
2620
3136
  );
3137
+ if (!stillPending()) return;
2621
3138
  if (admission.type === "nonce-challenge") {
2622
3139
  nonceResponse = toDpopNonceChallengeResponse(admission, "api", policy.algorithms);
2623
3140
  throw new OidcVaultHttpError(401, "OIDC_VAULT_USE_DPOP_NONCE", "A fresh DPoP nonce is required.");
2624
3141
  }
2625
3142
  if (confirmation) deviceBinding = admission.binding;
2626
- }
3143
+ } else diagnostic.emit("presentation", "ignored", "proof-absent");
2627
3144
  }
2628
3145
  const authenticatedToken = token;
2629
3146
  const auth = {
@@ -2647,9 +3164,12 @@ function createOidcVaultAccessTokenMiddleware(options) {
2647
3164
  authenticatedRequest.auth = auth;
2648
3165
  try {
2649
3166
  await onAuthContext?.({ req: authenticatedRequest, res, auth });
3167
+ if (!stillPending()) return;
2650
3168
  authenticatedRequest.auth = restoreAuth();
2651
3169
  } catch (hookError) {
2652
- const response = prepareError(hookError, true);
3170
+ if (!stillPending()) return;
3171
+ state.status = "failed";
3172
+ const response = prepareError(hookError, scheme, void 0, true);
2653
3173
  delete authenticatedRequest.auth;
2654
3174
  let errorAuth;
2655
3175
  try {
@@ -2668,13 +3188,25 @@ function createOidcVaultAccessTokenMiddleware(options) {
2668
3188
  next(hookError);
2669
3189
  return;
2670
3190
  }
2671
- sendError(response);
3191
+ sendError(res, response);
2672
3192
  return;
2673
3193
  }
3194
+ state.auth = authenticatedRequest.auth;
3195
+ state.snapshot = { ...authenticatedRequest.auth };
3196
+ state.status = "authenticated";
2674
3197
  if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
2675
3198
  next();
2676
3199
  } catch (error) {
2677
- const response = prepareError(error);
3200
+ if (!stillPending()) return;
3201
+ diagnostic.emit(
3202
+ "preflight",
3203
+ "rejected",
3204
+ stage === "proof" ? diagnosticPreflightReason(error instanceof OidcVaultHttpError ? error.code : void 0) : "credential-rejected"
3205
+ );
3206
+ const response = prepareError(error, scheme, nonceResponse);
3207
+ const rejected = stage === "validator" ? isAccessTokenRejected(error) : (stage === "credential" || stage === "proof") && error instanceof OidcVaultHttpError && credentialRejectionCodes.has(error.code);
3208
+ const anonymous = authentication === "optional" && invalidCredentials === "anonymous" && rejected && !nonceResponse;
3209
+ state.status = anonymous ? "invalid" : nonceResponse ? "challenged" : "failed";
2678
3210
  delete authenticatedRequest.auth;
2679
3211
  await notifyMiddlewareError(onError, { error, req, res, ...token ? { token } : {} });
2680
3212
  delete authenticatedRequest.auth;
@@ -2682,9 +3214,139 @@ function createOidcVaultAccessTokenMiddleware(options) {
2682
3214
  next(error);
2683
3215
  return;
2684
3216
  }
2685
- sendError(response);
3217
+ if (anonymous && state.status === "invalid") {
3218
+ clearChallenge(res);
3219
+ res.status(200);
3220
+ next();
3221
+ } else sendError(res, response);
2686
3222
  }
2687
3223
  };
3224
+ return Object.freeze({ authenticate, requireAuth: guard(false), requireDeviceBinding: guard(true) });
3225
+ }
3226
+
3227
+ // src/jwt-token-issuer.ts
3228
+ import { createPrivateKey, createSecretKey, KeyObject } from "crypto";
3229
+ import { types } from "util";
3230
+ import { SignJWT } from "jose";
3231
+ var ALGORITHMS = /* @__PURE__ */ new Set([
3232
+ "HS256",
3233
+ "HS384",
3234
+ "HS512",
3235
+ "RS256",
3236
+ "RS384",
3237
+ "RS512",
3238
+ "PS256",
3239
+ "PS384",
3240
+ "PS512",
3241
+ "ES256",
3242
+ "ES384",
3243
+ "ES512",
3244
+ "EdDSA"
3245
+ ]);
3246
+ var RESERVED = ["iss", "aud", "iat", "exp", "sub", "cnf"];
3247
+ var CURVES = { ES256: "prime256v1", ES384: "secp384r1", ES512: "secp521r1" };
3248
+ var requireString = (value, name) => {
3249
+ if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`${name} must be a nonblank string.`);
3250
+ return value;
3251
+ };
3252
+ var signingKey = (configured, algorithm) => {
3253
+ const symmetric = algorithm.startsWith("HS");
3254
+ let key;
3255
+ let cryptoKey;
3256
+ if (configured instanceof Uint8Array) {
3257
+ key = createSecretKey(Uint8Array.from(configured));
3258
+ } else if (configured instanceof KeyObject) {
3259
+ key = configured;
3260
+ } else if (types.isCryptoKey(configured)) {
3261
+ cryptoKey = configured;
3262
+ const expectedName = symmetric ? "HMAC" : algorithm.startsWith("RS") ? "RSASSA-PKCS1-v1_5" : algorithm.startsWith("PS") ? "RSA-PSS" : algorithm.startsWith("ES") ? "ECDSA" : "Ed25519";
3263
+ const parameters = configured.algorithm;
3264
+ if (!configured.usages.includes("sign") || parameters.name !== expectedName || (symmetric || algorithm.startsWith("RS") || algorithm.startsWith("PS")) && parameters.hash?.name !== `SHA-${algorithm.slice(2)}`)
3265
+ throw new TypeError("JWT signing CryptoKey algorithm/hash/usages must match the signing algorithm.");
3266
+ key = KeyObject.from(configured);
3267
+ } else {
3268
+ if (!isRecord(configured)) throw new TypeError("JWT signing key must be a JOSE key.");
3269
+ const jwk = { ...configured, ...configured.key_ops ? { key_ops: [...configured.key_ops] } : {} };
3270
+ 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")))
3271
+ throw new TypeError("JWT signing JWK alg/use/key_ops must permit the signing algorithm.");
3272
+ if (jwk.kty === "oct") {
3273
+ if (typeof jwk.k !== "string" || !/^[A-Za-z0-9_-]+$/.test(jwk.k) || Buffer.from(jwk.k, "base64url").toString("base64url") !== jwk.k)
3274
+ throw new TypeError("JWT signing JWK k must be canonical base64url secret bytes.");
3275
+ key = createSecretKey(Buffer.from(jwk.k, "base64url"));
3276
+ } else {
3277
+ key = createPrivateKey({ key: jwk, format: "jwk" });
3278
+ }
3279
+ }
3280
+ const bits = Number(algorithm.slice(2));
3281
+ const curve = CURVES[algorithm];
3282
+ 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);
3283
+ if (!compatible) throw new TypeError("JWT signing key type/size/curve must match the signing algorithm.");
3284
+ return cryptoKey ?? key;
3285
+ };
3286
+ var tokenTimes = (now, lifetimeSeconds) => {
3287
+ const milliseconds = now();
3288
+ const lifetimeMs = lifetimeSeconds * 1e3;
3289
+ const iat = Math.floor(milliseconds / 1e3);
3290
+ const exp = iat + lifetimeSeconds;
3291
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || !Number.isSafeInteger(lifetimeMs) || !Number.isSafeInteger(milliseconds + lifetimeMs) || !Number.isSafeInteger(exp) || !Number.isSafeInteger(exp * 1e3))
3292
+ throw new TypeError("JWT clock/lifetime must produce safe nonnegative integer milliseconds and expiry.");
3293
+ return { iat, exp };
3294
+ };
3295
+ function assertApplicationClaims(claims) {
3296
+ if (!isRecord(claims) || Array.isArray(claims)) throw new TypeError("JWT mapClaims must return a claims object.");
3297
+ for (const field of RESERVED) {
3298
+ if (field in claims) throw new TypeError(`JWT mapClaims must not supply reserved claim ${field}.`);
3299
+ }
3300
+ }
3301
+ function createOidcVaultJwtAccessTokenIssuer(options) {
3302
+ const {
3303
+ algorithm,
3304
+ issuer: configuredIssuer,
3305
+ audience: configuredAudience,
3306
+ lifetimeSeconds = 300,
3307
+ now = Date.now,
3308
+ mapSubject,
3309
+ mapClaims
3310
+ } = options;
3311
+ if (!ALGORITHMS.has(algorithm)) throw new TypeError("JWT signing algorithm is unsupported.");
3312
+ const issuer = requireString(configuredIssuer, "JWT issuer");
3313
+ const audience = Array.isArray(configuredAudience) ? [...configuredAudience] : configuredAudience;
3314
+ if (Array.isArray(audience)) {
3315
+ if (audience.length === 0) throw new TypeError("JWT audience must not be empty.");
3316
+ for (const value of audience) requireString(value, "JWT audience");
3317
+ } else requireString(audience, "JWT audience");
3318
+ if (!Number.isSafeInteger(lifetimeSeconds) || lifetimeSeconds < 0)
3319
+ throw new TypeError("JWT lifetimeSeconds must be a nonnegative safe integer.");
3320
+ if (typeof now !== "function" || mapSubject !== void 0 && typeof mapSubject !== "function" || mapClaims !== void 0 && typeof mapClaims !== "function")
3321
+ throw new TypeError("JWT clock and mappers must be functions.");
3322
+ const key = signingKey(options.key, algorithm);
3323
+ tokenTimes(now, lifetimeSeconds);
3324
+ return Object.freeze({
3325
+ async issue(input) {
3326
+ const { deviceBinding, session } = input;
3327
+ const { subject: defaultSubject, scope } = session;
3328
+ if (scope !== void 0 && typeof scope !== "string") throw new TypeError("JWT session scope must be a string.");
3329
+ if (deviceBinding !== void 0 && deviceBinding.type !== "dpop")
3330
+ throw new TypeError("JWT deviceBinding must be verified DPoP context.");
3331
+ const cnf = deviceBinding === void 0 ? void 0 : snapshotJkt({ jkt: deviceBinding.jkt }, (message) => new TypeError(message));
3332
+ const subject = requireString(mapSubject ? await mapSubject(input) : defaultSubject, "JWT subject");
3333
+ const mapped = mapClaims ? await mapClaims(input) : {};
3334
+ assertApplicationClaims(mapped);
3335
+ const claims = JSON.parse(JSON.stringify({ ...scope === void 0 ? {} : { scope }, ...mapped }));
3336
+ assertApplicationClaims(claims);
3337
+ const { iat, exp } = tokenTimes(now, lifetimeSeconds);
3338
+ const accessToken = await new SignJWT({
3339
+ ...claims,
3340
+ iss: issuer,
3341
+ aud: audience,
3342
+ sub: subject,
3343
+ iat,
3344
+ exp,
3345
+ ...cnf === void 0 ? {} : { cnf }
3346
+ }).setProtectedHeader({ alg: algorithm, typ: "JWT" }).sign(key);
3347
+ return { accessToken, expiresIn: lifetimeSeconds, tokenType: cnf === void 0 ? "Bearer" : "DPoP" };
3348
+ }
3349
+ });
2688
3350
  }
2689
3351
 
2690
3352
  // src/index.ts
@@ -2699,24 +3361,30 @@ var CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store";
2699
3361
  var applyCredentialResponseCachePolicy = (res) => {
2700
3362
  res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL);
2701
3363
  };
2702
- var createOpaqueId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
2703
- var createPkceVerifier = () => randomBytes3(32).toString("base64url");
2704
- var createPkceChallenge = (verifier) => createHash6("sha256").update(verifier).digest("base64url");
3364
+ var createOpaqueId = (prefix) => `${prefix}_${randomBytes4(16).toString("base64url")}`;
3365
+ var createPkceVerifier = () => randomBytes4(32).toString("base64url");
3366
+ var createPkceChallenge = (verifier) => createHash8("sha256").update(verifier).digest("base64url");
2705
3367
  var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
2706
3368
  var validateOidcVaultOptions = (options) => {
2707
3369
  const {
2708
3370
  deviceBinding: configuredDeviceBinding,
2709
3371
  fingerprintRecognition: configuredFingerprintRecognition,
2710
3372
  transactionCookie: configuredTransactionCookie,
3373
+ logoutNavigation: configuredLogoutNavigation,
2711
3374
  ...otherOptions
2712
3375
  } = options;
2713
3376
  const cookie = otherOptions.cookie ? { ...otherOptions.cookie } : void 0;
2714
3377
  const cookieOptions = { ...otherOptions, cookie };
2715
- validateLifetimeOptions(options, getNow(options));
3378
+ const now = getNow(options);
3379
+ validateLifetimeOptions(options, now);
2716
3380
  const deviceBinding = resolveDeviceBindingOptions(configuredDeviceBinding);
2717
3381
  const fingerprintRecognition = resolveFingerprintRecognitionOptions(configuredFingerprintRecognition);
2718
3382
  const backendOrigin = deviceBinding === void 0 ? resolveBackendOrigin(options) : resolveDeviceBindingOrigin(options.backendOrigin);
2719
- if (deviceBinding !== void 0) assertDeviceBindingStoreCapabilities(options.storeProvider);
3383
+ if (deviceBinding !== void 0)
3384
+ assertDeviceBindingStoreCapabilities(
3385
+ options.storeProvider,
3386
+ configuredLogoutNavigation === void 0 ? "deviceBinding" : "logoutNavigation"
3387
+ );
2720
3388
  if (fingerprintRecognition !== void 0)
2721
3389
  assertDeviceBindingStoreCapabilities(options.storeProvider, "fingerprintRecognition");
2722
3390
  const frontendRedirectUri = resolveFrontendRedirectUri(options);
@@ -2732,6 +3400,16 @@ var validateOidcVaultOptions = (options) => {
2732
3400
  backendOrigin
2733
3401
  );
2734
3402
  const config = resolveOidcVaultConfig(options.config ? { ...options.config } : void 0);
3403
+ const logoutNavigation = resolveLogoutNavigationOptions(configuredLogoutNavigation, {
3404
+ backendOrigin,
3405
+ basePath: normalizeOidcVaultBasePath(otherOptions.basePath),
3406
+ config,
3407
+ frontendRedirectUri,
3408
+ postLogoutRedirectUri: otherOptions.postLogoutRedirectUri,
3409
+ deviceBindingConfigured: deviceBinding !== void 0,
3410
+ store: otherOptions.storeProvider,
3411
+ now
3412
+ });
2735
3413
  const configuredTrustedOrigins = resolveTrustedOrigins(options);
2736
3414
  if (usesCrossSiteCookieTransport(cookieOptions) && configuredTrustedOrigins.size === 0) {
2737
3415
  throw new Error("trustedOrigins is required when using cross-site cookie transport.");
@@ -2743,6 +3421,8 @@ var validateOidcVaultOptions = (options) => {
2743
3421
  deviceBinding,
2744
3422
  fingerprintRecognition,
2745
3423
  transactionCookie,
3424
+ logoutNavigation,
3425
+ ...logoutNavigation === void 0 ? {} : { postLogoutRedirectUri: logoutNavigation.postLogoutRedirectUri },
2746
3426
  ...frontendRedirectUri !== void 0 ? { frontendRedirectUri } : {},
2747
3427
  ...options.trustedOrigins ? { trustedOrigins: [...options.trustedOrigins] } : {},
2748
3428
  ...cookie === void 0 ? {} : { cookie },
@@ -2890,10 +3570,18 @@ async function handleRouteError(route, req, res, next, options, error) {
2890
3570
  sendVaultRoutePolicyResponse(res, response);
2891
3571
  }
2892
3572
  function createAsyncHandler(route, options, handler) {
3573
+ const createDiagnostic = createDpopDiagnosticFactory(options.onDpopDiagnostic);
3574
+ const notify = createDiagnosticDispatcher(options.onTokenOutcome);
2893
3575
  return async (req, res, next) => {
3576
+ const diagnostic = req.method === "POST" && route !== "callback" && route !== "backchannel-logout" ? createDiagnostic(route) : void 0;
2894
3577
  try {
2895
- await handler(req, res, next);
3578
+ await handler(req, res, next, diagnostic, notify);
2896
3579
  } catch (error) {
3580
+ diagnostic?.emit(
3581
+ "preflight",
3582
+ "rejected",
3583
+ diagnosticPreflightReason(error instanceof OidcVaultHttpError ? error.code : void 0)
3584
+ );
2897
3585
  await handleRouteError(route, req, res, next, options, error);
2898
3586
  }
2899
3587
  };
@@ -2939,8 +3627,8 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
2939
3627
  });
2940
3628
  res.redirect(302, authorizationUrl);
2941
3629
  });
2942
- var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrigin, basePath, proofVerifier) => createAsyncHandler("login", options, async (req, res) => {
2943
- const capturedProof = captureVaultRouteProof(req);
3630
+ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrigin, basePath, proofVerifier) => createAsyncHandler("login", options, async (req, res, _next, diagnostic) => {
3631
+ const capturedProof = captureVaultRouteProof(req, diagnostic);
2944
3632
  const fingerprintRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
2945
3633
  assertLoginInitiationRequest(req, options, trustedOrigins);
2946
3634
  const body = getBody(req);
@@ -2949,6 +3637,7 @@ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrig
2949
3637
  }
2950
3638
  const returnTo = resolveLoginReturnTo(body.returnTo, options);
2951
3639
  if (proofVerifier === void 0 && capturedProof.header.count !== 0) {
3640
+ diagnostic?.emit("presentation", "rejected", "policy-disabled");
2952
3641
  throw new OidcVaultHttpError(
2953
3642
  401,
2954
3643
  "OIDC_VAULT_INVALID_DPOP_PROOF",
@@ -2957,6 +3646,7 @@ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrig
2957
3646
  );
2958
3647
  }
2959
3648
  const verifiedBinding = await proofVerifier?.initiate(capturedProof);
3649
+ if (proofVerifier === void 0) diagnostic?.emit("presentation", "ignored", "policy-disabled");
2960
3650
  const binding = snapshotDpopBinding(verifiedBinding);
2961
3651
  const metadata = await resolveProviderMetadata(config, options);
2962
3652
  await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: { ...metadata } });
@@ -3174,8 +3864,8 @@ var copySessionApplicationFields = (session) => {
3174
3864
  }
3175
3865
  return result;
3176
3866
  };
3177
- var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("exchange", options, async (req, res) => {
3178
- const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3867
+ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("exchange", options, async (req, res, _next, diagnostic, notify) => {
3868
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req, diagnostic);
3179
3869
  const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
3180
3870
  const assertExchangeOrigin = captureTrustedOriginGuard(req, options, trustedOrigins, "exchange");
3181
3871
  const cookieHeader = req.headers.cookie;
@@ -3187,14 +3877,24 @@ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) =>
3187
3877
  throw new TypeError("Device-bound exchange requires guarded store reads and atomic consumption.");
3188
3878
  }
3189
3879
  const rawCode = canPreflight ? await store.getExchangeCode(code) : await store.consumeExchangeCode(code);
3190
- if (!rawCode) throw invalidExchangeCode();
3880
+ if (!rawCode) {
3881
+ diagnostic?.emit("preflight", "rejected", "session-unavailable");
3882
+ throw invalidExchangeCode();
3883
+ }
3191
3884
  const record = snapshotVaultExchangeCode(rawCode, code, getNow(options));
3885
+ diagnostic?.associate(diagnosticCorrelation("candidate-session", void 0, record.sessionId));
3192
3886
  const guarded = record.browserBindingHash !== void 0;
3193
3887
  if (guarded) assertExchangeOrigin();
3194
3888
  assertDeviceBindingPolicy(options.deviceBinding, record.deviceBinding);
3195
3889
  const rawSession = await store.getSession(record.sessionId);
3196
- if (!rawSession) throw invalidVaultSession();
3890
+ if (!rawSession) {
3891
+ diagnostic?.emit("preflight", "rejected", "session-unavailable");
3892
+ throw invalidVaultSession();
3893
+ }
3197
3894
  const session = snapshotVaultSession(rawSession, record.sessionId, getNow(options));
3895
+ diagnostic?.associate(
3896
+ diagnosticCorrelation("candidate-session", session.subject, session.logicalSessionId ?? session.sessionId)
3897
+ );
3198
3898
  assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
3199
3899
  assertVaultExchangeSessionBinding(record, session);
3200
3900
  const protectedExchange = proofVerifier !== void 0 || options.fingerprintRecognition !== void 0 || guarded;
@@ -3206,6 +3906,7 @@ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) =>
3206
3906
  const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(session.metadata);
3207
3907
  assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
3208
3908
  const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, session.deviceBinding);
3909
+ if (proofVerifier === void 0) diagnostic?.emit("presentation", "ignored", "policy-disabled");
3209
3910
  if (canPreflight) {
3210
3911
  const consumed = protectedExchange ? await store.consumeExchangeCodeIfMatches({
3211
3912
  code,
@@ -3226,34 +3927,45 @@ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) =>
3226
3927
  }
3227
3928
  assertVaultSessionIdentity(session, config);
3228
3929
  try {
3229
- const issuedToken = await withIssuedToken(req, res, options, session, verifiedBinding);
3930
+ const issuedToken = await withIssuedToken(req, res, options, session, "exchange", verifiedBinding);
3230
3931
  if (usesCookieTransport(options)) setSessionCookie(res, options, session.sessionId);
3231
3932
  if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
3232
3933
  applyCredentialResponseCachePolicy(res);
3233
- res.status(200).json(createExchangeResponse(options, session, issuedToken));
3934
+ const response = createExchangeResponse(options, session, issuedToken);
3935
+ notifyTokenOutcomes("exchange", session, issuedToken, notify, diagnostic);
3936
+ res.status(200).json(response);
3234
3937
  } catch (error) {
3235
3938
  if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
3236
3939
  throw error;
3237
3940
  }
3238
3941
  });
3239
- var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("refresh", options, async (req, res) => {
3942
+ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("refresh", options, async (req, res, _next, diagnostic, notify) => {
3240
3943
  const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
3241
3944
  assertTrustedOrigin(req, options, trustedOrigins, "refresh");
3242
- const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3945
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req, diagnostic);
3243
3946
  const sessionId = getSessionIdFromRequest(req, options, "refresh");
3244
3947
  const currentRecord = await options.storeProvider.getSession(sessionId);
3245
3948
  if (!currentRecord) {
3949
+ diagnostic?.emit("preflight", "rejected", "session-unavailable");
3246
3950
  if (usesCookieTransport(options)) {
3247
3951
  clearSessionCookie(res, options);
3248
3952
  }
3249
3953
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
3250
3954
  }
3251
3955
  const currentSession = snapshotVaultSession(currentRecord, sessionId, getNow(options));
3956
+ diagnostic?.associate(
3957
+ diagnosticCorrelation(
3958
+ "candidate-session",
3959
+ currentSession.subject,
3960
+ currentSession.logicalSessionId ?? currentSession.sessionId
3961
+ )
3962
+ );
3252
3963
  assertVaultSessionIdentity(currentSession, config);
3253
3964
  assertDeviceBindingPolicy(options.deviceBinding, currentSession.deviceBinding);
3254
3965
  const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(currentSession.metadata);
3255
3966
  assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
3256
3967
  const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, currentSession.deviceBinding);
3968
+ if (proofVerifier === void 0) diagnostic?.emit("presentation", "ignored", "policy-disabled");
3257
3969
  const metadata = await resolveProviderMetadata(config, options);
3258
3970
  if (proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) {
3259
3971
  const latest = await options.storeProvider.getSession(sessionId);
@@ -3327,7 +4039,7 @@ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => c
3327
4039
  throw error;
3328
4040
  }
3329
4041
  const rotatedSession = expectedSession;
3330
- const issuedToken = await withIssuedToken(req, res, options, rotatedSession, verifiedBinding);
4042
+ const issuedToken = await withIssuedToken(req, res, options, rotatedSession, "refresh", verifiedBinding);
3331
4043
  await callPostCommitHook(
3332
4044
  "refresh",
3333
4045
  options,
@@ -3344,14 +4056,21 @@ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => c
3344
4056
  }
3345
4057
  const response = createExchangeResponse(options, rotatedSession, issuedToken);
3346
4058
  applyCredentialResponseCachePolicy(res);
4059
+ notifyTokenOutcomes("refresh", rotatedSession, issuedToken, notify, diagnostic);
3347
4060
  res.status(200).json(response);
3348
4061
  });
3349
- var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("logout", options, async (req, res) => {
3350
- assertTrustedOrigin(req, options, trustedOrigins, "logout");
3351
- const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3352
- const body = getBody(req);
4062
+ var notifyLogoutError = async (options, req, res, error, session) => {
4063
+ try {
4064
+ await options.hooks?.onError?.({
4065
+ ...createHookContext("logout", req, res, session === void 0 ? void 0 : copyVaultSession(session)),
4066
+ error
4067
+ });
4068
+ } catch {
4069
+ }
4070
+ };
4071
+ var performLogout = async (options, config, proofVerifier, req, res, diagnostic, redirect, navigation) => {
4072
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req, diagnostic);
3353
4073
  const sessionId = getSessionIdFromRequest(req, options, "logout");
3354
- const redirect = body.redirect === true;
3355
4074
  const store = options.storeProvider;
3356
4075
  const hasContext = typeof store.getSessionRevocationContext === "function";
3357
4076
  if (proofVerifier !== void 0 && !hasContext) {
@@ -3361,18 +4080,29 @@ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => cr
3361
4080
  const context = rawContext ? snapshotVaultRevocationContext(rawContext) : void 0;
3362
4081
  const rawSession = await store.getSession(sessionId);
3363
4082
  const session = rawSession ? snapshotVaultSession(rawSession, sessionId, getNow(options)) : void 0;
4083
+ if (session || context)
4084
+ diagnostic?.associate(
4085
+ diagnosticCorrelation(
4086
+ "candidate-session",
4087
+ session?.subject,
4088
+ context?.logicalSessionId ?? session?.logicalSessionId ?? session?.sessionId
4089
+ )
4090
+ );
3364
4091
  const finishLocalLogout = () => {
4092
+ diagnostic?.emit(
4093
+ "presentation",
4094
+ "ignored",
4095
+ options.deviceBinding === void 0 ? "policy-disabled" : "target-absent"
4096
+ );
3365
4097
  if (usesCookieTransport(options)) clearSessionCookie(res, options);
3366
- applyCredentialResponseCachePolicy(res);
3367
- res.status(200).json({ loggedOut: true });
4098
+ return {};
3368
4099
  };
3369
4100
  if (hasContext && context === void 0) {
3370
4101
  if (session !== void 0) {
3371
4102
  assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
3372
4103
  throw invalidVaultSession();
3373
4104
  }
3374
- finishLocalLogout();
3375
- return;
4105
+ return finishLocalLogout();
3376
4106
  }
3377
4107
  const authority = context ?? (session === void 0 ? void 0 : snapshotVaultRevocationContext({
3378
4108
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
@@ -3381,11 +4111,12 @@ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => cr
3381
4111
  }));
3382
4112
  if (authority === void 0) {
3383
4113
  await store.deleteSession(sessionId);
3384
- finishLocalLogout();
3385
- return;
4114
+ return finishLocalLogout();
3386
4115
  }
3387
4116
  assertVaultSessionIdentity(authority, config);
3388
4117
  assertDeviceBindingPolicy(options.deviceBinding, authority.deviceBinding);
4118
+ if (navigation !== void 0 && authority.deviceBinding === void 0)
4119
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
3389
4120
  if (session !== void 0)
3390
4121
  assertVaultRevocationContextMatches(
3391
4122
  snapshotVaultRevocationContext({
@@ -3396,54 +4127,95 @@ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => cr
3396
4127
  authority
3397
4128
  );
3398
4129
  if (proofVerifier !== void 0) await proofVerifier.verify(capturedProof, authority.deviceBinding);
4130
+ else diagnostic?.emit("presentation", "ignored", "policy-disabled");
3399
4131
  if (hasContext) {
3400
4132
  const latest = await store.getSessionRevocationContext(sessionId);
3401
4133
  if (!latest) {
3402
- finishLocalLogout();
3403
- return;
4134
+ return finishLocalLogout();
3404
4135
  }
3405
4136
  assertVaultRevocationContextMatches(snapshotVaultRevocationContext(latest), authority);
3406
4137
  }
3407
4138
  if (session !== void 0)
3408
4139
  await callHook("logout", options.hooks?.onBeforeLogout, req, res, copyVaultSession(session));
3409
4140
  await store.deleteSessionsByLogicalSessionId({ logicalSessionId: authority.logicalSessionId });
3410
- if (usesCookieTransport(options)) {
3411
- clearSessionCookie(res, options);
4141
+ try {
4142
+ if (usesCookieTransport(options)) clearSessionCookie(res, options);
4143
+ if (session === void 0 || !redirect && navigation === void 0) return {};
4144
+ if (navigation !== void 0 && !isString(session.idToken)) return {};
4145
+ try {
4146
+ const metadata = await resolveProviderMetadata(config, options);
4147
+ if (!metadata.endSessionEndpoint) return {};
4148
+ const endpoint = navigation === void 0 ? metadata.endSessionEndpoint : validateUpstreamLogoutUrl(metadata.endSessionEndpoint);
4149
+ const builtUrl = buildLogoutUrl(endpoint, session.idToken, options.postLogoutRedirectUri);
4150
+ if (navigation === void 0) return { upstreamLogoutUrl: builtUrl };
4151
+ const upstreamLogoutUrl = validateUpstreamLogoutUrl(builtUrl);
4152
+ const navigationUrl = await persistLogoutNavigation(navigation, store, upstreamLogoutUrl, getNow(options));
4153
+ return { navigationUrl };
4154
+ } catch (error) {
4155
+ await notifyLogoutError(options, req, res, error, session);
4156
+ return {};
4157
+ }
4158
+ } finally {
4159
+ if (session !== void 0)
4160
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3412
4161
  }
3413
- if (session === void 0) {
3414
- applyCredentialResponseCachePolicy(res);
3415
- res.status(200).json({ loggedOut: true });
4162
+ };
4163
+ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier, navigation) => createAsyncHandler("logout", options, async (req, res, _next, diagnostic) => {
4164
+ if (navigation === void 0) assertTrustedOrigin(req, options, trustedOrigins, "logout");
4165
+ else {
4166
+ assertLogoutNavigationRequest(req, options, trustedOrigins);
4167
+ const body = req.body;
4168
+ if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).some((key) => key !== "sessionId") || Object.hasOwn(body, "sessionId") && typeof body.sessionId !== "string")
4169
+ throw new OidcVaultHttpError(
4170
+ 400,
4171
+ "OIDC_VAULT_INVALID_REQUEST_BODY",
4172
+ "Logout navigation request body is invalid."
4173
+ );
4174
+ }
4175
+ const redirect = navigation === void 0 && getBody(req).redirect === true;
4176
+ const result = await performLogout(options, config, proofVerifier, req, res, diagnostic, redirect, navigation);
4177
+ applyCredentialResponseCachePolicy(res);
4178
+ if (navigation !== void 0) {
4179
+ res.status(200).json({
4180
+ loggedOut: true,
4181
+ navigationUrl: result.navigationUrl ?? navigation.navigationUrl
4182
+ });
3416
4183
  return;
3417
4184
  }
3418
- if (!redirect) {
3419
- await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3420
- applyCredentialResponseCachePolicy(res);
3421
- res.status(200).json({ loggedOut: true });
4185
+ if (result.upstreamLogoutUrl) {
4186
+ res.redirect(302, result.upstreamLogoutUrl);
3422
4187
  return;
3423
4188
  }
3424
- let upstreamLogoutUrl;
3425
- try {
3426
- const metadata = await resolveProviderMetadata(config, options);
3427
- upstreamLogoutUrl = metadata.endSessionEndpoint ? buildLogoutUrl(metadata.endSessionEndpoint, session.idToken, options.postLogoutRedirectUri) : void 0;
3428
- } catch (error) {
4189
+ res.status(200).json({ loggedOut: true });
4190
+ });
4191
+ var assertLogoutNavigationRequest = (req, options, trustedOrigins) => {
4192
+ captureTrustedOriginGuard(req, options, trustedOrigins, "logout", true)();
4193
+ if (!req.is("application/json"))
4194
+ throw new OidcVaultHttpError(
4195
+ 415,
4196
+ "OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE",
4197
+ "Logout navigation requires a JSON request body."
4198
+ );
4199
+ };
4200
+ var createLogoutNavigationGetHandler = (options) => async (req, res) => {
4201
+ const policy = options.logoutNavigation;
4202
+ let location = policy.fallbackRedirectUri;
4203
+ const ticketHash = navigationTicketHash(req.originalUrl);
4204
+ if (ticketHash !== void 0) {
3429
4205
  try {
3430
- await options.hooks?.onError?.({
3431
- ...createHookContext("logout", req, res, copyVaultSession(session)),
3432
- error
4206
+ const record = await options.storeProvider.consumeLogoutNavigationTicket({
4207
+ namespace: policy.namespace,
4208
+ ticketHash
3433
4209
  });
3434
- } catch {
4210
+ if (record !== null)
4211
+ location = consumedLogoutDestination(record, policy.namespace, ticketHash, getNow(options));
4212
+ } catch (error) {
4213
+ await notifyLogoutError(options, req, res, error);
3435
4214
  }
3436
4215
  }
3437
- await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3438
- applyCredentialResponseCachePolicy(res);
3439
- if (redirect && upstreamLogoutUrl) {
3440
- res.redirect(302, upstreamLogoutUrl);
3441
- return;
3442
- }
3443
- res.status(200).json({
3444
- loggedOut: true
3445
- });
3446
- });
4216
+ res.status(302).setHeader("Location", location);
4217
+ res.end();
4218
+ };
3447
4219
  var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("backchannel-logout", options, async (req, res) => {
3448
4220
  const metadata = await resolveProviderMetadata(config, options);
3449
4221
  const logoutToken = getLogoutTokenFromRequest(req);
@@ -3505,6 +4277,10 @@ function registerRoutes(router, options, config, trustedOrigins, backendOrigin,
3505
4277
  router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config, trustedOrigins, proofVerifier));
3506
4278
  router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins, proofVerifier));
3507
4279
  router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins, proofVerifier));
4280
+ if (options.logoutNavigation !== void 0) {
4281
+ const path = OIDC_VAULT_ROUTE_PATHS["logout-navigation"];
4282
+ router.post(path, createLogoutHandler(options, config, trustedOrigins, proofVerifier, options.logoutNavigation));
4283
+ }
3508
4284
  router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
3509
4285
  }
3510
4286
  function createOidcVaultMiddleware(options) {
@@ -3517,6 +4293,26 @@ function createOidcVaultMiddleware(options) {
3517
4293
  applyCredentialResponseCachePolicy(res);
3518
4294
  next();
3519
4295
  });
4296
+ if (resolvedOptions.logoutNavigation !== void 0) {
4297
+ const path = OIDC_VAULT_ROUTE_PATHS["logout-navigation"];
4298
+ baseRouter.all(path, (_req, res, next) => {
4299
+ res.setHeader("Referrer-Policy", "no-referrer");
4300
+ next();
4301
+ });
4302
+ baseRouter.head(path, (_req, res) => {
4303
+ res.setHeader("Allow", "GET, POST");
4304
+ res.status(405).end();
4305
+ });
4306
+ baseRouter.get(path, createLogoutNavigationGetHandler(resolvedOptions));
4307
+ baseRouter.post(
4308
+ path,
4309
+ createAsyncHandler("logout", resolvedOptions, async (req, _res, next) => {
4310
+ assertLogoutNavigationRequest(req, resolvedOptions, trustedOrigins);
4311
+ next();
4312
+ })
4313
+ );
4314
+ baseRouter.post(path, express.json({ limit: requestBodyLimit, strict: false }));
4315
+ }
3520
4316
  if (resolvedOptions.deviceBinding !== void 0 || resolvedOptions.fingerprintRecognition !== void 0) {
3521
4317
  baseRouter.post(
3522
4318
  OIDC_VAULT_ROUTE_PATHS.login,
@@ -3547,9 +4343,14 @@ export {
3547
4343
  DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
3548
4344
  OIDC_VAULT_ROUTE_PATHS,
3549
4345
  OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
4346
+ OidcVaultAuthDeniedError,
3550
4347
  OidcVaultDpopReplayCapacityError,
4348
+ OidcVaultInvalidAccessTokenError,
4349
+ OidcVaultLogoutNavigationCapacityError,
3551
4350
  OidcVaultStoreConflictError,
3552
4351
  createOidcVaultAccessTokenMiddleware,
4352
+ createOidcVaultAuthenticator,
4353
+ createOidcVaultJwtAccessTokenIssuer,
3553
4354
  createOidcVaultJwtAccessTokenValidator,
3554
4355
  createOidcVaultMiddleware,
3555
4356
  normalizeOidcVaultBasePath,