@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.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import {
|
|
3
|
-
OidcVaultStoreConflictError
|
|
3
|
+
OidcVaultStoreConflictError,
|
|
4
|
+
OidcVaultDpopReplayCapacityError
|
|
4
5
|
} from "@web-ts-toolkit/express-oidc-vault";
|
|
5
6
|
|
|
6
7
|
// src/keys.ts
|
|
@@ -44,6 +45,9 @@ var RedisOidcVaultStoreKeys = class {
|
|
|
44
45
|
backchannelLogoutTokenJti(jti) {
|
|
45
46
|
return prefixKey(this.keyPrefix, "backchannel-logout-jti", jti);
|
|
46
47
|
}
|
|
48
|
+
dpopProofs() {
|
|
49
|
+
return `${this.keyPrefix}:dpop-proofs`;
|
|
50
|
+
}
|
|
47
51
|
rotatedSessionAliasIndex(logicalSessionId) {
|
|
48
52
|
return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
|
|
49
53
|
}
|
|
@@ -71,14 +75,27 @@ var OidcVaultRedisStoreRecordError = class extends Error {
|
|
|
71
75
|
};
|
|
72
76
|
var isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
73
77
|
var isString = (value) => typeof value === "string";
|
|
78
|
+
var isBindingHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
|
|
79
|
+
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);
|
|
80
|
+
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);
|
|
81
|
+
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));
|
|
82
|
+
var assertRecordBinding = (record) => {
|
|
83
|
+
if (!validateRecordBinding(record))
|
|
84
|
+
throw new TypeError("OIDC vault store record has invalid device/browser binding.");
|
|
85
|
+
};
|
|
86
|
+
var assertSessionBinding = (record) => {
|
|
87
|
+
if (record.deviceBinding !== void 0 && !validateDpopBinding(record.deviceBinding)) {
|
|
88
|
+
throw new TypeError("OIDC vault store session has invalid device binding.");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
74
91
|
var isOptionalString = (value) => value === void 0 || isString(value);
|
|
75
92
|
var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
76
93
|
var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
|
|
77
94
|
var isStringRecord = (value) => isPlainRecord(value);
|
|
78
|
-
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));
|
|
79
|
-
var validateExchangeCodeRecord = (value) => isPlainRecord(value) && isString(value.code) && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
|
|
95
|
+
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));
|
|
96
|
+
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);
|
|
80
97
|
var validateProviderMetadata = (value) => value === void 0 || isPlainRecord(value) && isOptionalString(value.issuer) && isOptionalString(value.clientId);
|
|
81
|
-
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));
|
|
98
|
+
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));
|
|
82
99
|
var parseStoredJson = (value, recordKind, validate) => {
|
|
83
100
|
if (value === null) {
|
|
84
101
|
return null;
|
|
@@ -107,6 +124,228 @@ var evalCommand = (script, keys, args) => [
|
|
|
107
124
|
...keys,
|
|
108
125
|
...args
|
|
109
126
|
];
|
|
127
|
+
var BINDING_VALIDATION_HELPER = `
|
|
128
|
+
local function isHash(value)
|
|
129
|
+
return type(value) == 'string' and #value == 43
|
|
130
|
+
and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
|
|
131
|
+
and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
|
|
132
|
+
end
|
|
133
|
+
local function isBinding(value)
|
|
134
|
+
if type(value) ~= 'table' or value['type'] ~= 'dpop' or not isHash(value['jkt']) then return false end
|
|
135
|
+
local count = 0
|
|
136
|
+
for key, _ in pairs(value) do
|
|
137
|
+
if key ~= 'type' and key ~= 'jkt' then return false end
|
|
138
|
+
count = count + 1
|
|
139
|
+
end
|
|
140
|
+
return count == 2
|
|
141
|
+
end
|
|
142
|
+
local function validBinding(value)
|
|
143
|
+
return value == nil or isBinding(value)
|
|
144
|
+
end
|
|
145
|
+
local function validRecordBinding(record)
|
|
146
|
+
return validBinding(record['deviceBinding'])
|
|
147
|
+
and (record['browserBindingHash'] == nil or isHash(record['browserBindingHash']))
|
|
148
|
+
and (record['deviceBinding'] == nil or record['browserBindingHash'] ~= nil)
|
|
149
|
+
end
|
|
150
|
+
local function sameBinding(left, right)
|
|
151
|
+
if left == nil or right == nil then return left == right end
|
|
152
|
+
return isBinding(left) and isBinding(right) and left['jkt'] == right['jkt']
|
|
153
|
+
end
|
|
154
|
+
local function optionalString(value)
|
|
155
|
+
return value == nil or type(value) == 'string'
|
|
156
|
+
end
|
|
157
|
+
local function finiteNumber(value)
|
|
158
|
+
return type(value) == 'number' and value == value and value > -math.huge and value < math.huge
|
|
159
|
+
end
|
|
160
|
+
local function serverNow()
|
|
161
|
+
local time = redis.call('TIME')
|
|
162
|
+
return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
163
|
+
end
|
|
164
|
+
-- cjson decodes both {} and [] to a Lua table. Inspect the top-level value's
|
|
165
|
+
-- lexical kind so an empty provider array cannot masquerade as legacy {}.
|
|
166
|
+
-- Skip strings/escapes and nested containers; duplicate keys use the last
|
|
167
|
+
-- occurrence, matching cjson/JSON.parse. No credential values are returned.
|
|
168
|
+
local function optionalObjectField(raw, wanted)
|
|
169
|
+
local depth, position, shape = 0, 1, nil
|
|
170
|
+
while position <= #raw do
|
|
171
|
+
local char = string.sub(raw, position, position)
|
|
172
|
+
if char == '"' then
|
|
173
|
+
local start = position
|
|
174
|
+
position = position + 1
|
|
175
|
+
while position <= #raw do
|
|
176
|
+
local current = string.sub(raw, position, position)
|
|
177
|
+
if current == '\\\\' then position = position + 2
|
|
178
|
+
elseif current == '"' then break
|
|
179
|
+
else position = position + 1 end
|
|
180
|
+
end
|
|
181
|
+
if depth == 1 then
|
|
182
|
+
local after = position + 1
|
|
183
|
+
while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
|
|
184
|
+
if string.sub(raw, after, after) == ':' and cjson.decode(string.sub(raw, start, position)) == wanted then
|
|
185
|
+
after = after + 1
|
|
186
|
+
while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
|
|
187
|
+
shape = string.sub(raw, after, after)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
elseif char == '{' or char == '[' then depth = depth + 1
|
|
191
|
+
elseif char == '}' or char == ']' then depth = depth - 1 end
|
|
192
|
+
position = position + 1
|
|
193
|
+
end
|
|
194
|
+
return shape == nil or shape == '{'
|
|
195
|
+
end
|
|
196
|
+
`;
|
|
197
|
+
var CONSUME_GUARDED_RECORD_SCRIPT = `
|
|
198
|
+
${BINDING_VALIDATION_HELPER}
|
|
199
|
+
local raw = redis.call('GET', KEYS[1])
|
|
200
|
+
if raw == false then return false end
|
|
201
|
+
local ok, record = pcall(cjson.decode, raw)
|
|
202
|
+
local kind = ARGV[1]
|
|
203
|
+
local valid = ok and type(record) == 'table' and validRecordBinding(record)
|
|
204
|
+
if valid then
|
|
205
|
+
valid = optionalString(record['returnTo']) and finiteNumber(record['createdAt']) and finiteNumber(record['expiresAt'])
|
|
206
|
+
if kind == 'transaction' then
|
|
207
|
+
valid = valid and record['state'] == ARGV[2] and type(record['nonce']) == 'string'
|
|
208
|
+
and type(record['pkceVerifier']) == 'string' and type(record['codeChallenge']) == 'string'
|
|
209
|
+
and (record['metadata'] == nil or type(record['metadata']) == 'table')
|
|
210
|
+
else
|
|
211
|
+
valid = valid and record['code'] == ARGV[2] and type(record['sessionId']) == 'string'
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
if not valid then
|
|
215
|
+
redis.call('DEL', KEYS[1])
|
|
216
|
+
return false
|
|
217
|
+
end
|
|
218
|
+
-- Legacy consumes preserve the historical Redis key-TTL policy. Guarded
|
|
219
|
+
-- consumes additionally check payload expiry with server time (D5).
|
|
220
|
+
if ARGV[3] ~= 'legacy' and record['expiresAt'] <= serverNow() then
|
|
221
|
+
redis.call('DEL', KEYS[1])
|
|
222
|
+
return false
|
|
223
|
+
end
|
|
224
|
+
if ARGV[3] == 'legacy' then
|
|
225
|
+
if record['deviceBinding'] ~= nil or record['browserBindingHash'] ~= nil then return false end
|
|
226
|
+
else
|
|
227
|
+
local match = cjson.decode(ARGV[4])
|
|
228
|
+
if (match['deviceBinding'] ~= cjson.null and not isBinding(match['deviceBinding']))
|
|
229
|
+
or (match['browserBindingHash'] ~= cjson.null and not isHash(match['browserBindingHash'])) then return false end
|
|
230
|
+
if match['deviceBinding'] == cjson.null then
|
|
231
|
+
if record['deviceBinding'] ~= nil then return false end
|
|
232
|
+
elseif not sameBinding(record['deviceBinding'], match['deviceBinding']) then return false end
|
|
233
|
+
if match['browserBindingHash'] == cjson.null then
|
|
234
|
+
if record['browserBindingHash'] ~= nil then return false end
|
|
235
|
+
elseif record['browserBindingHash'] ~= match['browserBindingHash'] then return false end
|
|
236
|
+
if kind == 'exchange' and record['sessionId'] ~= ARGV[5] then return false end
|
|
237
|
+
end
|
|
238
|
+
redis.call('DEL', KEYS[1])
|
|
239
|
+
return raw
|
|
240
|
+
`;
|
|
241
|
+
var buildConsumeGuardedRecordCommand = (key, kind, id, match, expectedSessionId) => evalCommand(
|
|
242
|
+
CONSUME_GUARDED_RECORD_SCRIPT,
|
|
243
|
+
[key],
|
|
244
|
+
[
|
|
245
|
+
kind,
|
|
246
|
+
id,
|
|
247
|
+
match === void 0 ? "legacy" : "guarded",
|
|
248
|
+
match === void 0 ? "" : serialize(match),
|
|
249
|
+
expectedSessionId ?? ""
|
|
250
|
+
]
|
|
251
|
+
);
|
|
252
|
+
var RESERVE_DPOP_PROOF_SCRIPT = `
|
|
253
|
+
${BINDING_VALIDATION_HELPER}
|
|
254
|
+
local expiresAt = tonumber(ARGV[2])
|
|
255
|
+
local now = serverNow()
|
|
256
|
+
if not finiteNumber(expiresAt) or expiresAt ~= math.floor(expiresAt) or expiresAt > 9007199254740991
|
|
257
|
+
or expiresAt <= now or expiresAt - now > 360000 then return 0 end
|
|
258
|
+
local keyType = redis.call('TYPE', KEYS[1])['ok']
|
|
259
|
+
if keyType ~= 'none' and keyType ~= 'zset' then error('OIDC vault Redis replay key has unexpected type') end
|
|
260
|
+
local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
|
|
261
|
+
if existing ~= false and tonumber(existing) > now then return 0 end
|
|
262
|
+
-- Hard LIMIT, not SCAN's hint: at most 64 expired entries per admission.
|
|
263
|
+
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 64)
|
|
264
|
+
if #expired > 0 then redis.call('ZREM', KEYS[1], unpack(expired)) end
|
|
265
|
+
if existing ~= false then redis.call('ZREM', KEYS[1], ARGV[1]) end
|
|
266
|
+
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 2 end
|
|
267
|
+
redis.call('ZADD', KEYS[1], expiresAt, ARGV[1])
|
|
268
|
+
-- Reclaim the whole set if traffic stops after its last retained expiry.
|
|
269
|
+
local last = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
|
270
|
+
redis.call('PEXPIREAT', KEYS[1], last[2])
|
|
271
|
+
return 1
|
|
272
|
+
`;
|
|
273
|
+
var buildReserveDpopProofCommand = (keys, input, maxEntries) => evalCommand(
|
|
274
|
+
RESERVE_DPOP_PROOF_SCRIPT,
|
|
275
|
+
[keys.dpopProofs()],
|
|
276
|
+
[input.replayKey, String(input.expiresAt), String(maxEntries)]
|
|
277
|
+
);
|
|
278
|
+
var SESSION_REVOCATION_CONTEXT_SCRIPT = `
|
|
279
|
+
${BINDING_VALIDATION_HELPER}
|
|
280
|
+
local now = serverNow()
|
|
281
|
+
local handle = ARGV[1]
|
|
282
|
+
local sessionPrefix = ARGV[2]
|
|
283
|
+
local logicalPrefix = ARGV[3]
|
|
284
|
+
local function authority(raw, id)
|
|
285
|
+
local ok, record = pcall(cjson.decode, raw)
|
|
286
|
+
if not ok or type(record) ~= 'table' or record['sessionId'] ~= id
|
|
287
|
+
or not optionalString(record['logicalSessionId']) or type(record['subject']) ~= 'string'
|
|
288
|
+
or not validBinding(record['deviceBinding']) then error('OIDC vault Redis lineage has malformed session authority') end
|
|
289
|
+
local provider = record['provider']
|
|
290
|
+
if not optionalObjectField(raw, 'provider') or (provider ~= nil and (type(provider) ~= 'table' or not optionalString(provider['issuer'])
|
|
291
|
+
or not optionalString(provider['clientId']))) then error('OIDC vault Redis lineage has malformed provider identity') end
|
|
292
|
+
local expiry = record['expiresAt']
|
|
293
|
+
if expiry ~= nil and not finiteNumber(expiry) then error('OIDC vault Redis lineage has malformed session expiry') end
|
|
294
|
+
if expiry ~= nil and expiry <= now then return nil end
|
|
295
|
+
return record
|
|
296
|
+
end
|
|
297
|
+
local logicalId = nil
|
|
298
|
+
local source = nil
|
|
299
|
+
local primary = redis.call('GET', KEYS[1])
|
|
300
|
+
if primary ~= false then
|
|
301
|
+
local record = authority(primary, handle)
|
|
302
|
+
if record ~= nil then
|
|
303
|
+
logicalId = record['logicalSessionId'] or handle
|
|
304
|
+
-- The directly observed live handle must participate even if its reverse
|
|
305
|
+
-- index membership was corrupted; never infer unboundness from peers alone.
|
|
306
|
+
source = record
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
if logicalId == nil then
|
|
310
|
+
local alias = redis.call('GET', KEYS[2])
|
|
311
|
+
if alias == false then return false end
|
|
312
|
+
local ok, decoded = pcall(cjson.decode, alias)
|
|
313
|
+
if not ok or type(decoded) ~= 'string' then error('OIDC vault Redis store has malformed revocation alias') end
|
|
314
|
+
logicalId = decoded
|
|
315
|
+
end
|
|
316
|
+
local members = redis.call('ZRANGE', logicalPrefix .. logicalId, 0, -1)
|
|
317
|
+
for _, id in ipairs(members) do
|
|
318
|
+
local raw = redis.call('GET', sessionPrefix .. id)
|
|
319
|
+
if raw ~= false then
|
|
320
|
+
local member = authority(raw, id)
|
|
321
|
+
if member ~= nil and (member['logicalSessionId'] or id) == logicalId then
|
|
322
|
+
if source ~= nil then
|
|
323
|
+
local left = source['provider'] or {}
|
|
324
|
+
local right = member['provider'] or {}
|
|
325
|
+
if not sameBinding(source['deviceBinding'], member['deviceBinding']) or source['subject'] ~= member['subject']
|
|
326
|
+
or left['issuer'] ~= right['issuer'] or left['clientId'] ~= right['clientId'] then
|
|
327
|
+
error('OIDC vault Redis lineage has inconsistent device binding or provider identity')
|
|
328
|
+
end
|
|
329
|
+
else source = member end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
if source == nil then return false end
|
|
334
|
+
local result = { logicalSessionId = logicalId }
|
|
335
|
+
if source['deviceBinding'] ~= nil then result['deviceBinding'] = source['deviceBinding'] end
|
|
336
|
+
if source['provider'] ~= nil then
|
|
337
|
+
local provider = {}
|
|
338
|
+
provider['issuer'] = source['provider']['issuer']
|
|
339
|
+
provider['clientId'] = source['provider']['clientId']
|
|
340
|
+
result['provider'] = provider
|
|
341
|
+
end
|
|
342
|
+
return cjson.encode(result)
|
|
343
|
+
`;
|
|
344
|
+
var buildSessionRevocationContextCommand = (keys, sessionId) => evalCommand(
|
|
345
|
+
SESSION_REVOCATION_CONTEXT_SCRIPT,
|
|
346
|
+
[keys.session(sessionId), keys.rotatedSessionAlias(sessionId)],
|
|
347
|
+
[sessionId, keys.sessionPrefix(), keys.logicalSessionIndexPrefix()]
|
|
348
|
+
);
|
|
110
349
|
var scriptSha1 = (script) => createHash("sha1").update(script).digest("hex");
|
|
111
350
|
var NOSCRIPT_ERROR_PREFIX = "NOSCRIPT";
|
|
112
351
|
var isNoScriptError = (error) => {
|
|
@@ -244,7 +483,42 @@ end
|
|
|
244
483
|
|
|
245
484
|
return 1
|
|
246
485
|
`;
|
|
486
|
+
var LIVE_LINEAGE_HELPER = `
|
|
487
|
+
local function hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, excludedId)
|
|
488
|
+
local members = redis.call('ZRANGE', logicalIndexKey, 0, -1)
|
|
489
|
+
for _, memberId in ipairs(members) do
|
|
490
|
+
if memberId ~= excludedId then
|
|
491
|
+
local value = redis.call('GET', sessionKeyPrefix .. memberId)
|
|
492
|
+
if value ~= false then
|
|
493
|
+
local ok, member = pcall(cjson.decode, value)
|
|
494
|
+
if ok and type(member) == 'table' and member['sessionId'] == memberId
|
|
495
|
+
and (member['logicalSessionId'] or memberId) == logicalSessionId then
|
|
496
|
+
return true
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
return false
|
|
502
|
+
end
|
|
503
|
+
`;
|
|
504
|
+
var CLEANUP_INACTIVE_ALIASES_SCRIPT = `
|
|
505
|
+
${LIVE_LINEAGE_HELPER}
|
|
506
|
+
if hasLiveMember(KEYS[1], ARGV[1], ARGV[3], nil) then
|
|
507
|
+
return 0
|
|
508
|
+
end
|
|
509
|
+
local aliases = redis.call('ZRANGE', KEYS[2], 0, -1)
|
|
510
|
+
for _, aliasId in ipairs(aliases) do
|
|
511
|
+
redis.call('DEL', ARGV[2] .. aliasId)
|
|
512
|
+
end
|
|
513
|
+
return redis.call('DEL', KEYS[2])
|
|
514
|
+
`;
|
|
515
|
+
var buildCleanupInactiveAliasesCommand = (keys, logicalSessionId) => evalCommand(
|
|
516
|
+
CLEANUP_INACTIVE_ALIASES_SCRIPT,
|
|
517
|
+
[keys.logicalSessionIndex(logicalSessionId), keys.rotatedSessionAliasIndex(logicalSessionId)],
|
|
518
|
+
[keys.sessionPrefix(), keys.rotatedSessionAliasPrefix(), logicalSessionId]
|
|
519
|
+
);
|
|
247
520
|
var DELETE_SESSION_SCRIPT = `
|
|
521
|
+
${LIVE_LINEAGE_HELPER}
|
|
248
522
|
local expected = cjson.decode(ARGV[1])
|
|
249
523
|
local scopeKind = ARGV[2]
|
|
250
524
|
local scopeValue = ARGV[3]
|
|
@@ -324,7 +598,16 @@ local function deleteRecord(sessionKey, session)
|
|
|
324
598
|
assertZsetOrMissing(providerIndexKeyPrefix .. session['providerSessionId'])
|
|
325
599
|
end
|
|
326
600
|
|
|
327
|
-
|
|
601
|
+
-- Check actual primaries, not just scores: TTL is authoritative and stale
|
|
602
|
+
-- logical memberships must not keep a terminated lineage's aliases alive.
|
|
603
|
+
-- This check and deletion share the script's atomic boundary with rotation.
|
|
604
|
+
local logicalIndexKey = logicalIndexKeyPrefix .. logicalSessionId
|
|
605
|
+
local hasSurvivor = hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, sessionId)
|
|
606
|
+
|
|
607
|
+
local aliasSessionIds = {}
|
|
608
|
+
if not hasSurvivor then
|
|
609
|
+
aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
|
|
610
|
+
end
|
|
328
611
|
|
|
329
612
|
if redis.call('DEL', sessionKey) == 0 then
|
|
330
613
|
return 0
|
|
@@ -341,7 +624,9 @@ local function deleteRecord(sessionKey, session)
|
|
|
341
624
|
redis.call('DEL', aliasKeyPrefix .. aliasSessionId)
|
|
342
625
|
end
|
|
343
626
|
|
|
344
|
-
|
|
627
|
+
if not hasSurvivor then
|
|
628
|
+
redis.call('DEL', aliasIndexKey)
|
|
629
|
+
end
|
|
345
630
|
|
|
346
631
|
return 1
|
|
347
632
|
end
|
|
@@ -380,6 +665,7 @@ end
|
|
|
380
665
|
return deleted
|
|
381
666
|
`;
|
|
382
667
|
var ROTATE_SESSION_SCRIPT = `
|
|
668
|
+
${BINDING_VALIDATION_HELPER}
|
|
383
669
|
local newValue = ARGV[1]
|
|
384
670
|
local newExpiresAt = ARGV[2]
|
|
385
671
|
local oldSessionId = ARGV[3]
|
|
@@ -393,6 +679,7 @@ local oldAliasValue = ARGV[10]
|
|
|
393
679
|
local oldAliasExpiresAt = ARGV[11]
|
|
394
680
|
local aliasIndexKey = ARGV[12]
|
|
395
681
|
local aliasIndexKeyPrefix = ARGV[13]
|
|
682
|
+
local expectedSource = cjson.decode(ARGV[14])
|
|
396
683
|
|
|
397
684
|
local function assertStringOrMissing(key)
|
|
398
685
|
local keyType = redis.call('TYPE', key)['ok']
|
|
@@ -451,6 +738,25 @@ if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
|
451
738
|
return 0
|
|
452
739
|
end
|
|
453
740
|
|
|
741
|
+
local ok, source = pcall(cjson.decode, redis.call('GET', KEYS[1]))
|
|
742
|
+
local next = cjson.decode(newValue)
|
|
743
|
+
if not ok or type(source) ~= 'table' or source['sessionId'] ~= oldSessionId
|
|
744
|
+
or not validBinding(source['deviceBinding']) or not validBinding(next['deviceBinding']) then return 3 end
|
|
745
|
+
local sourceProvider = source['provider'] or {}
|
|
746
|
+
local expectedProvider = expectedSource['provider'] or {}
|
|
747
|
+
if type(sourceProvider) ~= 'table' or type(expectedProvider) ~= 'table' then return 3 end
|
|
748
|
+
-- Source authority CAS protects immutable binding and index ownership from
|
|
749
|
+
-- same-ID replacement between the application preflight and this script.
|
|
750
|
+
if (source['logicalSessionId'] or oldSessionId) ~= (expectedSource['logicalSessionId'] or oldSessionId)
|
|
751
|
+
or source['subject'] ~= expectedSource['subject'] or source['providerSessionId'] ~= expectedSource['providerSessionId']
|
|
752
|
+
or sourceProvider['issuer'] ~= expectedProvider['issuer'] or sourceProvider['clientId'] ~= expectedProvider['clientId']
|
|
753
|
+
or source['refreshToken'] ~= expectedSource['refreshToken'] or source['idToken'] ~= expectedSource['idToken']
|
|
754
|
+
or source['accessToken'] ~= expectedSource['accessToken'] or source['scope'] ~= expectedSource['scope']
|
|
755
|
+
or source['createdAt'] ~= expectedSource['createdAt'] or source['updatedAt'] ~= expectedSource['updatedAt']
|
|
756
|
+
or source['expiresAt'] ~= expectedSource['expiresAt']
|
|
757
|
+
or not sameBinding(source['deviceBinding'], expectedSource['deviceBinding'])
|
|
758
|
+
or not sameBinding(source['deviceBinding'], next['deviceBinding']) then return 3 end
|
|
759
|
+
|
|
454
760
|
if oldSessionId == newSessionId then
|
|
455
761
|
return 2
|
|
456
762
|
end
|
|
@@ -503,6 +809,30 @@ redis.call('ZADD', aliasIndexKey, newScore, oldSessionId)
|
|
|
503
809
|
|
|
504
810
|
return 1
|
|
505
811
|
`;
|
|
812
|
+
var COMPARE_AND_DELETE_SCRIPT = `
|
|
813
|
+
local current = redis.call('GET', KEYS[1])
|
|
814
|
+
|
|
815
|
+
if current == ARGV[1] then
|
|
816
|
+
return redis.call('DEL', KEYS[1])
|
|
817
|
+
else
|
|
818
|
+
return 0
|
|
819
|
+
end
|
|
820
|
+
`;
|
|
821
|
+
var buildCompareAndDeleteCommand = (key, observedValue) => evalCommand(COMPARE_AND_DELETE_SCRIPT, [key], [observedValue]);
|
|
822
|
+
var REPAIR_SESSION_INDEX_SCRIPT = `
|
|
823
|
+
local current = redis.call('GET', KEYS[1])
|
|
824
|
+
|
|
825
|
+
if current == false or (ARGV[3] == '1' and current == ARGV[2]) then
|
|
826
|
+
return redis.call('ZREM', KEYS[2], ARGV[1])
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
return 0
|
|
830
|
+
`;
|
|
831
|
+
var buildRepairSessionIndexCommand = (sessionKey, indexKey, sessionId, observedValue) => evalCommand(
|
|
832
|
+
REPAIR_SESSION_INDEX_SCRIPT,
|
|
833
|
+
[sessionKey, indexKey],
|
|
834
|
+
[sessionId, observedValue ?? "", observedValue === null ? "0" : "1"]
|
|
835
|
+
);
|
|
506
836
|
var buildWriteSessionCommand = (keys, session) => evalCommand(
|
|
507
837
|
WRITE_SESSION_SCRIPT,
|
|
508
838
|
[
|
|
@@ -560,18 +890,38 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
|
|
|
560
890
|
serialize(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
561
891
|
serializeExpiresAt(nextSession.expiresAt),
|
|
562
892
|
keys.rotatedSessionAliasIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
563
|
-
keys.rotatedSessionAliasIndexPrefix()
|
|
893
|
+
keys.rotatedSessionAliasIndexPrefix(),
|
|
894
|
+
serialize(previousSession)
|
|
564
895
|
]
|
|
565
896
|
);
|
|
566
897
|
|
|
567
898
|
// src/index.ts
|
|
568
899
|
var INDEX_CLEANUP_SCAN_COUNT = 100;
|
|
569
900
|
var INDEX_REVOCATION_SCAN_COUNT = 250;
|
|
901
|
+
var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
902
|
+
if (input === null || typeof input !== "object") return input;
|
|
903
|
+
const prototype = Object.getPrototypeOf(input);
|
|
904
|
+
if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
|
|
905
|
+
if (seen.has(input)) return seen.get(input);
|
|
906
|
+
const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
|
|
907
|
+
seen.set(input, copy);
|
|
908
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
909
|
+
if (Object.prototype.propertyIsEnumerable.call(input, key)) {
|
|
910
|
+
Object.defineProperty(copy, key, {
|
|
911
|
+
value: snapshotInput(Reflect.get(input, key), seen),
|
|
912
|
+
enumerable: true,
|
|
913
|
+
configurable: true,
|
|
914
|
+
writable: true
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return copy;
|
|
919
|
+
};
|
|
570
920
|
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
571
921
|
var isUnsupportedClusterClient = (client) => isRecord(client) && typeof client.getSlotMaster === "function" && typeof client.nodeClient === "function" && Array.isArray(client.masters) && Array.isArray(client.slots);
|
|
572
|
-
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
|
|
573
|
-
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
|
|
574
|
-
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
|
|
922
|
+
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
|
|
923
|
+
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
|
|
924
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
|
|
575
925
|
var matchesProviderScope = (session, input) => {
|
|
576
926
|
if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
|
|
577
927
|
return false;
|
|
@@ -587,7 +937,12 @@ var RedisOidcVaultStore = class {
|
|
|
587
937
|
now;
|
|
588
938
|
scriptRunner;
|
|
589
939
|
indexCleanupCursor = "0";
|
|
940
|
+
dpopReplayMaxEntries;
|
|
590
941
|
constructor(options) {
|
|
942
|
+
this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
|
|
943
|
+
if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
|
|
944
|
+
throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
|
|
945
|
+
}
|
|
591
946
|
if (typeof options.client.sendCommand !== "function") {
|
|
592
947
|
throw new Error("Redis store client must implement sendCommand(args) for atomic vault operations.");
|
|
593
948
|
}
|
|
@@ -602,22 +957,109 @@ var RedisOidcVaultStore = class {
|
|
|
602
957
|
this.scriptRunner = new RedisScriptRunner(options.client);
|
|
603
958
|
}
|
|
604
959
|
async createAuthorizationTransaction(input) {
|
|
960
|
+
input = snapshotInput({ ...input });
|
|
961
|
+
assertRecordBinding(input);
|
|
605
962
|
await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
|
|
606
963
|
}
|
|
607
964
|
async consumeAuthorizationTransaction(state) {
|
|
608
|
-
|
|
965
|
+
const value = await this.runScript(
|
|
966
|
+
buildConsumeGuardedRecordCommand(this.keys.authorizationTransaction(state), "transaction", state)
|
|
967
|
+
);
|
|
968
|
+
return this.parseOneTimeRecord(
|
|
969
|
+
value,
|
|
970
|
+
"authorization transaction",
|
|
971
|
+
(value2) => validateAuthorizationTransaction(value2, state)
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
async getAuthorizationTransaction(state) {
|
|
975
|
+
return this.getJson(
|
|
609
976
|
this.keys.authorizationTransaction(state),
|
|
610
977
|
"authorization transaction",
|
|
611
|
-
validateAuthorizationTransaction
|
|
978
|
+
(value) => validateAuthorizationTransaction(value, state),
|
|
979
|
+
{ deleteMalformed: true, checkExpiry: true }
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
async consumeAuthorizationTransactionIfMatches(input) {
|
|
983
|
+
input = snapshotInput({ ...input });
|
|
984
|
+
if (!validateBindingMatch(input.match)) return null;
|
|
985
|
+
const value = await this.runScript(
|
|
986
|
+
buildConsumeGuardedRecordCommand(
|
|
987
|
+
this.keys.authorizationTransaction(input.state),
|
|
988
|
+
"transaction",
|
|
989
|
+
input.state,
|
|
990
|
+
input.match
|
|
991
|
+
)
|
|
992
|
+
);
|
|
993
|
+
return this.parseOneTimeRecord(
|
|
994
|
+
value,
|
|
995
|
+
"authorization transaction",
|
|
996
|
+
(value2) => validateAuthorizationTransaction(value2, input.state)
|
|
612
997
|
);
|
|
613
998
|
}
|
|
614
999
|
async createExchangeCode(input) {
|
|
1000
|
+
input = snapshotInput({ ...input });
|
|
1001
|
+
assertRecordBinding(input);
|
|
615
1002
|
await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
|
|
616
1003
|
}
|
|
617
1004
|
async consumeExchangeCode(code) {
|
|
618
|
-
|
|
1005
|
+
const value = await this.runScript(
|
|
1006
|
+
buildConsumeGuardedRecordCommand(this.keys.exchangeCode(code), "exchange", code)
|
|
1007
|
+
);
|
|
1008
|
+
return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, code));
|
|
1009
|
+
}
|
|
1010
|
+
async getExchangeCode(code) {
|
|
1011
|
+
return this.getJson(
|
|
1012
|
+
this.keys.exchangeCode(code),
|
|
1013
|
+
"exchange code",
|
|
1014
|
+
(value) => validateExchangeCodeRecord(value, code),
|
|
1015
|
+
{ deleteMalformed: true, checkExpiry: true }
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
async consumeExchangeCodeIfMatches(input) {
|
|
1019
|
+
input = snapshotInput({ ...input });
|
|
1020
|
+
if (!validateBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
|
|
1021
|
+
const value = await this.runScript(
|
|
1022
|
+
buildConsumeGuardedRecordCommand(
|
|
1023
|
+
this.keys.exchangeCode(input.code),
|
|
1024
|
+
"exchange",
|
|
1025
|
+
input.code,
|
|
1026
|
+
input.match,
|
|
1027
|
+
input.expectedSessionId
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
1030
|
+
return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, input.code));
|
|
619
1031
|
}
|
|
1032
|
+
async getSessionRevocationContext(sessionId) {
|
|
1033
|
+
const raw = await this.runScript(buildSessionRevocationContextCommand(this.keys, sessionId));
|
|
1034
|
+
if (typeof raw !== "string") return null;
|
|
1035
|
+
const context = JSON.parse(raw);
|
|
1036
|
+
if (Array.isArray(context.provider) && context.provider.length === 0) context.provider = {};
|
|
1037
|
+
return context;
|
|
1038
|
+
}
|
|
1039
|
+
async reserveDpopProof(input) {
|
|
1040
|
+
input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
|
|
1041
|
+
if (typeof input.replayKey !== "string" || !Number.isSafeInteger(input.expiresAt)) return false;
|
|
1042
|
+
const result = await this.runScript(buildReserveDpopProofCommand(this.keys, input, this.dpopReplayMaxEntries));
|
|
1043
|
+
if (result === 2 || result === "2") throw new OidcVaultDpopReplayCapacityError();
|
|
1044
|
+
return result === 1 || result === "1";
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Creates a session record atomically, then runs best-effort index
|
|
1048
|
+
* maintenance.
|
|
1049
|
+
*
|
|
1050
|
+
* Result policy (SVH-02): once `writeSessionRecord` reports success the
|
|
1051
|
+
* session is committed and this method resolves with the session even if
|
|
1052
|
+
* post-commit index maintenance (`SCAN`/`TYPE`/`TIME`/`ZREMRANGEBYSCORE`)
|
|
1053
|
+
* fails. A maintenance failure is reported once via a fixed-text
|
|
1054
|
+
* `console.warn` identifying only the operation (no adapter error details,
|
|
1055
|
+
* session IDs, keys, or token material) and is retried opportunistically by
|
|
1056
|
+
* a later `createSession`/`rotateSession` call. Mutation-command failures (the
|
|
1057
|
+
* atomic write script) still reject, and this method never retries a
|
|
1058
|
+
* committed mutation.
|
|
1059
|
+
*/
|
|
620
1060
|
async createSession(input) {
|
|
1061
|
+
input = snapshotInput({ ...input });
|
|
1062
|
+
assertSessionBinding(input);
|
|
621
1063
|
const timestamp = this.now();
|
|
622
1064
|
const session = {
|
|
623
1065
|
...input,
|
|
@@ -629,13 +1071,17 @@ var RedisOidcVaultStore = class {
|
|
|
629
1071
|
if (!written) {
|
|
630
1072
|
throw new OidcVaultStoreConflictError("OIDC vault session already exists.");
|
|
631
1073
|
}
|
|
632
|
-
await this.
|
|
1074
|
+
await this.runPostCommitIndexMaintenance("createSession");
|
|
633
1075
|
return session;
|
|
634
1076
|
}
|
|
635
1077
|
async getSession(sessionId) {
|
|
636
|
-
return this.getJson(this.keys.session(sessionId), "session", validateSession, {
|
|
1078
|
+
return this.getJson(this.keys.session(sessionId), "session", (value) => validateSession(value, sessionId), {
|
|
1079
|
+
deleteMalformed: true
|
|
1080
|
+
});
|
|
637
1081
|
}
|
|
638
1082
|
async rotateSession(input) {
|
|
1083
|
+
input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
|
|
1084
|
+
assertSessionBinding(input.nextSession);
|
|
639
1085
|
const previousSession = await this.getSession(input.sessionId);
|
|
640
1086
|
if (!previousSession) {
|
|
641
1087
|
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
@@ -643,15 +1089,19 @@ var RedisOidcVaultStore = class {
|
|
|
643
1089
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
644
1090
|
throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
645
1091
|
}
|
|
1092
|
+
if (input.nextSession.deviceBinding !== void 0 && input.nextSession.deviceBinding.jkt !== previousSession.deviceBinding?.jkt) {
|
|
1093
|
+
throw new OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
|
|
1094
|
+
}
|
|
646
1095
|
const nextSession = {
|
|
647
1096
|
...input.nextSession,
|
|
1097
|
+
...previousSession.deviceBinding === void 0 ? {} : { deviceBinding: { ...previousSession.deviceBinding } },
|
|
648
1098
|
logicalSessionId: input.nextSession.logicalSessionId ?? previousSession.logicalSessionId ?? previousSession.sessionId
|
|
649
1099
|
};
|
|
650
1100
|
const rotated = await this.rotateSessionRecord(previousSession, nextSession);
|
|
651
1101
|
if (!rotated) {
|
|
652
1102
|
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
653
1103
|
}
|
|
654
|
-
await this.
|
|
1104
|
+
await this.runPostCommitIndexMaintenance("rotateSession");
|
|
655
1105
|
return nextSession;
|
|
656
1106
|
}
|
|
657
1107
|
async deleteSession(sessionId) {
|
|
@@ -665,13 +1115,11 @@ var RedisOidcVaultStore = class {
|
|
|
665
1115
|
);
|
|
666
1116
|
if (logicalSessionId) {
|
|
667
1117
|
await this.deleteSessionsByLogicalSessionId(logicalSessionId);
|
|
668
|
-
await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
|
|
669
1118
|
}
|
|
670
|
-
await this.
|
|
1119
|
+
await this.deleteSessionKeyIfMalformed(sessionId);
|
|
671
1120
|
return;
|
|
672
1121
|
}
|
|
673
1122
|
await this.deleteSessionRecord(session, { kind: "single" });
|
|
674
|
-
await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
|
|
675
1123
|
}
|
|
676
1124
|
async deleteSessionsByLogicalSessionId(input) {
|
|
677
1125
|
const resolved = toLogicalSessionDeleteInput(input);
|
|
@@ -701,18 +1149,21 @@ var RedisOidcVaultStore = class {
|
|
|
701
1149
|
await this.client.set(key, serialize(value), options);
|
|
702
1150
|
}
|
|
703
1151
|
async getJson(key, recordKind, validate, options) {
|
|
1152
|
+
const raw = await this.client.get(key);
|
|
704
1153
|
try {
|
|
705
|
-
|
|
1154
|
+
const record = parseStoredJson(raw, recordKind, validate);
|
|
1155
|
+
if (record !== null && options?.checkExpiry && isPlainRecord(record) && typeof record.expiresAt === "number" && record.expiresAt <= await this.redisServerTime())
|
|
1156
|
+
return null;
|
|
1157
|
+
return record;
|
|
706
1158
|
} catch (error) {
|
|
707
|
-
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
|
|
708
|
-
await this.
|
|
1159
|
+
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
|
|
1160
|
+
await this.deleteMalformedValueIfUnchanged(key, raw);
|
|
709
1161
|
return null;
|
|
710
1162
|
}
|
|
711
1163
|
throw error;
|
|
712
1164
|
}
|
|
713
1165
|
}
|
|
714
|
-
|
|
715
|
-
const value = await this.sendCommand(["GETDEL", key]);
|
|
1166
|
+
parseOneTimeRecord(value, recordKind, validate) {
|
|
716
1167
|
try {
|
|
717
1168
|
return typeof value === "string" ? parseStoredJson(value, recordKind, validate) : null;
|
|
718
1169
|
} catch (error) {
|
|
@@ -730,11 +1181,51 @@ var RedisOidcVaultStore = class {
|
|
|
730
1181
|
const result = await this.runScript(buildDeleteSessionCommand(this.keys, session, scope));
|
|
731
1182
|
return typeof result === "number" ? result : Number(result);
|
|
732
1183
|
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Removes a stored value only when it still equals the malformed payload
|
|
1186
|
+
* observed by a prior read (SVH-03). The equality check runs inside the Lua
|
|
1187
|
+
* script, so a concurrent `createSession` reusing the same ID is never
|
|
1188
|
+
* destroyed by a stale repair.
|
|
1189
|
+
*/
|
|
1190
|
+
async deleteMalformedValueIfUnchanged(key, observedRaw) {
|
|
1191
|
+
await this.runScript(buildCompareAndDeleteCommand(key, observedRaw));
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Best-effort cleanup for the missing-session branch of `deleteSession`.
|
|
1195
|
+
* Re-reads the session key and removes it only when it still holds malformed
|
|
1196
|
+
* data. A missing key needs no cleanup and a freshly created valid session
|
|
1197
|
+
* (same-ID reuse after the earlier read) is preserved; genuine logout of a
|
|
1198
|
+
* live session never reaches this branch.
|
|
1199
|
+
*/
|
|
1200
|
+
async deleteSessionKeyIfMalformed(sessionId) {
|
|
1201
|
+
const sessionKey = this.keys.session(sessionId);
|
|
1202
|
+
let raw;
|
|
1203
|
+
try {
|
|
1204
|
+
raw = await this.client.get(sessionKey);
|
|
1205
|
+
} catch {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (raw === null) {
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
parseStoredJson(raw, "session", (value) => validateSession(value, sessionId));
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
1215
|
+
await this.deleteMalformedValueIfUnchanged(sessionKey, raw);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
throw error;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
733
1221
|
async rotateSessionRecord(previousSession, nextSession) {
|
|
734
1222
|
const result = await this.runScript(buildRotateSessionCommand(this.keys, previousSession, nextSession));
|
|
735
1223
|
if (result === 2 || result === "2") {
|
|
736
1224
|
throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
737
1225
|
}
|
|
1226
|
+
if (result === 3 || result === "3") {
|
|
1227
|
+
throw new OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
|
|
1228
|
+
}
|
|
738
1229
|
return result === 1 || result === "1";
|
|
739
1230
|
}
|
|
740
1231
|
async runScript(command) {
|
|
@@ -743,6 +1234,20 @@ var RedisOidcVaultStore = class {
|
|
|
743
1234
|
async cleanupExpiredIndexMembers(indexKey) {
|
|
744
1235
|
await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(await this.redisServerTime())]);
|
|
745
1236
|
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Runs optional post-commit index maintenance without letting it masquerade
|
|
1239
|
+
* as a failed mutation. Any failure is reported once via a fixed-text
|
|
1240
|
+
* warning and swallowed so the already-committed session result stands; the
|
|
1241
|
+
* next successful create/rotate retries the incremental scan from the
|
|
1242
|
+
* retained cursor. Never retries the committed mutation itself.
|
|
1243
|
+
*/
|
|
1244
|
+
async runPostCommitIndexMaintenance(operation) {
|
|
1245
|
+
try {
|
|
1246
|
+
await this.cleanupStaleIndexKeys();
|
|
1247
|
+
} catch {
|
|
1248
|
+
console.warn(`OIDC vault Redis index maintenance failed after ${operation} and will retry on a later write.`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
746
1251
|
async cleanupStaleIndexKeys() {
|
|
747
1252
|
const response = await this.sendCommand([
|
|
748
1253
|
"SCAN",
|
|
@@ -793,10 +1298,6 @@ var RedisOidcVaultStore = class {
|
|
|
793
1298
|
}
|
|
794
1299
|
return seconds * 1e3 + Math.floor(microseconds / 1e3);
|
|
795
1300
|
}
|
|
796
|
-
async getSessionIdsFromIndex(indexKey) {
|
|
797
|
-
const response = await this.sendCommand(["ZRANGE", indexKey, "0", "-1"]);
|
|
798
|
-
return Array.isArray(response) ? response.filter((value) => typeof value === "string") : [];
|
|
799
|
-
}
|
|
800
1301
|
async scanSessionIdsFromIndex(indexKey, cursor) {
|
|
801
1302
|
const response = await this.sendCommand(["ZSCAN", indexKey, cursor, "COUNT", String(INDEX_REVOCATION_SCAN_COUNT)]);
|
|
802
1303
|
if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
|
|
@@ -821,21 +1322,25 @@ var RedisOidcVaultStore = class {
|
|
|
821
1322
|
throw new Error("OIDC vault Redis store received an unexpected MGET response.");
|
|
822
1323
|
}
|
|
823
1324
|
return Promise.all(
|
|
824
|
-
|
|
1325
|
+
sessionIds.map(async (sessionId, index) => {
|
|
1326
|
+
const value = response[index];
|
|
1327
|
+
const raw = typeof value === "string" ? value : null;
|
|
825
1328
|
try {
|
|
826
|
-
return
|
|
1329
|
+
return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
|
|
827
1330
|
} catch (error) {
|
|
828
|
-
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
829
|
-
await this.
|
|
830
|
-
return null;
|
|
1331
|
+
if (error instanceof OidcVaultRedisStoreRecordError && typeof value === "string") {
|
|
1332
|
+
await this.deleteMalformedValueIfUnchanged(this.keys.session(sessionId), value);
|
|
1333
|
+
return { session: null, raw };
|
|
831
1334
|
}
|
|
832
1335
|
throw error;
|
|
833
1336
|
}
|
|
834
1337
|
})
|
|
835
1338
|
);
|
|
836
1339
|
}
|
|
837
|
-
async removeSessionIdFromIndex(indexKey, sessionId) {
|
|
838
|
-
await this.
|
|
1340
|
+
async removeSessionIdFromIndex(indexKey, sessionId, observedRaw) {
|
|
1341
|
+
await this.runScript(
|
|
1342
|
+
buildRepairSessionIndexCommand(this.keys.session(sessionId), indexKey, sessionId, observedRaw)
|
|
1343
|
+
);
|
|
839
1344
|
}
|
|
840
1345
|
async deleteSessionsFromIndex(indexKey, scope) {
|
|
841
1346
|
await this.cleanupExpiredIndexMembers(indexKey);
|
|
@@ -849,21 +1354,21 @@ var RedisOidcVaultStore = class {
|
|
|
849
1354
|
}
|
|
850
1355
|
const sessions = await this.getSessionRecords(batch.sessionIds);
|
|
851
1356
|
for (const [index, sessionId] of batch.sessionIds.entries()) {
|
|
852
|
-
const session = sessions[index];
|
|
1357
|
+
const { session, raw } = sessions[index] ?? { session: null, raw: null };
|
|
853
1358
|
if (!session) {
|
|
854
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1359
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
855
1360
|
continue;
|
|
856
1361
|
}
|
|
857
1362
|
if ("logicalSessionId" in scope && (session.logicalSessionId ?? session.sessionId) !== scope.logicalSessionId) {
|
|
858
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1363
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
859
1364
|
continue;
|
|
860
1365
|
}
|
|
861
1366
|
if ("subject" in scope && session.subject !== scope.subject) {
|
|
862
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1367
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
863
1368
|
continue;
|
|
864
1369
|
}
|
|
865
1370
|
if ("providerSessionId" in scope && session.providerSessionId !== scope.providerSessionId) {
|
|
866
|
-
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
1371
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
|
|
867
1372
|
continue;
|
|
868
1373
|
}
|
|
869
1374
|
if (!("logicalSessionId" in scope) && !matchesProviderScope(session, scope)) {
|
|
@@ -871,11 +1376,13 @@ var RedisOidcVaultStore = class {
|
|
|
871
1376
|
}
|
|
872
1377
|
const revoked = await this.deleteSessionRecord(session, this.toDeleteScriptScope(scope));
|
|
873
1378
|
if (revoked > 0) {
|
|
874
|
-
await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
|
|
875
1379
|
deleted += revoked;
|
|
876
1380
|
}
|
|
877
1381
|
}
|
|
878
1382
|
} while (cursor !== "0");
|
|
1383
|
+
if (deleted === 0 && "logicalSessionId" in scope) {
|
|
1384
|
+
await this.runScript(buildCleanupInactiveAliasesCommand(this.keys, scope.logicalSessionId));
|
|
1385
|
+
}
|
|
879
1386
|
return deleted;
|
|
880
1387
|
}
|
|
881
1388
|
toDeleteScriptScope(scope) {
|
|
@@ -892,16 +1399,6 @@ var RedisOidcVaultStore = class {
|
|
|
892
1399
|
clientId: scope.clientId
|
|
893
1400
|
};
|
|
894
1401
|
}
|
|
895
|
-
async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
|
|
896
|
-
const aliasIndexKey = this.keys.rotatedSessionAliasIndex(logicalSessionId);
|
|
897
|
-
await this.cleanupExpiredIndexMembers(aliasIndexKey);
|
|
898
|
-
const aliasSessionIds = await this.getSessionIdsFromIndex(aliasIndexKey);
|
|
899
|
-
if (aliasSessionIds.length === 0) {
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
await this.client.del(aliasSessionIds.map((sessionId) => this.keys.rotatedSessionAlias(sessionId)));
|
|
903
|
-
await this.client.del(aliasIndexKey);
|
|
904
|
-
}
|
|
905
1402
|
async sendCommand(args) {
|
|
906
1403
|
return this.client.sendCommand(args);
|
|
907
1404
|
}
|