@web-ts-toolkit/express-oidc-vault-memory-store 0.31.5 → 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
@@ -16,7 +16,7 @@ pnpm add @web-ts-toolkit/express-oidc-vault @web-ts-toolkit/express-oidc-vault-m
16
16
 
17
17
  ## Production Note
18
18
 
19
- This package stores authorization transactions, exchange codes, and sessions in process memory.
19
+ This package stores authorization transactions, exchange codes, sessions, rotated-session aliases, and backchannel logout replay JTIs in process memory.
20
20
 
21
21
  Do not use it for multi-instance or production deployments. Use Redis or MongoDB provider packages for durable or horizontally scaled deployments.
22
22
 
@@ -33,12 +33,57 @@ const storeProvider = createMemoryOidcVaultStore();
33
33
  app.use(
34
34
  createOidcVaultMiddleware({
35
35
  basePath: '/auth/oidc',
36
+ backendOrigin: 'https://api.example.com',
37
+ config: {
38
+ issuer: process.env.OIDC_ISSUER,
39
+ clientId: process.env.OIDC_CLIENT_ID,
40
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
41
+ },
42
+ frontendRedirectUri: 'https://frontend.example.com/callback',
36
43
  storeProvider,
37
44
  }),
38
45
  );
39
46
  ```
40
47
 
48
+ For local development, this is the shortest setup because it has no Redis, MongoDB, or file-system dependency. Sessions are lost on process restart and are not visible to other Node.js instances.
49
+
50
+ ### Test-Friendly Clock Override
51
+
52
+ `now()` returns epoch milliseconds and defaults to `Date.now`. Override it only as a deterministic test seam; the returned value must be monotonic enough for the expiry scenarios your test exercises.
53
+
54
+ ```ts
55
+ const storeProvider = createMemoryOidcVaultStore({
56
+ now: () => 1_700_000_000_000,
57
+ });
58
+ ```
59
+
41
60
  ## Main Exports
42
61
 
43
62
  - `createMemoryOidcVaultStore(...)`
44
63
  - `type MemoryOidcVaultStoreOptions`
64
+
65
+ ## Session Rotation Aliases
66
+
67
+ When a session is rotated, the previous public session ID remains a revocation alias for the current logical session while that logical session is still live. Aliases expire with the rotated target session and are removed when logical, subject, provider-session, direct, or expiry deletion leaves no live session in that logical lineage.
68
+
69
+ 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.
70
+
71
+ ## Store Contract
72
+
73
+ The store keeps these record kinds in separate in-process maps:
74
+
75
+ - authorization transactions, consumed once by `state`
76
+ - exchange codes, consumed once by `code`
77
+ - sessions, read and deleted by session, logical session, subject, or provider session identifiers
78
+ - rotated-session aliases, used only so an old public session ID can revoke the current logical session
79
+ - backchannel logout token JTIs, consumed once until their expiry time
80
+
81
+ `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; creating the same key again replaces the old value. Metadata should be structured-clone compatible and JSON-compatible for portability across the memory, Redis, and MongoDB stores. The memory store clones inputs and returned records with `structuredClone`, so callers retain ownership of their objects and later mutations do not update persisted state.
82
+
83
+ Expiry checks use `expiresAt <= now()` as expired. Cleanup is opportunistic: reads and writes prune expired records in bounded batches or when a specific record is accessed, not on a background timer.
84
+
85
+ `deleteSession(...)`, `deleteSessionsByLogicalSessionId(...)`, `deleteSessionsBySubject(...)`, and `deleteSessionsByProviderSessionId(...)` logically revoke live sessions and remove stale rotation aliases when no live session remains in a logical lineage.
86
+
87
+ ## Replay JTI Expiry
88
+
89
+ Backchannel logout token JTI records are consumed only when `expiresAt` is a finite timestamp greater than the store clock at consume time. Expired, equal-to-current-time, `NaN`, and infinite expiries return `false` and are not stored.
package/index.d.mts CHANGED
@@ -1,10 +1,22 @@
1
1
  import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  interface MemoryOidcVaultStoreOptions {
4
+ /**
5
+ * Store clock in epoch milliseconds.
6
+ *
7
+ * Defaults to `Date.now`. Override this in deterministic tests only; the
8
+ * returned value should move forward enough for the expiry scenarios being
9
+ * tested.
10
+ */
4
11
  now?: () => number;
5
12
  }
