@web-ts-toolkit/express-oidc-vault-memory-store 0.43.0 → 0.48.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/index.js CHANGED
@@ -23,10 +23,133 @@ __export(index_exports, {
23
23
  createMemoryOidcVaultStore: () => createMemoryOidcVaultStore
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
+ var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
27
+
28
+ // src/binding.ts
26
29
  var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
30
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
31
+ var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
32
+ var isBinding = (value) => isRecord(value) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isHash(value.jkt);
33
+ var hasValidBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
34
+ var hasValidRecordBinding = (record) => hasValidBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
35
+ var assertRecordBinding = (record) => {
36
+ if (!hasValidRecordBinding(record))
37
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
38
+ };
39
+ var assertSessionBinding = (record) => {
40
+ if (!hasValidBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
41
+ };
42
+ var isBindingMatch = (match) => isRecord(match) && Object.hasOwn(match, "deviceBinding") && Object.hasOwn(match, "browserBindingHash") && (match.deviceBinding === null || isBinding(match.deviceBinding)) && (match.browserBindingHash === null || isHash(match.browserBindingHash));
43
+ var matchesRecordBinding = (record, match) => (match.deviceBinding === null ? record.deviceBinding === void 0 : record.deviceBinding?.type === match.deviceBinding.type && record.deviceBinding.jkt === match.deviceBinding.jkt) && (match.browserBindingHash === null ? record.browserBindingHash === void 0 : record.browserBindingHash === match.browserBindingHash);
44
+ var inheritSessionBinding = (source, next) => {
45
+ assertSessionBinding(source);
46
+ assertSessionBinding(next);
47
+ if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
48
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
49
+ }
50
+ if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
51
+ };
52
+ var sessionRevocationContext = (session) => {
53
+ assertSessionBinding(session);
54
+ if (typeof session.sessionId !== "string" || typeof session.subject !== "string" || session.logicalSessionId !== void 0 && typeof session.logicalSessionId !== "string") {
55
+ throw new Error("OIDC vault store lineage has malformed session authority.");
56
+ }
57
+ const provider = session.provider;
58
+ if (provider !== void 0 && (!isRecord(provider) || provider.issuer !== void 0 && typeof provider.issuer !== "string" || provider.clientId !== void 0 && typeof provider.clientId !== "string")) {
59
+ throw new Error("OIDC vault store lineage has malformed provider identity.");
60
+ }
61
+ return {
62
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
63
+ ...provider === void 0 ? {} : {
64
+ provider: {
65
+ ...typeof provider.issuer === "string" ? { issuer: provider.issuer } : {},
66
+ ...typeof provider.clientId === "string" ? { clientId: provider.clientId } : {}
67
+ }
68
+ },
69
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } }
70
+ };
71
+ };
72
+ var hasValidSessionRecord = (session, sessionId) => session.sessionId === sessionId && typeof session.subject === "string" && (session.logicalSessionId === void 0 || typeof session.logicalSessionId === "string") && typeof session.refreshToken === "string" && typeof session.idToken === "string" && (session.expiresAt === void 0 || Number.isFinite(session.expiresAt)) && hasValidBinding(session);
73
+ var assertSameLineageAuthority = (source, member) => {
74
+ assertSessionBinding(member);
75
+ const authority = sessionRevocationContext(member);
76
+ if (source.deviceBinding?.jkt !== authority.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== authority.provider?.issuer || source.provider?.clientId !== authority.provider?.clientId) {
77
+ throw new Error("OIDC vault store lineage has inconsistent device binding or provider identity.");
78
+ }
79
+ };
80
+
81
+ // src/dpop-replay.ts
82
+ var import_express_oidc_vault2 = require("@web-ts-toolkit/express-oidc-vault");
83
+ var DpopReplayReservations = class {
84
+ constructor(maxEntries) {
85
+ this.maxEntries = maxEntries;
86
+ this.reservations = /* @__PURE__ */ new Map();
87
+ this.expiryHeap = [];
88
+ }
89
+ reserve({ replayKey, expiresAt }, now) {
90
+ if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
91
+ if (typeof replayKey !== "string" || !Number.isSafeInteger(expiresAt) || expiresAt <= now || expiresAt - now > 36e4) {
92
+ return false;
93
+ }
94
+ const existing = this.reservations.get(replayKey);
95
+ if (existing && existing.expiresAt > now) return false;
96
+ if (existing) this.remove(existing.index);
97
+ for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.expiresAt <= now; cleaned += 1) this.remove(0);
98
+ if (this.reservations.size >= this.maxEntries) throw new import_express_oidc_vault2.OidcVaultDpopReplayCapacityError();
99
+ const reservation = { replayKey, expiresAt, index: this.expiryHeap.length };
100
+ this.reservations.set(replayKey, reservation);
101
+ this.expiryHeap.push(reservation);
102
+ this.moveUp(reservation.index);
103
+ return true;
104
+ }
105
+ remove(index) {
106
+ const removed = this.expiryHeap[index];
107
+ const last = this.expiryHeap.pop();
108
+ this.reservations.delete(removed.replayKey);
109
+ if (index === this.expiryHeap.length) return;
110
+ this.expiryHeap[index] = last;
111
+ last.index = index;
112
+ this.moveUp(index);
113
+ this.moveDown(last.index);
114
+ }
115
+ swap(left, right) {
116
+ [this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
117
+ this.expiryHeap[left].index = left;
118
+ this.expiryHeap[right].index = right;
119
+ }
120
+ moveUp(index) {
121
+ while (index > 0) {
122
+ const parent = Math.floor((index - 1) / 2);
123
+ if (this.expiryHeap[parent].expiresAt <= this.expiryHeap[index].expiresAt) break;
124
+ this.swap(parent, index);
125
+ index = parent;
126
+ }
127
+ }
128
+ moveDown(index) {
129
+ for (; ; ) {
130
+ const left = index * 2 + 1;
131
+ if (left >= this.expiryHeap.length) return;
132
+ const right = left + 1;
133
+ const child = right < this.expiryHeap.length && this.expiryHeap[right].expiresAt < this.expiryHeap[left].expiresAt ? right : left;
134
+ if (this.expiryHeap[index].expiresAt <= this.expiryHeap[child].expiresAt) return;
135
+ this.swap(index, child);
136
+ index = child;
137
+ }
138
+ }
139
+ };
140
+
141
+ // src/index.ts
27
142
  var cloneRecord = (value) => structuredClone(value);
