@web-ts-toolkit/express-oidc-vault-redis-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 +80 -13
- package/index.d.mts +13 -8
- package/index.d.ts +13 -8
- package/index.js +546 -50
- package/index.mjs +548 -51
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -67,6 +67,9 @@ var RedisOidcVaultStoreKeys = class {
|
|
|
67
67
|
backchannelLogoutTokenJti(jti) {
|
|
68
68
|
return prefixKey(this.keyPrefix, "backchannel-logout-jti", jti);
|
|
69
69
|
}
|
|
70
|
+
dpopProofs() {
|
|
71
|
+
return `${this.keyPrefix}:dpop-proofs`;
|
|
72
|
+
}
|
|
70
73
|
rotatedSessionAliasIndex(logicalSessionId) {
|
|
71
74
|
return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
|
|
72
75
|
}
|
|
@@ -94,14 +97,27 @@ var OidcVaultRedisStoreRecordError = class extends Error {
|
|
|
94
97
|
};
|
|
95
98
|
var isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
99
|
var isString = (value) => typeof value === "string";
|
|
100
|
+
var isBindingHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
|
|
101
|
+
var validateDpopBinding = (value) => isPlainRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isBindingHash(value.jkt);
|
|
102
|
+
var validateRecordBinding = (record) => (record.deviceBinding === void 0 || validateDpopBinding(record.deviceBinding)) && (record.browserBindingHash === void 0 || isBindingHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
|
|
103
|
+
var validateBindingMatch = (value) => isPlainRecord(value) && Object.hasOwn(value, "deviceBinding") && Object.hasOwn(value, "browserBindingHash") && (value.deviceBinding === null || validateDpopBinding(value.deviceBinding)) && (value.browserBindingHash === null || isBindingHash(value.browserBindingHash));
|
|
104
|
+
var assertRecordBinding = (record) => {
|
|
105
|
+
if (!validateRecordBinding(record))
|
|
106
|
+
throw new TypeError("OIDC vault store record has invalid device/browser binding.");
|
|
107
|
+
};
|
|
108
|
+
var assertSessionBinding = (record) => {
|
|
109
|
+
if (record.deviceBinding !== void 0 && !validateDpopBinding(record.deviceBinding)) {
|
|
110
|
+
throw new TypeError("OIDC vault store session has invalid device binding.");
|
|
111
|
+
}
|
|
112
|
+
};
|
|
97
113
|
var isOptionalString = (value) => value === void 0 || isString(value);
|
|
98
114
|
var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
99
115
|
var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
|
|
100
116
|
var isStringRecord = (value) => isPlainRecord(value);
|
|
101
|
-
var validateAuthorizationTransaction = (value) => isPlainRecord(value) && isString(value.state) && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
|
|
102
|
-
var validateExchangeCodeRecord = (value) => isPlainRecord(value) && isString(value.code) && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
|
|
117
|
+
var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === state && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
|
|
118
|
+
var validateExchangeCodeRecord = (value, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
|
|
103
119
|
var validateProviderMetadata = (value) => value === void 0 || isPlainRecord(value) && isOptionalString(value.issuer) && isOptionalString(value.clientId);
|
|
104
|
-
var validateSession = (value) => isPlainRecord(value) && isString(value.sessionId) && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
|
|
120
|
+
var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === sessionId && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && (value.deviceBinding === void 0 || validateDpopBinding(value.deviceBinding)) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
|
|
105
121
|
var parseStoredJson = (value, recordKind, validate) => {
|
|
106
122
|
if (value === null) {
|
|
107
123
|
return null;
|
|
@@ -130,6 +146,228 @@ var evalCommand = (script, keys, args) => [
|
|
|
130
146
|
...keys,
|
|
131
147
|
...args
|
|
132
148
|
];
|
|
149
|
+
var BINDING_VALIDATION_HELPER = `
|
|
150
|
+
local function isHash(value)
|
|
151
|
+
return type(value) == 'string' and #value == 43
|
|
152
|
+
and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
|
|
153
|
+
and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
|
|
154
|
+
end
|
|
155
|
+
local function isBinding(value)
|
|
156
|
+
if type(value) ~= 'table' or value['type'] ~= 'dpop' or not isHash(value['jkt']) then return false end
|
|
157
|
+
local count = 0
|
|
158
|
+
for key, _ in pairs(value) do
|
|
159
|
+
if key ~= 'type' and key ~= 'jkt' then return false end
|
|
160
|
+
count = count + 1
|
|
161
|
+
end
|
|
162
|
+
return count == 2
|
|
163
|
+
end
|
|
164
|
+
local function validBinding(value)
|
|
165
|
+
return value == nil or isBinding(value)
|
|
166
|
+
end
|
|
167
|
+
local function validRecordBinding(record)
|
|
168
|
+
return validBinding(record['deviceBinding'])
|
|
169
|
+
and (record['browserBindingHash'] == nil or isHash(record['browserBindingHash']))
|
|
170
|
+
and (record['deviceBinding'] == nil or record['browserBindingHash'] ~= nil)
|
|
171
|
+
end
|
|
172
|
+
local function sameBinding(left, right)
|
|
173
|
+
if left == nil or right == nil then return left == right end
|
|
174
|
+
return isBinding(left) and isBinding(right) and left['jkt'] == right['jkt']
|
|
175
|
+
end
|
|
176
|
+
local function optionalString(value)
|
|
177
|
+
return value == nil or type(value) == 'string'
|
|
178
|
+
end
|
|
179
|
+
local function finiteNumber(value)
|
|
180
|
+
return type(value) == 'number' and value == value and value > -math.huge and value < math.huge
|
|
181
|
+
end
|
|
182
|
+
local function serverNow()
|
|
183
|
+
local time = redis.call('TIME')
|
|
184
|
+
return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
185
|
+
end
|
|
186
|
+
-- cjson decodes both {} and [] to a Lua table. Inspect the top-level value's
|
|
187
|
+
-- lexical kind so an empty provider array cannot masquerade as legacy {}.
|
|
188
|
+
-- Skip strings/escapes and nested containers; duplicate keys use the last
|
|
189
|
+
-- occurrence, matching cjson/JSON.parse. No credential values are returned.
|
|
190
|
+
local function optionalObjectField(raw, wanted)
|
|
191
|
+
local depth, position, shape = 0, 1, nil
|
|
192
|
+
while position <= #raw do
|
|
193
|
+
local char = string.sub(raw, position, position)
|
|
194
|
+
if char == '"' then
|
|
195
|
+
local start = position
|
|
196
|
+
position = position + 1
|
|
197
|
+
while position <= #raw do
|
|
198
|
+
local current = string.sub(raw, position, position)
|
|
199
|
+
if current == '\\\\' then position = position + 2
|
|
200
|
+
elseif current == '"' then break
|
|
201
|
+
else position = position + 1 end
|
|
202
|
+
end
|
|
203
|
+
if depth == 1 then
|
|
204
|
+
local after = position + 1
|
|
205
|
+
while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
|
|
206
|
+
if string.sub(raw, after, after) == ':' and cjson.decode(string.sub(raw, start, position)) == wanted then
|
|
207
|
+
after = after + 1
|
|
208
|
+
while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
|
|
209
|
+
shape = string.sub(raw, after, after)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
elseif char == '{' or char == '[' then depth = depth + 1
|
|
213
|
+
elseif char == '}' or char == ']' then depth = depth - 1 end
|
|
214
|
+
position = position + 1
|
|
215
|
+
end
|
|
216
|
+
return shape == nil or shape == '{'
|
|
217
|
+
end
|
|
218
|
+
`;
|
|
219
|
+
var CONSUME_GUARDED_RECORD_SCRIPT = `
|
|
220
|
+
${BINDING_VALIDATION_HELPER}
|
|
221
|
+
local raw = redis.call('GET', KEYS[1])
|
|
222
|
+
if raw == false then return false end
|
|
223
|
+
local ok, record = pcall(cjson.decode, raw)
|
|
224
|
+
local kind = ARGV[1]
|
|
225
|
+
local valid = ok and type(record) == 'table' and validRecordBinding(record)
|
|
226
|
+
if valid then
|
|
227
|
+
valid = optionalString(record['returnTo']) and finiteNumber(record['createdAt']) and finiteNumber(record['expiresAt'])
|
|
228
|
+
if kind == 'transaction' then
|
|
229
|
+
valid = valid and record['state'] == ARGV[2] and type(record['nonce']) == 'string'
|
|
230
|
+
and type(record['pkceVerifier']) == 'string' and type(record['codeChallenge']) == 'string'
|
|
231
|
+
and (record['metadata'] == nil or type(record['metadata']) == 'table')
|
|
232
|
+
else
|
|
233
|
+
valid = valid and record['code'] == ARGV[2] and type(record['sessionId']) == 'string'
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
if not valid then
|
|
237
|
+
redis.call('DEL', KEYS[1])
|
|
238
|
+
return false
|
|
239
|
+
end
|
|
240
|
+
-- Legacy consumes preserve the historical Redis key-TTL policy. Guarded
|
|
241
|
+
-- consumes additionally check payload expiry with server time (D5).
|
|
242
|
+
if ARGV[3] ~= 'legacy' and record['expiresAt'] <= serverNow() then
|
|
243
|
+
redis.call('DEL', KEYS[1])
|
|
244
|
+
return false
|
|
245
|
+
end
|
|
246
|
+
if ARGV[3] == 'legacy' then
|
|
247
|
+
if record['deviceBinding'] ~= nil or record['browserBindingHash'] ~= nil then return false end
|
|
248
|
+
else
|
|
249
|
+
local match = cjson.decode(ARGV[4])
|
|
250
|
+
if (match['deviceBinding'] ~= cjson.null and not isBinding(match['deviceBinding']))
|
|
251
|
+
or (match['browserBindingHash'] ~= cjson.null and not isHash(match['browserBindingHash'])) then return false end
|
|
252
|
+
if match['deviceBinding'] == cjson.null then
|
|
253
|
+
if record['deviceBinding'] ~= nil then return false end
|
|
254
|
+
elseif not sameBinding(record['deviceBinding'], match['deviceBinding']) then return false end
|
|
255
|
+
if match['browserBindingHash'] == cjson.null then
|
|
256
|
+
if record['browserBindingHash'] ~= nil then return false end
|
|
257
|
+
elseif record['browserBindingHash'] ~= match['browserBindingHash'] then return false end
|
|
258
|
+
if kind == 'exchange' and record['sessionId'] ~= ARGV[5] then return false end
|
|
259
|
+
end
|
|
260
|
+
redis.call('DEL', KEYS[1])
|
|
261
|
+
return raw
|
|
262
|
+
`;
|
|
263
|
+
var buildConsumeGuardedRecordCommand = (key, kind, id, match, expectedSessionId) => evalCommand(
|
|
264
|
+
CONSUME_GUARDED_RECORD_SCRIPT,
|
|
265
|
+
[key],
|
|
266
|
+
[
|
|
267
|
+
kind,
|
|
268
|
+
id,
|
|
269
|
+
match === void 0 ? "legacy" : "guarded",
|
|
270
|
+
match === void 0 ? "" : serialize(match),
|
|
271
|
+
expectedSessionId ?? ""
|
|
272
|
+
]
|
|
273
|
+
);
|
|
274
|
+
var RESERVE_DPOP_PROOF_SCRIPT = `
|
|
275
|
+
${BINDING_VALIDATION_HELPER}
|
|
276
|
+
local expiresAt = tonumber(ARGV[2])
|
|
277
|
+
local now = serverNow()
|
|
278
|
+
if not finiteNumber(expiresAt) or expiresAt ~= math.floor(expiresAt) or expiresAt > 9007199254740991
|
|
279
|
+
or expiresAt <= now or expiresAt - now > 360000 then return 0 end
|
|
280
|
+
local keyType = redis.call('TYPE', KEYS[1])['ok']
|
|
281
|
+
if keyType ~= 'none' and keyType ~= 'zset' then error('OIDC vault Redis replay key has unexpected type') end
|
|
282
|
+
local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
|
|
283
|
+
if existing ~= false and tonumber(existing) > now then return 0 end
|
|
284
|
+
-- Hard LIMIT, not SCAN's hint: at most 64 expired entries per admission.
|
|
285
|
+
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 64)
|
|
286
|
+
if #expired > 0 then redis.call('ZREM', KEYS[1], unpack(expired)) end
|
|
287
|
+
if existing ~= false then redis.call('ZREM', KEYS[1], ARGV[1]) end
|
|
288
|
+
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 2 end
|
|
289
|
+
redis.call('ZADD', KEYS[1], expiresAt, ARGV[1])
|
|
290
|
+
-- Reclaim the whole set if traffic stops after its last retained expiry.
|
|
291
|
+
local last = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
|
292
|
+
redis.call('PEXPIREAT', KEYS[1], last[2])
|
|
293
|
+
return 1
|
|
294
|
+
`;
|
|
295
|
+
var buildReserveDpopProofCommand = (keys, input, maxEntries) => evalCommand(
|
|
296
|
+
RESERVE_DPOP_PROOF_SCRIPT,
|
|
297
|
+
[keys.dpopProofs()],
|
|
298
|
+
[input.replayKey, String(input.expiresAt), String(maxEntries)]
|
|
299
|
+
);
|
|
300
|
+
var SESSION_REVOCATION_CONTEXT_SCRIPT = `
|
|
301
|
+
${BINDING_VALIDATION_HELPER}
|
|
302
|
+
local now = serverNow()
|
|
303
|
+
local handle = ARGV[1]
|
|
304
|
+
local sessionPrefix = ARGV[2]
|
|
305
|
+
local logicalPrefix = ARGV[3]
|
|
306
|
+
local function authority(raw, id)
|
|
307
|
+
local ok, record = pcall(cjson.decode, raw)
|
|
308
|
+
if not ok or type(record) ~= 'table' or record['sessionId'] ~= id
|
|
309
|
+
or not optionalString(record['logicalSessionId']) or type(record['subject']) ~= 'string'
|
|
310
|
+
or not validBinding(record['deviceBinding']) then error('OIDC vault Redis lineage has malformed session authority') end
|
|
311
|
+
local provider = record['provider']
|
|
312
|
+
if not optionalObjectField(raw, 'provider') or (provider ~= nil and (type(provider) ~= 'table' or not optionalString(provider['issuer'])
|
|
313
|
+
or not optionalString(provider['clientId']))) then error('OIDC vault Redis lineage has malformed provider identity') end
|
|
314
|
+
local expiry = record['expiresAt']
|
|
315
|
+
if expiry ~= nil and not finiteNumber(expiry) then error('OIDC vault Redis lineage has malformed session expiry') end
|
|
316
|
+
if expiry ~= nil and expiry <= now then return nil end
|
|
317
|
+
return record
|
|
318
|
+
end
|
|
319
|
+
local logicalId = nil
|
|
320
|
+
local source = nil
|
|
321
|
+
local primary = redis.call('GET', KEYS[1])
|
|
322
|
+
if primary ~= false then
|
|
323
|
+
local record = authority(primary, handle)
|
|
324
|
+
if record ~= nil then
|
|
325
|
+
logicalId = record['logicalSessionId'] or handle
|
|
326
|
+
-- The directly observed live handle must participate even if its reverse
|
|
327
|
+
-- index membership was corrupted; never infer unboundness from peers alone.
|
|
328
|
+
source = record
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
if logicalId == nil then
|
|
332
|
+
local alias = redis.call('GET', KEYS[2])
|
|
333
|
+
if alias == false then return false end
|
|
334
|
+
local ok, decoded = pcall(cjson.decode, alias)
|
|
335
|
+
if not ok or type(decoded) ~= 'string' then error('OIDC vault Redis store has malformed revocation alias') end
|
|
336
|
+
logicalId = decoded
|
|
337
|
+
end
|
|
338
|
+
local members = redis.call('ZRANGE', logicalPrefix .. logicalId, 0, -1)
|
|
339
|
+
for _, id in ipairs(members) do
|
|
340
|
+
local raw = redis.call('GET', sessionPrefix .. id)
|
|
341
|
+
if raw ~= false then
|
|
342
|
+
local member = authority(raw, id)
|
|
343
|
+
if member ~= nil and (member['logicalSessionId'] or id) == logicalId then
|
|
344
|
+
if source ~= nil then
|
|
345
|
+
local left = source['provider'] or {}
|
|
346
|
+
local right = member['provider'] or {}
|
|
347
|
+
if not sameBinding(source['deviceBinding'], member['deviceBinding']) or source['subject'] ~= member['subject']
|
|
348
|
+
or left['issuer'] ~= right['issuer'] or left['clientId'] ~= right['clientId'] then
|
|
349
|
+
error('OIDC vault Redis lineage has inconsistent device binding or provider identity')
|
|
350
|
+
end
|
|
351
|
+
else source = member end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
if source == nil then return false end
|
|
356
|
+
local result = { logicalSessionId = logicalId }
|
|
357
|
+
if source['deviceBinding'] ~= nil then result['deviceBinding'] = source['deviceBinding'] end
|
|
358
|
+
if source['provider'] ~= nil then
|
|
359
|
+
local provider = {}
|
|
360
|
+
provider['issuer'] = source['provider']['issuer']
|
|
361
|
+
provider['clientId'] = source['provider']['clientId']
|
|
362
|
+
result['provider'] = provider
|
|
363
|
+
end
|
|
364
|
+
return cjson.encode(result)
|
|
365
|
+
`;
|
|
366
|
+
var buildSessionRevocationContextCommand = (keys, sessionId) => evalCommand(
|
|
367
|
+
SESSION_REVOCATION_CONTEXT_SCRIPT,
|
|
368
|
+
[keys.session(sessionId), keys.rotatedSessionAlias(sessionId)],
|
|
369
|
+
[sessionId, keys.sessionPrefix(), keys.logicalSessionIndexPrefix()]
|
|
370
|
+
);
|
|
133
371
|
var scriptSha1 = (script) => (0, import_node_crypto.createHash)("sha1").update(script).digest("hex");
|
|
134
372
|
var NOSCRIPT_ERROR_PREFIX = "NOSCRIPT";
|
|
135
373
|
var isNoScriptError = (error) => {
|
|
@@ -267,7 +505,42 @@ end
|
|
|
267
505
|
|
|
268
506
|
return 1
|
|
269
507
|
`;
|
|
508
|
+
var LIVE_LINEAGE_HELPER = `
|
|
509
|
+
local function hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, excludedId)
|
|
510
|
+
local members = redis.call('ZRANGE', logicalIndexKey, 0, -1)
|
|
511
|
+
for _, memberId in ipairs(members) do
|
|
512
|
+
if memberId ~= excludedId then
|
|
513
|
+
local value = redis.call('GET', sessionKeyPrefix .. memberId)
|
|
514
|
+
if value ~= false then
|
|
515
|
+
local ok, member = pcall(cjson.decode, value)
|
|
516
|
+
if ok and type(member) == 'table' and member['sessionId'] == memberId
|
|
517
|
+
and (member['logicalSessionId'] or memberId) == logicalSessionId then
|
|
518
|
+
return true
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
return false
|
|
524
|
+
end
|
|
525
|
+
`;
|
|
526
|
+
var CLEANUP_INACTIVE_ALIASES_SCRIPT = `
|
|
527
|
+
${LIVE_LINEAGE_HELPER}
|
|
528
|
+
if hasLiveMember(KEYS[1], ARGV[1], ARGV[3], nil) then
|
|
529
|
+
return 0
|
|
530
|
+
end
|
|
531
|
+
local aliases = redis.call('ZRANGE', KEYS[2], 0, -1)
|
|
532
|
+
for _, aliasId in ipairs(aliases) do
|
|
533
|
+
redis.call('DEL', ARGV[2] .. aliasId)
|
|
534
|
+
end
|
|
535
|
+
return redis.call('DEL', KEYS[2])
|
|
536
|
+
`;
|
|
537
|
+
var buildCleanupInactiveAliasesCommand = (keys, logicalSessionId) => evalCommand(
|
|
538
|
+
CLEANUP_INACTIVE_ALIASES_SCRIPT,
|
|
539
|
+
[keys.logicalSessionIndex(logicalSessionId), keys.rotatedSessionAliasIndex(logicalSessionId)],
|
|
540
|
+
[keys.sessionPrefix(), keys.rotatedSessionAliasPrefix(), logicalSessionId]
|
|
541
|
+
);
|
|
270
542
|
var DELETE_SESSION_SCRIPT = `
|
|
543
|
+
${LIVE_LINEAGE_HELPER}
|
|
271
544
|
local expected = cjson.decode(ARGV[1])
|
|
272
545
|
local scopeKind = ARGV[2]
|
|
273
546
|
local scopeValue = ARGV[3]
|
|
@@ -347,7 +620,16 @@ local function deleteRecord(sessionKey, session)
|
|
|
347
620
|
assertZsetOrMissing(providerIndexKeyPrefix .. session['providerSessionId'])
|
|
348
621
|
end
|
|
349
622
|
|
|
350
|
-
|
|
623
|
+
-- Check actual primaries, not just scores: TTL is authoritative and stale
|
|
624
|
+
-- logical memberships must not keep a terminated lineage's aliases alive.
|
|
625
|
+
-- This check and deletion share the script's atomic boundary with rotation.
|
|
626
|
+
local logicalIndexKey = logicalIndexKeyPrefix .. logicalSessionId
|
|
627
|
+
local hasSurvivor = hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, sessionId)
|
|
628
|
+
|
|
629
|
+
local aliasSessionIds = {}
|
|
630
|
+
if not hasSurvivor then
|
|
631
|
+
aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
|
|
632
|
+
end
|
|
351
633
|
|
|
352
634
|
if redis.call('DEL', sessionKey) == 0 then
|
|
353
635
|
return 0
|
|
@@ -364,7 +646,9 @@ local function deleteRecord(sessionKey, session)
|
|
|
364
646
|
redis.call('DEL', aliasKeyPrefix .. aliasSessionId)
|
|
365
647
|
end
|
|
366
648
|
|
|
367
|
-
|
|
649
|
+
if not hasSurvivor then
|
|
650
|
+
redis.call('DEL', aliasIndexKey)
|
|
651
|
+
end
|
|
368
652
|
|
|
369
653
|
return 1
|
|
370
654
|
end
|
|
@@ -403,6 +687,7 @@ end
|
|
|
403
687
|
return deleted
|
|
404
688
|
`;
|
|
405
689
|
var ROTATE_SESSION_SCRIPT = `
|
|
690
|
+
${BINDING_VALIDATION_HELPER}
|
|
406
691
|
local newValue = ARGV[1]
|
|
407
692
|
local newExpiresAt = ARGV[2]
|
|
408
693
|
local oldSessionId = ARGV[3]
|
|
@@ -416,6 +701,7 @@ local oldAliasValue = ARGV[10]
|
|
|
416
701
|
local oldAliasExpiresAt = ARGV[11]
|
|
417
702
|
local aliasIndexKey = ARGV[12]
|
|
418
703
|
local aliasIndexKeyPrefix = ARGV[13]
|
|
704
|
+
local expectedSource = cjson.decode(ARGV[14])
|
|
419
705
|
|
|
420
706
|
local function assertStringOrMissing(key)
|
|
421
707
|
local keyType = redis.call('TYPE', key)['ok']
|
|
@@ -474,6 +760,25 @@ if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
|
474
760
|
return 0
|
|
475
761
|
end
|
|
476
762
|
|
|
763
|
+
local ok, source = pcall(cjson.decode, redis.call('GET', KEYS[1]))
|
|
764
|
+
local next = cjson.decode(newValue)
|
|
765
|
+
if not ok or type(source) ~= 'table' or source['sessionId'] ~= oldSessionId
|
|
766
|
+
or not validBinding(source['deviceBinding']) or not validBinding(next['deviceBinding']) then return 3 end
|
|
767
|
+
local sourceProvider = source['provider'] or {}
|
|
768
|
+
local expectedProvider = expectedSource['provider'] or {}
|
|
769
|
+
if type(sourceProvider) ~= 'table' or type(expectedProvider) ~= 'table' then return 3 end
|
|
770
|
+
-- Source authority CAS protects immutable binding and index ownership from
|
|
771
|
+
-- same-ID replacement between the application preflight and this script.
|
|
772
|
+
if (source['logicalSessionId'] or oldSessionId) ~= (expectedSource['logicalSessionId'] or oldSessionId)
|
|
773
|
+
or source['subject'] ~= expectedSource['subject'] or source['providerSessionId'] ~= expectedSource['providerSessionId']
|
|
774
|
+
or sourceProvider['issuer'] ~= expectedProvider['issuer'] or sourceProvider['clientId'] ~= expectedProvider['clientId']
|
|
775
|
+
or source['refreshToken'] ~= expectedSource['refreshToken'] or source['idToken'] ~= expectedSource['idToken']
|
|
776
|
+
or source['accessToken'] ~= expectedSource['accessToken'] or source['scope'] ~= expectedSource['scope']
|
|
777
|
+
or source['createdAt'] ~= expectedSource['createdAt'] or source['updatedAt'] ~= expectedSource['updatedAt']
|
|
778
|
+
or source['expiresAt'] ~= expectedSource['expiresAt']
|
|
779
|
+
or not sameBinding(source['deviceBinding'], expectedSource['deviceBinding'])
|
|
780
|
+
or not sameBinding(source['deviceBinding'], next['deviceBinding']) then return 3 end
|
|
781
|
+
|
|
477
782
|
if oldSessionId == newSessionId then
|
|
478
783
|
return 2
|
|
479
784
|
end
|
|
@@ -526,6 +831,30 @@ redis.call('ZADD', aliasIndexKey, newScore, oldSessionId)
|
|
|
526
831
|
|
|
527
832
|
return 1
|
|
528
833
|
`;
|
|
834
|
+
var COMPARE_AND_DELETE_SCRIPT = `
|
|
835
|
+
local current = redis.call('GET', KEYS[1])
|
|
836
|
+
|
|
837
|
+
if current == ARGV[1] then
|
|
838
|
+
return redis.call('DEL', KEYS[1])
|
|
839
|
+
else
|
|
840
|
+
return 0
|
|
841
|
+
end
|
|
842
|
+
`;
|
|
843
|
+
var buildCompareAndDeleteCommand = (key, observedValue) => evalCommand(COMPARE_AND_DELETE_SCRIPT, [key], [observedValue]);
|
|
844
|
+
var REPAIR_SESSION_INDEX_SCRIPT = `
|
|
845
|
+
local current = redis.call('GET', KEYS[1])
|
|
846
|
+
|
|
847
|
+
if current == false or (ARGV[3] == '1' and current == ARGV[2]) then
|
|
848
|
+
return redis.call('ZREM', KEYS[2], ARGV[1])
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
return 0
|
|
852
|
+
`;
|
|
853
|
+
var buildRepairSessionIndexCommand = (sessionKey, indexKey, sessionId, observedValue) => evalCommand(
|
|
854
|
+
REPAIR_SESSION_INDEX_SCRIPT,
|
|
855
|
+
[sessionKey, indexKey],
|
|
856
|
+
[sessionId, observedValue ?? "", observedValue === null ? "0" : "1"]
|
|
857
|
+
);
|
|
529
858
|
var buildWriteSessionCommand = (keys, session) => evalCommand(
|
|
530
859
|
WRITE_SESSION_SCRIPT,
|
|
531
860
|
[
|
|
@@ -583,18 +912,38 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
|
|
|
583
912
|
serialize(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
584
913
|
serializeExpiresAt(nextSession.expiresAt),
|
|
585
914
|
keys.rotatedSessionAliasIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
586
|
-
keys.rotatedSessionAliasIndexPrefix()
|
|
915
|
+
keys.rotatedSessionAliasIndexPrefix(),
|
|
916
|
+
serialize(previousSession)
|
|
587
917
|
]
|
|
588
918
|
);
|
|
589
919
|
|
|
590
920
|
// src/index.ts
|
|
591
921
|
var INDEX_CLEANUP_SCAN_COUNT = 100;
|
|
592
922
|
var INDEX_REVOCATION_SCAN_COUNT = 250;
|
|
923
|
+
var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
924
|
+
if (input === null || typeof input !== "object") return input;
|
|
925
|
+
const prototype = Object.getPrototypeOf(input);
|
|
926
|
+
if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
|
|
927
|
+
if (seen.has(input)) return seen.get(input);
|
|
928
|
+
const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
|
|
929
|
+
seen.set(input, copy);
|
|
930
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
931
|
+
if (Object.prototype.propertyIsEnumerable.call(input, key)) {
|
|
932
|
+
Object.defineProperty(copy, key, {
|
|
933
|
+
value: snapshotInput(Reflect.get(input, key), seen),
|
|
934
|
+
enumerable: true,
|
|
935
|
+
configurable: true,
|
|
936
|
+
writable: true
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return copy;
|
|
941
|
+
};
|
|
593
942
|
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
594
943
|
var isUnsupportedClusterClient = (client) => isRecord(client) && typeof client.getSlotMaster === "function" && typeof client.nodeClient === "function" && Array.isArray(client.masters) && Array.isArray(client.slots);
|
|
595
|
-
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
|
|
596
|
-
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
|
|
597
|
-
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
|
|
944
|
+
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
|
|
945
|
+
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
|
|
946
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
|
|
598
947
|
var matchesProviderScope = (session, input) => {
|
|
599
948
|
if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
|
|
600
949
|
return false;
|
|
@@ -610,7 +959,12 @@ var RedisOidcVaultStore = class {
|
|
|
610
959
|
now;
|
|
611
960
|
scriptRunner;
|
|
612
961
|
indexCleanupCursor = "0";
|
|
962
|
+
dpopReplayMaxEntries;
|
|
613
963
|
constructor(options) {
|
|
964
|
+
this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
|
|
965
|
+
if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
|
|
966
|
+
throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
|
|
967
|
+
}
|
|
614
968
|
if (typeof options.client.sendCommand !== "function") {
|
|
615
969
|
throw new Error("Redis store client must implement sendCommand(args) for atomic vault operations.");
|
|
616
970
|
}
|
|
@@ -625,22 +979,109 @@ var RedisOidcVaultStore = class {
|
|
|
625
979
|
this.scriptRunner = new RedisScriptRunner(options.client);
|
|
626
980
|
}
|
|
627
981
|
async createAuthorizationTransaction(input) {
|
|
982
|
+
input = snapshotInput({ ...input });
|
|
983
|
+
assertRecordBinding(input);
|
|
628
984
|
await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
|
|
629
985
|
}
|
|
630
986
|
async consumeAuthorizationTransaction(state) {
|
|
631
|
-
|
|
987
|
+
const value = await this.runScript(
|
|
988
|
+
buildConsumeGuardedRecordCommand(this.keys.authorizationTransaction(state), "transaction", state)
|
|
989
|
+
);
|
|
990
|
+
return this.parseOneTimeRecord(
|
|
991
|
+
value,
|
|
992
|
+
"authorization transaction",
|
|
993
|
+
(value2) => validateAuthorizationTransaction(value2, state)
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
async getAuthorizationTransaction(state) {
|
|
997
|
+
return this.getJson(
|
|
632
998
|
this.keys.authorizationTransaction(state),
|
|
633
999
|
"authorization transaction",
|
|
634
|
-
validateAuthorizationTransaction
|
|
1000
|
+
(value) => validateAuthorizationTransaction(value, state),
|
|
1001
|
+
{ deleteMalformed: true, checkExpiry: true }
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
async consumeAuthorizationTransactionIfMatches(input) {
|
|
1005
|
+
input = snapshotInput({ ...input });
|
|
1006
|
+
if (!validateBindingMatch(input.match)) return null;
|
|
1007
|
+
const value = await this.runScript(
|
|
1008
|
+
buildConsumeGuardedRecordCommand(
|
|
1009
|
+
this.keys.authorizationTransaction(input.state),
|
|
1010
|
+
"transaction",
|
|
1011
|
+
input.state,
|
|
1012
|
+
input.match
|
|
1013
|
+
)
|
|
1014
|
+
);
|
|
1015
|
+
return this.parseOneTimeRecord(
|
|
1016
|
+
value,
|
|
1017
|
+
"authorization transaction",
|
|
1018
|
+
(value2) => validateAuthorizationTransaction(value2, input.state)
|
|
635
1019
|
);
|
|
636
1020
|
}
|
|
637
1021
|
async createExchangeCode(input) {
|
|
1022
|
+
input = snapshotInput({ ...input });
|
|
1023
|
+
assertRecordBinding(input);
|
|
638
1024
|
await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
|
|
639
1025
|
}
|
|
640
1026
|
async consumeExchangeCode(code) {
|
|
641
|
-
|
|
1027
|
+
const value = await this.runScript(
|
|
1028
|
+
buildConsumeGuardedRecordCommand(this.keys.exchangeCode(code), "exchange", code)
|
|
1029
|
+
);
|
|
1030
|
+
return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, code));
|
|
1031
|
+
}
|
|
1032
|
+
async getExchangeCode(code) {
|
|
1033
|
+
return this.getJson(
|
|
1034
|
+
this.keys.exchangeCode(code),
|
|
1035
|
+
"exchange code",
|
|
1036
|
+
(value) => validateExchangeCodeRecord(value, code),
|
|
1037
|
+
{ deleteMalformed: true, checkExpiry: true }
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
async consumeExchangeCodeIfMatches(input) {
|
|
1041
|
+
input = snapshotInput({ ...input });
|
|
1042
|
+
if (!validateBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
|
|
1043
|
+
const value = await this.runScript(
|
|
1044
|
+
buildConsumeGuardedRecordCommand(
|
|
1045
|
+
this.keys.exchangeCode(input.code),
|
|
1046
|
+
"exchange",
|
|
1047
|
+
input.code,
|
|
1048
|
+
input.match,
|
|
1049
|
+
input.expectedSessionId
|
|
1050
|
+
)
|
|
1051
|
+
);
|
|
1052
|
+
return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, input.code));
|
|
642
1053
|
}
|
|
1054
|
+
async getSessionRevocationContext(sessionId) {
|
|
1055
|
+
const raw = await this.runScript(buildSessionRevocationContextCommand(this.keys, sessionId));
|
|
1056
|
+
if (typeof raw !== "string") return null;
|
|
1057
|
+
const context = JSON.parse(raw);
|
|
1058
|
+
if (Array.isArray(context.provider) && context.provider.length === 0) context.provider = {};
|
|
1059
|
+
return context;
|
|
1060
|
+
}
|
|
1061
|
+
async reserveDpopProof(input) {
|
|
1062
|
+
input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
|
|
1063
|
+
if (typeof input.replayKey !== "string" || !Number.isSafeInteger(input.expiresAt)) return false;
|
|
1064
|
+
const result = await this.runScript(buildReserveDpopProofCommand(this.keys, input, this.dpopReplayMaxEntries));
|
|
1065
|
+
if (result === 2 || result === "2") throw new import_express_oidc_vault.OidcVaultDpopReplayCapacityError();
|
|
1066
|
+
return result === 1 || result === "1";
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Creates a session record atomically, then runs best-effort index
|
|
1070
|
+
* maintenance.
|
|
1071
|
+
*
|
|
1072
|
+
* Result policy (SVH-02): once `writeSessionRecord` reports success the
|
|
1073
|
+
* session is committed and this method resolves with the session even if
|
|
1074
|
+
* post-commit index maintenance (`SCAN`/`TYPE`/`TIME`/`ZREMRANGEBYSCORE`)
|
|
1075
|
+
* fails. A maintenance failure is reported once via a fixed-text
|
|
1076
|
+
* `console.warn` identifying only the operation (no adapter error details,
|
|
1077
|
+
* session IDs, keys, or token material) and is retried opportunistically by
|
|
1078
|
+
* a later `createSession`/`rotateSession` call. Mutation-command failures (the
|
|
1079
|
+
* atomic write script) still reject, and this method never retries a
|
|
1080
|
+
* committed mutation.
|
|
1081
|
+
*/
|
|
643
1082
|
async createSession(input) {
|
|
1083
|
+
input = snapshotInput({ ...input });
|
|
1084
|
+
assertSessionBinding(input);
|
|
644
1085
|
const timestamp = this.now();
|
|
645
1086
|
const session = {
|
|
646
1087
|
...input,
|
|
@@ -652,13 +1093,17 @@ var RedisOidcVaultStore = class {
|
|
|
652
1093
|
if (!written) {
|
|
653
1094
|
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session already exists.");
|
|
654
1095
|
}
|
|
655
|
-
await this.
|
|
1096
|
+
await this.runPostCommitIndexMaintenance("createSession");
|
|
656
1097
|
return session;
|
|
657
1098
|
}
|
|
658
1099
|
async getSession(sessionId) {
|
|
659
|
-
return this.getJson(this.keys.session(sessionId), "session", validateSession, {
|
|
1100
|
+
return this.getJson(this.keys.session(sessionId), "session", (value) => validateSession(value, sessionId), {
|
|
1101
|
+
deleteMalformed: true
|
|
1102
|
+
});
|
|
660
1103
|
}
|
|
661
1104
|
async rotateSession(input) {
|
|
1105
|
+
input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
|
|
1106
|
+
assertSessionBinding(input.nextSession);
|
|
662
1107
|
const previousSession = await this.getSession(input.sessionId);
|
|
663
1108
|
if (!previousSession) {
|
|
664
1109
|
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
@@ -666,15 +1111,19 @@ var RedisOidcVaultStore = class {
|
|
|
666
1111
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
667
1112
|
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
668
1113
|
}
|
|
1114
|
+
if (input.nextSession.deviceBinding !== void 0 && input.nextSession.deviceBinding.jkt !== previousSession.deviceBinding?.jkt) {
|
|
1115
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
|
|
1116
|
+
}
|
|
669
1117
|
const nextSession = {
|
|
670
1118
|
...input.nextSession,
|
|
1119
|
+
...previousSession.deviceBinding === void 0 ? {} : { deviceBinding: { ...previousSession.deviceBinding } },
|
|
671
1120
|
logicalSessionId: input.nextSession.logicalSessionId ?? previousSession.logicalSessionId ?? previousSession.sessionId
|
|
672
1121
|
};
|
|
673
1122
|
const rotated = await this.rotateSessionRecord(previousSession, nextSession);
|
|
674
1123
|
if (!rotated) {
|
|
675
1124
|
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
676
1125
|
}
|
|
677
|
-
await this.
|
|
1126
|
+
await this.runPostCommitIndexMaintenance("rotateSession");
|
|
678
1127
|
return nextSession;
|
|
679
1128
|
}
|
|
680
1129
|
async deleteSession(sessionId) {
|
|
@@ -688,13 +1137,11 @@ var RedisOidcVaultStore = class {
|
|
|
688
1137
|
);
|
|
689
1138
|
if (logicalSessionId) {
|
|
690
1139
|
await this.deleteSessionsByLogicalSessionId(logicalSessionId);
|
|
691
|
-
await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
|
|
692
1140
|
}
|
|
693
|
-
await this.
|
|
1141
|
+
await this.deleteSessionKeyIfMalformed(sessionId);
|
|
694
1142
|
return;
|
|
695
1143
|
}
|
|
696
1144
|
await this.deleteSessionRecord(session, { kind: "single" });
|
|
697
|
-
await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
|
|
698
1145
|
}
|
|
699
1146
|
async deleteSessionsByLogicalSessionId(input) {
|
|
700
1147
|
const resolved = toLogicalSessionDeleteInput(input);
|
|
@@ -724,18 +1171,21 @@ var RedisOidcVaultStore = class {
|
|
|
724
1171
|
await this.client.set(key, serialize(value), options);
|
|
725
1172
|
}
|
|
726
1173
|
async getJson(key, recordKind, validate, options) {
|
|
1174
|
+
const raw = await this.client.get(key);
|
|
727
1175
|
try {
|
|
728
|
-
|
|
1176
|
+
const record = parseStoredJson(raw, recordKind, validate);
|
|
1177
|
+
if (record !== null && options?.checkExpiry && isPlainRecord(record) && typeof record.expiresAt === "number" && record.expiresAt <= await this.redisServerTime())
|
|
1178
|
+
return null;
|
|
1179
|
+
return record;
|
|
729
1180
|
} catch (error) {
|
|
730
|
-
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
|
|
731
|
-
await this.
|
|
1181
|
+
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
|
|
1182
|
+
await this.deleteMalformedValueIfUnchanged(key, raw);
|
|
732
1183
|
return null;
|
|
733
1184
|
}
|
|
734
1185
|
throw error;
|
|
735
1186
|
}
|
|
736
1187
|
}
|
|
737
|
-
|
|
738
|
-
const value = await this.sendCommand(["GETDEL", key]);
|
|
1188
|
+
parseOneTimeRecord(value, recordKind, validate) {
|
|
739
1189
|
try {
|
|
740
1190
|
return typeof value === "string" ? parseStoredJson(value, recordKind, validate) : null;
|
|
741
1191
|
} catch (error) {
|
|
@@ -753,11 +1203,51 @@ var RedisOidcVaultStore = class {
|
|
|
753
1203
|
const result = await this.runScript(buildDeleteSessionCommand(this.keys, session, scope));
|
|
754
1204
|
return typeof result === "number" ? result : Number(result);
|
|
755
1205
|
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Removes a stored value only when it still equals the malformed payload
|
|
1208
|
+
* observed by a prior read (SVH-03). The equality check runs inside the Lua
|
|
1209
|
+
* script, so a concurrent `createSession` reusing the same ID is never
|
|
1210
|
+
* destroyed by a stale repair.
|
|
1211
|
+
*/
|
|
1212
|
+
async deleteMalformedValueIfUnchanged(key, observedRaw) {
|
|
1213
|
+
await this.runScript(buildCompareAndDeleteCommand(key, observedRaw));
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Best-effort cleanup for the missing-session branch of `deleteSession`.
|
|
1217
|
+
* Re-reads the session key and removes it only when it still holds malformed
|
|
1218
|
+
* data. A missing key needs no cleanup and a freshly created valid session
|
|
1219
|
+
* (same-ID reuse after the earlier read) is preserved; genuine logout of a
|
|
1220
|
+
* live session never reaches this branch.
|
|
1221
|
+
*/
|
|
1222
|
+
async deleteSessionKeyIfMalformed(sessionId) {
|
|
1223
|
+
const sessionKey = this.keys.session(sessionId);
|
|
1224
|
+
let raw;
|
|
1225
|
+
try {
|
|
1226
|
+
raw = await this.client.get(sessionKey);
|
|
1227
|
+
} catch {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (raw === null) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
parseStoredJson(raw, "session", (value) => validateSession(value, sessionId));
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
1237
|
+
await this.deleteMalformedValueIfUnchanged(sessionKey, raw);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
756
1243
|
async rotateSessionRecord(previousSession, nextSession) {
|
|
757
1244
|
const result = await this.runScript(buildRotateSessionCommand(this.keys, previousSession, nextSession));
|
|
758
1245
|
if (result === 2 || result === "2") {
|
|
759
1246
|
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
760
1247
|
}
|
|
1248
|
+
if (result === 3 || result === "3") {
|
|
1249
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
|
|
1250
|
+
}
|
|
761
1251
|
return result === 1 || result === "1";
|
|
762
1252
|
}
|
|
763
1253
|
async runScript(command) {
|
|
@@ -766,6 +1256,20 @@ var RedisOidcVaultStore = class {
|
|
|
766
1256
|
async cleanupExpiredIndexMembers(indexKey) {
|
|
767
1257
|
await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(await this.redisServerTime())]);
|
|
768
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Runs optional post-commit index maintenance without letting it masquerade
|
|
1261
|
+
* as a failed mutation. Any failure is reported once via a fixed-text
|
|
1262
|
+
* warning and swallowed so the already-committed session result stands; the
|
|
1263
|
+
* next successful create/rotate retries the incremental scan from the
|
|
1264
|
+
* retained cursor. Never retries the committed mutation itself.
|
|
1265
|
+
*/
|
|
1266
|
+
async runPostCommitIndexMaintenance(operation) {
|
|
1267
|
+
try {
|
|
1268
|
+
await this.cleanupStaleIndexKeys();
|
|
1269
|
+
} catch {
|
|
1270
|
+
console.warn(`OIDC vault Redis index maintenance failed after ${operation} and will retry on a later write.`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
769
1273
|
async cleanupStaleIndexKeys() {
|
|
770
1274
|
const response = await this.sendCommand([
|
|
771
1275
|
"SCAN",
|
|
@@ -816,10 +1320,6 @@ var RedisOidcVaultStore = class {
|
|
|
816
1320
|
}
|
|
817
1321
|
return seconds * 1e3 + Math.floor(microseconds / 1e3);
|
|
818
1322
|
}
|
|
819
|
-
async getSessionIdsFromIndex(indexKey) {
|
|
820
|
-
const response = await this.sendCommand(["ZRANGE", indexKey, "0", "-1"]);
|
|
821
|
-
return Array.isArray(response) ? response.filter((value) => typeof value === "string") : [];
|
|
822
|
-
}
|
|
823
1323
|
async scanSessionIdsFromIndex(indexKey, cursor) {
|
|
824
1324
|
const response = await this.sendCommand(["ZSCAN", indexKey, cursor, "COUNT", String(INDEX_REVOCATION_SCAN_COUNT)]);
|
|
825
1325
|
if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
|
|
@@ -844,21 +1344,25 @@ var RedisOidcVaultStore = class {
|
|
|
844
1344
|
throw new Error("OIDC vault Redis store received an unexpected MGET response.");
|
|
845
1345
|
}
|
|
846
1346
|
return Promise.all(
|
|
847
|
-
|
|
1347
|
+
sessionIds.map(async (sessionId, index) => {
|
|
1348
|
+
const value = response[index];
|
|
1349
|
+
const raw = typeof value === "string" ? value : null;
|
|
848
1350
|
try {
|
|
849
|
-
return
|
|
1351
|
+
return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
|
|
850
1352
|
} catch (error) {
|
|
851
|
-
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
852
|
-
await this.
|
|
853
|
-
return null;
|
|
1353
|
+
if (error instanceof OidcVaultRedisStoreRecordError && typeof value === "string") {
|
|
1354
|
+
await this.deleteMalformedValueIfUnchanged(this.keys.session(sessionId), value);
|
|
1355
|
+
return { session: null, raw };
|
|
854
1356
|
}
|
|
855
1357
|
throw error;
|
|
856
1358
|
}
|
|
857
1359
|
})
|
|
858
1360
|
);
|
|
859
1361
|
}
|
|
860
|
-
async removeSessionIdFromIndex(indexKey, sessionId) {
|
|
861
|
-
await this.
|
|
1362
|
+
async removeSessionIdFromIndex(indexKey, sessionId, observedRaw) {
|
|
1363
|
+
await this.runScript(
|
|
1364
|
+
buildRepairSessionIndexCommand(this.keys.session(sessionId), indexKey, sessionId, observedRaw)
|
|
1365
|
+
);
|
|
862
1366
|
}
|
|
863
1367
|
async deleteSessionsFromIndex(indexKey, scope) {
|
|
864
1368
|
await this.cleanupExpiredIndexMembers(indexKey);
|
|
@@ -872,21 +1376,21 @@ var RedisOidcVaultStore = class {
|
|
|
872
1376
|
}
|
|
873
1377
|
const sessions = await this.getSessionRecords(batch.sessionIds);
|
|
874
1378
|
for (const [index, sessionId] of batch.sessionIds.entries()) {
|
|
875
|
-
const session = sessions[index];
|
|
1379
|
+
const { session, raw } = sessions[index] ?? { session: null, raw: null };
|
|
876
1380
|
if (!session) {
|
|
877
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1381
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
878
1382
|
continue;
|
|
879
1383
|
}
|
|
880
1384
|
if ("logicalSessionId" in scope && (session.logicalSessionId ?? session.sessionId) !== scope.logicalSessionId) {
|
|
881
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1385
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
882
1386
|
continue;
|
|
883
1387
|
}
|
|
884
1388
|
if ("subject" in scope && session.subject !== scope.subject) {
|
|
885
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1389
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
886
1390
|
continue;
|
|
887
1391
|
}
|
|
888
1392
|
if ("providerSessionId" in scope && session.providerSessionId !== scope.providerSessionId) {
|
|
889
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1393
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
890
1394
|
continue;
|
|
891
1395
|
}
|
|
892
1396
|
if (!("logicalSessionId" in scope) && !matchesProviderScope(session, scope)) {
|
|
@@ -894,11 +1398,13 @@ var RedisOidcVaultStore = class {
|
|
|
894
1398
|
}
|
|
895
1399
|
const revoked = await this.deleteSessionRecord(session, this.toDeleteScriptScope(scope));
|
|
896
1400
|
if (revoked > 0) {
|
|
897
|
-
await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
|
|
898
1401
|
deleted += revoked;
|
|
899
1402
|
}
|
|
900
1403
|
}
|
|
901
1404
|
} while (cursor !== "0");
|
|
1405
|
+
if (deleted === 0 && "logicalSessionId" in scope) {
|
|
1406
|
+
await this.runScript(buildCleanupInactiveAliasesCommand(this.keys, scope.logicalSessionId));
|
|
1407
|
+
}
|
|
902
1408
|
return deleted;
|
|
903
1409
|
}
|
|
904
1410
|
toDeleteScriptScope(scope) {
|
|
@@ -915,16 +1421,6 @@ var RedisOidcVaultStore = class {
|
|
|
915
1421
|
clientId: scope.clientId
|
|
916
1422
|
};
|
|
917
1423
|
}
|
|
918
|
-
async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
|
|
919
|
-
const aliasIndexKey = this.keys.rotatedSessionAliasIndex(logicalSessionId);
|
|
920
|
-
await this.cleanupExpiredIndexMembers(aliasIndexKey);
|
|
921
|
-
const aliasSessionIds = await this.getSessionIdsFromIndex(aliasIndexKey);
|
|
922
|
-
if (aliasSessionIds.length === 0) {
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
await this.client.del(aliasSessionIds.map((sessionId) => this.keys.rotatedSessionAlias(sessionId)));
|
|
926
|
-
await this.client.del(aliasIndexKey);
|
|
927
|
-
}
|
|
928
1424
|
async sendCommand(args) {
|
|
929
1425
|
return this.client.sendCommand(args);
|
|
930
1426
|
}
|