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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +27 -3
  2. package/index.js +128 -87
  3. package/index.mjs +128 -87
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -59,14 +59,30 @@ const storeProvider = createMemoryOidcVaultStore({
59
59
 
60
60
  ## Main Exports
61
61
 
62
+ Use named imports from the package root; there is no default export or public subpath API. Use Node.js `>=22.12.0` for the core dependency's CJS/ESM runtime support. TypeScript apps also need `@types/node` and `@types/express`.
63
+
62
64
  - `createMemoryOidcVaultStore(...)`
63
65
  - `type MemoryOidcVaultStoreOptions`
64
66
 
67
+ ## Lifecycle And Diagnostics
68
+
69
+ The factory is immediately usable: no connection, `ready()` call, background timer, or store teardown is required. Reuse one instance across requests in the same process. Operations perform their map mutations synchronously before yielding; this does not share state with other processes. Discarding the instance or restarting loses all records.
70
+
71
+ The store does not log records or errors. Session tokens, authorization state/PKCE data, exchange codes, public session IDs, and arbitrary backend/hook errors may be sensitive. Application diagnostics should use fixed operation names and allowlisted categories, not whole records, raw error messages, connection URLs, or credential-valued metric labels.
72
+
65
73
  ## Session Rotation Aliases
66
74
 
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.
75
+ When a session is rotated, the previous public session ID becomes a revocation alias for the target's logical session. Each alias keeps the immediate target's `expiresAt`, if present; later rotations do not extend that deadline. An unexpired alias remains usable while its logical lineage has a live member. Aliases without an expiry can remain for the lifetime of that lineage.
76
+
77
+ For example, `A -> B (expiresAt=T1) -> C (expiresAt=T2)` leaves A usable only before T1, even if T2 is later or absent; B uses C's deadline. `getSession(A)` returns `null`: an alias is only a logout handle, never a readable session. With `A/L1 -> B/L1 -> C/L2`, B revokes L2 and any retained A alias revokes L1, not C. Omitting the next logical ID preserves the source lineage. Logical IDs are unscoped: use distinct IDs for unrelated login families.
78
+
79
+ Without successor `expiresAt`, memory and Redis impose no alias time limit; MongoDB instead uses a finite fallback (default five minutes). Long-lived non-expiring lineages can accumulate arbitrarily many memory/Redis aliases. Set explicit session lifetimes for portable finite windows; after an alias expires, revoke using the live ID or the appropriate logical/subject/provider-session deletion method. Core refresh preserves the session expiry.
68
80
 
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.
81
+ Aliases are removed when logical, subject, provider-session, direct, or expiry deletion leaves no live session in that lineage. A lineage-changing create-upsert or rotation also retires the old lineage's aliases when no live member remains; expired records awaiting cleanup do not count as survivors. If another live member survives, including in another issuer/client scope, the earlier aliases retain their original lineage and expiry. They are never retargeted to the new lineage.
82
+
83
+ Rotation requires a different target session ID with no live session record. An alias-only ID or an expired target is accepted. Successful rotation takes ownership of the target ID and clears any former alias under that ID, just as `createSession` does. Missing-source, same-ID, and live-target rotation conflicts throw `OidcVaultStoreConflictError`; conflicts and input-clone failures preserve live source/target records and their aliases.
84
+
85
+ Compatibility note: ownership transitions now retire aliases for terminated lineages, so reusing an old logical session ID cannot resurrect those handles. Reusing an alias-only rotation target also prevents that ID from reverting to its former lineage after replacement deletion or expiry.
70
86
 
71
87
  ## Store Contract
72
88
 
@@ -78,12 +94,20 @@ The store keeps these record kinds in separate in-process maps:
78
94
  - rotated-session aliases, used only so an old public session ID can revoke the current logical session
79
95
  - backchannel logout token JTIs, consumed once until their expiry time
80
96
 
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.
97
+ `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; creating the same key again replaces the old value. A `createSession` replacement takes over the session ID with its own subject/logical/provider-session scope, and clears any stale rotation alias held under that ID so the reused ID cannot invoke an old logical-session meaning. Portable callers must still create sessions with a fresh unused ID and handle `OidcVaultStoreConflictError`: reusing a live ID replaces on memory/MongoDB but rejects on Redis. MongoDB also retains an alias row under a reused ID until expiry or lineage cleanup; only fresh-ID creation is portable.
98
+
99
+ Portable data consists of strings, finite numbers, booleans, null, arrays, and plain objects (no cycles, custom prototypes/serializers, functions, symbols, or undefined properties). Inputs are captured at invocation, including nested provider/user/metadata data, and returned values are detached from caller and stored data. Mutation immediately after calling a method cannot change its target, scope, or portable value. Memory uses `structuredClone`; unsupported inputs reject. Outside the portable subset, memory structured-clone, MongoDB BSON, and Redis JSON semantics differ; do not assume native types or object identity survive across providers. MongoDB/Redis snapshot plain containers but do not recursively isolate opaque native objects.
82
100
 
83
101
  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
102
 
103
+ Each same-map sweep inspects at most 64 snapshot slots per operation from a positional cursor, so a late cursor never rescans the map prefix. A key snapshot is rebuilt once per full pass (amortized over that pass's sweeps); keys added mid-pass are picked up on the next pass. Nested rotated-alias/lineage cleanup after session expiry, deletion, or a lineage-changing ownership transition is not batch-bounded: for each affected lineage it can scan the retained session map for liveness and the alias map for removal. These full-map scans grow with store populations; the 64-slot sweep bound does not bound total operation work.
104
+
85
105
  `deleteSession(...)`, `deleteSessionsByLogicalSessionId(...)`, `deleteSessionsBySubject(...)`, and `deleteSessionsByProviderSessionId(...)` logically revoke live sessions and remove stale rotation aliases when no live session remains in a logical lineage.
86
106
 
107
+ Deleting a live public ID removes that record; deleting an unexpired alias revokes its logical lineage. Subject/provider-session object inputs filter by each supplied `issuer` and `clientId`; string inputs omit those filters and match across providers. A surviving member in another scope retains its lineage's unexpired aliases. Logical deletion has no issuer/client filter, and aliases do not carry one.
108
+
109
+ Bulk methods return the number of live sessions removed, excluding expired records and alias cleanup. They scan the session map synchronously, but sessions created after that work can survive. Across providers, a count is deletion accounting, not proof that no matching sessions remain: MongoDB may count expired documents awaiting TTL cleanup; Redis counts primary deletions during cursor traversal. Vault deletion does not revoke already-issued stateless access tokens or perform upstream provider logout.
110
+
87
111
  ## Replay JTI Expiry
88
112
 
89
113
  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.js CHANGED
@@ -27,6 +27,14 @@ 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
29
  var EXPIRY_SWEEP_BATCH_SIZE = 64;
30
+ var createMapSweepState = () => ({ keys: [], position: 0 });
31
+ var createMemoryStoreSweepWork = () => ({
32
+ inspectedKeys: 0,
33
+ staleSnapshotSlots: 0,
34
+ snapshotRebuilds: 0,
35
+ aliasSessionVisits: 0,
36
+ aliasEntryVisits: 0
37
+ });
30
38
  var matchesProviderScope = (session, input) => {
31
39
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
32
40
  return false;
@@ -46,24 +54,22 @@ var MemoryOidcVaultStore = class {
46
54
  this.sessions = /* @__PURE__ */ new Map();
47
55
  this.rotatedSessionAliases = /* @__PURE__ */ new Map();
48
56
  this.backchannelLogoutTokenJtis = /* @__PURE__ */ new Map();
57
+ this.authorizationTransactionSweep = createMapSweepState();
58
+ this.exchangeCodeSweep = createMapSweepState();
59
+ this.sessionSweep = createMapSweepState();
60
+ this.rotatedSessionAliasSweep = createMapSweepState();
61
+ this.backchannelLogoutTokenJtiSweep = createMapSweepState();
62
+ this.sweepWork = createMemoryStoreSweepWork();
49
63
  this.now = options.now ?? (() => Date.now());
50
64
  }
51
65
  async createAuthorizationTransaction(input) {
52
66
  const now = this.now();
53
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
54
- this.authorizationTransactions,
55
- now,
56
- this.authorizationTransactionSweepCursor
57
- );
67
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
58
68
  this.authorizationTransactions.set(input.state, cloneRecord(input));
59
69
  }
60
70
  async consumeAuthorizationTransaction(state) {
61
71
  const now = this.now();
62
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
63
- this.authorizationTransactions,
64
- now,
65
- this.authorizationTransactionSweepCursor
66
- );
72
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
67
73
  const record = this.authorizationTransactions.get(state);
68
74
  if (!record) {
69
75
  return null;
@@ -76,12 +82,12 @@ var MemoryOidcVaultStore = class {
76
82
  }
77
83
  async createExchangeCode(input) {
78
84
  const now = this.now();
79
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
85
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
80
86
  this.exchangeCodes.set(input.code, cloneRecord(input));
81
87
  }
82
88
  async consumeExchangeCode(code) {
83
89
  const now = this.now();
84
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
90
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
85
91
  const record = this.exchangeCodes.get(code);
86
92
  if (!record) {
87
93
  return null;
@@ -95,19 +101,20 @@ var MemoryOidcVaultStore = class {
95
101
  async createSession(input) {
96
102
  const timestamp = this.now();
97
103
  this.pruneSessionsBatch(timestamp);
98
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
99
- this.rotatedSessionAliases,
100
- timestamp,
101
- this.rotatedSessionAliasSweepCursor
102
- );
104
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
103
105
  const session = {
104
106
  ...cloneRecord(input),
105
107
  logicalSessionId: input.logicalSessionId ?? input.sessionId,
106
108
  createdAt: input.createdAt ?? timestamp,
107
109
  updatedAt: input.updatedAt ?? timestamp
108
110
  };
111
+ const previousSession = this.sessions.get(session.sessionId);
112
+ const previousLogicalSessionId = previousSession?.logicalSessionId ?? previousSession?.sessionId;
109
113
  this.sessions.set(session.sessionId, session);
110
114
  this.rotatedSessionAliases.delete(session.sessionId);
115
+ if (previousLogicalSessionId !== void 0 && previousLogicalSessionId !== session.logicalSessionId) {
116
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
117
+ }
111
118
  return cloneRecord(session);
112
119
  }
113
120
  async getSession(sessionId) {
@@ -115,7 +122,7 @@ var MemoryOidcVaultStore = class {
115
122
  const session = this.sessions.get(sessionId);
116
123
  if (session && isExpiredRecord(session, now)) {
117
124
  this.sessions.delete(sessionId);
118
- this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId);
125
+ this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId, now);
119
126
  return null;
120
127
  }
121
128
  return session ? cloneRecord(session) : null;
@@ -125,7 +132,7 @@ var MemoryOidcVaultStore = class {
125
132
  const sourceSession = this.sessions.get(input.sessionId);
126
133
  if (sourceSession && isExpiredRecord(sourceSession, timestamp)) {
127
134
  this.sessions.delete(input.sessionId);
128
- this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId);
135
+ this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId, timestamp);
129
136
  }
130
137
  const liveSourceSession = this.sessions.get(input.sessionId);
131
138
  if (!liveSourceSession) {
@@ -135,12 +142,7 @@ var MemoryOidcVaultStore = class {
135
142
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
136
143
  }
137
144
  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) {
145
+ if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
144
146
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
145
147
  }
146
148
  const nextSession = cloneRecord(input.nextSession);
@@ -150,28 +152,36 @@ var MemoryOidcVaultStore = class {
150
152
  createdAt: nextSession.createdAt ?? timestamp,
151
153
  updatedAt: nextSession.updatedAt ?? timestamp
152
154
  };
155
+ if (existingTargetSession) {
156
+ this.sessions.delete(session.sessionId);
157
+ this.removeAliasesForInactiveLogicalSession(
158
+ existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId,
159
+ timestamp
160
+ );
161
+ }
162
+ const previousLogicalSessionId = liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId;
153
163
  this.sessions.delete(input.sessionId);
154
164
  this.sessions.set(session.sessionId, session);
165
+ this.rotatedSessionAliases.delete(session.sessionId);
155
166
  this.rotatedSessionAliases.set(input.sessionId, {
156
167
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
157
168
  expiresAt: session.expiresAt
158
169
  });
170
+ if (previousLogicalSessionId !== session.logicalSessionId) {
171
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
172
+ }
159
173
  return cloneRecord(session);
160
174
  }
161
175
  async deleteSession(sessionId) {
162
176
  const now = this.now();
163
177
  this.pruneSessionsBatch(now);
164
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
165
- this.rotatedSessionAliases,
166
- now,
167
- this.rotatedSessionAliasSweepCursor
168
- );
178
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, now);
169
179
  const session = this.sessions.get(sessionId);
170
180
  if (session) {
171
181
  const logicalSessionId = session.logicalSessionId ?? session.sessionId;
172
182
  this.sessions.delete(sessionId);
173
183
  this.rotatedSessionAliases.delete(sessionId);
174
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
184
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
175
185
  return;
176
186
  }
177
187
  const alias = this.rotatedSessionAliases.get(sessionId);
@@ -200,17 +210,13 @@ var MemoryOidcVaultStore = class {
200
210
  deleted += 1;
201
211
  }
202
212
  }
203
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
213
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
204
214
  this.removeAliasesForLogicalSession(resolved.logicalSessionId);
205
215
  return deleted;
206
216
  }
207
217
  async consumeBackchannelLogoutTokenJti(input) {
208
218
  const now = this.now();
209
- this.backchannelLogoutTokenJtiSweepCursor = this.pruneMapBatch(
210
- this.backchannelLogoutTokenJtis,
211
- now,
212
- this.backchannelLogoutTokenJtiSweepCursor
213
- );
219
+ this.pruneMapBatch(this.backchannelLogoutTokenJtis, this.backchannelLogoutTokenJtiSweep, now);
214
220
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
215
221
  return false;
216
222
  }
@@ -239,8 +245,8 @@ var MemoryOidcVaultStore = class {
239
245
  deleted += 1;
240
246
  }
241
247
  }
242
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
243
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
248
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
249
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
244
250
  return deleted;
245
251
  }
246
252
  async deleteSessionsByProviderSessionId(input) {
@@ -261,83 +267,118 @@ var MemoryOidcVaultStore = class {
261
267
  deleted += 1;
262
268
  }
263
269
  }
264
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
265
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
270
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
271
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
266
272
  return deleted;
267
273
  }
268
- pruneMapBatch(map, now, cursor) {
269
- let inspected = 0;
270
- let nextCursor;
271
- for (const [key, value] of this.entriesAfterCursor(map, cursor)) {
274
+ pruneMapBatch(map, sweep, now) {
275
+ if (map.size === 0) {
276
+ sweep.keys = [];
277
+ sweep.position = 0;
278
+ return;
279
+ }
280
+ if (sweep.position >= sweep.keys.length) {
281
+ this.rebuildSweepSnapshot(map, sweep);
282
+ }
283
+ let visited = 0;
284
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
285
+ if (sweep.position >= sweep.keys.length) {
286
+ if (map.size <= sweep.keys.length) {
287
+ break;
288
+ }
289
+ this.rebuildSweepSnapshot(map, sweep);
290
+ }
291
+ const key = sweep.keys[sweep.position];
292
+ sweep.position += 1;
293
+ visited += 1;
294
+ const value = map.get(key);
295
+ if (value === void 0) {
296
+ this.sweepWork.staleSnapshotSlots += 1;
297
+ continue;
298
+ }
299
+ this.sweepWork.inspectedKeys += 1;
272
300
  if (isExpiredRecord(value, now)) {
273
301
  map.delete(key);
274
302
  }
275
- inspected += 1;
276
- nextCursor = key;
277
- if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
278
- return nextCursor;
279
- }
280
303
  }
281
- return void 0;
282
304
  }
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);
305
+ rebuildSweepSnapshot(map, sweep) {
306
+ sweep.keys = Array.from(map.keys());
307
+ sweep.position = 0;
308
+ this.sweepWork.snapshotRebuilds += 1;
300
309
  }
301
- *entriesAfterCursor(map, cursor) {
302
- if (!cursor || !map.has(cursor)) {
303
- yield* map.entries();
310
+ pruneSessionsBatch(now) {
311
+ const sweep = this.sessionSweep;
312
+ if (this.sessions.size === 0) {
313
+ sweep.keys = [];
314
+ sweep.position = 0;
304
315
  return;
305
316
  }
306
- let foundCursor = false;
307
- for (const entry of map.entries()) {
308
- if (!foundCursor) {
309
- foundCursor = entry[0] === cursor;
317
+ if (sweep.position >= sweep.keys.length) {
318
+ this.rebuildSweepSnapshot(this.sessions, sweep);
319
+ }
320
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
321
+ let visited = 0;
322
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
323
+ if (sweep.position >= sweep.keys.length) {
324
+ if (this.sessions.size <= sweep.keys.length) {
325
+ break;
326
+ }
327
+ this.rebuildSweepSnapshot(this.sessions, sweep);
328
+ }
329
+ const sessionId = sweep.keys[sweep.position];
330
+ sweep.position += 1;
331
+ visited += 1;
332
+ const session = this.sessions.get(sessionId);
333
+ if (session === void 0) {
334
+ this.sweepWork.staleSnapshotSlots += 1;
310
335
  continue;
311
336
  }
312
- yield entry;
313
- }
314
- for (const entry of map.entries()) {
315
- if (entry[0] === cursor) {
316
- return;
337
+ this.sweepWork.inspectedKeys += 1;
338
+ if (isExpiredRecord(session, now)) {
339
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
340
+ this.sessions.delete(sessionId);
317
341
  }
318
- yield entry;
319
342
  }
343
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
320
344
  }
321
- removeAliasesForInactiveLogicalSessions(logicalSessionIds) {
345
+ removeAliasesForInactiveLogicalSessions(logicalSessionIds, now) {
322
346
  for (const logicalSessionId of logicalSessionIds) {
323
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
347
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
324
348
  }
325
349
  }
326
- removeAliasesForInactiveLogicalSession(logicalSessionId) {
327
- if (!this.hasLiveSessionForLogicalSession(logicalSessionId)) {
350
+ removeAliasesForInactiveLogicalSession(logicalSessionId, now) {
351
+ if (!this.hasLiveSessionForLogicalSession(logicalSessionId, now)) {
328
352
  this.removeAliasesForLogicalSession(logicalSessionId);
329
353
  }
330
354
  }
331
- hasLiveSessionForLogicalSession(logicalSessionId) {
355
+ /**
356
+ * Residual cost note (SVH-04): liveness is a full scan of `sessions`
357
+ * (early-out on the first live member), so per retired logical lineage the
358
+ * cleanup work is proportional to retained sessions, not to the 64-entry sweep
359
+ * batch. Visits are counted in `sweepWork.aliasSessionVisits` so this
360
+ * nested cost stays measurable and is never implied to be constant-time.
361
+ */
362
+ hasLiveSessionForLogicalSession(logicalSessionId, now) {
332
363
  for (const session of this.sessions.values()) {
333
- if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId) {
364
+ this.sweepWork.aliasSessionVisits += 1;
365
+ if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId && !isExpiredRecord(session, now)) {
334
366
  return true;
335
367
  }
336
368
  }
337
369
  return false;
338
370
  }
371
+ /**
372
+ * Residual cost note (SVH-04): alias removal scans every
373
+ * `rotatedSessionAliases` entry for the lineage, so per expired lineage the
374
+ * work is proportional to live aliases. Visits are counted in
375
+ * `sweepWork.aliasEntryVisits`. Bounding this further would need a
376
+ * lineage-to-alias index; that was deferred as unjustified complexity for
377
+ * the measured workloads (see README sweep-bounds note).
378
+ */
339
379
  removeAliasesForLogicalSession(logicalSessionId) {
340
380
  for (const [sessionId, alias] of this.rotatedSessionAliases.entries()) {
381
+ this.sweepWork.aliasEntryVisits += 1;
341
382
  if (alias.logicalSessionId === logicalSessionId) {
342
383
  this.rotatedSessionAliases.delete(sessionId);
343
384
  }
package/index.mjs CHANGED
@@ -5,6 +5,14 @@ import {
5
5
  var cloneRecord = (value) => structuredClone(value);
6
6
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
7
7
  var EXPIRY_SWEEP_BATCH_SIZE = 64;
8
+ var createMapSweepState = () => ({ keys: [], position: 0 });
9
+ var createMemoryStoreSweepWork = () => ({
10
+ inspectedKeys: 0,
11
+ staleSnapshotSlots: 0,
12
+ snapshotRebuilds: 0,
13
+ aliasSessionVisits: 0,
14
+ aliasEntryVisits: 0
15
+ });
8
16
  var matchesProviderScope = (session, input) => {
9
17
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
10
18
  return false;
@@ -24,24 +32,22 @@ var MemoryOidcVaultStore = class {
24
32
  this.sessions = /* @__PURE__ */ new Map();
25
33
  this.rotatedSessionAliases = /* @__PURE__ */ new Map();
26
34
  this.backchannelLogoutTokenJtis = /* @__PURE__ */ new Map();
35
+ this.authorizationTransactionSweep = createMapSweepState();
36
+ this.exchangeCodeSweep = createMapSweepState();
37
+ this.sessionSweep = createMapSweepState();
38
+ this.rotatedSessionAliasSweep = createMapSweepState();
39
+ this.backchannelLogoutTokenJtiSweep = createMapSweepState();
40
+ this.sweepWork = createMemoryStoreSweepWork();
27
41
  this.now = options.now ?? (() => Date.now());
28
42
  }
29
43
  async createAuthorizationTransaction(input) {
30
44
  const now = this.now();
31
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
32
- this.authorizationTransactions,
33
- now,
34
- this.authorizationTransactionSweepCursor
35
- );
45
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
36
46
  this.authorizationTransactions.set(input.state, cloneRecord(input));
37
47
  }
38
48
  async consumeAuthorizationTransaction(state) {
39
49
  const now = this.now();
40
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
41
- this.authorizationTransactions,
42
- now,
43
- this.authorizationTransactionSweepCursor
44
- );
50
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
45
51
  const record = this.authorizationTransactions.get(state);
46
52
  if (!record) {
47
53
  return null;
@@ -54,12 +60,12 @@ var MemoryOidcVaultStore = class {
54
60
  }
55
61
  async createExchangeCode(input) {
56
62
  const now = this.now();
57
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
63
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
58
64
  this.exchangeCodes.set(input.code, cloneRecord(input));
59
65
  }
60
66
  async consumeExchangeCode(code) {
61
67
  const now = this.now();
62
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
68
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
63
69
  const record = this.exchangeCodes.get(code);
64
70
  if (!record) {
65
71
  return null;
@@ -73,19 +79,20 @@ var MemoryOidcVaultStore = class {
73
79
  async createSession(input) {
74
80
  const timestamp = this.now();
75
81
  this.pruneSessionsBatch(timestamp);
76
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
77
- this.rotatedSessionAliases,
78
- timestamp,
79
- this.rotatedSessionAliasSweepCursor
80
- );
82
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
81
83
  const session = {
82
84
  ...cloneRecord(input),
83
85
  logicalSessionId: input.logicalSessionId ?? input.sessionId,
84
86
  createdAt: input.createdAt ?? timestamp,
85
87
  updatedAt: input.updatedAt ?? timestamp
86
88
  };
89
+ const previousSession = this.sessions.get(session.sessionId);
90
+ const previousLogicalSessionId = previousSession?.logicalSessionId ?? previousSession?.sessionId;
87
91
  this.sessions.set(session.sessionId, session);
88
92
  this.rotatedSessionAliases.delete(session.sessionId);
93
+ if (previousLogicalSessionId !== void 0 && previousLogicalSessionId !== session.logicalSessionId) {
94
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
95
+ }
89
96
  return cloneRecord(session);
90
97
  }
91
98
  async getSession(sessionId) {
@@ -93,7 +100,7 @@ var MemoryOidcVaultStore = class {
93
100
  const session = this.sessions.get(sessionId);
94
101
  if (session && isExpiredRecord(session, now)) {
95
102
  this.sessions.delete(sessionId);
96
- this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId);
103
+ this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId, now);
97
104
  return null;
98
105
  }
99
106
  return session ? cloneRecord(session) : null;
@@ -103,7 +110,7 @@ var MemoryOidcVaultStore = class {
103
110
  const sourceSession = this.sessions.get(input.sessionId);
104
111
  if (sourceSession && isExpiredRecord(sourceSession, timestamp)) {
105
112
  this.sessions.delete(input.sessionId);
106
- this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId);
113
+ this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId, timestamp);
107
114
  }
108
115
  const liveSourceSession = this.sessions.get(input.sessionId);
109
116
  if (!liveSourceSession) {
@@ -113,12 +120,7 @@ var MemoryOidcVaultStore = class {
113
120
  throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
114
121
  }
115
122
  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) {
123
+ if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
122
124
  throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
123
125
  }
124
126
  const nextSession = cloneRecord(input.nextSession);
@@ -128,28 +130,36 @@ var MemoryOidcVaultStore = class {
128
130
  createdAt: nextSession.createdAt ?? timestamp,
129
131
  updatedAt: nextSession.updatedAt ?? timestamp
130
132
  };
133
+ if (existingTargetSession) {
134
+ this.sessions.delete(session.sessionId);
135
+ this.removeAliasesForInactiveLogicalSession(
136
+ existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId,
137
+ timestamp
138
+ );
139
+ }
140
+ const previousLogicalSessionId = liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId;
131
141
  this.sessions.delete(input.sessionId);
132
142
  this.sessions.set(session.sessionId, session);
143
+ this.rotatedSessionAliases.delete(session.sessionId);
133
144
  this.rotatedSessionAliases.set(input.sessionId, {
134
145
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
135
146
  expiresAt: session.expiresAt
136
147
  });
148
+ if (previousLogicalSessionId !== session.logicalSessionId) {
149
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
150
+ }
137
151
  return cloneRecord(session);
138
152
  }
139
153
  async deleteSession(sessionId) {
140
154
  const now = this.now();
141
155
  this.pruneSessionsBatch(now);
142
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
143
- this.rotatedSessionAliases,
144
- now,
145
- this.rotatedSessionAliasSweepCursor
146
- );
156
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, now);
147
157
  const session = this.sessions.get(sessionId);
148
158
  if (session) {
149
159
  const logicalSessionId = session.logicalSessionId ?? session.sessionId;
150
160
  this.sessions.delete(sessionId);
151
161
  this.rotatedSessionAliases.delete(sessionId);
152
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
162
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
153
163
  return;
154
164
  }
155
165
  const alias = this.rotatedSessionAliases.get(sessionId);
@@ -178,17 +188,13 @@ var MemoryOidcVaultStore = class {
178
188
  deleted += 1;
179
189
  }
180
190
  }
181
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
191
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
182
192
  this.removeAliasesForLogicalSession(resolved.logicalSessionId);
183
193
  return deleted;
184
194
  }
185
195
  async consumeBackchannelLogoutTokenJti(input) {
186
196
  const now = this.now();
187
- this.backchannelLogoutTokenJtiSweepCursor = this.pruneMapBatch(
188
- this.backchannelLogoutTokenJtis,
189
- now,
190
- this.backchannelLogoutTokenJtiSweepCursor
191
- );
197
+ this.pruneMapBatch(this.backchannelLogoutTokenJtis, this.backchannelLogoutTokenJtiSweep, now);
192
198
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
193
199
  return false;
194
200
  }
@@ -217,8 +223,8 @@ var MemoryOidcVaultStore = class {
217
223
  deleted += 1;
218
224
  }
219
225
  }
220
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
221
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
226
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
227
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
222
228
  return deleted;
223
229
  }
224
230
  async deleteSessionsByProviderSessionId(input) {
@@ -239,83 +245,118 @@ var MemoryOidcVaultStore = class {
239
245
  deleted += 1;
240
246
  }
241
247
  }
242
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
243
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
248
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
249
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
244
250
  return deleted;
245
251
  }
246
- pruneMapBatch(map, now, cursor) {
247
- let inspected = 0;
248
- let nextCursor;
249
- for (const [key, value] of this.entriesAfterCursor(map, cursor)) {
252
+ pruneMapBatch(map, sweep, now) {
253
+ if (map.size === 0) {
254
+ sweep.keys = [];
255
+ sweep.position = 0;
256
+ return;
257
+ }
258
+ if (sweep.position >= sweep.keys.length) {
259
+ this.rebuildSweepSnapshot(map, sweep);
260
+ }
261
+ let visited = 0;
262
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
263
+ if (sweep.position >= sweep.keys.length) {
264
+ if (map.size <= sweep.keys.length) {
265
+ break;
266
+ }
267
+ this.rebuildSweepSnapshot(map, sweep);
268
+ }
269
+ const key = sweep.keys[sweep.position];
270
+ sweep.position += 1;
271
+ visited += 1;
272
+ const value = map.get(key);
273
+ if (value === void 0) {
274
+ this.sweepWork.staleSnapshotSlots += 1;
275
+ continue;
276
+ }
277
+ this.sweepWork.inspectedKeys += 1;
250
278
  if (isExpiredRecord(value, now)) {
251
279
  map.delete(key);
252
280
  }
253
- inspected += 1;
254
- nextCursor = key;
255
- if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
256
- return nextCursor;
257
- }
258
281
  }
259
- return void 0;
260
282
  }
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);
283
+ rebuildSweepSnapshot(map, sweep) {
284
+ sweep.keys = Array.from(map.keys());
285
+ sweep.position = 0;
286
+ this.sweepWork.snapshotRebuilds += 1;
278
287
  }
279
- *entriesAfterCursor(map, cursor) {
280
- if (!cursor || !map.has(cursor)) {
281
- yield* map.entries();
288
+ pruneSessionsBatch(now) {
289
+ const sweep = this.sessionSweep;
290
+ if (this.sessions.size === 0) {
291
+ sweep.keys = [];
292
+ sweep.position = 0;
282
293
  return;
283
294
  }
284
- let foundCursor = false;
285
- for (const entry of map.entries()) {
286
- if (!foundCursor) {
287
- foundCursor = entry[0] === cursor;
295
+ if (sweep.position >= sweep.keys.length) {
296
+ this.rebuildSweepSnapshot(this.sessions, sweep);
297
+ }
298
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
299
+ let visited = 0;
300
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
301
+ if (sweep.position >= sweep.keys.length) {
302
+ if (this.sessions.size <= sweep.keys.length) {
303
+ break;
304
+ }
305
+ this.rebuildSweepSnapshot(this.sessions, sweep);
306
+ }
307
+ const sessionId = sweep.keys[sweep.position];
308
+ sweep.position += 1;
309
+ visited += 1;
310
+ const session = this.sessions.get(sessionId);
311
+ if (session === void 0) {
312
+ this.sweepWork.staleSnapshotSlots += 1;
288
313
  continue;
289
314
  }
290
- yield entry;
291
- }
292
- for (const entry of map.entries()) {
293
- if (entry[0] === cursor) {
294
- return;
315
+ this.sweepWork.inspectedKeys += 1;
316
+ if (isExpiredRecord(session, now)) {
317
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
318
+ this.sessions.delete(sessionId);
295
319
  }
296
- yield entry;
297
320
  }
321
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
298
322
  }
299
- removeAliasesForInactiveLogicalSessions(logicalSessionIds) {
323
+ removeAliasesForInactiveLogicalSessions(logicalSessionIds, now) {
300
324
  for (const logicalSessionId of logicalSessionIds) {
301
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
325
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
302
326
  }
303
327
  }
304
- removeAliasesForInactiveLogicalSession(logicalSessionId) {
305
- if (!this.hasLiveSessionForLogicalSession(logicalSessionId)) {
328
+ removeAliasesForInactiveLogicalSession(logicalSessionId, now) {
329
+ if (!this.hasLiveSessionForLogicalSession(logicalSessionId, now)) {
306
330
  this.removeAliasesForLogicalSession(logicalSessionId);
307
331
  }
308
332
  }
309
- hasLiveSessionForLogicalSession(logicalSessionId) {
333
+ /**
334
+ * Residual cost note (SVH-04): liveness is a full scan of `sessions`
335
+ * (early-out on the first live member), so per retired logical lineage the
336
+ * cleanup work is proportional to retained sessions, not to the 64-entry sweep
337
+ * batch. Visits are counted in `sweepWork.aliasSessionVisits` so this
338
+ * nested cost stays measurable and is never implied to be constant-time.
339
+ */
340
+ hasLiveSessionForLogicalSession(logicalSessionId, now) {
310
341
  for (const session of this.sessions.values()) {
311
- if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId) {
342
+ this.sweepWork.aliasSessionVisits += 1;
343
+ if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId && !isExpiredRecord(session, now)) {
312
344
  return true;
313
345
  }
314
346
  }
315
347
  return false;
316
348
  }
349
+ /**
350
+ * Residual cost note (SVH-04): alias removal scans every
351
+ * `rotatedSessionAliases` entry for the lineage, so per expired lineage the
352
+ * work is proportional to live aliases. Visits are counted in
353
+ * `sweepWork.aliasEntryVisits`. Bounding this further would need a
354
+ * lineage-to-alias index; that was deferred as unjustified complexity for
355
+ * the measured workloads (see README sweep-bounds note).
356
+ */
317
357
  removeAliasesForLogicalSession(logicalSessionId) {
318
358
  for (const [sessionId, alias] of this.rotatedSessionAliases.entries()) {
359
+ this.sweepWork.aliasEntryVisits += 1;
319
360
  if (alias.logicalSessionId === logicalSessionId) {
320
361
  this.rotatedSessionAliases.delete(sessionId);
321
362
  }
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.43.0",
5
+ "version": "0.47.1",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -29,7 +29,7 @@
29
29
  "node": ">=22"
30
30
  },
31
31
  "dependencies": {
32
- "@web-ts-toolkit/express-oidc-vault": "0.43.0"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.47.1"
33
33
  },
34
34
  "files": [
35
35
  "**/*",