6
13
  /**
7
- * Create an in-memory store provider for local development and tests.
14
+ * Create a process-local OIDC vault store provider for local development and tests.
15
+ *
16
+ * Records are kept in memory, cloned on read/write, cleaned up opportunistically
17
+ * during store operations, and lost when the Node.js process exits. Do not use
18
+ * this provider when sessions must survive restarts or be shared by multiple
19
+ * application instances.
8
20
  */
9
21
  declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultStoreProvider;
10
22
 
package/index.d.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  interface MemoryOidcVaultStoreOptions {
4
+ /**
5
+ * Store clock in epoch milliseconds.
6
+ *
7
+ * Defaults to `Date.now`. Override this in deterministic tests only; the
8
+ * returned value should move forward enough for the expiry scenarios being
9
+ * tested.
10
+ */
4
11
  now?: () => number;
5
12
  }
6
13
  /**
7
- * Create an in-memory store provider for local development and tests.
14
+ * Create a process-local OIDC vault store provider for local development and tests.
15
+ *
16
+ * Records are kept in memory, cloned on read/write, cleaned up opportunistically
17
+ * during store operations, and lost when the Node.js process exits. Do not use
18
+ * this provider when sessions must survive restarts or be shared by multiple
19
+ * application instances.
8
20
  */
9
21
  declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultStoreProvider;
10
22
 
package/index.js CHANGED
@@ -26,6 +26,7 @@ module.exports = __toCommonJS(index_exports);
26
26
  var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
27
27
  var cloneRecord = (value) => structuredClone(value);
28
28
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
29
+ var EXPIRY_SWEEP_BATCH_SIZE = 64;
29
30
  var matchesProviderScope = (session, input) => {
30
31
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
31
32
  return false;
@@ -37,109 +38,309 @@ var matchesProviderScope = (session, input) => {
37
38
  };
38
39
  var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
39
40
  var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
41
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
40
42
  var MemoryOidcVaultStore = class {
41
43
  constructor(options = {}) {
42
44
  this.authorizationTransactions = /* @__PURE__ */ new Map();
43
45
  this.exchangeCodes = /* @__PURE__ */ new Map();
44
46
  this.sessions = /* @__PURE__ */ new Map();
47
+ this.rotatedSessionAliases = /* @__PURE__ */ new Map();
48
+ this.backchannelLogoutTokenJtis = /* @__PURE__ */ new Map();
45
49
  this.now = options.now ?? (() => Date.now());
46
50
  }
47
51
  async createAuthorizationTransaction(input) {
48
- this.pruneExpiredRecords();
52
+ const now = this.now();
53
+ this.authorizationTransactionSweepCursor = this.pruneMapBatch(
54
+ this.authorizationTransactions,
55
+ now,
56
+ this.authorizationTransactionSweepCursor
57
+ );
49
58
  this.authorizationTransactions.set(input.state, cloneRecord(input));
50
59
  }
51
60
  async consumeAuthorizationTransaction(state) {
52
- this.pruneExpiredRecords();
61
+ const now = this.now();
62
+ this.authorizationTransactionSweepCursor = this.pruneMapBatch(
63
+ this.authorizationTransactions,
64
+ now,
65
+ this.authorizationTransactionSweepCursor
66
+ );
53
67
  const record = this.authorizationTransactions.get(state);
54
68
  if (!record) {
55
69
  return null;
56
70
  }
57
71
  this.authorizationTransactions.delete(state);
72
+ if (isExpiredRecord(record, now)) {
73
+ return null;
74
+ }
58
75
  return cloneRecord(record);
59
76
  }
60
77
  async createExchangeCode(input) {
61
- this.pruneExpiredRecords();
78
+ const now = this.now();
79
+ this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
62
80
  this.exchangeCodes.set(input.code, cloneRecord(input));
63
81
  }
64
82
  async consumeExchangeCode(code) {
65
- this.pruneExpiredRecords();
83
+ const now = this.now();
84
+ this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
66
85
  const record = this.exchangeCodes.get(code);
67
86
  if (!record) {
68
87
  return null;
69
88
  }
70
89
  this.exchangeCodes.delete(code);
90
+ if (isExpiredRecord(record, now)) {
91
+ return null;
92
+ }
71
93
  return cloneRecord(record);
72
94
  }
73
95
  async createSession(input) {
74
- this.pruneExpiredRecords();
75
96
  const timestamp = this.now();
97
+ this.pruneSessionsBatch(timestamp);
98
+ this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
99
+ this.rotatedSessionAliases,
100
+ timestamp,
101
+ this.rotatedSessionAliasSweepCursor
102
+ );
76
103
  const session = {
77
104
  ...cloneRecord(input),
105
+ logicalSessionId: input.logicalSessionId ?? input.sessionId,
78
106
  createdAt: input.createdAt ?? timestamp,
79
107
  updatedAt: input.updatedAt ?? timestamp
80
108
  };
81
109
  this.sessions.set(session.sessionId, session);
110
+ this.rotatedSessionAliases.delete(session.sessionId);
82
111
  return cloneRecord(session);
83
112
  }
84
113
  async getSession(sessionId) {
85
- this.pruneExpiredRecords();
114
+ const now = this.now();
86
115
  const session = this.sessions.get(sessionId);
116
+ if (session && isExpiredRecord(session, now)) {
117
+ this.sessions.delete(sessionId);
118
+ this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId);
119
+ return null;
120
+ }
87
121
  return session ? cloneRecord(session) : null;
88
122
  }
