@web-ts-toolkit/express-oidc-vault-mongodb-store 0.32.0 → 0.33.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/README.md CHANGED
@@ -20,6 +20,11 @@ await client.connect();
20
20
  const storeProvider = createMongoOidcVaultStore({
21
21
  db: client.db('app-auth'),
22
22
  });
23
+
24
+ await storeProvider.ready();
25
+
26
+ // Later, during application shutdown:
27
+ await client.close();
23
28
  ```
24
29
 
25
30
  ## Express Wiring Example
@@ -35,9 +40,16 @@ const mongo = new MongoClient(process.env.MONGODB_URI!);
35
40
 
36
41
  await mongo.connect();
37
42
 
43
+ const storeProvider = createMongoOidcVaultStore({
44
+ db: mongo.db('app-auth'),
45
+ });
46
+
47
+ await storeProvider.ready();
48
+
38
49
  app.use(
39
50
  createOidcVaultMiddleware({
40
51
  basePath: '/auth/oidc',
52
+ backendOrigin: 'https://api.example.com',
41
53
  config: {
42
54
  issuer: process.env.OIDC_ISSUER,
43
55
  clientId: process.env.OIDC_CLIENT_ID,
@@ -45,21 +57,86 @@ app.use(
45
57
  },
46
58
  frontendRedirectUri: 'https://frontend.example.com/callback',
47
59
  postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
48
- storeProvider: createMongoOidcVaultStore({
49
- db: mongo.db('app-auth'),
50
- }),
60
+ storeProvider,
51
61
  }),
52
62
  );
63
+
64
+ const server = app.listen(3000);
65
+
66
+ process.once('SIGTERM', async () => {
67
+ server.close();
68
+ await mongo.close();
69
+ });
70
+ ```
71
+
72
+ ## Collection Names
73
+
74
+ The provider uses five separate MongoDB collections by default:
75
+
76
+ - `oidc_vault_authorization_transactions`: authorization transaction state, nonce, PKCE verifier, and metadata
77
+ - `oidc_vault_exchange_codes`: short-lived frontend exchange codes
78
+ - `oidc_vault_sessions`: sessions, user data, and bearer-equivalent token material
79
+ - `oidc_vault_backchannel_logout_token_jtis`: consumed backchannel logout token JTIs
80
+ - `oidc_vault_rotated_session_aliases`: stale rotated session IDs mapped to active logical sessions
81
+
82
+ Override collection names when your deployment needs explicit naming:
83
+
84
+ ```ts
85
+ const storeProvider = createMongoOidcVaultStore({
86
+ db: mongo.db('app-auth'),
87
+ authorizationTransactionsCollectionName: 'auth_oidc_transactions',
88
+ exchangeCodesCollectionName: 'auth_oidc_exchange_codes',
89
+ sessionsCollectionName: 'auth_oidc_sessions',
90
+ backchannelLogoutTokenJtisCollectionName: 'auth_oidc_backchannel_logout_jtis',
91
+ rotatedSessionAliasesCollectionName: 'auth_oidc_rotated_session_aliases',
92
+ });
53
93
  ```
54
94
 
55
95
  ## Notes
56
96
 
57
97
  - use MongoDB when your team already standardizes on Mongo and you want the auth vault data in the same operational platform
58
- - the provider uses separate collections for authorization transactions, exchange codes, and sessions
59
- - TTL indexes are created on `expiresAt`, and the provider also checks expiration on reads so behavior does not depend on Mongo's background TTL monitor timing
60
- - session rotation uses MongoDB transactions when connected to a replica set or sharded deployment, and falls back to conflict-safe rollback behavior on standalone servers
98
+ - 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
101
+ - startup readiness creates required indexes and verifies transaction-capable topology; connect the MongoDB client, create the store, await `storeProvider.ready()`, then accept traffic
102
+ - 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
+ - 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
108
+ - backchannel logout token JTI records are consumed only when `expiresAt` is finite and greater than the store clock at consume time
109
+
110
+ ## Security And Operations
111
+
112
+ 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.
113
+
114
+ Recommended controls:
115
+
116
+ - require TLS for MongoDB connections and for any network path carrying session data
117
+ - grant the application least-privilege roles scoped to the configured database and collections
118
+ - enable encryption at rest for the database and backups
119
+ - restrict observability tooling so token fields and whole session documents are not logged, indexed, sampled, or exported
120
+ - define an explicit data-retention policy for sessions, aliases, authorization transactions, exchange codes, and consumed logout JTIs
121
+ - close the caller-owned `MongoClient` from application shutdown code; this package never closes it
122
+
123
+ 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
+
125
+ ## Scoped Deletion Indexes
126
+
127
+ The sessions collection creates these deletion indexes:
128
+
129
+ - `subject_scope_idx`: `{ subject: 1, 'provider.issuer': 1, 'provider.clientId': 1 }`
130
+ - `provider_session_scope_idx`: `{ providerSessionId: 1, 'provider.issuer': 1, 'provider.clientId': 1 }`
131
+ - `logical_session_idx`: `{ logicalSessionId: 1 }`
132
+
133
+ Representative query-plan evidence used a dataset with 2 repeated identities, 10 issuers, 10 clients, and 10 duplicate sessions per issuer/client scope. With only single-field `subject` or `providerSessionId` indexes, scoped delete lookups examined all 1,000 matching identity documents. With the compound indexes above, `subject/providerSessionId + issuer`, `subject/providerSessionId + clientId`, and `subject/providerSessionId + issuer + clientId` examined 100, 100, and 10 documents respectively, matching the scoped result set size in that dataset.
134
+
135
+ The package intentionally creates one compound index per public scoped identity delete path rather than one index per optional-filter permutation. The leading identity key still supports identity-only deletes with the same result-set-sized scan as the previous single-field indexes, while issuer/client scoped deletes avoid broad scans in multi-tenant collections. The residual assumption is that very large deployments should keep subject/provider-session identifiers reasonably distributed within each issuer/client scope; otherwise the scoped result set itself is large and deletion cost is expected to scale with the number of sessions being revoked.
61
136
 
62
137
  ## Main Exports
63
138
 
64
139
  - `createMongoOidcVaultStore(...)`
