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