@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/README.md +72 -4
- package/index.d.mts +9 -3
- package/index.d.ts +9 -3
- package/index.js +332 -107
- package/index.mjs +333 -108
- package/package.json +3 -3
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.
|
|
54
|
-
|
|
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.
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
80
|
-
this.exchangeCodes.set(input.code,
|
|
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.
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
99
|
-
this.rotatedSessionAliases,
|
|
100
|
-
timestamp,
|
|
101
|
-
this.rotatedSessionAliasSweepCursor
|
|
102
|
-
);
|
|
257
|
+
this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
|
|
103
258
|
const session = {
|
|
104
|
-
...
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|