@web-ts-toolkit/express-oidc-vault-mongodb-store 0.47.1 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -26,21 +26,96 @@ __export(index_exports, {
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/store.ts
29
- var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
29
+ var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
30
30
  var import_node_util = require("util");
31
31
 
32
+ // src/binding.ts
33
+ var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
34
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
35
+ var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
36
+ var isBinding = (value) => isRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isHash(value.jkt);
37
+ var hasValidSessionBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
38
+ var hasValidRecordBinding = (record) => hasValidSessionBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
39
+ var assertRecordBinding = (record) => {
40
+ if (!hasValidRecordBinding(record))
41
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
42
+ };
43
+ var assertSessionBinding = (record) => {
44
+ if (!hasValidSessionBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
45
+ };
46
+ var isBindingMatch = (match) => isRecord(match) && Object.hasOwn(match, "deviceBinding") && Object.hasOwn(match, "browserBindingHash") && (match.deviceBinding === null || isBinding(match.deviceBinding)) && (match.browserBindingHash === null || isHash(match.browserBindingHash));
47
+ var bindingMatchFilter = (match) => ({
48
+ ...match.deviceBinding === null ? { deviceBinding: { $exists: false } } : {
49
+ "deviceBinding.type": match.deviceBinding.type,
50
+ "deviceBinding.jkt": match.deviceBinding.jkt,
51
+ $expr: {
52
+ $eq: [
53
+ {
54
+ $size: {
55
+ $objectToArray: { $cond: [{ $eq: [{ $type: "$deviceBinding" }, "object"] }, "$deviceBinding", {}] }
56
+ }
57
+ },
58
+ 2
59
+ ]
60
+ }
61
+ },
62
+ browserBindingHash: match.browserBindingHash === null ? { $exists: false } : match.browserBindingHash
63
+ });
64
+ var inheritSessionBinding = (source, next) => {
65
+ assertSessionBinding(source);
66
+ assertSessionBinding(next);
67
+ if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
68
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
69
+ }
70
+ if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
71
+ };
72
+ var toRevocationContext = (session) => {
73
+ assertSessionBinding(session);
74
+ const provider = session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider;
75
+ const expiry = session.deviceBinding === void 0 && session.expiresAt === null ? void 0 : session.expiresAt;
76
+ if (typeof session._id !== "string" || typeof session.subject !== "string" || session.logicalSessionId !== void 0 && typeof session.logicalSessionId !== "string" || expiry !== void 0 && (!(expiry instanceof Date) || !Number.isFinite(expiry.getTime())) || provider !== void 0 && (!isRecord(provider) || provider.issuer !== void 0 && typeof provider.issuer !== "string" || provider.clientId !== void 0 && typeof provider.clientId !== "string")) {
77
+ throw new Error("OIDC vault MongoDB lineage has malformed session/provider authority.");
78
+ }
79
+ return {
80
+ logicalSessionId: session.logicalSessionId ?? session._id,
81
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
82
+ ...provider === void 0 ? {} : {
83
+ provider: {
84
+ ...provider.issuer === void 0 ? {} : { issuer: provider.issuer },
85
+ ...provider.clientId === void 0 ? {} : { clientId: provider.clientId }
86
+ }
87
+ }
88
+ };
89
+ };
90
+ var assertSameLineageAuthority = (source, member) => {
91
+ toRevocationContext(member);
92
+ if (source.deviceBinding?.jkt !== member.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== member.provider?.issuer || source.provider?.clientId !== member.provider?.clientId) {
93
+ throw new Error("OIDC vault MongoDB lineage has inconsistent device binding or provider identity.");
94
+ }
95
+ };
96
+
32
97
  // src/documents.ts
98
+ var providerToDocument = (provider) => {
99
+ if (provider === null || typeof provider !== "object") return provider;
100
+ const prototype = Object.getPrototypeOf(provider);
101
+ if (prototype !== Object.prototype && prototype !== null) return provider;
102
+ const copy = { ...provider };
103
+ if (copy.issuer === void 0) delete copy.issuer;
104
+ if (copy.clientId === void 0) delete copy.clientId;
105
+ return copy;
106
+ };
33
107
  var sessionToDocument = (session) => ({
34
108
  _id: session.sessionId,
35
109
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
36
110
  subject: session.subject,
37
111
  providerSessionId: session.providerSessionId,
38
- provider: session.provider,
112
+ ...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
113
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
39
114
  refreshToken: session.refreshToken,
40
115
  idToken: session.idToken,
41
116
  accessToken: session.accessToken,
42
117
  scope: session.scope,
43
- expiresAt: typeof session.expiresAt === "number" ? new Date(session.expiresAt) : void 0,
118
+ ...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
44
119
  createdAt: session.createdAt,
45
120
  updatedAt: session.updatedAt,
46
121
  user: session.user,
@@ -51,7 +126,8 @@ var documentToSession = (session) => ({
51
126
  logicalSessionId: session.logicalSessionId ?? session._id,
52
127
  subject: session.subject,
53
128
  providerSessionId: session.providerSessionId,
54
- provider: session.provider,
129
+ provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
130
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
55
131
  refreshToken: session.refreshToken,
56
132
  idToken: session.idToken,
57
133
  accessToken: session.accessToken,
@@ -62,22 +138,26 @@ var documentToSession = (session) => ({
62
138
  user: session.user,
63
139
  metadata: session.metadata
64
140
  });
65
- var authorizationTransactionToDocument = (record) => ({
66
- _id: record.state,
67
- ...record,
68
- expiresAt: new Date(record.expiresAt)
69
- });
70
- var exchangeCodeToDocument = (record) => ({
71
- _id: record.code,
72
- ...record,
73
- expiresAt: new Date(record.expiresAt)
74
- });
141
+ var authorizationTransactionToDocument = (record) => {
142
+ const document = { _id: record.state, ...record, expiresAt: new Date(record.expiresAt) };
143
+ if (document.deviceBinding === void 0) delete document.deviceBinding;
144
+ if (document.browserBindingHash === void 0) delete document.browserBindingHash;
145
+ return document;
146
+ };
147
+ var exchangeCodeToDocument = (record) => {
148
+ const document = { _id: record.code, ...record, expiresAt: new Date(record.expiresAt) };
149
+ if (document.deviceBinding === void 0) delete document.deviceBinding;
150
+ if (document.browserBindingHash === void 0) delete document.browserBindingHash;
151
+ return document;
152
+ };
75
153
  var authorizationDocumentToRecord = (record) => ({
76
154
  state: record.state,
77
155
  nonce: record.nonce,
78
156
  pkceVerifier: record.pkceVerifier,
79
157
  codeChallenge: record.codeChallenge,
80
158
  returnTo: record.returnTo,
159
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
160
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
81
161
  createdAt: record.createdAt,
82
162
  expiresAt: record.expiresAt.getTime(),
83
163
  metadata: record.metadata
@@ -86,10 +166,17 @@ var exchangeDocumentToRecord = (record) => ({
86
166
  code: record.code,
87
167
  sessionId: record.sessionId,
88
168
  returnTo: record.returnTo,
169
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
170
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
89
171
  createdAt: record.createdAt,
90
172
  expiresAt: record.expiresAt.getTime()
91
173
  });
92
174
  var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
175
+ var validDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
176
+ var optionalString = (value) => value === void 0 || typeof value === "string";
177
+ var validAuthorizationDocument = (record, state) => record._id === state && record.state === state && typeof record.nonce === "string" && typeof record.pkceVerifier === "string" && typeof record.codeChallenge === "string" && Number.isFinite(record.createdAt) && validDate(record.expiresAt) && hasValidRecordBinding(record);
178
+ var validExchangeDocument = (record, code) => record._id === code && record.code === code && typeof record.sessionId === "string" && Number.isFinite(record.createdAt) && validDate(record.expiresAt) && hasValidRecordBinding(record);
179
+ var validSessionDocument = (record, sessionId) => record._id === sessionId && typeof record.subject === "string" && optionalString(record.logicalSessionId) && typeof record.refreshToken === "string" && typeof record.idToken === "string" && (record.expiresAt === void 0 || record.deviceBinding === void 0 && record.expiresAt === null || validDate(record.expiresAt)) && hasValidSessionBinding(record);
93
180
 
94
181
  // src/options.ts
95
182
  var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
@@ -98,7 +185,9 @@ var DEFAULT_COLLECTION_NAMES = {
98
185
  exchangeCodes: "oidc_vault_exchange_codes",
99
186
  sessions: "oidc_vault_sessions",
100
187
  backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
101
- rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
188
+ rotatedSessionAliases: "oidc_vault_rotated_session_aliases",
189
+ dpopProofs: "oidc_vault_dpop_proofs",
190
+ dpopReplayCapacity: "oidc_vault_dpop_replay_capacity"
102
191
  };
103
192
  var validateCollectionName = (role, name) => {
104
193
  if (name.length === 0) {
@@ -120,7 +209,9 @@ var resolveCollectionNames = (options) => {
120
209
  exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
121
210
  sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
122
211
  backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
123
- rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
212
+ rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases,
213
+ dpopProofs: options.dpopProofsCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopProofs,
214
+ dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity
124
215
  };
125
216
  for (const [role, name] of Object.entries(collectionNames)) {
126
217
  validateCollectionName(role, name);
@@ -162,6 +253,10 @@ var ensureStoreIndexes = async (collections) => {
162
253
  createTtlIndex(collections.sessions),
163
254
  createTtlIndex(collections.backchannelLogoutTokenJtis),
164
255
  createTtlIndex(collections.rotatedSessionAliases),
256
+ createTtlIndex(collections.dpopProofs),
257
+ // Accounting outlives physical proof TTL deletion until a serialized
258
+ // bounded cleanup decrements capacity. Never TTL-delete these entries.
259
+ collections.dpopReplayCapacity.createIndex({ kind: 1, expiresAt: 1 }, { name: "dpop_expiry_accounting_idx" }),
165
260
  collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
166
261
  collections.sessions.createIndex(
167
262
  { subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
@@ -175,6 +270,120 @@ var ensureStoreIndexes = async (collections) => {
175
270
  ]);
176
271
  };
177
272
 
273
+ // src/dpop-replay.ts
274
+ var import_express_oidc_vault2 = require("@web-ts-toolkit/express-oidc-vault");
275
+ var CAPACITY_ID = "capacity";
276
+ var reservationId = (replayKey) => `proof:${replayKey}`;
277
+ var isDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
278
+ var MongoDpopReplayReservations = class {
279
+ constructor(db, proofs, capacity, maxEntries, now) {
280
+ this.db = db;
281
+ this.proofs = proofs;
282
+ this.capacity = capacity;
283
+ this.maxEntries = maxEntries;
284
+ this.now = now;
285
+ }
286
+ async initialize() {
287
+ const indexes = await this.capacity.listIndexes().toArray();
288
+ if (indexes.some((index) => index.expireAfterSeconds !== void 0)) {
289
+ throw new Error("OIDC vault MongoDB replay capacity accounting must not have TTL indexes.");
290
+ }
291
+ try {
292
+ await this.capacity.updateOne(
293
+ { _id: CAPACITY_ID },
294
+ {
295
+ $setOnInsert: {
296
+ kind: "capacity",
297
+ entries: 0,
298
+ revision: 0,
299
+ maxEntries: this.maxEntries,
300
+ proofsCollectionName: this.proofs.collectionName
301
+ }
302
+ },
303
+ { upsert: true }
304
+ );
305
+ } catch (error) {
306
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === 11e3)) throw error;
307
+ }
308
+ this.validateCapacity(await this.capacity.findOne({ _id: CAPACITY_ID }));
309
+ }
310
+ async reserve(input) {
311
+ if (!this.validWindow(input, this.now())) return false;
312
+ const session = this.db.client.startSession();
313
+ try {
314
+ const result = await session.withTransaction(
315
+ async () => {
316
+ const now = this.now();
317
+ if (!this.validWindow(input, now)) return false;
318
+ const capacity = await this.capacity.findOneAndUpdate(
319
+ { _id: CAPACITY_ID },
320
+ { $inc: { revision: 1 } },
321
+ { session, returnDocument: "after" }
322
+ );
323
+ this.validateCapacity(capacity);
324
+ const proof = await this.proofs.findOne({ _id: input.replayKey }, { session });
325
+ if (proof && !isDate(proof.expiresAt))
326
+ throw new Error("OIDC vault MongoDB replay proof has malformed expiry.");
327
+ if (proof && proof.expiresAt.getTime() > now) return false;
328
+ const id = reservationId(input.replayKey);
329
+ const existing = await this.capacity.findOne({ _id: id }, { session });
330
+ if (existing) {
331
+ this.validateReservation(existing);
332
+ if (existing.expiresAt.getTime() > now) return false;
333
+ }
334
+ let removed = 0;
335
+ if (existing) removed += (await this.capacity.deleteOne({ _id: id }, { session })).deletedCount;
336
+ const expired = await this.capacity.find({ kind: "reservation", expiresAt: { $lte: new Date(now) } }, { session }).sort({ expiresAt: 1 }).limit(64).toArray();
337
+ for (const row of expired) this.validateReservation(row);
338
+ if (expired.length > 0) {
339
+ removed += (await this.capacity.deleteMany(
340
+ { _id: { $in: expired.map((row) => row._id) }, kind: "reservation" },
341
+ { session }
342
+ )).deletedCount;
343
+ await this.proofs.deleteMany(
344
+ { _id: { $in: expired.map((row) => row.replayKey) }, expiresAt: { $lte: new Date(now) } },
345
+ { session }
346
+ );
347
+ }
348
+ const entries = capacity.entries - removed;
349
+ if (!Number.isSafeInteger(entries) || entries < 0)
350
+ throw new Error("OIDC vault MongoDB replay capacity accounting is inconsistent.");
351
+ if (entries >= this.maxEntries) throw new import_express_oidc_vault2.OidcVaultDpopReplayCapacityError();
352
+ await this.proofs.replaceOne(
353
+ { _id: input.replayKey },
354
+ { expiresAt: new Date(input.expiresAt) },
355
+ { upsert: true, session }
356
+ );
357
+ await this.capacity.insertOne(
358
+ { _id: id, kind: "reservation", replayKey: input.replayKey, expiresAt: new Date(input.expiresAt) },
359
+ { session }
360
+ );
361
+ await this.capacity.updateOne({ _id: CAPACITY_ID }, { $set: { entries: entries + 1 } }, { session });
362
+ return true;
363
+ },
364
+ { readConcern: { level: "snapshot" } }
365
+ );
366
+ return result === true;
367
+ } finally {
368
+ await session.endSession();
369
+ }
370
+ }
371
+ validWindow(input, now) {
372
+ if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
373
+ return typeof input.replayKey === "string" && Number.isSafeInteger(input.expiresAt) && input.expiresAt > now && input.expiresAt - now <= 36e4;
374
+ }
375
+ validateCapacity(row) {
376
+ if (!row || row.kind !== "capacity" || row.maxEntries !== this.maxEntries || row.proofsCollectionName !== this.proofs.collectionName || !Number.isSafeInteger(row.entries) || row.entries < 0 || row.entries > this.maxEntries || !Number.isSafeInteger(row.revision)) {
377
+ throw new Error("OIDC vault MongoDB replay capacity configuration/accounting is inconsistent.");
378
+ }
379
+ }
380
+ validateReservation(row) {
381
+ if (row.kind !== "reservation" || typeof row.replayKey !== "string" || row._id !== reservationId(row.replayKey) || !isDate(row.expiresAt)) {
382
+ throw new Error("OIDC vault MongoDB replay expiry accounting is malformed.");
383
+ }
384
+ }
385
+ };
386
+
178
387
  // src/store.ts
179
388
  var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
180
389
  if (input === null || typeof input !== "object") return input;
@@ -199,10 +408,17 @@ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: inp
199
408
  var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
200
409
  var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
201
410
  var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
202
- var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && (0, import_node_util.isDeepStrictEqual)(current.provider, expected.provider) && current.refreshToken === expected.refreshToken && current.idToken === expected.idToken && current.accessToken === expected.accessToken && current.scope === expected.scope && (current.expiresAt?.getTime() ?? null) === (expected.expiresAt?.getTime() ?? null) && current.createdAt === expected.createdAt && current.updatedAt === expected.updatedAt && (0, import_node_util.isDeepStrictEqual)(current.user, expected.user) && (0, import_node_util.isDeepStrictEqual)(current.metadata, expected.metadata);
411
+ var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && (0, import_node_util.isDeepStrictEqual)(
412
+ current.deviceBinding === void 0 && current.provider === null ? void 0 : current.provider,
413
+ expected.provider
414
+ ) && (0, import_node_util.isDeepStrictEqual)(current.deviceBinding, expected.deviceBinding) && current.refreshToken === expected.refreshToken && current.idToken === expected.idToken && current.accessToken === expected.accessToken && current.scope === expected.scope && (current.expiresAt?.getTime() ?? null) === (expected.expiresAt?.getTime() ?? null) && current.createdAt === expected.createdAt && current.updatedAt === expected.updatedAt && (0, import_node_util.isDeepStrictEqual)(current.user, expected.user) && (0, import_node_util.isDeepStrictEqual)(current.metadata, expected.metadata);
203
415
  var MongoOidcVaultStore = class {
204
416
  constructor(options) {
205
417
  const collectionNames = resolveCollectionNames(options);
418
+ const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
419
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
420
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
421
+ }
206
422
  this.db = options.db;
207
423
  this.authorizationTransactions = options.db.collection(
208
424
  collectionNames.authorizationTransactions
@@ -215,11 +431,20 @@ var MongoOidcVaultStore = class {
215
431
  this.rotatedSessionAliases = options.db.collection(
216
432
  collectionNames.rotatedSessionAliases
217
433
  );
434
+ this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
435
+ this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
218
436
  this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
219
437
  if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
220
438
  throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
221
439
  }
222
440
  this.now = options.now ?? (() => Date.now());
441
+ this.dpopReplay = new MongoDpopReplayReservations(
442
+ this.db,
443
+ this.dpopProofs,
444
+ this.dpopReplayCapacity,
445
+ maxEntries,
446
+ this.now
447
+ );
223
448
  this.initialization = this.initialize().then(void 0, (error) => {
224
449
  this.initializationError = error;
225
450
  });
@@ -229,6 +454,7 @@ var MongoOidcVaultStore = class {
229
454
  }
230
455
  async createAuthorizationTransaction(input) {
231
456
  input = snapshotInput({ ...input });
457
+ assertRecordBinding(input);
232
458
  await this.waitUntilReady();
233
459
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
234
460
  upsert: true
@@ -236,27 +462,70 @@ var MongoOidcVaultStore = class {
236
462
  }
237
463
  async consumeAuthorizationTransaction(state) {
238
464
  await this.waitUntilReady();
239
- const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
240
- if (!record || isExpired(record, this.now())) {
465
+ const record = await this.authorizationTransactions.findOneAndDelete({
466
+ _id: state,
467
+ deviceBinding: { $exists: false },
468
+ browserBindingHash: { $exists: false }
469
+ });
470
+ if (!record || !validAuthorizationDocument(record, state) || isExpired(record, this.now())) {
241
471
  return null;
242
472
  }
243
473
  return authorizationDocumentToRecord(record);
244
474
  }
475
+ async getAuthorizationTransaction(state) {
476
+ await this.waitUntilReady();
477
+ const record = await this.authorizationTransactions.findOne({ _id: state });
478
+ return record && validAuthorizationDocument(record, state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
479
+ }
480
+ async consumeAuthorizationTransactionIfMatches(input) {
481
+ input = snapshotInput({ ...input });
482
+ if (!isBindingMatch(input.match)) return null;
483
+ await this.waitUntilReady();
484
+ const record = await this.authorizationTransactions.findOneAndDelete({
485
+ _id: input.state,
486
+ expiresAt: { $gt: new Date(this.now()) },
487
+ ...bindingMatchFilter(input.match)
488
+ });
489
+ return record && validAuthorizationDocument(record, input.state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
490
+ }
245
491
  async createExchangeCode(input) {
246
- input = { ...input };
492
+ input = snapshotInput({ ...input });
493
+ assertRecordBinding(input);
247
494
  await this.waitUntilReady();
248
495
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
249
496
  }
250
497
  async consumeExchangeCode(code) {
251
498
  await this.waitUntilReady();
252
- const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
253
- if (!record || isExpired(record, this.now())) {
499
+ const record = await this.exchangeCodes.findOneAndDelete({
500
+ _id: code,
501
+ deviceBinding: { $exists: false },
502
+ browserBindingHash: { $exists: false }
503
+ });
504
+ if (!record || !validExchangeDocument(record, code) || isExpired(record, this.now())) {
254
505
  return null;
255
506
  }
256
507
  return exchangeDocumentToRecord(record);
257
508
  }
509
+ async getExchangeCode(code) {
510
+ await this.waitUntilReady();
511
+ const record = await this.exchangeCodes.findOne({ _id: code });
512
+ return record && validExchangeDocument(record, code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
513
+ }
514
+ async consumeExchangeCodeIfMatches(input) {
515
+ input = snapshotInput({ ...input });
516
+ if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
517
+ await this.waitUntilReady();
518
+ const record = await this.exchangeCodes.findOneAndDelete({
519
+ _id: input.code,
520
+ sessionId: input.expectedSessionId,
521
+ expiresAt: { $gt: new Date(this.now()) },
522
+ ...bindingMatchFilter(input.match)
523
+ });
524
+ return record && validExchangeDocument(record, input.code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
525
+ }
258
526
  async createSession(input) {
259
527
  input = snapshotInput({ ...input });
528
+ assertSessionBinding(input);
260
529
  await this.waitUntilReady();
261
530
  const timestamp = this.now();
262
531
  const session = {
@@ -271,22 +540,69 @@ var MongoOidcVaultStore = class {
271
540
  async getSession(sessionId) {
272
541
  await this.waitUntilReady();
273
542
  const session = await this.sessions.findOne({ _id: sessionId });
274
- if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
543
+ if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
275
544
  return null;
276
545
  }
277
546
  return documentToSession(session);
278
547
  }
548
+ async getSessionRevocationContext(sessionId) {
549
+ await this.waitUntilReady();
550
+ const transaction = this.db.client.startSession();
551
+ const projection = { _id: 1, logicalSessionId: 1, subject: 1, provider: 1, deviceBinding: 1, expiresAt: 1 };
552
+ try {
553
+ return await transaction.withTransaction(
554
+ async () => {
555
+ const now = this.now();
556
+ const live = await this.sessions.findOne({ _id: sessionId }, { session: transaction, projection });
557
+ let logicalSessionId;
558
+ if (live) {
559
+ toRevocationContext(live);
560
+ if (!isExpired(live, now)) logicalSessionId = live.logicalSessionId ?? live._id;
561
+ }
562
+ if (logicalSessionId === void 0) {
563
+ const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId }, { session: transaction });
564
+ if (!alias || isExpired(alias, now)) return null;
565
+ if (typeof alias.logicalSessionId !== "string" || !(alias.expiresAt instanceof Date) || !Number.isFinite(alias.expiresAt.getTime())) {
566
+ throw new Error("OIDC vault MongoDB store has malformed revocation alias.");
567
+ }
568
+ logicalSessionId = alias.logicalSessionId;
569
+ }
570
+ const members = await this.sessions.find(
571
+ { $or: [{ logicalSessionId }, { logicalSessionId: { $exists: false }, _id: logicalSessionId }] },
572
+ { session: transaction, projection }
573
+ ).toArray();
574
+ let source;
575
+ for (const member of members) {
576
+ toRevocationContext(member);
577
+ if (isExpired(member, now)) continue;
578
+ if (source) assertSameLineageAuthority(source, member);
579
+ else source = member;
580
+ }
581
+ return source ? toRevocationContext(source) : null;
582
+ },
583
+ { readConcern: { level: "snapshot" } }
584
+ ) ?? null;
585
+ } finally {
586
+ await transaction.endSession();
587
+ }
588
+ }
589
+ async reserveDpopProof(input) {
590
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
591
+ await this.waitUntilReady();
592
+ return this.dpopReplay.reserve(input);
593
+ }
279
594
  async rotateSession(input) {
280
595
  input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
596
+ assertSessionBinding(input.nextSession);
281
597
  await this.waitUntilReady();
282
598
  if (input.nextSession.sessionId === input.sessionId) {
283
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
599
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
284
600
  }
285
601
  try {
286
602
  return await this.rotateSessionWithTransaction(input);
287
603
  } catch (error) {
288
604
  if (isDuplicateKeyError(error)) {
289
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
605
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
290
606
  }
291
607
  throw error;
292
608
  }
@@ -385,10 +701,13 @@ var MongoOidcVaultStore = class {
385
701
  exchangeCodes: this.exchangeCodes,
386
702
  sessions: this.sessions,
387
703
  backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
388
- rotatedSessionAliases: this.rotatedSessionAliases
704
+ rotatedSessionAliases: this.rotatedSessionAliases,
705
+ dpopProofs: this.dpopProofs,
706
+ dpopReplayCapacity: this.dpopReplayCapacity
389
707
  }),
390
708
  assertTransactionSupport(this.db)
391
709
  ]);
710
+ await this.dpopReplay.initialize();
392
711
  }
393
712
  async waitUntilReady() {
394
713
  await this.initialization;
@@ -404,15 +723,15 @@ var MongoOidcVaultStore = class {
404
723
  await session.withTransaction(async () => {
405
724
  const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
406
725
  if (!current || isExpired(current, this.now())) {
407
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
726
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
408
727
  }
409
728
  if (!isSameSessionDocumentGeneration(current, expectedSource)) {
410
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
729
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
411
730
  }
412
731
  await this.sessions.insertOne(sessionToDocument(nextSession), { session });
413
732
  const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
414
733
  if (deleteResult.deletedCount !== 1) {
415
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
734
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
416
735
  }
417
736
  await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
418
737
  });
@@ -442,8 +761,9 @@ var MongoOidcVaultStore = class {
442
761
  async normalizeRotatedSession(input) {
443
762
  const previous = await this.getSession(input.sessionId);
444
763
  if (!previous) {
445
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
764
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
446
765
  }
766
+ inheritSessionBinding(previous, input.nextSession);
447
767
  return {
448
768
  previous,
449
769
  nextSession: {