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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -26,20 +26,96 @@ __export(index_exports, {
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/store.ts
29
+ var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
30
+ var import_node_util = require("util");
31
+
32
+ // src/binding.ts
29
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
+ };
30
96
 
31
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
+ };
32
107
  var sessionToDocument = (session) => ({
33
108
  _id: session.sessionId,
34
109
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
35
110
  subject: session.subject,
36
111
  providerSessionId: session.providerSessionId,
37
- provider: session.provider,
112
+ ...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
113
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
38
114
  refreshToken: session.refreshToken,
39
115
  idToken: session.idToken,
40
116
  accessToken: session.accessToken,
41
117
  scope: session.scope,
42
- expiresAt: typeof session.expiresAt === "number" ? new Date(session.expiresAt) : void 0,
118
+ ...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
43
119
  createdAt: session.createdAt,
44
120
  updatedAt: session.updatedAt,
45
121
  user: session.user,
@@ -50,7 +126,8 @@ var documentToSession = (session) => ({
50
126
  logicalSessionId: session.logicalSessionId ?? session._id,
51
127
  subject: session.subject,
52
128
  providerSessionId: session.providerSessionId,
53
- provider: session.provider,
129
+ provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
130
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
54
131
  refreshToken: session.refreshToken,
55
132
  idToken: session.idToken,
56
133
  accessToken: session.accessToken,
@@ -61,22 +138,26 @@ var documentToSession = (session) => ({
61
138
  user: session.user,
62
139
  metadata: session.metadata
63
140
  });
64
- var authorizationTransactionToDocument = (record) => ({
65
- _id: record.state,
66
- ...record,
67
- expiresAt: new Date(record.expiresAt)
68
- });
69
- var exchangeCodeToDocument = (record) => ({
70
- _id: record.code,
71
- ...record,
72
- expiresAt: new Date(record.expiresAt)
73
- });
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
+ };
74
153
  var authorizationDocumentToRecord = (record) => ({
75
154
  state: record.state,
76
155
  nonce: record.nonce,
77
156
  pkceVerifier: record.pkceVerifier,
78
157
  codeChallenge: record.codeChallenge,
79
158
  returnTo: record.returnTo,
159
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
160
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
80
161
  createdAt: record.createdAt,
81
162
  expiresAt: record.expiresAt.getTime(),
82
163
  metadata: record.metadata
@@ -85,10 +166,17 @@ var exchangeDocumentToRecord = (record) => ({
85
166
  code: record.code,
86
167
  sessionId: record.sessionId,
87
168
  returnTo: record.returnTo,
169
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
170
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
88
171
  createdAt: record.createdAt,
89
172
  expiresAt: record.expiresAt.getTime()
90
173
  });
91
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);
92
180
 
93
181
  // src/options.ts
94
182
  var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
@@ -97,7 +185,9 @@ var DEFAULT_COLLECTION_NAMES = {
97
185
  exchangeCodes: "oidc_vault_exchange_codes",
98
186
  sessions: "oidc_vault_sessions",
99
187
  backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
100
- 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"
101
191
  };
102
192
  var validateCollectionName = (role, name) => {
103
193
  if (name.length === 0) {
@@ -119,7 +209,9 @@ var resolveCollectionNames = (options) => {
119
209
  exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
120
210
  sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
121
211
  backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
122
- 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
123
215
  };
124
216
  for (const [role, name] of Object.entries(collectionNames)) {
125
217
  validateCollectionName(role, name);
@@ -161,6 +253,10 @@ var ensureStoreIndexes = async (collections) => {
161
253
  createTtlIndex(collections.sessions),
162
254
  createTtlIndex(collections.backchannelLogoutTokenJtis),
163
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" }),
164
260
  collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
165
261
  collections.sessions.createIndex(
166
262
  { subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
@@ -174,14 +270,155 @@ var ensureStoreIndexes = async (collections) => {
174
270
  ]);
175
271
  };
176
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
+
177
387
  // src/store.ts
178
- var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
179
- var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
180
- var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
388
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
389
+ if (input === null || typeof input !== "object") return input;
390
+ const prototype = Object.getPrototypeOf(input);
391
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
392
+ if (seen.has(input)) return seen.get(input);
393
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
394
+ seen.set(input, copy);
395
+ for (const key of Reflect.ownKeys(input)) {
396
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
397
+ Object.defineProperty(copy, key, {
398
+ value: snapshotInput(Reflect.get(input, key), seen),
399
+ enumerable: true,
400
+ configurable: true,
401
+ writable: true
402
+ });
403
+ }
404
+ }
405
+ return copy;
406
+ };
407
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
408
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
409
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
181
410
  var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
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);
182
415
  var MongoOidcVaultStore = class {
183
416
  constructor(options) {
184
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
+ }
185
422
  this.db = options.db;
186
423
  this.authorizationTransactions = options.db.collection(
187
424
  collectionNames.authorizationTransactions
@@ -194,11 +431,20 @@ var MongoOidcVaultStore = class {
194
431
  this.rotatedSessionAliases = options.db.collection(
195
432
  collectionNames.rotatedSessionAliases
196
433
  );
434
+ this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
435
+ this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
197
436
  this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
198
437
  if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
199
438
  throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
200
439
  }
201
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
+ );
202
448
  this.initialization = this.initialize().then(void 0, (error) => {
203
449
  this.initializationError = error;
204
450
  });
@@ -207,6 +453,8 @@ var MongoOidcVaultStore = class {
207
453
  await this.waitUntilReady();
208
454
  }
209
455
  async createAuthorizationTransaction(input) {
456
+ input = snapshotInput({ ...input });
457
+ assertRecordBinding(input);
210
458
  await this.waitUntilReady();
211
459
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
212
460
  upsert: true
@@ -214,25 +462,70 @@ var MongoOidcVaultStore = class {
214
462
  }
215
463
  async consumeAuthorizationTransaction(state) {
216
464
  await this.waitUntilReady();
217
- const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
218
- 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())) {
219
471
  return null;
220
472
  }
221
473
  return authorizationDocumentToRecord(record);
222
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
+ }
223
491
  async createExchangeCode(input) {
492
+ input = snapshotInput({ ...input });
493
+ assertRecordBinding(input);
224
494
  await this.waitUntilReady();
225
495
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
226
496
  }
227
497
  async consumeExchangeCode(code) {
228
498
  await this.waitUntilReady();
229
- const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
230
- 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())) {
231
505
  return null;
232
506
  }
233
507
  return exchangeDocumentToRecord(record);
234
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
+ }
235
526
  async createSession(input) {
527
+ input = snapshotInput({ ...input });
528
+ assertSessionBinding(input);
236
529
  await this.waitUntilReady();
237
530
  const timestamp = this.now();
238
531
  const session = {
@@ -247,21 +540,69 @@ var MongoOidcVaultStore = class {
247
540
  async getSession(sessionId) {
248
541
  await this.waitUntilReady();
249
542
  const session = await this.sessions.findOne({ _id: sessionId });
250
- if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
543
+ if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
251
544
  return null;
252
545
  }
253
546
  return documentToSession(session);
254
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
+ }
255
594
  async rotateSession(input) {
595
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
596
+ assertSessionBinding(input.nextSession);
256
597
  await this.waitUntilReady();
257
598
  if (input.nextSession.sessionId === input.sessionId) {
258
- 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.");
259
600
  }
260
601
  try {
261
602
  return await this.rotateSessionWithTransaction(input);
262
603
  } catch (error) {
263
604
  if (isDuplicateKeyError(error)) {
264
- 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.");
265
606
  }
266
607
  throw error;
267
608
  }
@@ -273,7 +614,7 @@ var MongoOidcVaultStore = class {
273
614
  const logicalSessionId = session.logicalSessionId ?? session._id;
274
615
  const result = await this.sessions.deleteOne({ _id: sessionId });
275
616
  if (result.deletedCount === 1) {
276
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
617
+ await this.deleteAliasesForInactiveLogicalSessions([logicalSessionId]);
277
618
  return;
278
619
  }
279
620
  const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
@@ -288,13 +629,14 @@ var MongoOidcVaultStore = class {
288
629
  }
289
630
  }
290
631
  async deleteSessionsByLogicalSessionId(input) {
291
- await this.waitUntilReady();
292
632
  const resolved = toLogicalSessionDeleteInput(input);
633
+ await this.waitUntilReady();
293
634
  const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
294
- await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
635
+ await this.deleteAliasesForInactiveLogicalSessions([resolved.logicalSessionId]);
295
636
  return result.deletedCount;
296
637
  }
297
638
  async consumeBackchannelLogoutTokenJti(input) {
639
+ input = { ...input };
298
640
  await this.waitUntilReady();
299
641
  const now = this.now();
300
642
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
@@ -327,8 +669,8 @@ var MongoOidcVaultStore = class {
327
669
  }
328
670
  }
329
671
  async deleteSessionsBySubject(input) {
330
- await this.waitUntilReady();
331
672
  const resolved = toSubjectDeleteInput(input);
673
+ await this.waitUntilReady();
332
674
  const filter = { subject: resolved.subject };
333
675
  if (resolved.issuer !== void 0) {
334
676
  filter["provider.issuer"] = resolved.issuer;
@@ -340,8 +682,8 @@ var MongoOidcVaultStore = class {
340
682
  return result.deletedCount;
341
683
  }
342
684
  async deleteSessionsByProviderSessionId(input) {
343
- await this.waitUntilReady();
344
685
  const resolved = toProviderSessionDeleteInput(input);
686
+ await this.waitUntilReady();
345
687
  const filter = { providerSessionId: resolved.providerSessionId };
346
688
  if (resolved.issuer !== void 0) {
347
689
  filter["provider.issuer"] = resolved.issuer;
@@ -359,10 +701,13 @@ var MongoOidcVaultStore = class {
359
701
  exchangeCodes: this.exchangeCodes,
360
702
  sessions: this.sessions,
361
703
  backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
362
- rotatedSessionAliases: this.rotatedSessionAliases
704
+ rotatedSessionAliases: this.rotatedSessionAliases,
705
+ dpopProofs: this.dpopProofs,
706
+ dpopReplayCapacity: this.dpopReplayCapacity
363
707
  }),
364
708
  assertTransactionSupport(this.db)
365
709
  ]);
710
+ await this.dpopReplay.initialize();
366
711
  }
367
712
  async waitUntilReady() {
368
713
  await this.initialization;
@@ -371,14 +716,22 @@ var MongoOidcVaultStore = class {
371
716
  }
372
717
  }
373
718
  async rotateSessionWithTransaction(input) {
719
+ const { previous, nextSession } = await this.normalizeRotatedSession(input);
720
+ const expectedSource = sessionToDocument(previous);
374
721
  const session = this.db.client.startSession();
375
- const nextSession = await this.normalizeRotatedSession(input);
376
722
  try {
377
723
  await session.withTransaction(async () => {
724
+ const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
725
+ if (!current || isExpired(current, this.now())) {
726
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
727
+ }
728
+ if (!isSameSessionDocumentGeneration(current, expectedSource)) {
729
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
730
+ }
378
731
  await this.sessions.insertOne(sessionToDocument(nextSession), { session });
379
732
  const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
380
733
  if (deleteResult.deletedCount !== 1) {
381
- 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.");
382
735
  }
383
736
  await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
384
737
  });
@@ -397,7 +750,10 @@ var MongoOidcVaultStore = class {
397
750
  $set: {
398
751
  logicalSessionId,
399
752
  expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
400
- }
753
+ },
754
+ // A no-op alias update would not conflict with an older cleanup
755
+ // snapshot. Every rotation must write, even with identical values.
756
+ $inc: { revision: 1 }
401
757
  },
402
758
  { upsert: true, session }
403
759
  );
@@ -405,11 +761,15 @@ var MongoOidcVaultStore = class {
405
761
  async normalizeRotatedSession(input) {
406
762
  const previous = await this.getSession(input.sessionId);
407
763
  if (!previous) {
408
- 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.");
409
765
  }
766
+ inheritSessionBinding(previous, input.nextSession);
410
767
  return {
411
- ...input.nextSession,
412
- logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
768
+ previous,
769
+ nextSession: {
770
+ ...input.nextSession,
771
+ logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
772
+ }
413
773
  };
414
774
  }
415
775
  async deleteSessionsAndAliasesByFilter(filter) {
@@ -425,7 +785,7 @@ var MongoOidcVaultStore = class {
425
785
  }
426
786
  const result = await this.sessions.deleteMany(filter);
427
787
  deletedCount += result.deletedCount;
428
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
788
+ await this.deleteAliasesForInactiveLogicalSessions(logicalSessionIds);
429
789
  }
430
790
  }
431
791
  getRotatedSessionAliasExpiresAt(nextSession, now) {
@@ -433,8 +793,36 @@ var MongoOidcVaultStore = class {
433
793
  typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
434
794
  );
435
795
  }
436
- async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
437
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
796
+ async deleteAliasesForInactiveLogicalSessions(logicalSessionIds) {
797
+ const session = this.db.client.startSession();
798
+ try {
799
+ await session.withTransaction(
800
+ async () => {
801
+ const live = await this.sessions.find(
802
+ {
803
+ $and: [
804
+ {
805
+ $or: [
806
+ { logicalSessionId: { $in: logicalSessionIds } },
807
+ { logicalSessionId: { $exists: false }, _id: { $in: logicalSessionIds } }
808
+ ]
809
+ },
810
+ { $nor: [{ expiresAt: { $lte: new Date(this.now()) } }] }
811
+ ]
812
+ },
813
+ { session, projection: { _id: 1, logicalSessionId: 1 } }
814
+ ).toArray();
815
+ const survivingIds = new Set(live.map((record) => record.logicalSessionId ?? record._id));
816
+ const inactiveIds = logicalSessionIds.filter((id) => !survivingIds.has(id));
817
+ if (inactiveIds.length > 0) {
818
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
819
+ }
820
+ },
821
+ { readConcern: { level: "snapshot" } }
822
+ );
823
+ } finally {
824
+ await session.endSession();
825
+ }
438
826
  }
439
827
  async isExpiredAndCleanup(collection, record) {
440
828
  if (!isExpired(record, this.now())) {