@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.mjs CHANGED
@@ -1,20 +1,96 @@
1
1
  // src/store.ts
2
2
  import {
3
- OidcVaultStoreConflictError
3
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError2
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
+ import { isDeepStrictEqual } from "util";
6
+
7
+ // src/binding.ts
8
+ import { OidcVaultStoreConflictError } from "@web-ts-toolkit/express-oidc-vault";
9
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
11
+ 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);
12
+ var hasValidSessionBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
13
+ var hasValidRecordBinding = (record) => hasValidSessionBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
14
+ var assertRecordBinding = (record) => {
15
+ if (!hasValidRecordBinding(record))
16
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
17
+ };
18
+ var assertSessionBinding = (record) => {
19
+ if (!hasValidSessionBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
20
+ };
21
+ 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));
22
+ var bindingMatchFilter = (match) => ({
23
+ ...match.deviceBinding === null ? { deviceBinding: { $exists: false } } : {
24
+ "deviceBinding.type": match.deviceBinding.type,
25
+ "deviceBinding.jkt": match.deviceBinding.jkt,
26
+ $expr: {
27
+ $eq: [
28
+ {
29
+ $size: {
30
+ $objectToArray: { $cond: [{ $eq: [{ $type: "$deviceBinding" }, "object"] }, "$deviceBinding", {}] }
31
+ }
32
+ },
33
+ 2
34
+ ]
35
+ }
36
+ },
37
+ browserBindingHash: match.browserBindingHash === null ? { $exists: false } : match.browserBindingHash
38
+ });
39
+ var inheritSessionBinding = (source, next) => {
40
+ assertSessionBinding(source);
41
+ assertSessionBinding(next);
42
+ if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
43
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
44
+ }
45
+ if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
46
+ };
47
+ var toRevocationContext = (session) => {
48
+ assertSessionBinding(session);
49
+ const provider = session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider;
50
+ const expiry = session.deviceBinding === void 0 && session.expiresAt === null ? void 0 : session.expiresAt;
51
+ 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")) {
52
+ throw new Error("OIDC vault MongoDB lineage has malformed session/provider authority.");
53
+ }
54
+ return {
55
+ logicalSessionId: session.logicalSessionId ?? session._id,
56
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
57
+ ...provider === void 0 ? {} : {
58
+ provider: {
59
+ ...provider.issuer === void 0 ? {} : { issuer: provider.issuer },
60
+ ...provider.clientId === void 0 ? {} : { clientId: provider.clientId }
61
+ }
62
+ }
63
+ };
64
+ };
65
+ var assertSameLineageAuthority = (source, member) => {
66
+ toRevocationContext(member);
67
+ if (source.deviceBinding?.jkt !== member.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== member.provider?.issuer || source.provider?.clientId !== member.provider?.clientId) {
68
+ throw new Error("OIDC vault MongoDB lineage has inconsistent device binding or provider identity.");
69
+ }
70
+ };
5
71
 
6
72
  // src/documents.ts