140
+ - `type OidcVaultMongoStoreProvider`
65
141
  - `type MongoOidcVaultStoreOptions`
142
+ - `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`
package/index.d.mts CHANGED
@@ -1,13 +1,66 @@
1
- import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
1
  import { Db } from 'mongodb';
2
+ import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
3
3
 
4
+ /**
5
+ * Options for `createMongoOidcVaultStore`.
6
+ *
7
+ * Pass a `Db` from an already-connected caller-owned `MongoClient`. The store
8
+ * validates collection names, creates indexes, and verifies transaction support
9
+ * from `ready()`, so applications should await readiness before accepting
10
+ * traffic. Session rotation requires MongoDB transactions; standalone servers
11
+ * fail closed instead of using non-atomic multi-write fallback behavior.
12
+ */
4
13
  interface MongoOidcVaultStoreOptions {
14
+ /** MongoDB database handle. The caller owns the underlying client lifecycle. */
5
15
  db: Db;
16
+ /** Collection for authorization transactions, keyed by OIDC `state`. */
6
17
  authorizationTransactionsCollectionName?: string;
18
+ /** Collection for short-lived frontend exchange codes. */
7
19
  exchangeCodesCollectionName?: string;
20
+ /** Collection for OIDC vault sessions and bearer-equivalent token material. */
8
21
  sessionsCollectionName?: string;
22
+ /** Collection for consumed backchannel logout token JTIs. */
23
+ backchannelLogoutTokenJtisCollectionName?: string;
24
+ /** Collection mapping rotated stale session IDs to active logical sessions. */
25
+ rotatedSessionAliasesCollectionName?: string;
26
+ /**
27
+ * Finite alias retention for sessions without explicit expiry, in
28
+ * milliseconds. Defaults to `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`.
29
+ */
30
+ rotatedSessionAliasRetentionMs?: number;
31
+ /** Test-only clock override used for deterministic expiry and retention tests. */
9
32
  now?: () => number;
10
33
  }
11
- declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultStoreProvider;
34
+ /**
35
+ * MongoDB-backed OIDC vault store provider.
36
+ *
37
+ * Create the store after connecting the MongoDB client, then await `ready()`
38
+ * before accepting traffic. The application owns the MongoDB client lifecycle,
39
+ * including shutdown.
40
+ */
41
+ interface OidcVaultMongoStoreProvider extends OidcVaultStoreProvider {
42
+ /**
43
+ * Waits for startup validation to complete.
44
+ *
45
+ * Readiness validates collection names, creates required indexes, and verifies
46
+ * the MongoDB deployment supports transactions required by session rotation.
47
+ * If readiness fails, every store operation rejects with the same error.
48
+ */
49
+ ready(): Promise<void>;
50
+ }
51
+ declare const DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: number;
52
+
53
+ /**
54
+ * Creates a MongoDB-backed store provider.
55
+ *
56
+ * Use the named root import:
57
+ * `import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store'`.
58
+ * Pass a `Db` from an already-connected caller-owned `MongoClient`, await
59
+ * `store.ready()` before accepting traffic, and close the client from your own
60
+ * shutdown path. Readiness validates all configured collection names, creates
61
+ * required indexes for the five store collections, and verifies that the
62
+ * MongoDB deployment supports transactions required by session rotation.
63
+ */
64
+ declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultMongoStoreProvider;
12
65
 
13
- export { type MongoOidcVaultStoreOptions, createMongoOidcVaultStore };
66
+ export { DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS, type MongoOidcVaultStoreOptions, type OidcVaultMongoStoreProvider, createMongoOidcVaultStore };
package/index.d.ts CHANGED
@@ -1,13 +1,66 @@
1
- import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
1
  import { Db } from 'mongodb';
2
+ import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
3
3
 
4
+ /**
5
+ * Options for `createMongoOidcVaultStore`.
6
+ *
7
+ * Pass a `Db` from an already-connected caller-owned `MongoClient`. The store
8
+ * validates collection names, creates indexes, and verifies transaction support
9
+ * from `ready()`, so applications should await readiness before accepting
10
+ * traffic. Session rotation requires MongoDB transactions; standalone servers
11
+ * fail closed instead of using non-atomic multi-write fallback behavior.
12
+ */
4
13
  interface MongoOidcVaultStoreOptions {
14
+ /** MongoDB database handle. The caller owns the underlying client lifecycle. */
5
15
  db: Db;
16
+ /** Collection for authorization transactions, keyed by OIDC `state`. */
6
17
  authorizationTransactionsCollectionName?: string;
18
+ /** Collection for short-lived frontend exchange codes. */
7
19
  exchangeCodesCollectionName?: string;
20
+ /** Collection for OIDC vault sessions and bearer-equivalent token material. */
8
21
  sessionsCollectionName?: string;
22
+ /** Collection for consumed backchannel logout token JTIs. */
23
+ backchannelLogoutTokenJtisCollectionName?: string;
24
+ /** Collection mapping rotated stale session IDs to active logical sessions. */
25
+ rotatedSessionAliasesCollectionName?: string;
26
+ /**
27
+ * Finite alias retention for sessions without explicit expiry, in
28
+ * milliseconds. Defaults to `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`.
29
+ */
30
+ rotatedSessionAliasRetentionMs?: number;
31
+ /** Test-only clock override used for deterministic expiry and retention tests. */
9
32
  now?: () => number;
10
33
  }
11
- declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultStoreProvider;
34
+ /**
35
+ * MongoDB-backed OIDC vault store provider.
36
+ *
37
+ * Create the store after connecting the MongoDB client, then await `ready()`
38
+ * before accepting traffic. The application owns the MongoDB client lifecycle,
39
+ * including shutdown.
40
+ */
41
+ interface OidcVaultMongoStoreProvider extends OidcVaultStoreProvider {
42
+ /**
43
+ * Waits for startup validation to complete.
44
+ *
45
+ * Readiness validates collection names, creates required indexes, and verifies
46
+ * the MongoDB deployment supports transactions required by session rotation.
47
+ * If readiness fails, every store operation rejects with the same error.
48
+ */
49
+ ready(): Promise<void>;
50
+ }
51
+ declare const DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: number;
52
+
53
+ /**
54
+ * Creates a MongoDB-backed store provider.
55
+ *
56
+ * Use the named root import:
57
+ * `import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store'`.
58
+ * Pass a `Db` from an already-connected caller-owned `MongoClient`, await
59
+ * `store.ready()` before accepting traffic, and close the client from your own
60
+ * shutdown path. Readiness validates all configured collection names, creates
61
+ * required indexes for the five store collections, and verifies that the
62
+ * MongoDB deployment supports transactions required by session rotation.
63
+ */
64
+ declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultMongoStoreProvider;
12
65
 