89
123
  async rotateSession(input) {
90
- this.pruneExpiredRecords();
91
- if (!this.sessions.has(input.sessionId)) {
124
+ const timestamp = this.now();
125
+ const sourceSession = this.sessions.get(input.sessionId);
126
+ if (sourceSession && isExpiredRecord(sourceSession, timestamp)) {
127
+ this.sessions.delete(input.sessionId);
128
+ this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId);
129
+ }
130
+ const liveSourceSession = this.sessions.get(input.sessionId);
131
+ if (!liveSourceSession) {
92
132
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
93
133
  }
94
- this.sessions.delete(input.sessionId);
134
+ if (input.nextSession.sessionId === input.sessionId) {
135
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
136
+ }
137
+ const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
138
+ if (existingTargetSession && isExpiredRecord(existingTargetSession, timestamp)) {
139
+ this.sessions.delete(input.nextSession.sessionId);
140
+ this.removeAliasesForInactiveLogicalSession(
141
+ existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId
142
+ );
143
+ } else if (existingTargetSession) {
144
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
145
+ }
95
146
  const nextSession = cloneRecord(input.nextSession);
96
- const timestamp = this.now();
97
147
  const session = {
98
148
  ...nextSession,
149
+ logicalSessionId: nextSession.logicalSessionId ?? liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId,
99
150
  createdAt: nextSession.createdAt ?? timestamp,
100
151
  updatedAt: nextSession.updatedAt ?? timestamp
101
152
  };
153
+ this.sessions.delete(input.sessionId);
102
154
  this.sessions.set(session.sessionId, session);
155
+ this.rotatedSessionAliases.set(input.sessionId, {
156
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
157
+ expiresAt: session.expiresAt
158
+ });
103
159
  return cloneRecord(session);
104
160
  }
