@web-ts-toolkit/express-oidc-vault-mongodb-store 0.42.2 → 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.
- package/README.md +41 -8
- package/index.js +83 -15
- package/index.mjs +83 -15
- 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
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
105
|
-
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
412
|
-
|
|
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.
|
|
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
|
|
437
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
387
|
-
|
|
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.
|
|
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
|
|
412
|
-
|
|
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.
|
|
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.
|
|
28
|
+
"@web-ts-toolkit/express-oidc-vault": "0.47.1",
|
|
29
29
|
"mongodb": "^6.20.0"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|