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

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.
Files changed (4) hide show
  1. package/README.md +41 -8
  2. package/index.js +83 -15
  3. package/index.mjs +83 -15
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -64,7 +64,10 @@ app.use(
64
64
  const server = app.listen(3000);
65
65
 
66
66
  process.once('SIGTERM', async () => {
67
- server.close();
67
+ await new Promise<void>((resolve, reject) => {
68
+ server.close((error) => (error ? reject(error) : resolve()));
69
+ });
70
+ // Also settle any store work started outside HTTP requests before closing.
68
71
  await mongo.close();
69
72
  });
70
73
  ```
@@ -92,21 +95,47 @@ const storeProvider = createMongoOidcVaultStore({
92
95
  });
93
96
  ```
94
97
 
95
- ## Notes
98
+ ## Startup And Client Ownership
96
99
 
97
100
  - use MongoDB when your team already standardizes on Mongo and you want the auth vault data in the same operational platform
98
101
  - the provider uses separate collections for authorization transactions, exchange codes, sessions, backchannel logout token JTIs, and rotated-session aliases
99
- - TTL indexes are created on expiring records; authorization transactions, exchange codes, backchannel logout token JTIs, and rotated-session aliases are also checked for expiration during relevant reads or consumes so behavior does not depend only on Mongo's background TTL monitor timing
100
- - session rotation requires MongoDB transactions; use a replica set or sharded deployment because standalone servers fail closed instead of using non-atomic multi-write rotation
102
+ - TTL indexes are created on expiring records; sessions, authorization transactions, exchange codes, backchannel logout token JTIs, and rotated-session aliases are also checked for expiration during relevant reads or consumes (`expiresAt <= now()` is expired), so behavior does not depend only on Mongo's background TTL monitor timing
103
+ - session rotation and inactive-lineage alias cleanup require MongoDB transactions; use a replica set or sharded deployment because standalone servers fail readiness instead of using non-atomic multi-write fallbacks
101
104
  - startup readiness creates required indexes and verifies transaction-capable topology; connect the MongoDB client, create the store, await `storeProvider.ready()`, then accept traffic
102
105
  - session deletion by subject or provider session ID uses compound scoped indexes on the identity field plus `provider.issuer` and `provider.clientId`; these replace single-field identity indexes because the leading key still supports identity-only deletes while scoped logout deletes avoid scanning every repeated identity across tenants
103
106
  - the application owns MongoDB client shutdown; this package does not close the client
104
- - rotation requires a distinct unused target session ID; missing-source, same-ID, and existing-target rotation conflicts throw `OidcVaultStoreConflictError` without changing source or target records
105
- - rotated-session aliases are retained only long enough to bridge in-flight refresh/logout requests; sessions without explicit expiry use a finite 5 minute alias retention window by default, configurable with `rotatedSessionAliasRetentionMs`
106
- - deleting by current session ID, stale rotated ID, logical session ID, subject, or provider session ID removes aliases for the affected logical sessions
107
- - `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; metadata should be JSON-compatible for portability across store providers
107
+
108
+ Drain HTTP requests and any other in-flight store operations before closing the caller-owned `MongoClient`. The store has no shutdown method or background cleanup timer. Its explicit transaction sessions are ended in `finally` on success and failure; MongoDB's TTL monitor belongs to the server. Shared state and durability depend on your deployment, replication and backup configuration.
109
+
110
+ ## Rotation Aliases And Lifetime
111
+
112
+ - rotation requires a distinct unused target session ID; missing-source, same-ID, existing-target, changed-source, and expired-source rotation conflicts throw `OidcVaultStoreConflictError` without changing source or target records (the source document is re-read inside the transaction and must still match the generation seen before the transaction, including liveness, so a same-ID replacement committed after the rotation's source read aborts instead of being consumed)
113
+ - an expired target document still present before TTL cleanup can conflict; use fresh target IDs for portable rotation
114
+ - each alias takes its immediate successor's `expiresAt`, not the source's; later rotations never extend it. With `A -> B (expiresAt=T1) -> C (expiresAt=T2)`, A stops revoking at T1 even if T2 is later or absent. B uses C's deadline. `getSession(A)` returns `null`: aliases are only revocation handles
115
+ - without successor `expiresAt`, MongoDB uses `now() + rotatedSessionAliasRetentionMs` (default five minutes, finite positive duration). Memory/Redis instead have no alias time limit and can accumulate aliases for a long-lived lineage. Finite session expiry is the portable policy; core refresh preserves it
116
+ - omitting the next logical ID preserves the source lineage. With `A/L1 -> B/L1 -> C/L2`, the new B alias revokes L2; earlier A keeps L1 and its original deadline, never C. MongoDB does not eagerly retire L1 aliases merely because rotation/upsert moves its last member elsewhere (memory does); they can remain until expiry or explicit lineage cleanup. Use distinct logical IDs for unrelated login families rather than relying on retained handles disappearing on reuse
117
+ - after an alias expires, use the live ID or logical/subject/provider-session deletion, not that stale handle
118
+
119
+ ## Deletion, Counts And Concurrency
120
+
121
+ - deleting a live public ID removes that record; deleting an unexpired alias revokes its logical lineage. Logical deletion and aliases have no issuer/client filter. Subject/provider-session object inputs match each supplied `issuer` and `clientId`; string inputs omit both filters and match across providers
122
+ - deleting by current session ID, subject, or provider session ID preserves unexpired aliases while any live member of the logical session survives, including another issuer/client scope; a stale rotated ID still revokes its logical lineage
123
+ - after session deletion commits, alias cleanup checks live membership and removes inactive-lineage aliases in a snapshot transaction. Concurrently inserted rotation aliases are outside that snapshot; updating an existing alias conflicts and retries cleanup, including identical lineage/expiry reuse (an internal alias revision makes every rotation a write). Cleanup sessions are closed on success and failure. This is not a global logout snapshot: later arrivals can survive, and cleanup failure can reject after sessions were already deleted
124
+ - compatibility: scoped/direct deletion no longer discards the surviving lineage's revocation handles. Alias deadlines and the create-ID-reuse variation below are unchanged. Cleanup still materializes affected logical IDs and surviving members; very large `$in` batches remain a scale-dependent limit
125
+
126
+ Subject/provider-session deletion repeats find/delete/cleanup passes until a find observes no matches. It can catch a rotation or matching arrival on a later pass, but arrivals after the final query can survive; continuously arriving matches can prolong the call without a fixed pass bound. Logical deletion uses one `deleteMany` followed by alias cleanup. Counts sum MongoDB `deletedCount` for primary session documents, possibly including expired documents awaiting TTL cleanup; alias removal is never counted. Memory excludes expired sessions, and Redis counts primary deletions from its cursor traversal. Counts are not a global remaining-session census or a cross-provider live-user metric. An error can follow partial/committed deletion. Vault deletion does not revoke outstanding stateless access tokens or call upstream logout.
127
+
128
+ ## Creation And Data Ownership
129
+
130
+ - `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; a `createSession` replacement takes over the session ID with its own subject/logical/provider-session scope
131
+ - unlike memory/Redis, a `createSession` replacement does not clear a stale rotation alias row held under the reused ID; that row can remain until expiry or lineage cleanup. After removing the replacement, another delete of that ID can invoke its former lineage. Portable callers must create sessions with a fresh unused ID and handle `OidcVaultStoreConflictError` (reusing a live ID replaces here but rejects on Redis; concurrent reuse also races rotation source checks)
132
+ - portable data consists of strings, finite numbers, booleans, null, arrays, and plain objects; exclude cycles, custom prototypes/serializers, functions, symbols, and undefined properties. This applies to nested provider/user/metadata fields, not just top-level metadata
133
+ - portable plain-object/array inputs are snapshotted at invocation, before readiness or other asynchronous work; later caller mutation cannot change session/transaction data, rotation retries, JTI identity, or deletion scope. Returned session containers are independent from caller input, including nested provider/user/metadata arrays
134
+ - compatibility: ownership snapshots do not JSON-round-trip values or flatten BSON wrappers. MongoDB's native serialization still applies outside the portable subset (including Dates, BSON values, undefined properties and custom `toBSON` hooks); opaque non-plain objects are not recursively copied and have no portable mutation-isolation guarantee
108
135
  - backchannel logout token JTI records are consumed only when `expiresAt` is finite and greater than the store clock at consume time
109
136
 
137
+ Authorization transactions and exchange codes are atomically consumed once by state/code, returning `null` when absent or expired. JTI reservations return `false` for duplicate, past, exact-boundary, NaN, or infinite expiries. Core passes an opaque issuer/client/JTI-namespaced key; the store uses it verbatim. Session creation defaults omitted logical ID to session ID and omitted timestamps to the store clock.
138
+
110
139
  ## Security And Operations
111
140
 
112
141
  Session records contain refresh tokens, ID tokens, access tokens, and related bearer-equivalent secrets. Treat the MongoDB database and every backup, log, trace, metric label, and export path that can expose these records as sensitive auth infrastructure.
@@ -120,6 +149,8 @@ Recommended controls:
120
149
  - define an explicit data-retention policy for sessions, aliases, authorization transactions, exchange codes, and consumed logout JTIs
121
150
  - close the caller-owned `MongoClient` from application shutdown code; this package never closes it
122
151
 
152
+ The store does not emit record/error logs or sanitize arbitrary MongoDB/client exceptions for application logging. Use fixed operation names and allowlisted diagnostic categories; do not log whole errors, connection URLs, session IDs, state/PKCE values, exchange codes or token-valued labels. A private error observer is not an automatic redaction boundary.
153
+
123
154
  This package does not implement application-level field encryption or client-side field-level encryption. Add those controls at your MongoDB/client configuration layer if your deployment requires them.
124
155
 
125
156
  ## Scoped Deletion Indexes
@@ -136,6 +167,8 @@ The package intentionally creates one compound index per public scoped identity
136
167
 
137
168
  ## Main Exports
138
169
 
170
+ Use named imports from the package root; there is no default export or public subpath API. Use Node.js `>=22.12.0` for the core dependency's CJS/ESM runtime support. TypeScript apps also need `@types/node` and `@types/express`.
171
+
139
172
  - `createMongoOidcVaultStore(...)`
140
173
  - `type OidcVaultMongoStoreProvider`
141
174
  - `type MongoOidcVaultStoreOptions`
package/index.js CHANGED
@@ -27,6 +27,7 @@ module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/store.ts
29
29
  var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
30
+ var import_node_util = require("util");
30
31
 
31
32
  // src/documents.ts
32
33
  var sessionToDocument = (session) => ({
@@ -175,10 +176,30 @@ var ensureStoreIndexes = async (collections) => {
175
176
  };
176
177
 
177
178
  // 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;
179
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
180
+ if (input === null || typeof input !== "object") return input;
181
+ const prototype = Object.getPrototypeOf(input);
182
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
183
+ if (seen.has(input)) return seen.get(input);
184
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
185
+ seen.set(input, copy);
186
+ for (const key of Reflect.ownKeys(input)) {
187
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
188
+ Object.defineProperty(copy, key, {
189
+ value: snapshotInput(Reflect.get(input, key), seen),
190
+ enumerable: true,
191
+ configurable: true,
192
+ writable: true
193
+ });
194
+ }
195
+ }
196
+ return copy;
197
+ };
198
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
199
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
200
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
181
201
  var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
202
+ var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && (0, import_node_util.isDeepStrictEqual)(current.provider, expected.provider) && current.refreshToken === expected.refreshToken && current.idToken === expected.idToken && current.accessToken === expected.accessToken && current.scope === expected.scope && (current.expiresAt?.getTime() ?? null) === (expected.expiresAt?.getTime() ?? null) && current.createdAt === expected.createdAt && current.updatedAt === expected.updatedAt && (0, import_node_util.isDeepStrictEqual)(current.user, expected.user) && (0, import_node_util.isDeepStrictEqual)(current.metadata, expected.metadata);
182
203
  var MongoOidcVaultStore = class {
183
204
  constructor(options) {
184
205
  const collectionNames = resolveCollectionNames(options);
@@ -207,6 +228,7 @@ var MongoOidcVaultStore = class {
207
228
  await this.waitUntilReady();
208
229
  }
209
230
  async createAuthorizationTransaction(input) {
231
+ input = snapshotInput({ ...input });
210
232
  await this.waitUntilReady();
211
233
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
212
234
  upsert: true
@@ -221,6 +243,7 @@ var MongoOidcVaultStore = class {
221
243
  return authorizationDocumentToRecord(record);
222
244
  }
223
245
  async createExchangeCode(input) {
246
+ input = { ...input };
224
247
  await this.waitUntilReady();
225
248
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
226
249
  }
@@ -233,6 +256,7 @@ var MongoOidcVaultStore = class {
233
256
  return exchangeDocumentToRecord(record);
234
257
  }
235
258
  async createSession(input) {
259
+ input = snapshotInput({ ...input });
236
260
  await this.waitUntilReady();
237
261
  const timestamp = this.now();
238
262
  const session = {
@@ -253,6 +277,7 @@ var MongoOidcVaultStore = class {
253
277
  return documentToSession(session);
254
278
  }
255
279
  async rotateSession(input) {
280
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
256
281
  await this.waitUntilReady();
257
282
  if (input.nextSession.sessionId === input.sessionId) {
258
283
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
@@ -273,7 +298,7 @@ var MongoOidcVaultStore = class {
273
298
  const logicalSessionId = session.logicalSessionId ?? session._id;
274
299
  const result = await this.sessions.deleteOne({ _id: sessionId });
275
300
  if (result.deletedCount === 1) {
276
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
301
+ await this.deleteAliasesForInactiveLogicalSessions([logicalSessionId]);
277
302
  return;
278
303
  }
279
304
  const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
@@ -288,13 +313,14 @@ var MongoOidcVaultStore = class {
288
313
  }
289
314
  }
290
315
  async deleteSessionsByLogicalSessionId(input) {
291
- await this.waitUntilReady();
292
316
  const resolved = toLogicalSessionDeleteInput(input);
317
+ await this.waitUntilReady();
293
318
  const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
294
- await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
319
+ await this.deleteAliasesForInactiveLogicalSessions([resolved.logicalSessionId]);
295
320
  return result.deletedCount;
296
321
  }
297
322
  async consumeBackchannelLogoutTokenJti(input) {
323
+ input = { ...input };
298
324
  await this.waitUntilReady();
299
325
  const now = this.now();
300
326
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
@@ -327,8 +353,8 @@ var MongoOidcVaultStore = class {
327
353
  }
328
354
  }
329
355
  async deleteSessionsBySubject(input) {
330
- await this.waitUntilReady();
331
356
  const resolved = toSubjectDeleteInput(input);
357
+ await this.waitUntilReady();
332
358
  const filter = { subject: resolved.subject };
333
359
  if (resolved.issuer !== void 0) {
334
360
  filter["provider.issuer"] = resolved.issuer;
@@ -340,8 +366,8 @@ var MongoOidcVaultStore = class {
340
366
  return result.deletedCount;
341
367
  }
342
368
  async deleteSessionsByProviderSessionId(input) {
343
- await this.waitUntilReady();
344
369
  const resolved = toProviderSessionDeleteInput(input);
370
+ await this.waitUntilReady();
345
371
  const filter = { providerSessionId: resolved.providerSessionId };
346
372
  if (resolved.issuer !== void 0) {
347
373
  filter["provider.issuer"] = resolved.issuer;
@@ -371,10 +397,18 @@ var MongoOidcVaultStore = class {
371
397
  }
372
398
  }
373
399
  async rotateSessionWithTransaction(input) {
400
+ const { previous, nextSession } = await this.normalizeRotatedSession(input);
401
+ const expectedSource = sessionToDocument(previous);
374
402
  const session = this.db.client.startSession();
375
- const nextSession = await this.normalizeRotatedSession(input);
376
403
  try {
377
404
  await session.withTransaction(async () => {
405
+ const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
406
+ if (!current || isExpired(current, this.now())) {
407
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
408
+ }
409
+ if (!isSameSessionDocumentGeneration(current, expectedSource)) {
410
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
411
+ }
378
412
  await this.sessions.insertOne(sessionToDocument(nextSession), { session });
379
413
  const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
380
414
  if (deleteResult.deletedCount !== 1) {
@@ -397,7 +431,10 @@ var MongoOidcVaultStore = class {
397
431
  $set: {
398
432
  logicalSessionId,
399
433
  expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
400
- }
434
+ },
435
+ // A no-op alias update would not conflict with an older cleanup
436
+ // snapshot. Every rotation must write, even with identical values.
437
+ $inc: { revision: 1 }
401
438
  },
402
439
  { upsert: true, session }
403
440
  );
@@ -408,8 +445,11 @@ var MongoOidcVaultStore = class {
408
445
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
409
446
  }
410
447
  return {
411
- ...input.nextSession,
412
- logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
448
+ previous,
449
+ nextSession: {
450
+ ...input.nextSession,
451
+ logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
452
+ }
413
453
  };
414
454
  }
415
455
  async deleteSessionsAndAliasesByFilter(filter) {
@@ -425,7 +465,7 @@ var MongoOidcVaultStore = class {
425
465
  }
426
466
  const result = await this.sessions.deleteMany(filter);
427
467
  deletedCount += result.deletedCount;
428
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
468
+ await this.deleteAliasesForInactiveLogicalSessions(logicalSessionIds);
429
469
  }
430
470
  }
431
471
  getRotatedSessionAliasExpiresAt(nextSession, now) {
@@ -433,8 +473,36 @@ var MongoOidcVaultStore = class {
433
473
  typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
434
474
  );
435
475
  }
436
- async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
437
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
476
+ async deleteAliasesForInactiveLogicalSessions(logicalSessionIds) {
477
+ const session = this.db.client.startSession();
478
+ try {
479
+ await session.withTransaction(
480
+ async () => {
481
+ const live = await this.sessions.find(
482
+ {
483
+ $and: [
484
+ {
485
+ $or: [
486
+ { logicalSessionId: { $in: logicalSessionIds } },
487
+ { logicalSessionId: { $exists: false }, _id: { $in: logicalSessionIds } }
488
+ ]
489
+ },
490
+ { $nor: [{ expiresAt: { $lte: new Date(this.now()) } }] }
491
+ ]
492
+ },
493
+ { session, projection: { _id: 1, logicalSessionId: 1 } }
494
+ ).toArray();
495
+ const survivingIds = new Set(live.map((record) => record.logicalSessionId ?? record._id));
496
+ const inactiveIds = logicalSessionIds.filter((id) => !survivingIds.has(id));
497
+ if (inactiveIds.length > 0) {
498
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
499
+ }
500
+ },
501
+ { readConcern: { level: "snapshot" } }
502
+ );
503
+ } finally {
504
+ await session.endSession();
505
+ }
438
506
  }
439
507
  async isExpiredAndCleanup(collection, record) {
440
508
  if (!isExpired(record, this.now())) {
package/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  OidcVaultStoreConflictError
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
+ import { isDeepStrictEqual } from "util";
5
6
 
6
7
  // src/documents.ts
7
8
  var sessionToDocument = (session) => ({
@@ -150,10 +151,30 @@ var ensureStoreIndexes = async (collections) => {
150
151
  };
151
152
 
152
153
  // 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;
154
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
155
+ if (input === null || typeof input !== "object") return input;
156
+ const prototype = Object.getPrototypeOf(input);
157
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
158
+ if (seen.has(input)) return seen.get(input);
159
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
160
+ seen.set(input, copy);
161
+ for (const key of Reflect.ownKeys(input)) {
162
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
163
+ Object.defineProperty(copy, key, {
164
+ value: snapshotInput(Reflect.get(input, key), seen),
165
+ enumerable: true,
166
+ configurable: true,
167
+ writable: true
168
+ });
169
+ }
170
+ }
171
+ return copy;
172
+ };
173
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
174
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
175
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
156
176
  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);
157
178
  var MongoOidcVaultStore = class {
158
179
  constructor(options) {
159
180
  const collectionNames = resolveCollectionNames(options);
@@ -182,6 +203,7 @@ var MongoOidcVaultStore = class {
182
203
  await this.waitUntilReady();
183
204
  }
184
205
  async createAuthorizationTransaction(input) {
206
+ input = snapshotInput({ ...input });
185
207
  await this.waitUntilReady();
186
208
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
187
209
  upsert: true
@@ -196,6 +218,7 @@ var MongoOidcVaultStore = class {
196
218
  return authorizationDocumentToRecord(record);
197
219
  }
198
220
  async createExchangeCode(input) {
221
+ input = { ...input };
199
222
  await this.waitUntilReady();
200
223
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
201
224
  }
@@ -208,6 +231,7 @@ var MongoOidcVaultStore = class {
208
231
  return exchangeDocumentToRecord(record);
209
232
  }
210
233
  async createSession(input) {
234
+ input = snapshotInput({ ...input });
211
235
  await this.waitUntilReady();
212
236
  const timestamp = this.now();
213
237
  const session = {
@@ -228,6 +252,7 @@ var MongoOidcVaultStore = class {
228
252
  return documentToSession(session);
229
253
  }
230
254
  async rotateSession(input) {
255
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
231
256
  await this.waitUntilReady();
232
257
  if (input.nextSession.sessionId === input.sessionId) {
233
258
  throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
@@ -248,7 +273,7 @@ var MongoOidcVaultStore = class {
248
273
  const logicalSessionId = session.logicalSessionId ?? session._id;
249
274
  const result = await this.sessions.deleteOne({ _id: sessionId });
250
275
  if (result.deletedCount === 1) {
251
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
276
+ await this.deleteAliasesForInactiveLogicalSessions([logicalSessionId]);
252
277
  return;
253
278
  }
254
279
  const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
@@ -263,13 +288,14 @@ var MongoOidcVaultStore = class {
263
288
  }
264
289
  }
265
290
  async deleteSessionsByLogicalSessionId(input) {
266
- await this.waitUntilReady();
267
291
  const resolved = toLogicalSessionDeleteInput(input);
292
+ await this.waitUntilReady();
268
293
  const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
269
- await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
294
+ await this.deleteAliasesForInactiveLogicalSessions([resolved.logicalSessionId]);
270
295
  return result.deletedCount;
271
296
  }
272
297
  async consumeBackchannelLogoutTokenJti(input) {
298
+ input = { ...input };
273
299
  await this.waitUntilReady();
274
300
  const now = this.now();
275
301
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
@@ -302,8 +328,8 @@ var MongoOidcVaultStore = class {
302
328
  }
303
329
  }
304
330
  async deleteSessionsBySubject(input) {
305
- await this.waitUntilReady();
306
331
  const resolved = toSubjectDeleteInput(input);
332
+ await this.waitUntilReady();
307
333
  const filter = { subject: resolved.subject };
308
334
  if (resolved.issuer !== void 0) {
309
335
  filter["provider.issuer"] = resolved.issuer;
@@ -315,8 +341,8 @@ var MongoOidcVaultStore = class {
315
341
  return result.deletedCount;
316
342
  }
317
343
  async deleteSessionsByProviderSessionId(input) {
318
- await this.waitUntilReady();
319
344
  const resolved = toProviderSessionDeleteInput(input);
345
+ await this.waitUntilReady();
320
346
  const filter = { providerSessionId: resolved.providerSessionId };
321
347
  if (resolved.issuer !== void 0) {
322
348
  filter["provider.issuer"] = resolved.issuer;
@@ -346,10 +372,18 @@ var MongoOidcVaultStore = class {
346
372
  }
347
373
  }
348
374
  async rotateSessionWithTransaction(input) {
375
+ const { previous, nextSession } = await this.normalizeRotatedSession(input);
376
+ const expectedSource = sessionToDocument(previous);
349
377
  const session = this.db.client.startSession();
350
- const nextSession = await this.normalizeRotatedSession(input);
351
378
  try {
352
379
  await session.withTransaction(async () => {
380
+ const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
381
+ if (!current || isExpired(current, this.now())) {
382
+ throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
383
+ }
384
+ if (!isSameSessionDocumentGeneration(current, expectedSource)) {
385
+ throw new OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
386
+ }
353
387
  await this.sessions.insertOne(sessionToDocument(nextSession), { session });
354
388
  const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
355
389
  if (deleteResult.deletedCount !== 1) {
@@ -372,7 +406,10 @@ var MongoOidcVaultStore = class {
372
406
  $set: {
373
407
  logicalSessionId,
374
408
  expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
375
- }
409
+ },
410
+ // A no-op alias update would not conflict with an older cleanup
411
+ // snapshot. Every rotation must write, even with identical values.
412
+ $inc: { revision: 1 }
376
413
  },
377
414
  { upsert: true, session }
378
415
  );
@@ -383,8 +420,11 @@ var MongoOidcVaultStore = class {
383
420
  throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
384
421
  }
385
422
  return {
386
- ...input.nextSession,
387
- logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
423
+ previous,
424
+ nextSession: {
425
+ ...input.nextSession,
426
+ logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
427
+ }
388
428
  };
389
429
  }
390
430
  async deleteSessionsAndAliasesByFilter(filter) {
@@ -400,7 +440,7 @@ var MongoOidcVaultStore = class {
400
440
  }
401
441
  const result = await this.sessions.deleteMany(filter);
402
442
  deletedCount += result.deletedCount;
403
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
443
+ await this.deleteAliasesForInactiveLogicalSessions(logicalSessionIds);
404
444
  }
405
445
  }
406
446
  getRotatedSessionAliasExpiresAt(nextSession, now) {
@@ -408,8 +448,36 @@ var MongoOidcVaultStore = class {
408
448
  typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
409
449
  );
410
450
  }
411
- async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
412
- await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
451
+ async deleteAliasesForInactiveLogicalSessions(logicalSessionIds) {
452
+ const session = this.db.client.startSession();
453
+ try {
454
+ await session.withTransaction(
455
+ async () => {
456
+ const live = await this.sessions.find(
457
+ {
458
+ $and: [
459
+ {
460
+ $or: [
461
+ { logicalSessionId: { $in: logicalSessionIds } },
462
+ { logicalSessionId: { $exists: false }, _id: { $in: logicalSessionIds } }
463
+ ]
464
+ },
465
+ { $nor: [{ expiresAt: { $lte: new Date(this.now()) } }] }
466
+ ]
467
+ },
468
+ { session, projection: { _id: 1, logicalSessionId: 1 } }
469
+ ).toArray();
470
+ const survivingIds = new Set(live.map((record) => record.logicalSessionId ?? record._id));
471
+ const inactiveIds = logicalSessionIds.filter((id) => !survivingIds.has(id));
472
+ if (inactiveIds.length > 0) {
473
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
474
+ }
475
+ },
476
+ { readConcern: { level: "snapshot" } }
477
+ );
478
+ } finally {
479
+ await session.endSession();
480
+ }
413
481
  }
414
482
  async isExpiredAndCleanup(collection, record) {
415
483
  if (!isExpired(record, this.now())) {
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.43.0",
5
+ "version": "0.47.1",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -25,7 +25,7 @@
25
25
  "node": ">=22"
26
26
  },
27
27
  "dependencies": {
28
- "@web-ts-toolkit/express-oidc-vault": "0.43.0",
28
+ "@web-ts-toolkit/express-oidc-vault": "0.47.1",
29
29
  "mongodb": "^6.20.0"
30
30
  },
31
31
  "files": [