105
161
  async deleteSession(sessionId) {
106
- this.sessions.delete(sessionId);
162
+ const now = this.now();
163
+ this.pruneSessionsBatch(now);
164
+ this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
165
+ this.rotatedSessionAliases,
166
+ now,
167
+ this.rotatedSessionAliasSweepCursor
168
+ );
169
+ const session = this.sessions.get(sessionId);
170
+ if (session) {
171
+ const logicalSessionId = session.logicalSessionId ?? session.sessionId;
172
+ this.sessions.delete(sessionId);
173
+ this.rotatedSessionAliases.delete(sessionId);
174
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId);
175
+ return;
176
+ }
177
+ const alias = this.rotatedSessionAliases.get(sessionId);
178
+ if (alias) {
179
+ if (isExpiredRecord(alias, now)) {
180
+ this.rotatedSessionAliases.delete(sessionId);
181
+ return;
182
+ }
183
+ await this.deleteSessionsByLogicalSessionId(alias.logicalSessionId);
184
+ this.rotatedSessionAliases.delete(sessionId);
185
+ }
186
+ }
187
+ async deleteSessionsByLogicalSessionId(input) {
188
+ const now = this.now();
189
+ const resolved = toLogicalSessionDeleteInput(input);
190
+ let deleted = 0;
191
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
192
+ for (const [sessionId, session] of this.sessions.entries()) {
193
+ if (isExpiredRecord(session, now)) {
194
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
195
+ this.sessions.delete(sessionId);
196
+ continue;
197
+ }
198
+ if ((session.logicalSessionId ?? session.sessionId) === resolved.logicalSessionId) {
199
+ this.sessions.delete(sessionId);
200
+ deleted += 1;
201
+ }
202
+ }
203
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
204
+ this.removeAliasesForLogicalSession(resolved.logicalSessionId);
205
+ return deleted;
206
+ }
207
+ async consumeBackchannelLogoutTokenJti(input) {
208
+ const now = this.now();
209
+ this.backchannelLogoutTokenJtiSweepCursor = this.pruneMapBatch(
210
+ this.backchannelLogoutTokenJtis,
211
+ now,
212
+ this.backchannelLogoutTokenJtiSweepCursor
213
+ );
214
+ if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
215
+ return false;
216
+ }
217
+ const existingRecord = this.backchannelLogoutTokenJtis.get(input.jti);
218
+ if (existingRecord && !isExpiredRecord(existingRecord, now)) {
219
+ return false;
220
+ }
221
+ this.backchannelLogoutTokenJtis.set(input.jti, { expiresAt: input.expiresAt });
222
+ return true;
107
223
  }
108
224
  async deleteSessionsBySubject(input) {
109
- this.pruneExpiredRecords();
225
+ const now = this.now();
110
226
  const resolved = toSubjectDeleteInput(input);
111
227
  let deleted = 0;
228
+ const logicalSessionIds = /* @__PURE__ */ new Set();
229
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
112
230
  for (const [sessionId, session] of this.sessions.entries()) {
231
+ if (isExpiredRecord(session, now)) {
232
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
233
+ this.sessions.delete(sessionId);
234
+ continue;
235
+ }
113
236
  if (session.subject === resolved.subject && matchesProviderScope(session, resolved)) {
237
+ logicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
114
238
  this.sessions.delete(sessionId);
115
239
  deleted += 1;
116
240
  }
117
241
  }
242
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
243
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
118
244
  return deleted;
119
245
  }
120
246
  async deleteSessionsByProviderSessionId(input) {
121
- this.pruneExpiredRecords();
247
+ const now = this.now();
122
248
  const resolved = toProviderSessionDeleteInput(input);
123
249
  let deleted = 0;
250
+ const logicalSessionIds = /* @__PURE__ */ new Set();
251
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
124
252
  for (const [sessionId, session] of this.sessions.entries()) {
253
+ if (isExpiredRecord(session, now)) {
254
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
255
+ this.sessions.delete(sessionId);
256
+ continue;
257
+ }
125
258
  if (session.providerSessionId === resolved.providerSessionId && matchesProviderScope(session, resolved)) {
259
+ logicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
126
260
  this.sessions.delete(sessionId);
127
261
  deleted += 1;
128
262
  }
129
263
  }
264
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
265
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
130
266
  return deleted;
131
267
  }