28
143
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
29
144
  var EXPIRY_SWEEP_BATCH_SIZE = 64;
145
+ var createMapSweepState = () => ({ keys: [], position: 0 });
146
+ var createMemoryStoreSweepWork = () => ({
147
+ inspectedKeys: 0,
148
+ staleSnapshotSlots: 0,
149
+ snapshotRebuilds: 0,
150
+ aliasSessionVisits: 0,
151
+ aliasEntryVisits: 0
152
+ });
30
153
  var matchesProviderScope = (session, input) => {
31
154
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
32
155
  return false;
@@ -46,68 +169,105 @@ var MemoryOidcVaultStore = class {
46
169
  this.sessions = /* @__PURE__ */ new Map();
47
170
  this.rotatedSessionAliases = /* @__PURE__ */ new Map();
48
171
  this.backchannelLogoutTokenJtis = /* @__PURE__ */ new Map();
172
+ this.authorizationTransactionSweep = createMapSweepState();
173
+ this.exchangeCodeSweep = createMapSweepState();
174
+ this.sessionSweep = createMapSweepState();
175
+ this.rotatedSessionAliasSweep = createMapSweepState();
176
+ this.backchannelLogoutTokenJtiSweep = createMapSweepState();
177
+ this.sweepWork = createMemoryStoreSweepWork();
178
+ const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
179
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
180
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
181
+ }
182
+ this.dpopProofs = new DpopReplayReservations(maxEntries);
49
183
  this.now = options.now ?? (() => Date.now());
50
184
  }
51
185
  async createAuthorizationTransaction(input) {
186
+ input = cloneRecord(input);
187
+ assertRecordBinding(input);
52
188
  const now = this.now();
53
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
54
- this.authorizationTransactions,
55
- now,
56
- this.authorizationTransactionSweepCursor
57
- );
58
- this.authorizationTransactions.set(input.state, cloneRecord(input));
189
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
190
+ this.authorizationTransactions.set(input.state, input);
59
191
  }