73
+ var providerToDocument = (provider) => {
74
+ if (provider === null || typeof provider !== "object") return provider;
75
+ const prototype = Object.getPrototypeOf(provider);
76
+ if (prototype !== Object.prototype && prototype !== null) return provider;
77
+ const copy = { ...provider };
78
+ if (copy.issuer === void 0) delete copy.issuer;
79
+ if (copy.clientId === void 0) delete copy.clientId;
80
+ return copy;
81
+ };
7
82
  var sessionToDocument = (session) => ({
8
83
  _id: session.sessionId,
9
84
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
10
85
  subject: session.subject,
11
86
  providerSessionId: session.providerSessionId,
12
- provider: session.provider,
87
+ ...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
88
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
13
89
  refreshToken: session.refreshToken,
14
90
  idToken: session.idToken,
15
91
  accessToken: session.accessToken,
16
92
  scope: session.scope,
17
- expiresAt: typeof session.expiresAt === "number" ? new Date(session.expiresAt) : void 0,
93
+ ...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
18
94
  createdAt: session.createdAt,
19
95
  updatedAt: session.updatedAt,
20
96
  user: session.user,
@@ -25,7 +101,8 @@ var documentToSession = (session) => ({
25
101
  logicalSessionId: session.logicalSessionId ?? session._id,
26
102
  subject: session.subject,
27
103
  providerSessionId: session.providerSessionId,
28
- provider: session.provider,
104
+ provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
105
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
29
106
  refreshToken: session.refreshToken,
30
107
  idToken: session.idToken,
31
108
  accessToken: session.accessToken,
@@ -36,22 +113,26 @@ var documentToSession = (session) => ({
36
113
  user: session.user,
37
114
  metadata: session.metadata
38
115
  });
39
- var authorizationTransactionToDocument = (record) => ({
40
- _id: record.state,
41
- ...record,
42
- expiresAt: new Date(record.expiresAt)
43
- });
44
- var exchangeCodeToDocument = (record) => ({
45
- _id: record.code,
46
- ...record,
47
- expiresAt: new Date(record.expiresAt)
48
- });
116
+ var authorizationTransactionToDocument = (record) => {
117
+ const document = { _id: record.state, ...record, expiresAt: new Date(record.expiresAt) };
118
+ if (document.deviceBinding === void 0) delete document.deviceBinding;
119
+ if (document.browserBindingHash === void 0) delete document.browserBindingHash;
120
+ return document;
121
+ };
122
+ var exchangeCodeToDocument = (record) => {
123
+ const document = { _id: record.code, ...record, expiresAt: new Date(record.expiresAt) };
124
+ if (document.deviceBinding === void 0) delete document.deviceBinding;
125
+ if (document.browserBindingHash === void 0) delete document.browserBindingHash;
126
+ return document;
127
+ };
49
128
  var authorizationDocumentToRecord = (record) => ({
50
129
  state: record.state,
51
130
  nonce: record.nonce,
52
131
  pkceVerifier: record.pkceVerifier,
53
132
  codeChallenge: record.codeChallenge,
54
133
  returnTo: record.returnTo,
134
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
135
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
55
136
  createdAt: record.createdAt,
56
137
  expiresAt: record.expiresAt.getTime(),
57
138
  metadata: record.metadata
@@ -60,10 +141,17 @@ var exchangeDocumentToRecord = (record) => ({
60
141
  code: record.code,
61
142
  sessionId: record.sessionId,
62
143
  returnTo: record.returnTo,
144
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
145
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
63
146
  createdAt: record.createdAt,
64
147
  expiresAt: record.expiresAt.getTime()
65
148
  });
66
149
  var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
150
+ var validDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
151
+ var optionalString = (value) => value === void 0 || typeof value === "string";
152
+ 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);
153
+ var validExchangeDocument = (record, code) => record._id === code && record.code === code && typeof record.sessionId === "string" && Number.isFinite(record.createdAt) && validDate(record.expiresAt) && hasValidRecordBinding(record);
154
+ 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);
67
155
 
68
156
  // src/options.ts
69
157
  var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
@@ -72,7 +160,9 @@ var DEFAULT_COLLECTION_NAMES = {
72
160
  exchangeCodes: "oidc_vault_exchange_codes",
73
161
  sessions: "oidc_vault_sessions",
74
162
  backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
75
- rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
163
+ rotatedSessionAliases: "oidc_vault_rotated_session_aliases",
164
+ dpopProofs: "oidc_vault_dpop_proofs",
165
+ dpopReplayCapacity: "oidc_vault_dpop_replay_capacity"
76
166
  };
77
167
  var validateCollectionName = (role, name) => {
78
168
  if (name.length === 0) {
@@ -94,7 +184,9 @@ var resolveCollectionNames = (options) => {
94
184
  exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
95
185
  sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
96
186
  backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
97
- rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
187
+ rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases,
188
+ dpopProofs: options.dpopProofsCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopProofs,
189
+ dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity
98
190
  };
99
191
  for (const [role, name] of Object.entries(collectionNames)) {
100
192
  validateCollectionName(role, name);
@@ -136,6 +228,10 @@ var ensureStoreIndexes = async (collections) => {
136
228
  createTtlIndex(collections.sessions),
137
229
  createTtlIndex(collections.backchannelLogoutTokenJtis),
138
230
  createTtlIndex(collections.rotatedSessionAliases),
231
+ createTtlIndex(collections.dpopProofs),
232
+ // Accounting outlives physical proof TTL deletion until a serialized
233
+ // bounded cleanup decrements capacity. Never TTL-delete these entries.
234
+ collections.dpopReplayCapacity.createIndex({ kind: 1, expiresAt: 1 }, { name: "dpop_expiry_accounting_idx" }),
139
235
  collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
140
236
  collections.sessions.createIndex(
141
237
  { subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
@@ -149,14 +245,155 @@ var ensureStoreIndexes = async (collections) => {
149
245
  ]);
150
246
  };
151
247
 
248
+ // src/dpop-replay.ts
249
+ import { OidcVaultDpopReplayCapacityError } from "@web-ts-toolkit/express-oidc-vault";
250
+ var CAPACITY_ID = "capacity";
251
+ var reservationId = (replayKey) => `proof:${replayKey}`;
252
+ var isDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
253
+ var MongoDpopReplayReservations = class {
254
+ constructor(db, proofs, capacity, maxEntries, now) {
255
+ this.db = db;
256
+ this.proofs = proofs;
257
+ this.capacity = capacity;
258
+ this.maxEntries = maxEntries;
259
+ this.now = now;
260
+ }
261
+ async initialize() {
262
+ const indexes = await this.capacity.listIndexes().toArray();
263
+ if (indexes.some((index) => index.expireAfterSeconds !== void 0)) {
264
+ throw new Error("OIDC vault MongoDB replay capacity accounting must not have TTL indexes.");
265
+ }
266
+ try {
267
+ await this.capacity.updateOne(
268
+ { _id: CAPACITY_ID },
269
+ {
270
+ $setOnInsert: {
271
+ kind: "capacity",
272
+ entries: 0,
273
+ revision: 0,
274
+ maxEntries: this.maxEntries,
275
+ proofsCollectionName: this.proofs.collectionName
276
+ }
277
+ },
278
+ { upsert: true }
279
+ );
280
+ } catch (error) {
281
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === 11e3)) throw error;
282
+ }
283
+ this.validateCapacity(await this.capacity.findOne({ _id: CAPACITY_ID }));
284
+ }
285
+ async reserve(input) {
286
+ if (!this.validWindow(input, this.now())) return false;
287
+ const session = this.db.client.startSession();
288
+ try {
289
+ const result = await session.withTransaction(
290
+ async () => {
291
+ const now = this.now();
292
+ if (!this.validWindow(input, now)) return false;
293
+ const capacity = await this.capacity.findOneAndUpdate(
294
+ { _id: CAPACITY_ID },
295
+ { $inc: { revision: 1 } },
296
+ { session, returnDocument: "after" }
297
+ );
298
+ this.validateCapacity(capacity);
299
+ const proof = await this.proofs.findOne({ _id: input.replayKey }, { session });
300
+ if (proof && !isDate(proof.expiresAt))
301
+ throw new Error("OIDC vault MongoDB replay proof has malformed expiry.");
302
+ if (proof && proof.expiresAt.getTime() > now) return false;
303
+ const id = reservationId(input.replayKey);
304
+ const existing = await this.capacity.findOne({ _id: id }, { session });
305
+ if (existing) {
306
+ this.validateReservation(existing);
307
+ if (existing.expiresAt.getTime() > now) return false;
308
+ }
309
+ let removed = 0;
310
+ if (existing) removed += (await this.capacity.deleteOne({ _id: id }, { session })).deletedCount;
311
+ const expired = await this.capacity.find({ kind: "reservation", expiresAt: { $lte: new Date(now) } }, { session }).sort({ expiresAt: 1 }).limit(64).toArray();
312
+ for (const row of expired) this.validateReservation(row);
313
+ if (expired.length > 0) {
314
+ removed += (await this.capacity.deleteMany(
315
+ { _id: { $in: expired.map((row) => row._id) }, kind: "reservation" },
316
+ { session }
317
+ )).deletedCount;
318
+ await this.proofs.deleteMany(
319
+ { _id: { $in: expired.map((row) => row.replayKey) }, expiresAt: { $lte: new Date(now) } },
320
+ { session }
321
+ );
322
+ }
323
+ const entries = capacity.entries - removed;
324
+ if (!Number.isSafeInteger(entries) || entries < 0)
325
+ throw new Error("OIDC vault MongoDB replay capacity accounting is inconsistent.");
326
+ if (entries >= this.maxEntries) throw new OidcVaultDpopReplayCapacityError();
327
+ await this.proofs.replaceOne(
328
+ { _id: input.replayKey },
329
+ { expiresAt: new Date(input.expiresAt) },
330
+ { upsert: true, session }
331
+ );
332
+ await this.capacity.insertOne(
333
+ { _id: id, kind: "reservation", replayKey: input.replayKey, expiresAt: new Date(input.expiresAt) },
334
+ { session }
335
+ );
336
+ await this.capacity.updateOne({ _id: CAPACITY_ID }, { $set: { entries: entries + 1 } }, { session });
337
+ return true;
338
+ },
339
+ { readConcern: { level: "snapshot" } }
340
+ );
341
+ return result === true;
342
+ } finally {
343
+ await session.endSession();
344
+ }
345
+ }
346
+ validWindow(input, now) {
347
+ if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
348
+ return typeof input.replayKey === "string" && Number.isSafeInteger(input.expiresAt) && input.expiresAt > now && input.expiresAt - now <= 36e4;
349
+ }
350
+ validateCapacity(row) {
351
+ 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)) {
352
+ throw new Error("OIDC vault MongoDB replay capacity configuration/accounting is inconsistent.");
353
+ }
354
+ }
355
+ validateReservation(row) {
356
+ if (row.kind !== "reservation" || typeof row.replayKey !== "string" || row._id !== reservationId(row.replayKey) || !isDate(row.expiresAt)) {
357
+ throw new Error("OIDC vault MongoDB replay expiry accounting is malformed.");
358
+ }
359
+ }
360
+ };
361
+
152
362
  // src/store.ts
153
- var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
154
- var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
155
- var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
363
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
364
+ if (input === null || typeof input !== "object") return input;
365
+ const prototype = Object.getPrototypeOf(input);
366
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
367
+ if (seen.has(input)) return seen.get(input);
368
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
369
+ seen.set(input, copy);
370
+ for (const key of Reflect.ownKeys(input)) {
371
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
372
+ Object.defineProperty(copy, key, {
373
+ value: snapshotInput(Reflect.get(input, key), seen),
374
+ enumerable: true,
375
+ configurable: true,
376
+ writable: true
377
+ });
378
+ }
379
+ }
380
+ return copy;
381
+ };
382
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
383
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
384
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
156
385
  var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
386
+ var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && isDeepStrictEqual(
387
+ current.deviceBinding === void 0 && current.provider === null ? void 0 : current.provider,
388
+ expected.provider
389
+ ) && 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 && isDeepStrictEqual(current.user, expected.user) && isDeepStrictEqual(current.metadata, expected.metadata);
157
390
  var MongoOidcVaultStore = class {
158
391
  constructor(options) {
159
392
  const collectionNames = resolveCollectionNames(options);
393
+ const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
394
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
395
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
396
+ }
160
397
  this.db = options.db;
161
398
  this.authorizationTransactions = options.db.collection(
162
399
  collectionNames.authorizationTransactions
@@ -169,11 +406,20 @@ var MongoOidcVaultStore = class {
169
406
  this.rotatedSessionAliases = options.db.collection(
170
407
  collectionNames.rotatedSessionAliases
171
408
  );
409
+ this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
410
+ this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
172
411
  this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
173
412
  if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
174
413
  throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
175
414
  }
176
415
  this.now = options.now ?? (() => Date.now());
416
+ this.dpopReplay = new MongoDpopReplayReservations(
417
+ this.db,
418
+ this.dpopProofs,
419
+ this.dpopReplayCapacity,
420
+ maxEntries,
421
+ this.now
422
+ );
177
423
  this.initialization = this.initialize().then(void 0, (error) => {
178
424
  this.initializationError = error;
179
425
  });
@@ -182,6 +428,8 @@ var MongoOidcVaultStore = class {
182
428
  await this.waitUntilReady();
183
429
  }
184
430
  async createAuthorizationTransaction(input) {
431
+ input = snapshotInput({ ...input });
432
+ assertRecordBinding(input);
185
433
  await this.waitUntilReady();
186
434
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
187
435
  upsert: true
@@ -189,25 +437,70 @@ var MongoOidcVaultStore = class {
189
437
  }
190
438
  async consumeAuthorizationTransaction(state) {
191
439
  await this.waitUntilReady();
192
- const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
193
- if (!record || isExpired(record, this.now())) {
440
+ const record = await this.authorizationTransactions.findOneAndDelete({
441
+ _id: state,
442
+ deviceBinding: { $exists: false },
443
+ browserBindingHash: { $exists: false }
444
+ });
445
+ if (!record || !validAuthorizationDocument(record, state) || isExpired(record, this.now())) {
194
446
  return null;
195
447
  }
196
448
  return authorizationDocumentToRecord(record);
197
449
  }
450
+ async getAuthorizationTransaction(state) {
451
+ await this.waitUntilReady();
452
+ const record = await this.authorizationTransactions.findOne({ _id: state });
453
+ return record && validAuthorizationDocument(record, state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
454
+ }
455
+ async consumeAuthorizationTransactionIfMatches(input) {
456
+ input = snapshotInput({ ...input });
457
+ if (!isBindingMatch(input.match)) return null;
458
+ await this.waitUntilReady();
459
+ const record = await this.authorizationTransactions.findOneAndDelete({
460
+ _id: input.state,
461
+ expiresAt: { $gt: new Date(this.now()) },
462
+ ...bindingMatchFilter(input.match)
463
+ });
464
+ return record && validAuthorizationDocument(record, input.state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
465
+ }
198
466
  async createExchangeCode(input) {
467
+ input = snapshotInput({ ...input });
468
+ assertRecordBinding(input);
199
469
  await this.waitUntilReady();
200
470
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
201
471
  }
202
472
  async consumeExchangeCode(code) {
203
473
  await this.waitUntilReady();
204
- const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
205
- if (!record || isExpired(record, this.now())) {
474
+ const record = await this.exchangeCodes.findOneAndDelete({
475
+ _id: code,
476
+ deviceBinding: { $exists: false },
477
+ browserBindingHash: { $exists: false }
478
+ });
479
+ if (!record || !validExchangeDocument(record, code) || isExpired(record, this.now())) {
206
480
  return null;
207
481
  }
208
482
  return exchangeDocumentToRecord(record);
209
483
  }
484
+ async getExchangeCode(code) {
485
+ await this.waitUntilReady();
486
+ const record = await this.exchangeCodes.findOne({ _id: code });
487
+ return record && validExchangeDocument(record, code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
488
+ }
489
+ async consumeExchangeCodeIfMatches(input) {
490
+ input = snapshotInput({ ...input });
491
+ if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
492
+ await this.waitUntilReady();
493
+ const record = await this.exchangeCodes.findOneAndDelete({
494
+ _id: input.code,
495
+ sessionId: input.expectedSessionId,
496
+ expiresAt: { $gt: new Date(this.now()) },
497
+ ...bindingMatchFilter(input.match)
498
+ });
499
+ return record && validExchangeDocument(record, input.code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
500
+ }
210
501
  async createSession(input) {
502
+ input = snapshotInput({ ...input });
503
+ assertSessionBinding(input);
211
504
  await this.waitUntilReady();
212
505
  const timestamp = this.now();
213
506
  const session = {
@@ -222,21 +515,69 @@ var MongoOidcVaultStore = class {
222
515
  async getSession(sessionId) {
223
516
  await this.waitUntilReady();
224
517
  const session = await this.sessions.findOne({ _id: sessionId });
225
- if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
518
+ if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
226
519
  return null;
227
520
  }
228
521
  return documentToSession(session);
229
522
  }
523
+ async getSessionRevocationContext(sessionId) {
524
+ await this.waitUntilReady();
525
+ const transaction = this.db.client.startSession();
526
+ const projection = { _id: 1, logicalSessionId: 1, subject: 1, provider: 1, deviceBinding: 1, expiresAt: 1 };
527
+ try {
528
+ return await transaction.withTransaction(
529
+ async () => {
530
+ const now = this.now();
531
+ const live = await this.sessions.findOne({ _id: sessionId }, { session: transaction, projection });
532
+ let logicalSessionId;
533
+ if (live) {
534
+ toRevocationContext(live);
535
+ if (!isExpired(live, now)) logicalSessionId = live.logicalSessionId ?? live._id;
536
+ }
537
+ if (logicalSessionId === void 0) {
538
+ const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId }, { session: transaction });
539
+ if (!alias || isExpired(alias, now)) return null;
540
+ if (typeof alias.logicalSessionId !== "string" || !(alias.expiresAt instanceof Date) || !Number.isFinite(alias.expiresAt.getTime())) {
541
+ throw new Error("OIDC vault MongoDB store has malformed revocation alias.");
542
+ }
543
+ logicalSessionId = alias.logicalSessionId;
544
+ }
545
+ const members = await this.sessions.find(
546
+ { $or: [{ logicalSessionId }, { logicalSessionId: { $exists: false }, _id: logicalSessionId }] },
547
+ { session: transaction, projection }
548
+ ).toArray();
549
+ let source;
550
+ for (const member of members) {
551
+ toRevocationContext(member);
552
+ if (isExpired(member, now)) continue;
553
+ if (source) assertSameLineageAuthority(source, member);
554
+ else source = member;
555
+ }
556
+ return source ? toRevocationContext(source) : null;
557
+ },
558
+ { readConcern: { level: "snapshot" } }
559
+ ) ?? null;
560
+ } finally {
561
+ await transaction.endSession();
562
+ }
563
+ }
564
+ async reserveDpopProof(input) {
565
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
566
+ await this.waitUntilReady();
567
+ return this.dpopReplay.reserve(input);
568
+ }
230
569
  async rotateSession(input) {
570
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
571
+ assertSessionBinding(input.nextSession);
231
572
  await this.waitUntilReady();
232
573
  if (input.nextSession.sessionId === input.sessionId) {
233
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
574
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
234
575
  }
235
576
  try {
236
577
  return await this.rotateSessionWithTransaction(input);
237
578
  } catch (error) {
238
579
  if (isDuplicateKeyError(error)) {
239
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
580
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
240
581
  }
241
582
  throw error;
242
583
  }
@@ -248,7 +589,7 @@ var MongoOidcVaultStore = class {
248
589
  const logicalSessionId = session.logicalSessionId ?? session._id;
249
590
  const result = await this.sessions.deleteOne({ _id: sessionId });
250
591
  if (result.deletedCount === 1) {
251
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
592
+ await this.deleteAliasesForInactiveLogicalSessions([logicalSessionId]);
252
593
  return;
253
594
  }
254
595
  const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
@@ -263,13 +604,14 @@ var MongoOidcVaultStore = class {
263
604
  }
264
605
  }
265
606
  async deleteSessionsByLogicalSessionId(input) {
266
- await this.waitUntilReady();
267
607
  const resolved = toLogicalSessionDeleteInput(input);
608
+ await this.waitUntilReady();
268
609
  const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
269
- await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
610
+ await this.deleteAliasesForInactiveLogicalSessions([resolved.logicalSessionId]);
270
611
  return result.deletedCount;
271
612
  }
272
613
  async consumeBackchannelLogoutTokenJti(input) {
614
+ input = { ...input };
273
615
  await this.waitUntilReady();
274
616
  const now = this.now();
275
617
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
@@ -302,8 +644,8 @@ var MongoOidcVaultStore = class {
302
644
  }
303
645
  }
304
646
  async deleteSessionsBySubject(input) {
305
- await this.waitUntilReady();
306
647
  const resolved = toSubjectDeleteInput(input);
648
+ await this.waitUntilReady();
307
649
  const filter = { subject: resolved.subject };
308
650
  if (resolved.issuer !== void 0) {
309
651
  filter["provider.issuer"] = resolved.issuer;
@@ -315,8 +657,8 @@ var MongoOidcVaultStore = class {
315
657
  return result.deletedCount;
316
658
  }
317
659
  async deleteSessionsByProviderSessionId(input) {
318
- await this.waitUntilReady();
319
660
  const resolved = toProviderSessionDeleteInput(input);
661
+ await this.waitUntilReady();
320
662
  const filter = { providerSessionId: resolved.providerSessionId };
321
663
  if (resolved.issuer !== void 0) {
322
664
  filter["provider.issuer"] = resolved.issuer;
@@ -334,10 +676,13 @@ var MongoOidcVaultStore = class {
334
676
  exchangeCodes: this.exchangeCodes,
335
677
  sessions: this.sessions,
336
678
  backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
337
- rotatedSessionAliases: this.rotatedSessionAliases
679
+ rotatedSessionAliases: this.rotatedSessionAliases,
680
+ dpopProofs: this.dpopProofs,
681
+ dpopReplayCapacity: this.dpopReplayCapacity
338
682
  }),
339
683
  assertTransactionSupport(this.db)
340
684
  ]);
685
+ await this.dpopReplay.initialize();
341
686
  }
342
687
  async waitUntilReady() {
343
688
  await this.initialization;
@@ -346,14 +691,22 @@ var MongoOidcVaultStore = class {
346
691
  }
347
692
  }
348
693
  async rotateSessionWithTransaction(input) {
694
+ const { previous, nextSession } = await this.normalizeRotatedSession(input);
695
+ const expectedSource = sessionToDocument(previous);
349
696
  const session = this.db.client.startSession();
350
- const nextSession = await this.normalizeRotatedSession(input);
351
697
  try {
352
698
  await session.withTransaction(async () => {
699
+ const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
700
+ if (!current || isExpired(current, this.now())) {
701
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
702
+ }
703
+ if (!isSameSessionDocumentGeneration(current, expectedSource)) {
704
+ throw new OidcVaultStoreConflictError2("OIDC vault session changed before rotation could commit.");
705
+ }
353
706
  await this.sessions.insertOne(sessionToDocument(nextSession), { session });
354
707
  const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
355
708
  if (deleteResult.deletedCount !== 1) {
356
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
709
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
357
710
  }
358
711
  await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
359
712
  });
@@ -372,7 +725,10 @@ var MongoOidcVaultStore = class {
372
725
  $set: {
373
726
  logicalSessionId,
374
727
  expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
375
- }
728
+ },
729
+ // A no-op alias update would not conflict with an older cleanup
730
+ // snapshot. Every rotation must write, even with identical values.
731
+ $inc: { revision: 1 }
376
732
  },
377
733
  { upsert: true, session }
378
734
  );
@@ -380,11 +736,15 @@ var MongoOidcVaultStore = class {
380
736
  async normalizeRotatedSession(input) {
381
737
  const previous = await this.getSession(input.sessionId);
382
738
  if (!previous) {
383
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
739
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
384
740
  }
741
+ inheritSessionBinding(previous, input.nextSession);
385
742
  return {
386
- ...input.nextSession,
387
- logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
743
+ previous,
744
+ nextSession: {
745
+ ...input.nextSession,
746
+ logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
747
+ }
388
748
  };
389
749
  }
390
750
  async deleteSessionsAndAliasesByFilter(filter) {
@@ -400,7 +760,7 @@ var MongoOidcVaultStore = class {
400
760
  }
401
761
  const result = await this.sessions.deleteMany(filter);
402
762
  deletedCount += result.deletedCount;
403
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
763
+ await this.deleteAliasesForInactiveLogicalSessions(logicalSessionIds);
404
764
  }
405
765
  }
406
766
  getRotatedSessionAliasExpiresAt(nextSession, now) {
@@ -408,8 +768,36 @@ var MongoOidcVaultStore = class {
408
768
  typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
409
769
  );
410
770
  }
411
- async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
412
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
771
+ async deleteAliasesForInactiveLogicalSessions(logicalSessionIds) {
772
+ const session = this.db.client.startSession();
773
+ try {
774
+ await session.withTransaction(
775
+ async () => {
776
+ const live = await this.sessions.find(
777
+ {
778
+ $and: [
779
+ {
780
+ $or: [
781
+ { logicalSessionId: { $in: logicalSessionIds } },
782
+ { logicalSessionId: { $exists: false }, _id: { $in: logicalSessionIds } }
783
+ ]
784
+ },
785
+ { $nor: [{ expiresAt: { $lte: new Date(this.now()) } }] }
786
+ ]
787
+ },
788
+ { session, projection: { _id: 1, logicalSessionId: 1 } }
789
+ ).toArray();
790
+ const survivingIds = new Set(live.map((record) => record.logicalSessionId ?? record._id));
791
+ const inactiveIds = logicalSessionIds.filter((id) => !survivingIds.has(id));
792
+ if (inactiveIds.length > 0) {
793
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
794
+ }
795
+ },
796
+ { readConcern: { level: "snapshot" } }
797
+ );
798
+ } finally {
799
+ await session.endSession();
800
+ }
413
801
  }
414
802
  async isExpiredAndCleanup(collection, record) {
415
803
  if (!isExpired(record, this.now())) {