132
- pruneExpiredRecords() {
133
- const now = this.now();
134
- this.pruneMap(this.authorizationTransactions, now);
135
- this.pruneMap(this.exchangeCodes, now);
136
- this.pruneMap(this.sessions, now);
137
- }
138
- pruneMap(map, now) {
139
- for (const [key, value] of map.entries()) {
268
+ pruneMapBatch(map, now, cursor) {
269
+ let inspected = 0;
270
+ let nextCursor;
271
+ for (const [key, value] of this.entriesAfterCursor(map, cursor)) {
140
272
  if (isExpiredRecord(value, now)) {
141
273
  map.delete(key);
142
274
  }
275
+ inspected += 1;
276
+ nextCursor = key;
277
+ if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
278
+ return nextCursor;
279
+ }
280
+ }
281
+ return void 0;
282
+ }
283
+ pruneSessionsBatch(now) {
284
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
285
+ let inspected = 0;
286
+ for (const [sessionId, session] of this.entriesAfterCursor(this.sessions, this.sessionSweepCursor)) {
287
+ if (isExpiredRecord(session, now)) {
288
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
289
+ this.sessions.delete(sessionId);
290
+ }
291
+ inspected += 1;
292
+ this.sessionSweepCursor = sessionId;
293
+ if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
294
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
295
+ return;
296
+ }
297
+ }
298
+ this.sessionSweepCursor = void 0;
299
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
300
+ }
301
+ *entriesAfterCursor(map, cursor) {
302
+ if (!cursor || !map.has(cursor)) {
303
+ yield* map.entries();
304
+ return;
305
+ }
306
+ let foundCursor = false;
307
+ for (const entry of map.entries()) {
308
+ if (!foundCursor) {
309
+ foundCursor = entry[0] === cursor;
310
+ continue;
311
+ }
312
+ yield entry;
313
+ }
314
+ for (const entry of map.entries()) {
315
+ if (entry[0] === cursor) {
316
+ return;
317
+ }
318
+ yield entry;
319
+ }
320
+ }
321
+ removeAliasesForInactiveLogicalSessions(logicalSessionIds) {
322
+ for (const logicalSessionId of logicalSessionIds) {
323
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId);
324
+ }
325
+ }
326
+ removeAliasesForInactiveLogicalSession(logicalSessionId) {
327
+ if (!this.hasLiveSessionForLogicalSession(logicalSessionId)) {
328
+ this.removeAliasesForLogicalSession(logicalSessionId);
329
+ }
330
+ }
331
+ hasLiveSessionForLogicalSession(logicalSessionId) {
332
+ for (const session of this.sessions.values()) {
333
+ if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId) {
334
+ return true;
335
+ }
336
+ }
337
+ return false;
338
+ }
339
+ removeAliasesForLogicalSession(logicalSessionId) {
340
+ for (const [sessionId, alias] of this.rotatedSessionAliases.entries()) {
341
+ if (alias.logicalSessionId === logicalSessionId) {
342
+ this.rotatedSessionAliases.delete(sessionId);
343
+ }
143
344
  }
144
345
  }
145
346
  };
package/index.mjs CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
5
  var cloneRecord = (value) => structuredClone(value);