60
192
  async consumeAuthorizationTransaction(state) {
193
+ return this.consumeAuthorizationTransactionIfMatches({
194
+ state,
195
+ match: { deviceBinding: null, browserBindingHash: null }
196
+ });
197
+ }
198
+ async getAuthorizationTransaction(state) {
199
+ const record = this.readOneTimeRecord(this.authorizationTransactions, state, this.now());
200
+ return record && record.state === state && typeof record.nonce === "string" && typeof record.pkceVerifier === "string" && typeof record.codeChallenge === "string" ? cloneRecord(record) : null;
201
+ }
202
+ async consumeAuthorizationTransactionIfMatches(input) {
203
+ input = cloneRecord(input);
204
+ if (!isBindingMatch(input.match)) return null;
61
205
  const now = this.now();
62
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
63
- this.authorizationTransactions,
64
- now,
65
- this.authorizationTransactionSweepCursor
66
- );
67
- const record = this.authorizationTransactions.get(state);
68
- if (!record) {
206
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
207
+ const record = this.readOneTimeRecord(this.authorizationTransactions, input.state, now);
208
+ if (!record || record.state !== input.state || typeof record.nonce !== "string" || typeof record.pkceVerifier !== "string" || typeof record.codeChallenge !== "string" || !matchesRecordBinding(record, input.match))
69
209
  return null;
70
- }
71
- this.authorizationTransactions.delete(state);
72
- if (isExpiredRecord(record, now)) {
73
- return null;
74
- }
210
+ this.authorizationTransactions.delete(input.state);
75
211
  return cloneRecord(record);
76
212
  }
77
213
  async createExchangeCode(input) {
214
+ input = cloneRecord(input);
215
+ assertRecordBinding(input);
78
216
  const now = this.now();
79
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
80
- this.exchangeCodes.set(input.code, cloneRecord(input));
217
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
218
+ this.exchangeCodes.set(input.code, input);
81
219
  }
82
220
  async consumeExchangeCode(code) {
83
221
  const now = this.now();
84
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
85
- const record = this.exchangeCodes.get(code);
86
- if (!record) {
222
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
223
+ return this.consumeExchangeRecord(code, { deviceBinding: null, browserBindingHash: null }, now);
224
+ }
225
+ async getExchangeCode(code) {
226
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, this.now());
227
+ return record && record.code === code && typeof record.sessionId === "string" ? cloneRecord(record) : null;
228
+ }
229
+ async consumeExchangeCodeIfMatches(input) {
230
+ input = cloneRecord(input);
231
+ if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
232
+ const now = this.now();
233
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
234
+ return this.consumeExchangeRecord(input.code, input.match, now, input.expectedSessionId);
235
+ }
236
+ consumeExchangeRecord(code, match, now, expectedSessionId) {
237
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, now);
238
+ if (!record || record.code !== code || typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || !matchesRecordBinding(record, match))
87
239
  return null;
88
- }
89
240
  this.exchangeCodes.delete(code);
90
- if (isExpiredRecord(record, now)) {
241
+ return cloneRecord(record);
242
+ }
243
+ readOneTimeRecord(map, id, now) {
244
+ const record = map.get(id);
245
+ if (!record) return null;
246
+ if (!Number.isFinite(record.createdAt) || !Number.isFinite(record.expiresAt) || isExpiredRecord(record, now) || !hasValidRecordBinding(record)) {
247
+ map.delete(id);
91
248
  return null;
92
249
  }
93
- return cloneRecord(record);
250
+ return record;
94
251
  }
95
252
  async createSession(input) {
253
+ input = cloneRecord(input);
254
+ assertSessionBinding(input);
96
255
  const timestamp = this.now();
97
256
  this.pruneSessionsBatch(timestamp);
98
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
99
- this.rotatedSessionAliases,
100
- timestamp,
101
- this.rotatedSessionAliasSweepCursor
102
- );
257
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
103
258
  const session = {
104
- ...cloneRecord(input),
259
+ ...input,
105
260
  logicalSessionId: input.logicalSessionId ?? input.sessionId,
106
261
  createdAt: input.createdAt ?? timestamp,
107
262
  updatedAt: input.updatedAt ?? timestamp
108
263
  };
