@web-ts-toolkit/express-oidc-vault-redis-store 0.32.0 → 0.33.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 +63 -1
- package/index.d.mts +80 -2
- package/index.d.ts +80 -2
- package/index.js +753 -100
- package/index.mjs +752 -100
- package/package.json +8 -5
package/index.mjs
CHANGED
|
@@ -2,41 +2,382 @@
|
|
|
2
2
|
import {
|
|
3
3
|
OidcVaultStoreConflictError
|
|
4
4
|
} from "@web-ts-toolkit/express-oidc-vault";
|
|
5
|
+
|
|
6
|
+
// src/keys.ts
|
|
5
7
|
var DEFAULT_KEY_PREFIX = "oidc-vault";
|
|
8
|
+
var prefixKey = (keyPrefix, kind, id) => `${keyPrefix}:${kind}:${id}`;
|
|
9
|
+
var RedisOidcVaultStoreKeys = class {
|
|
10
|
+
constructor(keyPrefix) {
|
|
11
|
+
this.keyPrefix = keyPrefix;
|
|
12
|
+
}
|
|
13
|
+
keyPrefix;
|
|
14
|
+
authorizationTransaction(state) {
|
|
15
|
+
return prefixKey(this.keyPrefix, "txn", state);
|
|
16
|
+
}
|
|
17
|
+
exchangeCode(code) {
|
|
18
|
+
return prefixKey(this.keyPrefix, "exchange", code);
|
|
19
|
+
}
|
|
20
|
+
session(sessionId) {
|
|
21
|
+
return prefixKey(this.keyPrefix, "session", sessionId);
|
|
22
|
+
}
|
|
23
|
+
sessionPrefix() {
|
|
24
|
+
return `${this.keyPrefix}:session:`;
|
|
25
|
+
}
|
|
26
|
+
subjectIndex(subject) {
|
|
27
|
+
return prefixKey(this.keyPrefix, "subject", subject);
|
|
28
|
+
}
|
|
29
|
+
subjectIndexPrefix() {
|
|
30
|
+
return `${this.keyPrefix}:subject:`;
|
|
31
|
+
}
|
|
32
|
+
providerSessionIndex(providerSessionId) {
|
|
33
|
+
return prefixKey(this.keyPrefix, "provider-session", providerSessionId);
|
|
34
|
+
}
|
|
35
|
+
providerSessionIndexPrefix() {
|
|
36
|
+
return `${this.keyPrefix}:provider-session:`;
|
|
37
|
+
}
|
|
38
|
+
logicalSessionIndex(logicalSessionId) {
|
|
39
|
+
return prefixKey(this.keyPrefix, "logical-session", logicalSessionId);
|
|
40
|
+
}
|
|
41
|
+
logicalSessionIndexPrefix() {
|
|
42
|
+
return `${this.keyPrefix}:logical-session:`;
|
|
43
|
+
}
|
|
44
|
+
backchannelLogoutTokenJti(jti) {
|
|
45
|
+
return prefixKey(this.keyPrefix, "backchannel-logout-jti", jti);
|
|
46
|
+
}
|
|
47
|
+
rotatedSessionAliasIndex(logicalSessionId) {
|
|
48
|
+
return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
|
|
49
|
+
}
|
|
50
|
+
rotatedSessionAliasIndexPrefix() {
|
|
51
|
+
return `${this.keyPrefix}:rotated-session-alias-index:`;
|
|
52
|
+
}
|
|
53
|
+
rotatedSessionAlias(sessionId) {
|
|
54
|
+
return prefixKey(this.keyPrefix, "rotated-session-alias", sessionId);
|
|
55
|
+
}
|
|
56
|
+
rotatedSessionAliasPrefix() {
|
|
57
|
+
return `${this.keyPrefix}:rotated-session-alias:`;
|
|
58
|
+
}
|
|
59
|
+
scanPattern() {
|
|
60
|
+
return `${this.keyPrefix}:*`;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// src/records.ts
|
|
65
|
+
var serialize = (value) => JSON.stringify(value);
|
|
66
|
+
var OidcVaultRedisStoreRecordError = class extends Error {
|
|
67
|
+
constructor(recordKind) {
|
|
68
|
+
super(`OIDC vault Redis store found malformed ${recordKind} record.`);
|
|
69
|
+
this.name = "OidcVaultRedisStoreRecordError";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
73
|
+
var isString = (value) => typeof value === "string";
|
|
74
|
+
var isOptionalString = (value) => value === void 0 || isString(value);
|
|
75
|
+
var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
76
|
+
var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
|
|
77
|
+
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);
|
|
80
|
+
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));
|
|
82
|
+
var parseStoredJson = (value, recordKind, validate) => {
|
|
83
|
+
if (value === null) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(value);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new OidcVaultRedisStoreRecordError(recordKind);
|
|
91
|
+
}
|
|
92
|
+
if (!validate(parsed)) {
|
|
93
|
+
throw new OidcVaultRedisStoreRecordError(recordKind);
|
|
94
|
+
}
|
|
95
|
+
return parsed;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/scripts.ts
|
|
99
|
+
import { createHash } from "crypto";
|
|
6
100
|
var NON_EXPIRING_INDEX_SCORE = Number.MAX_SAFE_INTEGER;
|
|
101
|
+
var toIndexScore = (expiresAt) => expiresAt ?? NON_EXPIRING_INDEX_SCORE;
|
|
102
|
+
var serializeExpiresAt = (expiresAt) => typeof expiresAt === "number" ? String(expiresAt) : "";
|
|
103
|
+
var evalCommand = (script, keys, args) => [
|
|
104
|
+
"EVAL",
|
|
105
|
+
script,
|
|
106
|
+
String(keys.length),
|
|
107
|
+
...keys,
|
|
108
|
+
...args
|
|
109
|
+
];
|
|
110
|
+
var scriptSha1 = (script) => createHash("sha1").update(script).digest("hex");
|
|
111
|
+
var NOSCRIPT_ERROR_PREFIX = "NOSCRIPT";
|
|
112
|
+
var isNoScriptError = (error) => {
|
|
113
|
+
if (error instanceof Error) {
|
|
114
|
+
return error.message.startsWith(NOSCRIPT_ERROR_PREFIX);
|
|
115
|
+
}
|
|
116
|
+
if (typeof error === "string") {
|
|
117
|
+
return error.startsWith(NOSCRIPT_ERROR_PREFIX);
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
};
|
|
121
|
+
var RedisScriptRunner = class {
|
|
122
|
+
client;
|
|
123
|
+
digests = /* @__PURE__ */ new Map();
|
|
124
|
+
constructor(client) {
|
|
125
|
+
this.client = client;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Drops every cached script digest. Call after a reconnection or failover
|
|
129
|
+
* that the store owner knows invalidates the node's script cache. The next
|
|
130
|
+
* mutation will lazily reload via `SCRIPT LOAD` on the new node.
|
|
131
|
+
*/
|
|
132
|
+
reset() {
|
|
133
|
+
this.digests.clear();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Executes an `EVAL`-form command (`['EVAL', scriptBody, keyCount, ...keys,
|
|
137
|
+
* ...args]`) against the cached script. The leading `EVAL` and script body
|
|
138
|
+
* are replaced with `EVALSHA` and the script digest so steady-state
|
|
139
|
+
* mutations do not transmit full script bodies across the wire. The
|
|
140
|
+
* remaining positional layout (key count, keys, args) is preserved exactly.
|
|
141
|
+
*/
|
|
142
|
+
async run(command) {
|
|
143
|
+
if (command[0] !== "EVAL" || typeof command[1] !== "string" || typeof command[2] !== "string") {
|
|
144
|
+
throw new Error("RedisScriptRunner.run expects an EVAL-form command.");
|
|
145
|
+
}
|
|
146
|
+
const script = command[1];
|
|
147
|
+
const tail = command.slice(2);
|
|
148
|
+
return this.runDigest(script, tail);
|
|
149
|
+
}
|
|
150
|
+
async runDigest(script, tail) {
|
|
151
|
+
let digest = this.digests.get(script);
|
|
152
|
+
if (!digest) {
|
|
153
|
+
digest = scriptSha1(script);
|
|
154
|
+
this.digests.set(script, digest);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return await this.client.sendCommand(["EVALSHA", digest, ...tail]);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (!isNoScriptError(error)) {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const loaded = await this.client.sendCommand(["SCRIPT", "LOAD", script]);
|
|
163
|
+
if (typeof loaded === "string" && loaded.length > 0) {
|
|
164
|
+
digest = loaded;
|
|
165
|
+
this.digests.set(script, digest);
|
|
166
|
+
}
|
|
167
|
+
return this.client.sendCommand(["EVALSHA", digest, ...tail]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Exposed for tests: the SHA1 digest the runner would currently send. */
|
|
171
|
+
digestFor(script) {
|
|
172
|
+
return this.digests.get(script) ?? scriptSha1(script);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
7
175
|
var WRITE_SESSION_SCRIPT = `
|
|
8
176
|
local value = ARGV[1]
|
|
9
177
|
local expiresAt = ARGV[2]
|
|
10
178
|
local sessionId = ARGV[3]
|
|
11
179
|
local score = tonumber(ARGV[4])
|
|
12
180
|
local providerIndexKey = ARGV[5]
|
|
181
|
+
local aliasIndexKeyPrefix = ARGV[6]
|
|
182
|
+
|
|
183
|
+
local function assertStringOrMissing(key)
|
|
184
|
+
local keyType = redis.call('TYPE', key)['ok']
|
|
185
|
+
|
|
186
|
+
if keyType ~= 'none' and keyType ~= 'string' then
|
|
187
|
+
error('OIDC vault Redis store key has unexpected type')
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
local function assertZsetOrMissing(key)
|
|
192
|
+
local keyType = redis.call('TYPE', key)['ok']
|
|
193
|
+
|
|
194
|
+
if keyType ~= 'none' and keyType ~= 'zset' then
|
|
195
|
+
error('OIDC vault Redis store key has unexpected type')
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
assertStringOrMissing(KEYS[1])
|
|
200
|
+
assertStringOrMissing(KEYS[4])
|
|
201
|
+
assertZsetOrMissing(KEYS[2])
|
|
202
|
+
assertZsetOrMissing(KEYS[3])
|
|
203
|
+
|
|
204
|
+
if providerIndexKey ~= '' then
|
|
205
|
+
assertZsetOrMissing(providerIndexKey)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
local staleAliasLogicalSessionId = nil
|
|
209
|
+
local staleAliasValue = redis.call('GET', KEYS[4])
|
|
210
|
+
|
|
211
|
+
if staleAliasValue ~= false then
|
|
212
|
+
local decodedAliasOk, decodedAlias = pcall(cjson.decode, staleAliasValue)
|
|
213
|
+
|
|
214
|
+
if decodedAliasOk and type(decodedAlias) == 'string' then
|
|
215
|
+
staleAliasLogicalSessionId = decodedAlias
|
|
216
|
+
assertZsetOrMissing(aliasIndexKeyPrefix .. staleAliasLogicalSessionId)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
13
219
|
|
|
14
220
|
if expiresAt ~= '' then
|
|
15
|
-
redis.call('SET', KEYS[1], value, 'PXAT', expiresAt)
|
|
221
|
+
if redis.call('SET', KEYS[1], value, 'PXAT', expiresAt, 'NX') == false then
|
|
222
|
+
return 0
|
|
223
|
+
end
|
|
16
224
|
else
|
|
17
|
-
redis.call('SET', KEYS[1], value)
|
|
225
|
+
if redis.call('SET', KEYS[1], value, 'NX') == false then
|
|
226
|
+
return 0
|
|
227
|
+
end
|
|
18
228
|
end
|
|
19
229
|
|
|
20
230
|
redis.call('ZADD', KEYS[2], score, sessionId)
|
|
231
|
+
redis.call('ZADD', KEYS[3], score, sessionId)
|
|
21
232
|
|
|
22
233
|
if providerIndexKey ~= '' then
|
|
23
234
|
redis.call('ZADD', providerIndexKey, score, sessionId)
|
|
24
235
|
end
|
|
25
236
|
|
|
237
|
+
if staleAliasValue ~= false then
|
|
238
|
+
redis.call('DEL', KEYS[4])
|
|
239
|
+
|
|
240
|
+
if staleAliasLogicalSessionId ~= nil then
|
|
241
|
+
redis.call('ZREM', aliasIndexKeyPrefix .. staleAliasLogicalSessionId, sessionId)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
26
245
|
return 1
|
|
27
246
|
`;
|
|
28
247
|
var DELETE_SESSION_SCRIPT = `
|
|
29
|
-
local
|
|
30
|
-
local
|
|
248
|
+
local expected = cjson.decode(ARGV[1])
|
|
249
|
+
local scopeKind = ARGV[2]
|
|
250
|
+
local scopeValue = ARGV[3]
|
|
251
|
+
local issuer = ARGV[4]
|
|
252
|
+
local clientId = ARGV[5]
|
|
253
|
+
local sessionKeyPrefix = ARGV[6]
|
|
254
|
+
local subjectIndexKeyPrefix = ARGV[7]
|
|
255
|
+
local logicalIndexKeyPrefix = ARGV[8]
|
|
256
|
+
local providerIndexKeyPrefix = ARGV[9]
|
|
257
|
+
local aliasIndexKeyPrefix = ARGV[10]
|
|
258
|
+
local aliasKeyPrefix = ARGV[11]
|
|
259
|
+
local expectedLogicalSessionId = expected['logicalSessionId'] or expected['sessionId']
|
|
31
260
|
|
|
32
|
-
|
|
33
|
-
redis.call('
|
|
261
|
+
local function assertZsetOrMissing(key)
|
|
262
|
+
local keyType = redis.call('TYPE', key)['ok']
|
|
34
263
|
|
|
35
|
-
if
|
|
36
|
-
|
|
264
|
+
if keyType ~= 'none' and keyType ~= 'zset' then
|
|
265
|
+
error('OIDC vault Redis store key has unexpected type')
|
|
266
|
+
end
|
|
37
267
|
end
|
|
38
268
|
|
|
39
|
-
|
|
269
|
+
local function logicalSessionIdFor(session)
|
|
270
|
+
return session['logicalSessionId'] or session['sessionId']
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
local function matchesProviderScope(session)
|
|
274
|
+
local provider = session['provider']
|
|
275
|
+
|
|
276
|
+
if issuer ~= '' and (provider == nil or provider['issuer'] ~= issuer) then
|
|
277
|
+
return false
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if clientId ~= '' and (provider == nil or provider['clientId'] ~= clientId) then
|
|
281
|
+
return false
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
return true
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
local function matchesScope(session, allowLogicalSuccessor)
|
|
288
|
+
if allowLogicalSuccessor and logicalSessionIdFor(session) ~= expectedLogicalSessionId then
|
|
289
|
+
return false
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if scopeKind == 'single' then
|
|
293
|
+
if allowLogicalSuccessor then
|
|
294
|
+
return true
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
return session['sessionId'] == expected['sessionId']
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
if scopeKind == 'logical' then
|
|
301
|
+
return logicalSessionIdFor(session) == scopeValue
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
if scopeKind == 'subject' then
|
|
305
|
+
return session['subject'] == scopeValue and matchesProviderScope(session)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
if scopeKind == 'provider-session' then
|
|
309
|
+
return session['providerSessionId'] == scopeValue and matchesProviderScope(session)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
return false
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
local function deleteRecord(sessionKey, session)
|
|
316
|
+
local sessionId = session['sessionId']
|
|
317
|
+
local logicalSessionId = logicalSessionIdFor(session)
|
|
318
|
+
local aliasIndexKey = aliasIndexKeyPrefix .. logicalSessionId
|
|
319
|
+
assertZsetOrMissing(subjectIndexKeyPrefix .. session['subject'])
|
|
320
|
+
assertZsetOrMissing(logicalIndexKeyPrefix .. logicalSessionId)
|
|
321
|
+
assertZsetOrMissing(aliasIndexKey)
|
|
322
|
+
|
|
323
|
+
if session['providerSessionId'] ~= nil then
|
|
324
|
+
assertZsetOrMissing(providerIndexKeyPrefix .. session['providerSessionId'])
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
local aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
|
|
328
|
+
|
|
329
|
+
if redis.call('DEL', sessionKey) == 0 then
|
|
330
|
+
return 0
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
redis.call('ZREM', subjectIndexKeyPrefix .. session['subject'], sessionId)
|
|
334
|
+
redis.call('ZREM', logicalIndexKeyPrefix .. logicalSessionId, sessionId)
|
|
335
|
+
|
|
336
|
+
if session['providerSessionId'] ~= nil then
|
|
337
|
+
redis.call('ZREM', providerIndexKeyPrefix .. session['providerSessionId'], sessionId)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
for _, aliasSessionId in ipairs(aliasSessionIds) do
|
|
341
|
+
redis.call('DEL', aliasKeyPrefix .. aliasSessionId)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
redis.call('DEL', aliasIndexKey)
|
|
345
|
+
|
|
346
|
+
return 1
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
local currentValue = redis.call('GET', KEYS[1])
|
|
350
|
+
|
|
351
|
+
if currentValue ~= false then
|
|
352
|
+
local current = cjson.decode(currentValue)
|
|
353
|
+
|
|
354
|
+
if matchesScope(current, false) then
|
|
355
|
+
return deleteRecord(KEYS[1], current)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
return 0
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
local deleted = 0
|
|
362
|
+
local logicalIndexKey = logicalIndexKeyPrefix .. expectedLogicalSessionId
|
|
363
|
+
local sessionIds = redis.call('ZRANGE', logicalIndexKey, 0, -1)
|
|
364
|
+
|
|
365
|
+
for _, sessionId in ipairs(sessionIds) do
|
|
366
|
+
local sessionKey = sessionKeyPrefix .. sessionId
|
|
367
|
+
local value = redis.call('GET', sessionKey)
|
|
368
|
+
|
|
369
|
+
if value ~= false then
|
|
370
|
+
local session = cjson.decode(value)
|
|
371
|
+
|
|
372
|
+
if matchesScope(session, true) then
|
|
373
|
+
deleted = deleted + deleteRecord(sessionKey, session)
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
redis.call('ZREM', logicalIndexKey, sessionId)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
return deleted
|
|
40
381
|
`;
|
|
41
382
|
var ROTATE_SESSION_SCRIPT = `
|
|
42
383
|
local newValue = ARGV[1]
|
|
@@ -46,11 +387,78 @@ local newSessionId = ARGV[4]
|
|
|
46
387
|
local newScore = tonumber(ARGV[5])
|
|
47
388
|
local oldProviderIndexKey = ARGV[6]
|
|
48
389
|
local newProviderIndexKey = ARGV[7]
|
|
390
|
+
local oldLogicalIndexKey = ARGV[8]
|
|
391
|
+
local newLogicalIndexKey = ARGV[9]
|
|
392
|
+
local oldAliasValue = ARGV[10]
|
|
393
|
+
local oldAliasExpiresAt = ARGV[11]
|
|
394
|
+
local aliasIndexKey = ARGV[12]
|
|
395
|
+
local aliasIndexKeyPrefix = ARGV[13]
|
|
396
|
+
|
|
397
|
+
local function assertStringOrMissing(key)
|
|
398
|
+
local keyType = redis.call('TYPE', key)['ok']
|
|
399
|
+
|
|
400
|
+
if keyType ~= 'none' and keyType ~= 'string' then
|
|
401
|
+
error('OIDC vault Redis store key has unexpected type')
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
local function assertZsetOrMissing(key)
|
|
406
|
+
local keyType = redis.call('TYPE', key)['ok']
|
|
407
|
+
|
|
408
|
+
if keyType ~= 'none' and keyType ~= 'zset' then
|
|
409
|
+
error('OIDC vault Redis store key has unexpected type')
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
assertStringOrMissing(KEYS[1])
|
|
414
|
+
assertStringOrMissing(KEYS[2])
|
|
415
|
+
assertStringOrMissing(KEYS[5])
|
|
416
|
+
assertStringOrMissing(KEYS[6])
|
|
417
|
+
assertZsetOrMissing(KEYS[3])
|
|
418
|
+
assertZsetOrMissing(KEYS[4])
|
|
419
|
+
|
|
420
|
+
if oldProviderIndexKey ~= '' then
|
|
421
|
+
assertZsetOrMissing(oldProviderIndexKey)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
if newProviderIndexKey ~= '' then
|
|
425
|
+
assertZsetOrMissing(newProviderIndexKey)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
if oldLogicalIndexKey ~= '' then
|
|
429
|
+
assertZsetOrMissing(oldLogicalIndexKey)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
if newLogicalIndexKey ~= '' then
|
|
433
|
+
assertZsetOrMissing(newLogicalIndexKey)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
assertZsetOrMissing(aliasIndexKey)
|
|
437
|
+
|
|
438
|
+
local staleTargetAliasLogicalSessionId = nil
|
|
439
|
+
local staleTargetAliasValue = redis.call('GET', KEYS[6])
|
|
440
|
+
|
|
441
|
+
if staleTargetAliasValue ~= false then
|
|
442
|
+
local decodedAliasOk, decodedAlias = pcall(cjson.decode, staleTargetAliasValue)
|
|
443
|
+
|
|
444
|
+
if decodedAliasOk and type(decodedAlias) == 'string' then
|
|
445
|
+
staleTargetAliasLogicalSessionId = decodedAlias
|
|
446
|
+
assertZsetOrMissing(aliasIndexKeyPrefix .. staleTargetAliasLogicalSessionId)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
49
449
|
|
|
50
450
|
if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
51
451
|
return 0
|
|
52
452
|
end
|
|
53
453
|
|
|
454
|
+
if oldSessionId == newSessionId then
|
|
455
|
+
return 2
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
459
|
+
return 2
|
|
460
|
+
end
|
|
461
|
+
|
|
54
462
|
if newExpiresAt ~= '' then
|
|
55
463
|
redis.call('SET', KEYS[2], newValue, 'PXAT', newExpiresAt)
|
|
56
464
|
else
|
|
@@ -69,14 +477,101 @@ if newProviderIndexKey ~= '' then
|
|
|
69
477
|
redis.call('ZADD', newProviderIndexKey, newScore, newSessionId)
|
|
70
478
|
end
|
|
71
479
|
|
|
480
|
+
if oldLogicalIndexKey ~= '' then
|
|
481
|
+
redis.call('ZREM', oldLogicalIndexKey, oldSessionId)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
if newLogicalIndexKey ~= '' then
|
|
485
|
+
redis.call('ZADD', newLogicalIndexKey, newScore, newSessionId)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
if staleTargetAliasValue ~= false then
|
|
489
|
+
redis.call('DEL', KEYS[6])
|
|
490
|
+
|
|
491
|
+
if staleTargetAliasLogicalSessionId ~= nil then
|
|
492
|
+
redis.call('ZREM', aliasIndexKeyPrefix .. staleTargetAliasLogicalSessionId, newSessionId)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
if oldAliasExpiresAt ~= '' then
|
|
497
|
+
redis.call('SET', KEYS[5], oldAliasValue, 'PXAT', oldAliasExpiresAt)
|
|
498
|
+
else
|
|
499
|
+
redis.call('SET', KEYS[5], oldAliasValue)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
redis.call('ZADD', aliasIndexKey, newScore, oldSessionId)
|
|
503
|
+
|
|
72
504
|
return 1
|
|
73
505
|
`;
|
|
74
|
-
var
|
|
75
|
-
|
|
76
|
-
|
|
506
|
+
var buildWriteSessionCommand = (keys, session) => evalCommand(
|
|
507
|
+
WRITE_SESSION_SCRIPT,
|
|
508
|
+
[
|
|
509
|
+
keys.session(session.sessionId),
|
|
510
|
+
keys.subjectIndex(session.subject),
|
|
511
|
+
keys.logicalSessionIndex(session.logicalSessionId ?? session.sessionId),
|
|
512
|
+
keys.rotatedSessionAlias(session.sessionId)
|
|
513
|
+
],
|
|
514
|
+
[
|
|
515
|
+
serialize(session),
|
|
516
|
+
serializeExpiresAt(session.expiresAt),
|
|
517
|
+
session.sessionId,
|
|
518
|
+
String(toIndexScore(session.expiresAt)),
|
|
519
|
+
session.providerSessionId ? keys.providerSessionIndex(session.providerSessionId) : "",
|
|
520
|
+
keys.rotatedSessionAliasIndexPrefix()
|
|
521
|
+
]
|
|
522
|
+
);
|
|
523
|
+
var buildDeleteSessionCommand = (keys, session, scope) => evalCommand(
|
|
524
|
+
DELETE_SESSION_SCRIPT,
|
|
525
|
+
[keys.session(session.sessionId)],
|
|
526
|
+
[
|
|
527
|
+
serialize(session),
|
|
528
|
+
scope.kind,
|
|
529
|
+
"value" in scope ? scope.value : "",
|
|
530
|
+
"issuer" in scope ? scope.issuer ?? "" : "",
|
|
531
|
+
"clientId" in scope ? scope.clientId ?? "" : "",
|
|
532
|
+
keys.sessionPrefix(),
|
|
533
|
+
keys.subjectIndexPrefix(),
|
|
534
|
+
keys.logicalSessionIndexPrefix(),
|
|
535
|
+
keys.providerSessionIndexPrefix(),
|
|
536
|
+
keys.rotatedSessionAliasIndexPrefix(),
|
|
537
|
+
keys.rotatedSessionAliasPrefix()
|
|
538
|
+
]
|
|
539
|
+
);
|
|
540
|
+
var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalCommand(
|
|
541
|
+
ROTATE_SESSION_SCRIPT,
|
|
542
|
+
[
|
|
543
|
+
keys.session(previousSession.sessionId),
|
|
544
|
+
keys.session(nextSession.sessionId),
|
|
545
|
+
keys.subjectIndex(previousSession.subject),
|
|
546
|
+
keys.subjectIndex(nextSession.subject),
|
|
547
|
+
keys.rotatedSessionAlias(previousSession.sessionId),
|
|
548
|
+
keys.rotatedSessionAlias(nextSession.sessionId)
|
|
549
|
+
],
|
|
550
|
+
[
|
|
551
|
+
serialize(nextSession),
|
|
552
|
+
serializeExpiresAt(nextSession.expiresAt),
|
|
553
|
+
previousSession.sessionId,
|
|
554
|
+
nextSession.sessionId,
|
|
555
|
+
String(toIndexScore(nextSession.expiresAt)),
|
|
556
|
+
previousSession.providerSessionId ? keys.providerSessionIndex(previousSession.providerSessionId) : "",
|
|
557
|
+
nextSession.providerSessionId ? keys.providerSessionIndex(nextSession.providerSessionId) : "",
|
|
558
|
+
keys.logicalSessionIndex(previousSession.logicalSessionId ?? previousSession.sessionId),
|
|
559
|
+
keys.logicalSessionIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
560
|
+
serialize(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
561
|
+
serializeExpiresAt(nextSession.expiresAt),
|
|
562
|
+
keys.rotatedSessionAliasIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
|
|
563
|
+
keys.rotatedSessionAliasIndexPrefix()
|
|
564
|
+
]
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// src/index.ts
|
|
568
|
+
var INDEX_CLEANUP_SCAN_COUNT = 100;
|
|
569
|
+
var INDEX_REVOCATION_SCAN_COUNT = 250;
|
|
570
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
571
|
+
var isUnsupportedClusterClient = (client) => isRecord(client) && typeof client.getSlotMaster === "function" && typeof client.nodeClient === "function" && Array.isArray(client.masters) && Array.isArray(client.slots);
|
|
77
572
|
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
|
|
78
573
|
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
|
|
79
|
-
var
|
|
574
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
|
|
80
575
|
var matchesProviderScope = (session, input) => {
|
|
81
576
|
if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
|
|
82
577
|
return false;
|
|
@@ -87,169 +582,325 @@ var matchesProviderScope = (session, input) => {
|
|
|
87
582
|
return true;
|
|
88
583
|
};
|
|
89
584
|
var RedisOidcVaultStore = class {
|
|
585
|
+
client;
|
|
586
|
+
keys;
|
|
587
|
+
now;
|
|
588
|
+
scriptRunner;
|
|
589
|
+
indexCleanupCursor = "0";
|
|
90
590
|
constructor(options) {
|
|
91
|
-
if (
|
|
92
|
-
throw new Error("Redis store client must implement sendCommand for atomic vault operations.");
|
|
591
|
+
if (typeof options.client.sendCommand !== "function") {
|
|
592
|
+
throw new Error("Redis store client must implement sendCommand(args) for atomic vault operations.");
|
|
593
|
+
}
|
|
594
|
+
if (isUnsupportedClusterClient(options.client)) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
"Redis store supports standalone Redis and Redis Sentinel clients only. Redis Cluster is not supported because vault scripts touch multiple keys without a cluster hash-slot adapter."
|
|
597
|
+
);
|
|
93
598
|
}
|
|
94
599
|
this.client = options.client;
|
|
95
|
-
this.
|
|
600
|
+
this.keys = new RedisOidcVaultStoreKeys(options.keyPrefix ?? DEFAULT_KEY_PREFIX);
|
|
96
601
|
this.now = options.now ?? Date.now;
|
|
602
|
+
this.scriptRunner = new RedisScriptRunner(options.client);
|
|
97
603
|
}
|
|
98
604
|
async createAuthorizationTransaction(input) {
|
|
99
|
-
await this.setJson(this.
|
|
605
|
+
await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
|
|
100
606
|
}
|
|
101
607
|
async consumeAuthorizationTransaction(state) {
|
|
102
|
-
return this.consumeJson(
|
|
608
|
+
return this.consumeJson(
|
|
609
|
+
this.keys.authorizationTransaction(state),
|
|
610
|
+
"authorization transaction",
|
|
611
|
+
validateAuthorizationTransaction
|
|
612
|
+
);
|
|
103
613
|
}
|
|
104
614
|
async createExchangeCode(input) {
|
|
105
|
-
await this.setJson(this.
|
|
615
|
+
await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
|
|
106
616
|
}
|
|
107
617
|
async consumeExchangeCode(code) {
|
|
108
|
-
return this.consumeJson(this.
|
|
618
|
+
return this.consumeJson(this.keys.exchangeCode(code), "exchange code", validateExchangeCodeRecord);
|
|
109
619
|
}
|
|
110
620
|
async createSession(input) {
|
|
111
621
|
const timestamp = this.now();
|
|
112
622
|
const session = {
|
|
113
623
|
...input,
|
|
624
|
+
logicalSessionId: input.logicalSessionId ?? input.sessionId,
|
|
114
625
|
createdAt: input.createdAt ?? timestamp,
|
|
115
626
|
updatedAt: input.updatedAt ?? timestamp
|
|
116
627
|
};
|
|
117
|
-
await this.writeSessionRecord(session);
|
|
628
|
+
const written = await this.writeSessionRecord(session);
|
|
629
|
+
if (!written) {
|
|
630
|
+
throw new OidcVaultStoreConflictError("OIDC vault session already exists.");
|
|
631
|
+
}
|
|
632
|
+
await this.cleanupStaleIndexKeys();
|
|
118
633
|
return session;
|
|
119
634
|
}
|
|
120
635
|
async getSession(sessionId) {
|
|
121
|
-
return this.getJson(this.
|
|
636
|
+
return this.getJson(this.keys.session(sessionId), "session", validateSession, { deleteMalformed: true });
|
|
122
637
|
}
|
|
123
638
|
async rotateSession(input) {
|
|
124
639
|
const previousSession = await this.getSession(input.sessionId);
|
|
125
640
|
if (!previousSession) {
|
|
126
641
|
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
127
642
|
}
|
|
128
|
-
|
|
643
|
+
if (input.nextSession.sessionId === input.sessionId) {
|
|
644
|
+
throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
645
|
+
}
|
|
646
|
+
const nextSession = {
|
|
647
|
+
...input.nextSession,
|
|
648
|
+
logicalSessionId: input.nextSession.logicalSessionId ?? previousSession.logicalSessionId ?? previousSession.sessionId
|
|
649
|
+
};
|
|
650
|
+
const rotated = await this.rotateSessionRecord(previousSession, nextSession);
|
|
129
651
|
if (!rotated) {
|
|
130
652
|
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
131
653
|
}
|
|
132
|
-
|
|
654
|
+
await this.cleanupStaleIndexKeys();
|
|
655
|
+
return nextSession;
|
|
133
656
|
}
|
|
134
657
|
async deleteSession(sessionId) {
|
|
135
658
|
const session = await this.getSession(sessionId);
|
|
136
659
|
if (!session) {
|
|
137
|
-
await this.
|
|
660
|
+
const logicalSessionId = await this.getJson(
|
|
661
|
+
this.keys.rotatedSessionAlias(sessionId),
|
|
662
|
+
"rotated session alias",
|
|
663
|
+
isString,
|
|
664
|
+
{ deleteMalformed: true }
|
|
665
|
+
);
|
|
666
|
+
if (logicalSessionId) {
|
|
667
|
+
await this.deleteSessionsByLogicalSessionId(logicalSessionId);
|
|
668
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
|
|
669
|
+
}
|
|
670
|
+
await this.client.del(this.keys.session(sessionId));
|
|
138
671
|
return;
|
|
139
672
|
}
|
|
140
|
-
await this.deleteSessionRecord(session);
|
|
673
|
+
await this.deleteSessionRecord(session, { kind: "single" });
|
|
674
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
|
|
675
|
+
}
|
|
676
|
+
async deleteSessionsByLogicalSessionId(input) {
|
|
677
|
+
const resolved = toLogicalSessionDeleteInput(input);
|
|
678
|
+
return this.deleteSessionsFromIndex(this.keys.logicalSessionIndex(resolved.logicalSessionId), resolved);
|
|
679
|
+
}
|
|
680
|
+
async consumeBackchannelLogoutTokenJti(input) {
|
|
681
|
+
const now = this.now();
|
|
682
|
+
if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
const result = await this.client.set(this.keys.backchannelLogoutTokenJti(input.jti), "1", {
|
|
686
|
+
PXAT: input.expiresAt,
|
|
687
|
+
NX: true
|
|
688
|
+
});
|
|
689
|
+
return result === "OK" || result === true;
|
|
141
690
|
}
|
|
142
691
|
async deleteSessionsBySubject(input) {
|
|
143
692
|
const resolved = toSubjectDeleteInput(input);
|
|
144
|
-
return this.deleteSessionsFromIndex(this.
|
|
693
|
+
return this.deleteSessionsFromIndex(this.keys.subjectIndex(resolved.subject), resolved);
|
|
145
694
|
}
|
|
146
695
|
async deleteSessionsByProviderSessionId(input) {
|
|
147
696
|
const resolved = toProviderSessionDeleteInput(input);
|
|
148
|
-
return this.deleteSessionsFromIndex(this.
|
|
697
|
+
return this.deleteSessionsFromIndex(this.keys.providerSessionIndex(resolved.providerSessionId), resolved);
|
|
149
698
|
}
|
|
150
699
|
async setJson(key, value, expiresAt) {
|
|
151
700
|
const options = typeof expiresAt === "number" ? { PXAT: expiresAt } : void 0;
|
|
152
701
|
await this.client.set(key, serialize(value), options);
|
|
153
702
|
}
|
|
154
|
-
async getJson(key) {
|
|
155
|
-
|
|
703
|
+
async getJson(key, recordKind, validate, options) {
|
|
704
|
+
try {
|
|
705
|
+
return parseStoredJson(await this.client.get(key), recordKind, validate);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
|
|
708
|
+
await this.client.del(key);
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
156
713
|
}
|
|
157
|
-
async consumeJson(key) {
|
|
714
|
+
async consumeJson(key, recordKind, validate) {
|
|
158
715
|
const value = await this.sendCommand(["GETDEL", key]);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
716
|
+
try {
|
|
717
|
+
return typeof value === "string" ? parseStoredJson(value, recordKind, validate) : null;
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
163
724
|
}
|
|
164
|
-
|
|
165
|
-
|
|
725
|
+
async writeSessionRecord(session) {
|
|
726
|
+
const result = await this.runScript(buildWriteSessionCommand(this.keys, session));
|
|
727
|
+
return result === 1 || result === "1";
|
|
166
728
|
}
|
|
167
|
-
|
|
168
|
-
|
|
729
|
+
async deleteSessionRecord(session, scope) {
|
|
730
|
+
const result = await this.runScript(buildDeleteSessionCommand(this.keys, session, scope));
|
|
731
|
+
return typeof result === "number" ? result : Number(result);
|
|
169
732
|
}
|
|
170
|
-
|
|
171
|
-
|
|
733
|
+
async rotateSessionRecord(previousSession, nextSession) {
|
|
734
|
+
const result = await this.runScript(buildRotateSessionCommand(this.keys, previousSession, nextSession));
|
|
735
|
+
if (result === 2 || result === "2") {
|
|
736
|
+
throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
737
|
+
}
|
|
738
|
+
return result === 1 || result === "1";
|
|
172
739
|
}
|
|
173
|
-
|
|
174
|
-
return
|
|
740
|
+
async runScript(command) {
|
|
741
|
+
return this.scriptRunner.run(command);
|
|
175
742
|
}
|
|
176
|
-
async
|
|
177
|
-
await this.sendCommand([
|
|
178
|
-
"EVAL",
|
|
179
|
-
WRITE_SESSION_SCRIPT,
|
|
180
|
-
"2",
|
|
181
|
-
this.sessionKey(session.sessionId),
|
|
182
|
-
this.subjectIndexKey(session.subject),
|
|
183
|
-
serialize(session),
|
|
184
|
-
this.serializeExpiresAt(session.expiresAt),
|
|
185
|
-
session.sessionId,
|
|
186
|
-
String(toIndexScore(session.expiresAt)),
|
|
187
|
-
session.providerSessionId ? this.providerSessionIndexKey(session.providerSessionId) : ""
|
|
188
|
-
]);
|
|
743
|
+
async cleanupExpiredIndexMembers(indexKey) {
|
|
744
|
+
await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(await this.redisServerTime())]);
|
|
189
745
|
}
|
|
190
|
-
async
|
|
191
|
-
await this.sendCommand([
|
|
192
|
-
"
|
|
193
|
-
|
|
194
|
-
"
|
|
195
|
-
this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
session.providerSessionId ? this.providerSessionIndexKey(session.providerSessionId) : ""
|
|
746
|
+
async cleanupStaleIndexKeys() {
|
|
747
|
+
const response = await this.sendCommand([
|
|
748
|
+
"SCAN",
|
|
749
|
+
this.indexCleanupCursor,
|
|
750
|
+
"MATCH",
|
|
751
|
+
this.keys.scanPattern(),
|
|
752
|
+
"COUNT",
|
|
753
|
+
String(INDEX_CLEANUP_SCAN_COUNT)
|
|
199
754
|
]);
|
|
755
|
+
if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
|
|
756
|
+
throw new Error("OIDC vault Redis store received an unexpected SCAN response.");
|
|
757
|
+
}
|
|
758
|
+
this.indexCleanupCursor = response[0];
|
|
759
|
+
const indexKeyPrefixes = [
|
|
760
|
+
this.keys.subjectIndexPrefix(),
|
|
761
|
+
this.keys.providerSessionIndexPrefix(),
|
|
762
|
+
this.keys.logicalSessionIndexPrefix(),
|
|
763
|
+
this.keys.rotatedSessionAliasIndexPrefix()
|
|
764
|
+
];
|
|
765
|
+
const indexKeys = response[1].filter(
|
|
766
|
+
(key) => typeof key === "string" && indexKeyPrefixes.some((prefix) => key.startsWith(prefix))
|
|
767
|
+
);
|
|
768
|
+
for (const indexKey of indexKeys) {
|
|
769
|
+
if (await this.redisKeyType(indexKey) === "zset") {
|
|
770
|
+
await this.cleanupExpiredIndexMembers(indexKey);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
200
773
|
}
|
|
201
|
-
async
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
serialize(nextSession),
|
|
211
|
-
this.serializeExpiresAt(nextSession.expiresAt),
|
|
212
|
-
previousSession.sessionId,
|
|
213
|
-
nextSession.sessionId,
|
|
214
|
-
String(toIndexScore(nextSession.expiresAt)),
|
|
215
|
-
previousSession.providerSessionId ? this.providerSessionIndexKey(previousSession.providerSessionId) : "",
|
|
216
|
-
nextSession.providerSessionId ? this.providerSessionIndexKey(nextSession.providerSessionId) : ""
|
|
217
|
-
]);
|
|
218
|
-
return result === 1 || result === "1";
|
|
774
|
+
async redisKeyType(key) {
|
|
775
|
+
const response = await this.sendCommand(["TYPE", key]);
|
|
776
|
+
if (typeof response === "string") {
|
|
777
|
+
return response;
|
|
778
|
+
}
|
|
779
|
+
if (isPlainRecord(response) && typeof response.ok === "string") {
|
|
780
|
+
return response.ok;
|
|
781
|
+
}
|
|
782
|
+
throw new Error("OIDC vault Redis store received an unexpected TYPE response.");
|
|
219
783
|
}
|
|
220
|
-
async
|
|
221
|
-
await this.sendCommand(["
|
|
784
|
+
async redisServerTime() {
|
|
785
|
+
const response = await this.sendCommand(["TIME"]);
|
|
786
|
+
if (!Array.isArray(response) || response.length < 2) {
|
|
787
|
+
throw new Error("OIDC vault Redis store received an unexpected TIME response.");
|
|
788
|
+
}
|
|
789
|
+
const seconds = Number(response[0]);
|
|
790
|
+
const microseconds = Number(response[1]);
|
|
791
|
+
if (!Number.isFinite(seconds) || !Number.isFinite(microseconds)) {
|
|
792
|
+
throw new Error("OIDC vault Redis store received an unexpected TIME response.");
|
|
793
|
+
}
|
|
794
|
+
return seconds * 1e3 + Math.floor(microseconds / 1e3);
|
|
222
795
|
}
|
|
223
796
|
async getSessionIdsFromIndex(indexKey) {
|
|
224
797
|
const response = await this.sendCommand(["ZRANGE", indexKey, "0", "-1"]);
|
|
225
798
|
return Array.isArray(response) ? response.filter((value) => typeof value === "string") : [];
|
|
226
799
|
}
|
|
800
|
+
async scanSessionIdsFromIndex(indexKey, cursor) {
|
|
801
|
+
const response = await this.sendCommand(["ZSCAN", indexKey, cursor, "COUNT", String(INDEX_REVOCATION_SCAN_COUNT)]);
|
|
802
|
+
if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
|
|
803
|
+
throw new Error("OIDC vault Redis store received an unexpected ZSCAN response.");
|
|
804
|
+
}
|
|
805
|
+
const rawEntries = response[1];
|
|
806
|
+
const sessionIds = [];
|
|
807
|
+
for (let index = 0; index < rawEntries.length; index += 2) {
|
|
808
|
+
const sessionId = rawEntries[index];
|
|
809
|
+
if (typeof sessionId === "string") {
|
|
810
|
+
sessionIds.push(sessionId);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return { cursor: response[0], sessionIds };
|
|
814
|
+
}
|
|
815
|
+
async getSessionRecords(sessionIds) {
|
|
816
|
+
if (sessionIds.length === 0) {
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
const response = await this.sendCommand(["MGET", ...sessionIds.map((sessionId) => this.keys.session(sessionId))]);
|
|
820
|
+
if (!Array.isArray(response)) {
|
|
821
|
+
throw new Error("OIDC vault Redis store received an unexpected MGET response.");
|
|
822
|
+
}
|
|
823
|
+
return Promise.all(
|
|
824
|
+
response.map(async (value, index) => {
|
|
825
|
+
try {
|
|
826
|
+
return typeof value === "string" ? parseStoredJson(value, "session", validateSession) : null;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
829
|
+
await this.client.del(this.keys.session(sessionIds[index]));
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
})
|
|
835
|
+
);
|
|
836
|
+
}
|
|
227
837
|
async removeSessionIdFromIndex(indexKey, sessionId) {
|
|
228
838
|
await this.sendCommand(["ZREM", indexKey, sessionId]);
|
|
229
839
|
}
|
|
230
840
|
async deleteSessionsFromIndex(indexKey, scope) {
|
|
231
841
|
await this.cleanupExpiredIndexMembers(indexKey);
|
|
232
|
-
const sessionIds = await this.getSessionIdsFromIndex(indexKey);
|
|
233
|
-
if (sessionIds.length === 0) {
|
|
234
|
-
return 0;
|
|
235
|
-
}
|
|
236
842
|
let deleted = 0;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
843
|
+
let cursor = "0";
|
|
844
|
+
do {
|
|
845
|
+
const batch = await this.scanSessionIdsFromIndex(indexKey, cursor);
|
|
846
|
+
cursor = batch.cursor;
|
|
847
|
+
if (batch.sessionIds.length === 0) {
|
|
241
848
|
continue;
|
|
242
849
|
}
|
|
243
|
-
|
|
244
|
-
|
|
850
|
+
const sessions = await this.getSessionRecords(batch.sessionIds);
|
|
851
|
+
for (const [index, sessionId] of batch.sessionIds.entries()) {
|
|
852
|
+
const session = sessions[index];
|
|
853
|
+
if (!session) {
|
|
854
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
if ("logicalSessionId" in scope && (session.logicalSessionId ?? session.sessionId) !== scope.logicalSessionId) {
|
|
858
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
if ("subject" in scope && session.subject !== scope.subject) {
|
|
862
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if ("providerSessionId" in scope && session.providerSessionId !== scope.providerSessionId) {
|
|
866
|
+
await this.removeSessionIdFromIndex(indexKey, sessionId);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (!("logicalSessionId" in scope) && !matchesProviderScope(session, scope)) {
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const revoked = await this.deleteSessionRecord(session, this.toDeleteScriptScope(scope));
|
|
873
|
+
if (revoked > 0) {
|
|
874
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
|
|
875
|
+
deleted += revoked;
|
|
876
|
+
}
|
|
245
877
|
}
|
|
246
|
-
|
|
247
|
-
deleted += 1;
|
|
248
|
-
}
|
|
878
|
+
} while (cursor !== "0");
|
|
249
879
|
return deleted;
|
|
250
880
|
}
|
|
251
|
-
|
|
252
|
-
|
|
881
|
+
toDeleteScriptScope(scope) {
|
|
882
|
+
if ("logicalSessionId" in scope) {
|
|
883
|
+
return { kind: "logical", value: scope.logicalSessionId };
|
|
884
|
+
}
|
|
885
|
+
if ("subject" in scope) {
|
|
886
|
+
return { kind: "subject", value: scope.subject, issuer: scope.issuer, clientId: scope.clientId };
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
kind: "provider-session",
|
|
890
|
+
value: scope.providerSessionId,
|
|
891
|
+
issuer: scope.issuer,
|
|
892
|
+
clientId: scope.clientId
|
|
893
|
+
};
|
|
894
|
+
}
|
|
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);
|
|
253
904
|
}
|
|
254
905
|
async sendCommand(args) {
|
|
255
906
|
return this.client.sendCommand(args);
|
|
@@ -259,5 +910,6 @@ function createRedisOidcVaultStore(options) {
|
|
|
259
910
|
return new RedisOidcVaultStore(options);
|
|
260
911
|
}
|
|
261
912
|
export {
|
|
913
|
+
OidcVaultRedisStoreRecordError,
|
|
262
914
|
createRedisOidcVaultStore
|
|
263
915
|
};
|