6
6
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
7
+ var EXPIRY_SWEEP_BATCH_SIZE = 64;
7
8
  var matchesProviderScope = (session, input) => {
8
9
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
9
10
  return false;
@@ -15,109 +16,309 @@ var matchesProviderScope = (session, input) => {
15
16
  };
16
17
  var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
17
18
  var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
19
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
18
20
  var MemoryOidcVaultStore = class {
19
21
  constructor(options = {}) {
20
22
  this.authorizationTransactions = /* @__PURE__ */ new Map();
21
23
  this.exchangeCodes = /* @__PURE__ */ new Map();
22
24
  this.sessions = /* @__PURE__ */ new Map();
25
+ this.rotatedSessionAliases = /* @__PURE__ */ new Map();
26
+ this.backchannelLogoutTokenJtis = /* @__PURE__ */ new Map();
23
27
  this.now = options.now ?? (() => Date.now());
24
28
  }
25
29
  async createAuthorizationTransaction(input) {
26
- this.pruneExpiredRecords();
30
+ const now = this.now();
31
+ this.authorizationTransactionSweepCursor = this.pruneMapBatch(
32
+ this.authorizationTransactions,
33
+ now,
34
+ this.authorizationTransactionSweepCursor
35
+ );
27
36
  this.authorizationTransactions.set(input.state, cloneRecord(input));
28
37
  }
29
38
  async consumeAuthorizationTransaction(state) {
30
- this.pruneExpiredRecords();
39
+ const now = this.now();
40
+ this.authorizationTransactionSweepCursor = this.pruneMapBatch(
41
+ this.authorizationTransactions,
42
+ now,
43
+ this.authorizationTransactionSweepCursor
44
+ );
31
45
  const record = this.authorizationTransactions.get(state);
32
46
  if (!record) {
33
47
  return null;
34
48
  }
35
49
  this.authorizationTransactions.delete(state);
50
+ if (isExpiredRecord(record, now)) {
51
+ return null;
52
+ }
36
53
  return cloneRecord(record);
37
54
  }
38
55
  async createExchangeCode(input) {
39
- this.pruneExpiredRecords();
56
+ const now = this.now();
57
+ this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
40
58
  this.exchangeCodes.set(input.code, cloneRecord(input));
41
59
  }
42
60
  async consumeExchangeCode(code) {
43
- this.pruneExpiredRecords();
61
+ const now = this.now();
62
+ this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
44
63
  const record = this.exchangeCodes.get(code);
45
64
  if (!record) {
46
65
  return null;
47
66
  }
48
67
  this.exchangeCodes.delete(code);
68
+ if (isExpiredRecord(record, now)) {
69
+ return null;
70
+ }
49
71
  return cloneRecord(record);
50
72
  }
51
73
  async createSession(input) {
52
- this.pruneExpiredRecords();
53
74
  const timestamp = this.now();
75
+ this.pruneSessionsBatch(timestamp);
76
+ this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
77
+ this.rotatedSessionAliases,
78
+ timestamp,
79
+ this.rotatedSessionAliasSweepCursor
80
+ );
54
81
  const session = {
55
82
  ...cloneRecord(input),
83
+ logicalSessionId: input.logicalSessionId ?? input.sessionId,
56
84
  createdAt: input.createdAt ?? timestamp,
57
85
  updatedAt: input.updatedAt ?? timestamp
58
86
  };
59
87
  this.sessions.set(session.sessionId, session);
88
+ this.rotatedSessionAliases.delete(session.sessionId);
60
89
  return cloneRecord(session);
61
90
  }
62
91
  async getSession(sessionId) {
63
- this.pruneExpiredRecords();
92
+ const now = this.now();
64
93
  const session = this.sessions.get(sessionId);
94
+ if (session && isExpiredRecord(session, now)) {
95
+ this.sessions.delete(sessionId);
96
+ this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId);
97
+ return null;
98
+ }
65
99
  return session ? cloneRecord(session) : null;
66
100
  }
67
101
  async rotateSession(input) {
68
- this.pruneExpiredRecords();
69
- if (!this.sessions.has(input.sessionId)) {
102
+ const timestamp = this.now();
103
+ const sourceSession = this.sessions.get(input.sessionId);
104
+ if (sourceSession && isExpiredRecord(sourceSession, timestamp)) {
105
+ this.sessions.delete(input.sessionId);
106
+ this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId);
107
+ }
108
+ const liveSourceSession = this.sessions.get(input.sessionId);
109
+ if (!liveSourceSession) {
70
110
  throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
71
111
  }
72
- this.sessions.delete(input.sessionId);
112
+ if (input.nextSession.sessionId === input.sessionId) {
113
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
114
+ }
115
+ const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
116
+ if (existingTargetSession && isExpiredRecord(existingTargetSession, timestamp)) {
117
+ this.sessions.delete(input.nextSession.sessionId);
118
+ this.removeAliasesForInactiveLogicalSession(
119
+ existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId
120
+ );
121
+ } else if (existingTargetSession) {
122
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
123
+ }
73
124
  const nextSession = cloneRecord(input.nextSession);
74
- const timestamp = this.now();
75
125
  const session = {
76
126
  ...nextSession,
127
+ logicalSessionId: nextSession.logicalSessionId ?? liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId,
77
128
  createdAt: nextSession.createdAt ?? timestamp,
78
129
  updatedAt: nextSession.updatedAt ?? timestamp
79
130
  };
131
+ this.sessions.delete(input.sessionId);
80
132
  this.sessions.set(session.sessionId, session);
133
+ this.rotatedSessionAliases.set(input.sessionId, {
134
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
135
+ expiresAt: session.expiresAt
136
+ });
81
137
  return cloneRecord(session);
82
138
  }