264
+ const previousSession = this.sessions.get(session.sessionId);
265
+ const previousLogicalSessionId = previousSession?.logicalSessionId ?? previousSession?.sessionId;
109
266
  this.sessions.set(session.sessionId, session);
110
267
  this.rotatedSessionAliases.delete(session.sessionId);
268
+ if (previousLogicalSessionId !== void 0 && previousLogicalSessionId !== session.logicalSessionId) {
269
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
270
+ }
111
271
  return cloneRecord(session);
112
272
  }
113
273
  async getSession(sessionId) {
@@ -115,63 +275,97 @@ var MemoryOidcVaultStore = class {
115
275
  const session = this.sessions.get(sessionId);
116
276
  if (session && isExpiredRecord(session, now)) {
117
277
  this.sessions.delete(sessionId);
118
- this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId);
278
+ this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId, now);
119
279
  return null;
120
280
  }
121
- return session ? cloneRecord(session) : null;
281
+ return session && hasValidSessionRecord(session, sessionId) ? cloneRecord(session) : null;
282
+ }
283
+ async getSessionRevocationContext(sessionId) {
284
+ const now = this.now();
285
+ const live = this.sessions.get(sessionId);
286
+ const alias = this.rotatedSessionAliases.get(sessionId);
287
+ if (live && !isExpiredRecord(live, now)) {
288
+ if (live.sessionId !== sessionId) throw new Error("OIDC vault store has malformed revocation authority.");
289
+ sessionRevocationContext(live);
290
+ }
291
+ if ((!live || isExpiredRecord(live, now)) && alias && (typeof alias.logicalSessionId !== "string" || alias.expiresAt !== void 0 && !Number.isFinite(alias.expiresAt))) {
292
+ throw new Error("OIDC vault store has malformed revocation alias.");
293
+ }
294
+ const logicalSessionId = live && !isExpiredRecord(live, now) ? live.logicalSessionId ?? live.sessionId : alias && !isExpiredRecord(alias, now) ? alias.logicalSessionId : void 0;
295
+ if (logicalSessionId === void 0) return null;
296
+ if (typeof logicalSessionId !== "string") throw new Error("OIDC vault store has malformed revocation authority.");
297
+ let source;
298
+ for (const member of this.sessions.values()) {
299
+ if ((member.logicalSessionId ?? member.sessionId) !== logicalSessionId || isExpiredRecord(member, now)) continue;
300
+ if (member.sessionId === void 0 || typeof member.subject !== "string" || member.expiresAt !== void 0 && !Number.isFinite(member.expiresAt)) {
301
+ throw new Error("OIDC vault store lineage has malformed session authority.");
302
+ }
303
+ sessionRevocationContext(member);
304
+ if (source) assertSameLineageAuthority(source, member);
305
+ else source = member;
306
+ }
307
+ return source ? sessionRevocationContext(source) : null;
308
+ }
309
+ async reserveDpopProof(input) {
310
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
311
+ return this.dpopProofs.reserve(input, this.now());
122
312
  }
123
313
  async rotateSession(input) {
124
314
  const timestamp = this.now();
125
315
  const sourceSession = this.sessions.get(input.sessionId);
126
316
  if (sourceSession && isExpiredRecord(sourceSession, timestamp)) {
127
317
  this.sessions.delete(input.sessionId);
128
- this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId);
318
+ this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId, timestamp);
129
319
  }
130
320
  const liveSourceSession = this.sessions.get(input.sessionId);
131
- if (!liveSourceSession) {
132
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
321
+ if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
322
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
133
323
  }
134
324
  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.");
325
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
136
326
  }
137
327
  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.");
328
+ if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
329
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
145
330
  }
146
331
  const nextSession = cloneRecord(input.nextSession);
332
+ inheritSessionBinding(liveSourceSession, nextSession);
147
333
  const session = {
148
334
  ...nextSession,
149
335
  logicalSessionId: nextSession.logicalSessionId ?? liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId,
150
336
  createdAt: nextSession.createdAt ?? timestamp,
151
337
  updatedAt: nextSession.updatedAt ?? timestamp
152
338
  };
339
+ if (existingTargetSession) {
340
+ this.sessions.delete(session.sessionId);
341
+ this.removeAliasesForInactiveLogicalSession(
342
+ existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId,
343
+ timestamp
344
+ );
345
+ }
346
+ const previousLogicalSessionId = liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId;
153
347
  this.sessions.delete(input.sessionId);
