@web-ts-toolkit/express-oidc-vault-mongodb-store 0.47.1 → 0.49.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,21 +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
5
  import { isDeepStrictEqual } from "util";
6
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
+ };
71
+
7
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
+ };
8
82
  var sessionToDocument = (session) => ({
9
83
  _id: session.sessionId,
10
84
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
11
85
  subject: session.subject,
12
86
  providerSessionId: session.providerSessionId,
13
- provider: session.provider,
87
+ ...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
88
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
14
89
  refreshToken: session.refreshToken,
15
90
  idToken: session.idToken,
16
91
  accessToken: session.accessToken,
17
92
  scope: session.scope,
18
- expiresAt: typeof session.expiresAt === "number" ? new Date(session.expiresAt) : void 0,
93
+ ...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
19
94
  createdAt: session.createdAt,
20
95
  updatedAt: session.updatedAt,
21
96
  user: session.user,
@@ -26,7 +101,8 @@ var documentToSession = (session) => ({
26
101
  logicalSessionId: session.logicalSessionId ?? session._id,
27
102
  subject: session.subject,
28
103
  providerSessionId: session.providerSessionId,
29
- provider: session.provider,
104
+ provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
105
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
30
106
  refreshToken: session.refreshToken,
31
107
  idToken: session.idToken,
32
108
  accessToken: session.accessToken,
@@ -37,22 +113,26 @@ var documentToSession = (session) => ({
37
113
  user: session.user,
38
114
  metadata: session.metadata
39
115
  });
40
- var authorizationTransactionToDocument = (record) => ({
41
- _id: record.state,
42
- ...record,
43
- expiresAt: new Date(record.expiresAt)
44
- });
45
- var exchangeCodeToDocument = (record) => ({
46
- _id: record.code,
47
- ...record,
48
- expiresAt: new Date(record.expiresAt)
49
- });
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
+ };
50
128
  var authorizationDocumentToRecord = (record) => ({
51
129
  state: record.state,
52
130
  nonce: record.nonce,
53
131
  pkceVerifier: record.pkceVerifier,
54
132
  codeChallenge: record.codeChallenge,
55
133
  returnTo: record.returnTo,
134
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
135
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
56
136
  createdAt: record.createdAt,
57
137
  expiresAt: record.expiresAt.getTime(),
58
138
  metadata: record.metadata
@@ -61,10 +141,17 @@ var exchangeDocumentToRecord = (record) => ({
61
141
  code: record.code,
62
142
  sessionId: record.sessionId,
63
143
  returnTo: record.returnTo,
144
+ ...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
145
+ ...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
64
146
  createdAt: record.createdAt,
65
147
  expiresAt: record.expiresAt.getTime()
66
148
  });
67
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);
68
155
 
69
156
  // src/options.ts
70
157
  var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
@@ -73,7 +160,9 @@ var DEFAULT_COLLECTION_NAMES = {
73
160
  exchangeCodes: "oidc_vault_exchange_codes",
74
161
  sessions: "oidc_vault_sessions",
75
162
  backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
76
- 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"
77
166
  };
78
167
  var validateCollectionName = (role, name) => {
79
168
  if (name.length === 0) {
@@ -95,7 +184,9 @@ var resolveCollectionNames = (options) => {
95
184
  exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
96
185
  sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
97
186
  backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
98
- 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
99
190
  };
100
191
  for (const [role, name] of Object.entries(collectionNames)) {
101
192
  validateCollectionName(role, name);
@@ -137,6 +228,10 @@ var ensureStoreIndexes = async (collections) => {
137
228
  createTtlIndex(collections.sessions),
138
229
  createTtlIndex(collections.backchannelLogoutTokenJtis),
139
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" }),
140
235
  collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
141
236
  collections.sessions.createIndex(
142
237
  { subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
@@ -150,6 +245,120 @@ var ensureStoreIndexes = async (collections) => {
150
245
  ]);
151
246
  };
152
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
+
153
362
  // src/store.ts
154
363
  var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
155
364
  if (input === null || typeof input !== "object") return input;
@@ -174,10 +383,17 @@ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: inp
174
383
  var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
175
384
  var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
176
385
  var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
177
- 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(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 && isDeepStrictEqual(current.user, expected.user) && isDeepStrictEqual(current.metadata, expected.metadata);
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);
178
390
  var MongoOidcVaultStore = class {
179
391
  constructor(options) {
180
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
+ }
181
397
  this.db = options.db;
182
398
  this.authorizationTransactions = options.db.collection(
183
399
  collectionNames.authorizationTransactions
@@ -190,11 +406,20 @@ var MongoOidcVaultStore = class {
190
406
  this.rotatedSessionAliases = options.db.collection(
191
407
  collectionNames.rotatedSessionAliases
192
408
  );
409
+ this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
410
+ this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
193
411
  this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
194
412
  if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
195
413
  throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
196
414
  }
197
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
+ );
198
423
  this.initialization = this.initialize().then(void 0, (error) => {
199
424
  this.initializationError = error;
200
425
  });
@@ -204,6 +429,7 @@ var MongoOidcVaultStore = class {
204
429
  }
205
430
  async createAuthorizationTransaction(input) {
206
431
  input = snapshotInput({ ...input });
432
+ assertRecordBinding(input);
207
433
  await this.waitUntilReady();
208
434
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
209
435
  upsert: true
@@ -211,27 +437,70 @@ var MongoOidcVaultStore = class {
211
437
  }
212
438
  async consumeAuthorizationTransaction(state) {
213
439
  await this.waitUntilReady();
214
- const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
215
- 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())) {
216
446
  return null;
217
447
  }
218
448
  return authorizationDocumentToRecord(record);
219
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
+ }
220
466
  async createExchangeCode(input) {
221
- input = { ...input };
467
+ input = snapshotInput({ ...input });
468
+ assertRecordBinding(input);
222
469
  await this.waitUntilReady();
223
470
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
224
471
  }
225
472
  async consumeExchangeCode(code) {
226
473
  await this.waitUntilReady();
227
- const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
228
- 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())) {
229
480
  return null;
230
481
  }
231
482
  return exchangeDocumentToRecord(record);
232
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
+ }
233
501
  async createSession(input) {
234
502
  input = snapshotInput({ ...input });
503
+ assertSessionBinding(input);
235
504
  await this.waitUntilReady();
236
505
  const timestamp = this.now();
237
506
  const session = {
@@ -246,22 +515,69 @@ var MongoOidcVaultStore = class {
246
515
  async getSession(sessionId) {
247
516
  await this.waitUntilReady();
248
517
  const session = await this.sessions.findOne({ _id: sessionId });
249
- if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
518
+ if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
250
519
  return null;
251
520
  }
252
521
  return documentToSession(session);
253
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
+ }
254
569
  async rotateSession(input) {
255
570
  input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
571
+ assertSessionBinding(input.nextSession);
256
572
  await this.waitUntilReady();
257
573
  if (input.nextSession.sessionId === input.sessionId) {
258
- 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.");
259
575
  }
260
576
  try {
261
577
  return await this.rotateSessionWithTransaction(input);
262
578
  } catch (error) {
263
579
  if (isDuplicateKeyError(error)) {
264
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
580
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
265
581
  }
266
582
  throw error;
267
583
  }
@@ -360,10 +676,13 @@ var MongoOidcVaultStore = class {
360
676
  exchangeCodes: this.exchangeCodes,
361
677
  sessions: this.sessions,
362
678
  backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
363
- rotatedSessionAliases: this.rotatedSessionAliases
679
+ rotatedSessionAliases: this.rotatedSessionAliases,
680
+ dpopProofs: this.dpopProofs,
681
+ dpopReplayCapacity: this.dpopReplayCapacity
364
682
  }),
365
683
  assertTransactionSupport(this.db)
366
684
  ]);
685
+ await this.dpopReplay.initialize();
367
686
  }
368
687
  async waitUntilReady() {
369
688
  await this.initialization;
@@ -379,15 +698,15 @@ var MongoOidcVaultStore = class {
379
698
  await session.withTransaction(async () => {
380
699
  const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
381
700
  if (!current || isExpired(current, this.now())) {
382
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
701
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
383
702
  }
384
703
  if (!isSameSessionDocumentGeneration(current, expectedSource)) {
385
- throw new OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
704
+ throw new OidcVaultStoreConflictError2("OIDC vault session changed before rotation could commit.");
386
705
  }
387
706
  await this.sessions.insertOne(sessionToDocument(nextSession), { session });
388
707
  const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
389
708
  if (deleteResult.deletedCount !== 1) {
390
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
709
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
391
710
  }
392
711
  await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
393
712
  });
@@ -417,8 +736,9 @@ var MongoOidcVaultStore = class {
417
736
  async normalizeRotatedSession(input) {
418
737
  const previous = await this.getSession(input.sessionId);
419
738
  if (!previous) {
420
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
739
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
421
740
  }
741
+ inheritSessionBinding(previous, input.nextSession);
422
742
  return {
423
743
  previous,
424
744
  nextSession: {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-mongodb-store",
3
3
  "description": "MongoDB store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-mongodb-store",
5
- "version": "0.47.1",
5
+ "version": "0.49.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -15,17 +15,21 @@
15
15
  "types": "./index.d.ts",
16
16
  "exports": {
17
17
  ".": {
18
- "types": "./index.d.ts",
18
+ "types": {
19
+ "import": "./index.d.mts",
20
+ "require": "./index.d.ts",
21
+ "default": "./index.d.ts"
22
+ },
19
23
  "import": "./index.mjs",
20
24
  "require": "./index.js",
21
25
  "default": "./index.js"
22
26
  }
23
27
  },
24
28
  "engines": {
25
- "node": ">=22"
29
+ "node": ">=22.12.0"
26
30
  },
27
31
  "dependencies": {
28
- "@web-ts-toolkit/express-oidc-vault": "0.47.1",
32
+ "@web-ts-toolkit/express-oidc-vault": "0.49.0",
29
33
  "mongodb": "^6.20.0"
30
34
  },
31
35
  "files": [