83
139
  async deleteSession(sessionId) {
84
- this.sessions.delete(sessionId);
140
+ const now = this.now();
141
+ this.pruneSessionsBatch(now);
142
+ this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
143
+ this.rotatedSessionAliases,
144
+ now,
145
+ this.rotatedSessionAliasSweepCursor
146
+ );
147
+ const session = this.sessions.get(sessionId);
148
+ if (session) {
149
+ const logicalSessionId = session.logicalSessionId ?? session.sessionId;
150
+ this.sessions.delete(sessionId);
151
+ this.rotatedSessionAliases.delete(sessionId);
152
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId);
153
+ return;
154
+ }
155
+ const alias = this.rotatedSessionAliases.get(sessionId);
156
+ if (alias) {
157
+ if (isExpiredRecord(alias, now)) {
158
+ this.rotatedSessionAliases.delete(sessionId);
159
+ return;
160
+ }
161
+ await this.deleteSessionsByLogicalSessionId(alias.logicalSessionId);
162
+ this.rotatedSessionAliases.delete(sessionId);
163
+ }
164
+ }
165
+ async deleteSessionsByLogicalSessionId(input) {
166
+ const now = this.now();
167
+ const resolved = toLogicalSessionDeleteInput(input);
168
+ let deleted = 0;
169
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
170
+ for (const [sessionId, session] of this.sessions.entries()) {
171
+ if (isExpiredRecord(session, now)) {
172
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
173
+ this.sessions.delete(sessionId);
174
+ continue;
175
+ }
176
+ if ((session.logicalSessionId ?? session.sessionId) === resolved.logicalSessionId) {
177
+ this.sessions.delete(sessionId);
178
+ deleted += 1;
179
+ }
180
+ }
181
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
182
+ this.removeAliasesForLogicalSession(resolved.logicalSessionId);
183
+ return deleted;
184
+ }
185
+ async consumeBackchannelLogoutTokenJti(input) {
186
+ const now = this.now();
187
+ this.backchannelLogoutTokenJtiSweepCursor = this.pruneMapBatch(
188
+ this.backchannelLogoutTokenJtis,
189
+ now,
190
+ this.backchannelLogoutTokenJtiSweepCursor
191
+ );
192
+ if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
193
+ return false;
194
+ }
195
+ const existingRecord = this.backchannelLogoutTokenJtis.get(input.jti);
196
+ if (existingRecord && !isExpiredRecord(existingRecord, now)) {
197
+ return false;
198
+ }
199
+ this.backchannelLogoutTokenJtis.set(input.jti, { expiresAt: input.expiresAt });
200
+ return true;
85
201
  }
86
202
  async deleteSessionsBySubject(input) {
87
- this.pruneExpiredRecords();
203
+ const now = this.now();
88
204
  const resolved = toSubjectDeleteInput(input);
89
205
  let deleted = 0;
206
+ const logicalSessionIds = /* @__PURE__ */ new Set();
207
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
90
208
  for (const [sessionId, session] of this.sessions.entries()) {
209
+ if (isExpiredRecord(session, now)) {
210
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
211
+ this.sessions.delete(sessionId);
212
+ continue;
213
+ }
91
214
  if (session.subject === resolved.subject && matchesProviderScope(session, resolved)) {
215
+ logicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
92
216
  this.sessions.delete(sessionId);
93
217
  deleted += 1;
94
218
  }
95
219
  }
220
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
221
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
96
222
  return deleted;
97
223
  }
98
224
  async deleteSessionsByProviderSessionId(input) {
99
- this.pruneExpiredRecords();
225
+ const now = this.now();
100
226
  const resolved = toProviderSessionDeleteInput(input);
101
227
  let deleted = 0;
228
+ const logicalSessionIds = /* @__PURE__ */ new Set();
229
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
102
230
  for (const [sessionId, session] of this.sessions.entries()) {
231
+ if (isExpiredRecord(session, now)) {
232
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
233
+ this.sessions.delete(sessionId);
234
+ continue;
235
+ }
103
236
  if (session.providerSessionId === resolved.providerSessionId && matchesProviderScope(session, resolved)) {
237
+ logicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
104
238
  this.sessions.delete(sessionId);
105
239
  deleted += 1;
106
240
  }
107
241
  }
242
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
243
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
108
244
  return deleted;
109
245
  }