154
348
  this.sessions.set(session.sessionId, session);
349
+ this.rotatedSessionAliases.delete(session.sessionId);
155
350
  this.rotatedSessionAliases.set(input.sessionId, {
156
351
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
157
352
  expiresAt: session.expiresAt
158
353
  });
354
+ if (previousLogicalSessionId !== session.logicalSessionId) {
355
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
356
+ }
159
357
  return cloneRecord(session);
160
358
  }
161
359
  async deleteSession(sessionId) {
162
360
  const now = this.now();
163
361
  this.pruneSessionsBatch(now);
164
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
165
- this.rotatedSessionAliases,
166
- now,
167
- this.rotatedSessionAliasSweepCursor
168
- );
362
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, now);
169
363
  const session = this.sessions.get(sessionId);
170
364
  if (session) {
171
365
  const logicalSessionId = session.logicalSessionId ?? session.sessionId;
172
366
  this.sessions.delete(sessionId);
173
367
  this.rotatedSessionAliases.delete(sessionId);
174
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
368
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
175
369
  return;
176
370
  }
177
371
  const alias = this.rotatedSessionAliases.get(sessionId);
@@ -200,17 +394,13 @@ var MemoryOidcVaultStore = class {
200
394
  deleted += 1;
201
395
  }
202
396
  }
203
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
397
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
204
398
  this.removeAliasesForLogicalSession(resolved.logicalSessionId);
205
399
  return deleted;
206
400
  }
207
401
  async consumeBackchannelLogoutTokenJti(input) {
208
402
  const now = this.now();
209
- this.backchannelLogoutTokenJtiSweepCursor = this.pruneMapBatch(
210
- this.backchannelLogoutTokenJtis,
211
- now,
212
- this.backchannelLogoutTokenJtiSweepCursor
213
- );
403
+ this.pruneMapBatch(this.backchannelLogoutTokenJtis, this.backchannelLogoutTokenJtiSweep, now);
214
404
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
215
405
  return false;
216
406
  }
@@ -239,8 +429,8 @@ var MemoryOidcVaultStore = class {
239
429
  deleted += 1;
240
430
  }
241
431
  }
242
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
243
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
432
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
433
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
244
434
  return deleted;
245
435
  }
246
436
  async deleteSessionsByProviderSessionId(input) {
@@ -261,83 +451,118 @@ var MemoryOidcVaultStore = class {
261
451
  deleted += 1;
262
452
  }
263
453
  }
264
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
265
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
454
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
455
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
266
456
  return deleted;
267
457
  }
268
- pruneMapBatch(map, now, cursor) {
269
- let inspected = 0;
270
- let nextCursor;
271
- for (const [key, value] of this.entriesAfterCursor(map, cursor)) {
458
+ pruneMapBatch(map, sweep, now) {
459
+ if (map.size === 0) {
460
+ sweep.keys = [];
461
+ sweep.position = 0;
462
+ return;
463
+ }
464
+ if (sweep.position >= sweep.keys.length) {
465
+ this.rebuildSweepSnapshot(map, sweep);
466
+ }
467
+ let visited = 0;
468
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
469
+ if (sweep.position >= sweep.keys.length) {
470
+ if (map.size <= sweep.keys.length) {
471
+ break;
472
+ }
473
+ this.rebuildSweepSnapshot(map, sweep);
474
+ }
475
+ const key = sweep.keys[sweep.position];
476
+ sweep.position += 1;
477
+ visited += 1;
478
+ const value = map.get(key);
479
+ if (value === void 0) {
480
+ this.sweepWork.staleSnapshotSlots += 1;
481
+ continue;
482
+ }
483
+ this.sweepWork.inspectedKeys += 1;
272
484
  if (isExpiredRecord(value, now)) {
273
485
  map.delete(key);
274
486
  }
275
- inspected += 1;
276
- nextCursor = key;
277
- if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
278
- return nextCursor;
279
- }
280
487
  }
281
- return void 0;
282
488
  }
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);
489
+ rebuildSweepSnapshot(map, sweep) {
490
+ sweep.keys = Array.from(map.keys());
491
+ sweep.position = 0;
492
+ this.sweepWork.snapshotRebuilds += 1;
300
493
  }