13
- export { type MongoOidcVaultStoreOptions, createMongoOidcVaultStore };
66
+ export { DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS, type MongoOidcVaultStoreOptions, type OidcVaultMongoStoreProvider, createMongoOidcVaultStore };
package/index.js CHANGED
@@ -20,17 +20,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: () => DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS,
23
24
  createMongoOidcVaultStore: () => createMongoOidcVaultStore
24
25
  });
25
26
  module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/store.ts
26
29
  var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
27
- var DEFAULT_COLLECTION_NAMES = {
28
- authorizationTransactions: "oidc_vault_authorization_transactions",
29
- exchangeCodes: "oidc_vault_exchange_codes",
30
- sessions: "oidc_vault_sessions"
31
- };
30
+
31
+ // src/documents.ts
32
32
  var sessionToDocument = (session) => ({
33
33
  _id: session.sessionId,
34
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
34
35
  subject: session.subject,
35
36
  providerSessionId: session.providerSessionId,
36
37
  provider: session.provider,
@@ -46,6 +47,7 @@ var sessionToDocument = (session) => ({
46
47
  });
47
48
  var documentToSession = (session) => ({
48
49
  sessionId: session._id,
50
+ logicalSessionId: session.logicalSessionId ?? session._id,
49
51
  subject: session.subject,
50
52
  providerSessionId: session.providerSessionId,
51
53
  provider: session.provider,
@@ -87,56 +89,155 @@ var exchangeDocumentToRecord = (record) => ({
87
89
  expiresAt: record.expiresAt.getTime()
88
90
  });
89
91
  var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
92
+
93
+ // src/options.ts
94
+ var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
95
+ var DEFAULT_COLLECTION_NAMES = {
96
+ authorizationTransactions: "oidc_vault_authorization_transactions",
97
+ exchangeCodes: "oidc_vault_exchange_codes",
98
+ sessions: "oidc_vault_sessions",
99
+ backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
100
+ rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
101
+ };
102
+ var validateCollectionName = (role, name) => {
103
+ if (name.length === 0) {
104
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not be empty.`);
105
+ }
106
+ if (name.includes("\0")) {
107
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain null bytes.`);
108
+ }
109
+ if (name.startsWith("system.")) {
110
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not use the reserved system namespace.`);
111
+ }
112
+ if (name.includes("$")) {
113
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain '$'.`);
114
+ }
115
+ };
116
+ var resolveCollectionNames = (options) => {
117
+ const collectionNames = {
118
+ authorizationTransactions: options.authorizationTransactionsCollectionName ?? DEFAULT_COLLECTION_NAMES.authorizationTransactions,
119
+ exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
120
+ sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
121
+ backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
122
+ rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
123
+ };
124
+ for (const [role, name] of Object.entries(collectionNames)) {
125
+ validateCollectionName(role, name);
126
+ }
127
+ const seen = /* @__PURE__ */ new Map();
128
+ for (const [role, name] of Object.entries(collectionNames)) {
129
+ const existingRole = seen.get(name);
130
+ if (existingRole !== void 0) {
131
+ throw new TypeError(
132
+ `OIDC vault MongoDB ${role} collection name must be distinct from ${existingRole}; shared collections are not supported.`
133
+ );
134
+ }
135
+ seen.set(name, role);
136
+ }
137
+ return collectionNames;
138
+ };
139
+
140
+ // src/topology.ts
141
+ var isTransactionCapableHelloResponse = (value) => typeof value.setName === "string" || value.msg === "isdbgrid";
142
+ var assertTransactionSupport = async (db) => {
143
+ const hello = await db.admin().command({ hello: 1 });
144
+ if (!isTransactionCapableHelloResponse(hello)) {
145
+ throw new Error(
146
+ "OIDC vault MongoDB session rotation requires a transaction-capable MongoDB deployment. Use a replica set or sharded cluster."
147
+ );
148
+ }
149
+ };
150
+ var createTtlIndex = async (collection) => collection.createIndex(
151
+ { expiresAt: 1 },
152
+ {
153
+ expireAfterSeconds: 0,
154
+ name: "expiresAt_ttl"
155
+ }
156
+ );
157
+ var ensureStoreIndexes = async (collections) => {
158
+ await Promise.all([
159
+ createTtlIndex(collections.authorizationTransactions),
160
+ createTtlIndex(collections.exchangeCodes),
161
+ createTtlIndex(collections.sessions),
162
+ createTtlIndex(collections.backchannelLogoutTokenJtis),
163
+ createTtlIndex(collections.rotatedSessionAliases),
164
+ collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
165
+ collections.sessions.createIndex(
166
+ { subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
167
+ { name: "subject_scope_idx" }
168
+ ),
169
+ collections.sessions.createIndex(
170
+ { providerSessionId: 1, "provider.issuer": 1, "provider.clientId": 1 },
171
+ { name: "provider_session_scope_idx" }
172
+ ),
173
+ collections.sessions.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" })
174
+ ]);
175
+ };
176
+
177
+ // src/store.ts
90
178
  var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
91
179
  var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
92
- var isTransactionCapableHelloResponse = (value) => typeof value.setName === "string" || value.msg === "isdbgrid";
180
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
181
+ var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
93
182
  var MongoOidcVaultStore = class {
94
183
  constructor(options) {
184
+ const collectionNames = resolveCollectionNames(options);
95
185
  this.db = options.db;
96
186
  this.authorizationTransactions = options.db.collection(
97
- options.authorizationTransactionsCollectionName ?? DEFAULT_COLLECTION_NAMES.authorizationTransactions
187
+ collectionNames.authorizationTransactions
98
188
  );
99
- this.exchangeCodes = options.db.collection(
100
- options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes
189
+ this.exchangeCodes = options.db.collection(collectionNames.exchangeCodes);
190
+ this.sessions = options.db.collection(collectionNames.sessions);
191
+ this.backchannelLogoutTokenJtis = options.db.collection(
192
+ collectionNames.backchannelLogoutTokenJtis
101
193
  );
102
- this.sessions = options.db.collection(
103
- options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions
194
+ this.rotatedSessionAliases = options.db.collection(
195
+ collectionNames.rotatedSessionAliases
104
196
  );
197
+ this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
198
+ if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
199
+ throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
200
+ }
105
201
  this.now = options.now ?? (() => Date.now());
106
- this.ready = this.ensureIndexes();
107
- this.supportsTransactions = this.detectTransactionSupport();
202
+ this.initialization = this.initialize().then(void 0, (error) => {
203
+ this.initializationError = error;
204
+ });
205
+ }
206
+ async ready() {
207
+ await this.waitUntilReady();
108
208
  }
109
209
  async createAuthorizationTransaction(input) {
110
- await this.ready;
210
+ await this.waitUntilReady();
111
211
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
112
212
  upsert: true
113
213
  });
114
214
  }
115
215
  async consumeAuthorizationTransaction(state) {
116
- await this.ready;
216
+ await this.waitUntilReady();
117
217
  const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
118
- if (!record || await this.isExpiredAndCleanup(this.authorizationTransactions, record)) {
218
+ if (!record || isExpired(record, this.now())) {
119
219
  return null;
120
220
  }
121
221
  return authorizationDocumentToRecord(record);
122
222
  }
123
223
  async createExchangeCode(input) {
124
- await this.ready;
224
+ await this.waitUntilReady();
125
225
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
126
226
  }
127
227
  async consumeExchangeCode(code) {
128
- await this.ready;
228
+ await this.waitUntilReady();
129
229
  const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
130
- if (!record || await this.isExpiredAndCleanup(this.exchangeCodes, record)) {
230
+ if (!record || isExpired(record, this.now())) {
131
231
  return null;
132
232
  }
133
233
  return exchangeDocumentToRecord(record);
134
234
  }
135
235
  async createSession(input) {
136
- await this.ready;
236
+ await this.waitUntilReady();
137
237
  const timestamp = this.now();
138
238
  const session = {
139
239
  ...input,
240
+ logicalSessionId: input.logicalSessionId ?? input.sessionId,
140
241
  createdAt: input.createdAt ?? timestamp,
141
242
  updatedAt: input.updatedAt ?? timestamp
142
243
  };
@@ -144,7 +245,7 @@ var MongoOidcVaultStore = class {
144
245
  return session;
145
246
  }
146
247
  async getSession(sessionId) {
147
- await this.ready;
248
+ await this.waitUntilReady();
148
249
  const session = await this.sessions.findOne({ _id: sessionId });
149
250
  if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
150
251
  return null;
@@ -152,42 +253,81 @@ var MongoOidcVaultStore = class {
152
253
  return documentToSession(session);
153
254
  }
154
255
  async rotateSession(input) {
155
- await this.ready;
156
- if (await this.supportsTransactions) {
157
- return this.rotateSessionWithTransaction(input);
256
+ await this.waitUntilReady();
257
+ if (input.nextSession.sessionId === input.sessionId) {
258
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
259
+ }
260
+ try {
261
+ return await this.rotateSessionWithTransaction(input);
262
+ } catch (error) {
263
+ if (isDuplicateKeyError(error)) {
264
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
265
+ }
266
+ throw error;
158
267
  }
159
- return this.rotateSessionWithoutTransaction(input);
160
268
  }
161
- async rotateSessionWithoutTransaction(input) {
162
- await this.sessions.insertOne(sessionToDocument(input.nextSession));
163
- const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId });
164
- if (deleteResult.deletedCount !== 1) {
165
- await this.sessions.deleteOne({ _id: input.nextSession.sessionId });
166
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
269
+ async deleteSession(sessionId) {
270
+ await this.waitUntilReady();
271
+ const session = await this.sessions.findOne({ _id: sessionId });
272
+ if (session) {
273
+ const logicalSessionId = session.logicalSessionId ?? session._id;
274
+ const result = await this.sessions.deleteOne({ _id: sessionId });
275
+ if (result.deletedCount === 1) {
276
+ await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
277
+ return;
278
+ }
279
+ const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
280
+ if (alias2 && !isExpired(alias2, this.now())) {
281
+ await this.deleteSessionsByLogicalSessionId(alias2.logicalSessionId);
282
+ }
283
+ return;
284
+ }
285
+ const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId });
286
+ if (alias && !isExpired(alias, this.now())) {
287
+ await this.deleteSessionsByLogicalSessionId(alias.logicalSessionId);
167
288
  }
168
- return input.nextSession;
169
289
  }
170
- async rotateSessionWithTransaction(input) {
171
- const session = this.db.client.startSession();
290
+ async deleteSessionsByLogicalSessionId(input) {
291
+ await this.waitUntilReady();
292
+ const resolved = toLogicalSessionDeleteInput(input);
293
+ const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
294
+ await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
295
+ return result.deletedCount;
296
+ }
297
+ async consumeBackchannelLogoutTokenJti(input) {
298
+ await this.waitUntilReady();
299
+ const now = this.now();
300
+ if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
301
+ return false;
302
+ }
303
+ const replacedExpired = await this.backchannelLogoutTokenJtis.findOneAndUpdate(
304
+ {
305
+ _id: input.jti,
306
+ expiresAt: { $lte: new Date(now) }
307
+ },
308
+ {
309
+ $set: { expiresAt: new Date(input.expiresAt) }
310
+ },
311
+ { returnDocument: "after" }
312
+ );
313
+ if (replacedExpired) {
314
+ return true;
315
+ }
172
316
  try {
173
- await session.withTransaction(async () => {
174
- await this.sessions.insertOne(sessionToDocument(input.nextSession), { session });
175
- const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
176
- if (deleteResult.deletedCount !== 1) {
177
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
178
- }
317
+ await this.backchannelLogoutTokenJtis.insertOne({
318
+ _id: input.jti,
319
+ expiresAt: new Date(input.expiresAt)
179
320
  });
180
- return input.nextSession;
181
- } finally {
182
- await session.endSession();
321
+ return true;
322
+ } catch (error) {
323
+ if (isDuplicateKeyError(error)) {
324
+ return false;
325
+ }
326
+ throw error;
183
327
  }
184
328
  }
185
- async deleteSession(sessionId) {
186
- await this.ready;
187
- await this.sessions.deleteOne({ _id: sessionId });
188
- }
189
329
  async deleteSessionsBySubject(input) {
190
- await this.ready;
330
+ await this.waitUntilReady();
191
331
  const resolved = toSubjectDeleteInput(input);
192
332
  const filter = { subject: resolved.subject };
193
333
  if (resolved.issuer !== void 0) {
@@ -196,11 +336,11 @@ var MongoOidcVaultStore = class {
196
336
  if (resolved.clientId !== void 0) {
197
337
  filter["provider.clientId"] = resolved.clientId;
198
338
  }
199
- const result = await this.sessions.deleteMany(filter);
339
+ const result = await this.deleteSessionsAndAliasesByFilter(filter);
200
340
  return result.deletedCount;
201
341
  }
202
342
  async deleteSessionsByProviderSessionId(input) {
203
- await this.ready;
343
+ await this.waitUntilReady();
204
344
  const resolved = toProviderSessionDeleteInput(input);
205
345
  const filter = { providerSessionId: resolved.providerSessionId };
206
346
  if (resolved.issuer !== void 0) {
@@ -209,47 +349,108 @@ var MongoOidcVaultStore = class {
209
349
  if (resolved.clientId !== void 0) {
210
350
  filter["provider.clientId"] = resolved.clientId;
211
351
  }
212
- const result = await this.sessions.deleteMany(filter);
352
+ const result = await this.deleteSessionsAndAliasesByFilter(filter);
213
353
  return result.deletedCount;
214
354
  }
215
- async ensureIndexes() {
355
+ async initialize() {
216
356
  await Promise.all([
217
- this.createTtlIndex(this.authorizationTransactions),
218
- this.createTtlIndex(this.exchangeCodes),
219
- this.createTtlIndex(this.sessions),
220
- this.sessions.createIndex({ subject: 1 }, { name: "subject_idx" }),
221
- this.sessions.createIndex({ providerSessionId: 1 }, { name: "provider_session_idx" })
357
+ ensureStoreIndexes({
358
+ authorizationTransactions: this.authorizationTransactions,
359
+ exchangeCodes: this.exchangeCodes,
360
+ sessions: this.sessions,
361
+ backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
362
+ rotatedSessionAliases: this.rotatedSessionAliases
363
+ }),
364
+ assertTransactionSupport(this.db)
222
365
  ]);
223
366
  }
224
- async detectTransactionSupport() {
367
+ async waitUntilReady() {
368
+ await this.initialization;
369
+ if (this.initializationError !== void 0) {
370
+ throw this.initializationError;
371
+ }
372
+ }
373
+ async rotateSessionWithTransaction(input) {
374
+ const session = this.db.client.startSession();
375
+ const nextSession = await this.normalizeRotatedSession(input);
225
376
  try {
226
- const hello = await this.db.admin().command({ hello: 1 });
227
- return isTransactionCapableHelloResponse(hello);
228
- } catch {
229
- return false;
377
+ await session.withTransaction(async () => {
378
+ await this.sessions.insertOne(sessionToDocument(nextSession), { session });
379
+ const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
380
+ if (deleteResult.deletedCount !== 1) {
381
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
382
+ }
383
+ await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
384
+ });
385
+ return nextSession;
386
+ } finally {
387
+ await session.endSession();
230
388
  }
231
389
  }
232
- async createTtlIndex(collection) {
233
- return collection.createIndex(
234
- { expiresAt: 1 },
390
+ async createRotatedSessionAlias(previousSessionId, nextSession, session) {
391
+ const logicalSessionId = nextSession.logicalSessionId ?? nextSession.sessionId;
392
+ const now = this.now();
393
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId, expiresAt: { $lte: new Date(now) } }, { session });
394
+ await this.rotatedSessionAliases.updateOne(
395
+ { _id: previousSessionId },
235
396
  {
236
- expireAfterSeconds: 0,
237
- name: "expiresAt_ttl"
397
+ $set: {
398
+ logicalSessionId,
399
+ expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
400
+ }
401
+ },
402
+ { upsert: true, session }
403
+ );
404
+ }
405
+ async normalizeRotatedSession(input) {
406
+ const previous = await this.getSession(input.sessionId);
407
+ if (!previous) {
408
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
409
+ }
410
+ return {
411
+ ...input.nextSession,
412
+ logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
413
+ };
414
+ }
415
+ async deleteSessionsAndAliasesByFilter(filter) {
416
+ let deletedCount = 0;
417
+ for (; ; ) {
418
+ const sessions = await this.sessions.find(filter).project({
419
+ _id: 1,
420
+ logicalSessionId: 1
421
+ }).toArray();
422
+ const logicalSessionIds = [...new Set(sessions.map((session) => session.logicalSessionId ?? session._id))];
423
+ if (logicalSessionIds.length === 0) {
424
+ return { deletedCount };
238
425
  }
426
+ const result = await this.sessions.deleteMany(filter);
427
+ deletedCount += result.deletedCount;
428
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
429
+ }
430
+ }
431
+ getRotatedSessionAliasExpiresAt(nextSession, now) {
432
+ return new Date(
433
+ typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
239
434
  );
240
435
  }
436
+ async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
437
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
438
+ }
241
439
  async isExpiredAndCleanup(collection, record) {
242
440
  if (!isExpired(record, this.now())) {
243
441
  return false;
244
442
  }
245
- await collection.deleteOne({ _id: record._id });
443
+ await collection.deleteOne({ _id: record._id, expiresAt: record.expiresAt });
246
444
  return true;
247
445
  }
248
446
  };
447
+
448
+ // src/index.ts
249
449
  function createMongoOidcVaultStore(options) {
250
450
  return new MongoOidcVaultStore(options);
251
451
  }
252
452
  // Annotate the CommonJS export names for ESM import in node:
253
453
  0 && (module.exports = {
454
+ DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS,
254
455
  createMongoOidcVaultStore
255
456
  });
package/index.mjs CHANGED
@@ -1,14 +1,12 @@
1
- // src/index.ts
1
+ // src/store.ts
2
2
  import {
3
3
  OidcVaultStoreConflictError
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
- var DEFAULT_COLLECTION_NAMES = {
6
- authorizationTransactions: "oidc_vault_authorization_transactions",
7
- exchangeCodes: "oidc_vault_exchange_codes",
8
- sessions: "oidc_vault_sessions"
9
- };
5
+
6
+ // src/documents.ts
10
7
  var sessionToDocument = (session) => ({
11
8
  _id: session.sessionId,
9
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
12
10
  subject: session.subject,
13
11
  providerSessionId: session.providerSessionId,
14
12
  provider: session.provider,
@@ -24,6 +22,7 @@ var sessionToDocument = (session) => ({
24
22
  });
25
23
  var documentToSession = (session) => ({
26
24
  sessionId: session._id,
25
+ logicalSessionId: session.logicalSessionId ?? session._id,
27
26
  subject: session.subject,
28
27
  providerSessionId: session.providerSessionId,
29
28
  provider: session.provider,
@@ -65,56 +64,155 @@ var exchangeDocumentToRecord = (record) => ({
65
64
  expiresAt: record.expiresAt.getTime()
66
65
  });
67
66
  var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
67
+
68
+ // src/options.ts
69
+ var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
70
+ var DEFAULT_COLLECTION_NAMES = {
71
+ authorizationTransactions: "oidc_vault_authorization_transactions",
72
+ exchangeCodes: "oidc_vault_exchange_codes",
73
+ sessions: "oidc_vault_sessions",
74
+ backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
75
+ rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
76
+ };
77
+ var validateCollectionName = (role, name) => {
78
+ if (name.length === 0) {
79
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not be empty.`);
80
+ }
81
+ if (name.includes("\0")) {
82
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain null bytes.`);
83
+ }
84
+ if (name.startsWith("system.")) {
85
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not use the reserved system namespace.`);
86
+ }
87
+ if (name.includes("$")) {
88
+ throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain '$'.`);
89
+ }
90
+ };
91
+ var resolveCollectionNames = (options) => {
92
+ const collectionNames = {
93
+ authorizationTransactions: options.authorizationTransactionsCollectionName ?? DEFAULT_COLLECTION_NAMES.authorizationTransactions,
94
+ exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
95
+ sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
96
+ backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
97
+ rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
98
+ };
99
+ for (const [role, name] of Object.entries(collectionNames)) {
100
+ validateCollectionName(role, name);
101
+ }
102
+ const seen = /* @__PURE__ */ new Map();
103
+ for (const [role, name] of Object.entries(collectionNames)) {
104
+ const existingRole = seen.get(name);
105
+ if (existingRole !== void 0) {
106
+ throw new TypeError(
107
+ `OIDC vault MongoDB ${role} collection name must be distinct from ${existingRole}; shared collections are not supported.`
108
+ );
109
+ }
110
+ seen.set(name, role);
111
+ }
112
+ return collectionNames;
113
+ };
114
+
115
+ // src/topology.ts
116
+ var isTransactionCapableHelloResponse = (value) => typeof value.setName === "string" || value.msg === "isdbgrid";
117
+ var assertTransactionSupport = async (db) => {
118
+ const hello = await db.admin().command({ hello: 1 });
119
+ if (!isTransactionCapableHelloResponse(hello)) {
120
+ throw new Error(
121
+ "OIDC vault MongoDB session rotation requires a transaction-capable MongoDB deployment. Use a replica set or sharded cluster."
122
+ );
123
+ }
124
+ };
125
+ var createTtlIndex = async (collection) => collection.createIndex(
126
+ { expiresAt: 1 },
127
+ {
128
+ expireAfterSeconds: 0,
129
+ name: "expiresAt_ttl"
130
+ }
131
+ );
132
+ var ensureStoreIndexes = async (collections) => {
133
+ await Promise.all([
134
+ createTtlIndex(collections.authorizationTransactions),
135
+ createTtlIndex(collections.exchangeCodes),
136
+ createTtlIndex(collections.sessions),
137
+ createTtlIndex(collections.backchannelLogoutTokenJtis),
138
+ createTtlIndex(collections.rotatedSessionAliases),
139
+ collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
140
+ collections.sessions.createIndex(
141
+ { subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
142
+ { name: "subject_scope_idx" }
143
+ ),
144
+ collections.sessions.createIndex(
145
+ { providerSessionId: 1, "provider.issuer": 1, "provider.clientId": 1 },
146
+ { name: "provider_session_scope_idx" }
147
+ ),
148
+ collections.sessions.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" })
149
+ ]);
150
+ };
151
+
152
+ // src/store.ts
68
153
  var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
69
154
  var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
70
- var isTransactionCapableHelloResponse = (value) => typeof value.setName === "string" || value.msg === "isdbgrid";
155
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
156
+ var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
71
157
  var MongoOidcVaultStore = class {
72
158
  constructor(options) {
159
+ const collectionNames = resolveCollectionNames(options);
73
160
  this.db = options.db;
74
161
  this.authorizationTransactions = options.db.collection(
75
- options.authorizationTransactionsCollectionName ?? DEFAULT_COLLECTION_NAMES.authorizationTransactions
162
+ collectionNames.authorizationTransactions
76
163
  );
77
- this.exchangeCodes = options.db.collection(
78
- options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes
164
+ this.exchangeCodes = options.db.collection(collectionNames.exchangeCodes);
165
+ this.sessions = options.db.collection(collectionNames.sessions);
166
+ this.backchannelLogoutTokenJtis = options.db.collection(
167
+ collectionNames.backchannelLogoutTokenJtis
79
168
  );
80
- this.sessions = options.db.collection(
81
- options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions
169
+ this.rotatedSessionAliases = options.db.collection(
170
+ collectionNames.rotatedSessionAliases
82
171
  );
172
+ this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
173
+ if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
174
+ throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
175
+ }
83
176
  this.now = options.now ?? (() => Date.now());
84
- this.ready = this.ensureIndexes();
85
- this.supportsTransactions = this.detectTransactionSupport();
177
+ this.initialization = this.initialize().then(void 0, (error) => {
178
+ this.initializationError = error;
179
+ });
180
+ }
181
+ async ready() {
182
+ await this.waitUntilReady();
86
183
  }
87
184
  async createAuthorizationTransaction(input) {
88
- await this.ready;
185
+ await this.waitUntilReady();
89
186
  await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
90
187
  upsert: true
91
188
  });
92
189
  }
93
190
  async consumeAuthorizationTransaction(state) {
94
- await this.ready;
191
+ await this.waitUntilReady();
95
192
  const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
96
- if (!record || await this.isExpiredAndCleanup(this.authorizationTransactions, record)) {
193
+ if (!record || isExpired(record, this.now())) {
97
194
  return null;
98
195
  }
99
196
  return authorizationDocumentToRecord(record);
100
197
  }
101
198
  async createExchangeCode(input) {
102
- await this.ready;
199
+ await this.waitUntilReady();
103
200
  await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
104
201
  }
105
202
  async consumeExchangeCode(code) {
106
- await this.ready;
203
+ await this.waitUntilReady();
107
204
  const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
108
- if (!record || await this.isExpiredAndCleanup(this.exchangeCodes, record)) {
205
+ if (!record || isExpired(record, this.now())) {
109
206
  return null;
110
207
  }
111
208
  return exchangeDocumentToRecord(record);
112
209
  }
113
210
  async createSession(input) {
114
- await this.ready;
211
+ await this.waitUntilReady();
115
212
  const timestamp = this.now();
116
213
  const session = {
117
214
  ...input,
215
+ logicalSessionId: input.logicalSessionId ?? input.sessionId,
118
216
  createdAt: input.createdAt ?? timestamp,
119
217
  updatedAt: input.updatedAt ?? timestamp
120
218
  };
@@ -122,7 +220,7 @@ var MongoOidcVaultStore = class {
122
220
  return session;
123
221
  }
124
222
  async getSession(sessionId) {
125
- await this.ready;
223
+ await this.waitUntilReady();
126
224
  const session = await this.sessions.findOne({ _id: sessionId });
127
225
  if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
128
226
  return null;
@@ -130,42 +228,81 @@ var MongoOidcVaultStore = class {
130
228
  return documentToSession(session);
131
229
  }
132
230
  async rotateSession(input) {
133
- await this.ready;
134
- if (await this.supportsTransactions) {
135
- return this.rotateSessionWithTransaction(input);
231
+ await this.waitUntilReady();
232
+ if (input.nextSession.sessionId === input.sessionId) {
233
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
234
+ }
235
+ try {
236
+ return await this.rotateSessionWithTransaction(input);
237
+ } catch (error) {
238
+ if (isDuplicateKeyError(error)) {
239
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
240
+ }
241
+ throw error;
136
242
  }
137
- return this.rotateSessionWithoutTransaction(input);
138
243
  }
139
- async rotateSessionWithoutTransaction(input) {
140
- await this.sessions.insertOne(sessionToDocument(input.nextSession));
141
- const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId });
142
- if (deleteResult.deletedCount !== 1) {
143
- await this.sessions.deleteOne({ _id: input.nextSession.sessionId });
144
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
244
+ async deleteSession(sessionId) {
245
+ await this.waitUntilReady();
246
+ const session = await this.sessions.findOne({ _id: sessionId });
247
+ if (session) {
248
+ const logicalSessionId = session.logicalSessionId ?? session._id;
249
+ const result = await this.sessions.deleteOne({ _id: sessionId });
250
+ if (result.deletedCount === 1) {
251
+ await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
252
+ return;
253
+ }
254
+ const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
255
+ if (alias2 && !isExpired(alias2, this.now())) {
256
+ await this.deleteSessionsByLogicalSessionId(alias2.logicalSessionId);
257
+ }
258
+ return;
259
+ }
260
+ const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId });
261
+ if (alias && !isExpired(alias, this.now())) {
262
+ await this.deleteSessionsByLogicalSessionId(alias.logicalSessionId);
145
263
  }
146
- return input.nextSession;
147
264
  }
148
- async rotateSessionWithTransaction(input) {
149
- const session = this.db.client.startSession();
265
+ async deleteSessionsByLogicalSessionId(input) {
266
+ await this.waitUntilReady();
267
+ const resolved = toLogicalSessionDeleteInput(input);
268
+ const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
269
+ await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
270
+ return result.deletedCount;
271
+ }
272
+ async consumeBackchannelLogoutTokenJti(input) {
273
+ await this.waitUntilReady();
274
+ const now = this.now();
275
+ if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
276
+ return false;
277
+ }
278
+ const replacedExpired = await this.backchannelLogoutTokenJtis.findOneAndUpdate(
279
+ {
280
+ _id: input.jti,
281
+ expiresAt: { $lte: new Date(now) }
282
+ },
283
+ {
284
+ $set: { expiresAt: new Date(input.expiresAt) }
285
+ },
286
+ { returnDocument: "after" }
287
+ );
288
+ if (replacedExpired) {
289
+ return true;
290
+ }
150
291
  try {
151
- await session.withTransaction(async () => {
152
- await this.sessions.insertOne(sessionToDocument(input.nextSession), { session });
153
- const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
154
- if (deleteResult.deletedCount !== 1) {
155
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
156
- }
292
+ await this.backchannelLogoutTokenJtis.insertOne({
293
+ _id: input.jti,
294
+ expiresAt: new Date(input.expiresAt)
157
295
  });
158
- return input.nextSession;
159
- } finally {
160
- await session.endSession();
296
+ return true;
297
+ } catch (error) {
298
+ if (isDuplicateKeyError(error)) {
299
+ return false;
300
+ }
301
+ throw error;
161
302
  }
162
303
  }
163
- async deleteSession(sessionId) {
164
- await this.ready;
165
- await this.sessions.deleteOne({ _id: sessionId });
166
- }
167
304
  async deleteSessionsBySubject(input) {
168
- await this.ready;
305
+ await this.waitUntilReady();
169
306
  const resolved = toSubjectDeleteInput(input);
170
307
  const filter = { subject: resolved.subject };
171
308
  if (resolved.issuer !== void 0) {
@@ -174,11 +311,11 @@ var MongoOidcVaultStore = class {
174
311
  if (resolved.clientId !== void 0) {
175
312
  filter["provider.clientId"] = resolved.clientId;
176
313
  }
177
- const result = await this.sessions.deleteMany(filter);
314
+ const result = await this.deleteSessionsAndAliasesByFilter(filter);
178
315
  return result.deletedCount;
179
316
  }
180
317
  async deleteSessionsByProviderSessionId(input) {
181
- await this.ready;
318
+ await this.waitUntilReady();
182
319
  const resolved = toProviderSessionDeleteInput(input);
183
320
  const filter = { providerSessionId: resolved.providerSessionId };
184
321
  if (resolved.issuer !== void 0) {
@@ -187,46 +324,107 @@ var MongoOidcVaultStore = class {
187
324
  if (resolved.clientId !== void 0) {
188
325
  filter["provider.clientId"] = resolved.clientId;
189
326
  }
190
- const result = await this.sessions.deleteMany(filter);
327
+ const result = await this.deleteSessionsAndAliasesByFilter(filter);
191
328
  return result.deletedCount;
192
329
  }
193
- async ensureIndexes() {
330
+ async initialize() {
194
331
  await Promise.all([
195
- this.createTtlIndex(this.authorizationTransactions),
196
- this.createTtlIndex(this.exchangeCodes),
197
- this.createTtlIndex(this.sessions),
198
- this.sessions.createIndex({ subject: 1 }, { name: "subject_idx" }),
199
- this.sessions.createIndex({ providerSessionId: 1 }, { name: "provider_session_idx" })
332
+ ensureStoreIndexes({
333
+ authorizationTransactions: this.authorizationTransactions,
334
+ exchangeCodes: this.exchangeCodes,
335
+ sessions: this.sessions,
336
+ backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
337
+ rotatedSessionAliases: this.rotatedSessionAliases
338
+ }),
339
+ assertTransactionSupport(this.db)
200
340
  ]);
201
341
  }
202
- async detectTransactionSupport() {
342
+ async waitUntilReady() {
343
+ await this.initialization;
344
+ if (this.initializationError !== void 0) {
345
+ throw this.initializationError;
346
+ }
347
+ }
348
+ async rotateSessionWithTransaction(input) {
349
+ const session = this.db.client.startSession();
350
+ const nextSession = await this.normalizeRotatedSession(input);
203
351
  try {
204
- const hello = await this.db.admin().command({ hello: 1 });
205
- return isTransactionCapableHelloResponse(hello);
206
- } catch {
207
- return false;
352
+ await session.withTransaction(async () => {
353
+ await this.sessions.insertOne(sessionToDocument(nextSession), { session });
354
+ const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
355
+ if (deleteResult.deletedCount !== 1) {
356
+ throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
357
+ }
358
+ await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
359
+ });
360
+ return nextSession;
361
+ } finally {
362
+ await session.endSession();
208
363
  }
209
364
  }
210
- async createTtlIndex(collection) {
211
- return collection.createIndex(
212
- { expiresAt: 1 },
365
+ async createRotatedSessionAlias(previousSessionId, nextSession, session) {
366
+ const logicalSessionId = nextSession.logicalSessionId ?? nextSession.sessionId;
367
+ const now = this.now();
368
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId, expiresAt: { $lte: new Date(now) } }, { session });
369
+ await this.rotatedSessionAliases.updateOne(
370
+ { _id: previousSessionId },
213
371
  {
214
- expireAfterSeconds: 0,
215
- name: "expiresAt_ttl"
372
+ $set: {
373
+ logicalSessionId,
374
+ expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
375
+ }
376
+ },
377
+ { upsert: true, session }
378
+ );
379
+ }
380
+ async normalizeRotatedSession(input) {
381
+ const previous = await this.getSession(input.sessionId);
382
+ if (!previous) {
383
+ throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
384
+ }
385
+ return {
386
+ ...input.nextSession,
387
+ logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
388
+ };
389
+ }
390
+ async deleteSessionsAndAliasesByFilter(filter) {
391
+ let deletedCount = 0;
392
+ for (; ; ) {
393
+ const sessions = await this.sessions.find(filter).project({
394
+ _id: 1,
395
+ logicalSessionId: 1
396
+ }).toArray();
397
+ const logicalSessionIds = [...new Set(sessions.map((session) => session.logicalSessionId ?? session._id))];
398
+ if (logicalSessionIds.length === 0) {
399
+ return { deletedCount };
216
400
  }
401
+ const result = await this.sessions.deleteMany(filter);
402
+ deletedCount += result.deletedCount;
403
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
404
+ }
405
+ }
406
+ getRotatedSessionAliasExpiresAt(nextSession, now) {
407
+ return new Date(
408
+ typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
217
409
  );
218
410
  }
411
+ async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
412
+ await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
413
+ }
219
414
  async isExpiredAndCleanup(collection, record) {
220
415
  if (!isExpired(record, this.now())) {
221
416
  return false;
222
417
  }
223
- await collection.deleteOne({ _id: record._id });
418
+ await collection.deleteOne({ _id: record._id, expiresAt: record.expiresAt });
224
419
  return true;
225
420
  }
226
421
  };
422
+
423
+ // src/index.ts
227
424
  function createMongoOidcVaultStore(options) {
228
425
  return new MongoOidcVaultStore(options);
229
426
  }
230
427
  export {
428
+ DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS,
231
429
  createMongoOidcVaultStore
232
430
  };
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.32.0",
5
+ "version": "0.33.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -22,10 +22,10 @@
22
22
  }
23
23
  },
24
24
  "engines": {
25
- "node": ">=20"
25
+ "node": ">=22"
26
26
  },
27
27
  "dependencies": {
28
- "@web-ts-toolkit/express-oidc-vault": "0.32.0",
28
+ "@web-ts-toolkit/express-oidc-vault": "0.33.0",
29
29
  "mongodb": "^6.20.0"
30
30
  },
31
31
  "files": [