110
- pruneExpiredRecords() {
111
- const now = this.now();
112
- this.pruneMap(this.authorizationTransactions, now);
113
- this.pruneMap(this.exchangeCodes, now);
114
- this.pruneMap(this.sessions, now);
115
- }
116
- pruneMap(map, now) {
117
- for (const [key, value] of map.entries()) {
246
+ pruneMapBatch(map, now, cursor) {
247
+ let inspected = 0;
248
+ let nextCursor;
249
+ for (const [key, value] of this.entriesAfterCursor(map, cursor)) {
118
250
  if (isExpiredRecord(value, now)) {
119
251
  map.delete(key);
120
252
  }
253
+ inspected += 1;
254
+ nextCursor = key;
255
+ if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
256
+ return nextCursor;
257
+ }
258
+ }
259
+ return void 0;
260
+ }
261
+ pruneSessionsBatch(now) {
262
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
263
+ let inspected = 0;
264
+ for (const [sessionId, session] of this.entriesAfterCursor(this.sessions, this.sessionSweepCursor)) {
265
+ if (isExpiredRecord(session, now)) {
266
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
267
+ this.sessions.delete(sessionId);
268
+ }
269
+ inspected += 1;
270
+ this.sessionSweepCursor = sessionId;
271
+ if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
272
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
273
+ return;
274
+ }
275
+ }
276
+ this.sessionSweepCursor = void 0;
277
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
278
+ }
279
+ *entriesAfterCursor(map, cursor) {
280
+ if (!cursor || !map.has(cursor)) {
281
+ yield* map.entries();
282
+ return;
283
+ }
284
+ let foundCursor = false;
285
+ for (const entry of map.entries()) {
286
+ if (!foundCursor) {
287
+ foundCursor = entry[0] === cursor;
288
+ continue;
289
+ }
290
+ yield entry;
291
+ }
292
+ for (const entry of map.entries()) {
293
+ if (entry[0] === cursor) {
294
+ return;
295
+ }
296
+ yield entry;
297
+ }
298
+ }
299
+ removeAliasesForInactiveLogicalSessions(logicalSessionIds) {
300
+ for (const logicalSessionId of logicalSessionIds) {
301
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId);
302
+ }
303
+ }
304
+ removeAliasesForInactiveLogicalSession(logicalSessionId) {
305
+ if (!this.hasLiveSessionForLogicalSession(logicalSessionId)) {
306
+ this.removeAliasesForLogicalSession(logicalSessionId);
307
+ }
308
+ }
309
+ hasLiveSessionForLogicalSession(logicalSessionId) {
310
+ for (const session of this.sessions.values()) {
311
+ if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId) {
312
+ return true;
313
+ }
314
+ }
315
+ return false;
316
+ }
317
+ removeAliasesForLogicalSession(logicalSessionId) {
318
+ for (const [sessionId, alias] of this.rotatedSessionAliases.entries()) {
319
+ if (alias.logicalSessionId === logicalSessionId) {
320
+ this.rotatedSessionAliases.delete(sessionId);
321
+ }
121
322
  }
122
323
  }
123
324
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-memory-store",
3
3
  "description": "In-memory store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-memory-store",
5
- "version": "0.31.5",
5
+ "version": "0.33.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -15,17 +15,21 @@
15
15
  "types": "./index.d.ts",
16
16
  "exports": {
17
17
  ".": {
18
- "types": "./index.d.ts",
18
+ "types": {
19
+ "import": "./index.d.mts",
20
+ "require": "./index.d.ts",
21
+ "default": "./index.d.ts"
22
+ },
19
23
  "import": "./index.mjs",
20
24
  "require": "./index.js",
21
25
  "default": "./index.js"
22
26
  }
23
27
  },
24
28
  "engines": {
25
- "node": ">=20"
29
+ "node": ">=22"
26
30
  },
27
31
  "dependencies": {
28
- "@web-ts-toolkit/express-oidc-vault": "0.31.5"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.33.0"
29
33
  },
30
34
  "files": [
31
35
  "**/*",