301
- *entriesAfterCursor(map, cursor) {
302
- if (!cursor || !map.has(cursor)) {
303
- yield* map.entries();
494
+ pruneSessionsBatch(now) {
495
+ const sweep = this.sessionSweep;
496
+ if (this.sessions.size === 0) {
497
+ sweep.keys = [];
498
+ sweep.position = 0;
304
499
  return;
305
500
  }
306
- let foundCursor = false;
307
- for (const entry of map.entries()) {
308
- if (!foundCursor) {
309
- foundCursor = entry[0] === cursor;
501
+ if (sweep.position >= sweep.keys.length) {
502
+ this.rebuildSweepSnapshot(this.sessions, sweep);
503
+ }
504
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
505
+ let visited = 0;
506
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
507
+ if (sweep.position >= sweep.keys.length) {
508
+ if (this.sessions.size <= sweep.keys.length) {
509
+ break;
510
+ }
511
+ this.rebuildSweepSnapshot(this.sessions, sweep);
512
+ }
513
+ const sessionId = sweep.keys[sweep.position];
514
+ sweep.position += 1;
515
+ visited += 1;
516
+ const session = this.sessions.get(sessionId);
517
+ if (session === void 0) {
518
+ this.sweepWork.staleSnapshotSlots += 1;
310
519
  continue;
311
520
  }
312
- yield entry;
313
- }
314
- for (const entry of map.entries()) {
315
- if (entry[0] === cursor) {
316
- return;
521
+ this.sweepWork.inspectedKeys += 1;
522
+ if (isExpiredRecord(session, now)) {
523
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
524
+ this.sessions.delete(sessionId);
317
525
  }
318
- yield entry;
319
526
  }
527
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
320
528
  }
321
- removeAliasesForInactiveLogicalSessions(logicalSessionIds) {
529
+ removeAliasesForInactiveLogicalSessions(logicalSessionIds, now) {
322
530
  for (const logicalSessionId of logicalSessionIds) {
323
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
531
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
324
532
  }
325
533
  }
326
- removeAliasesForInactiveLogicalSession(logicalSessionId) {
327
- if (!this.hasLiveSessionForLogicalSession(logicalSessionId)) {
534
+ removeAliasesForInactiveLogicalSession(logicalSessionId, now) {
535
+ if (!this.hasLiveSessionForLogicalSession(logicalSessionId, now)) {
328
536
  this.removeAliasesForLogicalSession(logicalSessionId);
329
537
  }
330
538
  }
331
- hasLiveSessionForLogicalSession(logicalSessionId) {
539
+ /**
540
+ * Residual cost note (SVH-04): liveness is a full scan of `sessions`
541
+ * (early-out on the first live member), so per retired logical lineage the
542
+ * cleanup work is proportional to retained sessions, not to the 64-entry sweep
543
+ * batch. Visits are counted in `sweepWork.aliasSessionVisits` so this
544
+ * nested cost stays measurable and is never implied to be constant-time.
545
+ */
546
+ hasLiveSessionForLogicalSession(logicalSessionId, now) {
332
547
  for (const session of this.sessions.values()) {
333
- if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId) {
548
+ this.sweepWork.aliasSessionVisits += 1;
549
+ if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId && !isExpiredRecord(session, now)) {
334
550
  return true;
335
551
  }
336
552
  }
337
553
  return false;
338
554
  }
555
+ /**
556
+ * Residual cost note (SVH-04): alias removal scans every
557
+ * `rotatedSessionAliases` entry for the lineage, so per expired lineage the
558
+ * work is proportional to live aliases. Visits are counted in
559
+ * `sweepWork.aliasEntryVisits`. Bounding this further would need a
560
+ * lineage-to-alias index; that was deferred as unjustified complexity for
561
+ * the measured workloads (see README sweep-bounds note).
562
+ */
339
563
  removeAliasesForLogicalSession(logicalSessionId) {
340
564
  for (const [sessionId, alias] of this.rotatedSessionAliases.entries()) {
565
+ this.sweepWork.aliasEntryVisits += 1;
341
566
  if (alias.logicalSessionId === logicalSessionId) {
342
567
  this.rotatedSessionAliases.delete(sessionId);
343
568
  }