@web-ts-toolkit/express-oidc-vault-redis-store 0.32.0 → 0.34.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/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 sessionId = ARGV[1]
30
- local providerIndexKey = ARGV[2]
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
- redis.call('DEL', KEYS[1])
33
- redis.call('ZREM', KEYS[2], sessionId)
261
+ local function assertZsetOrMissing(key)
262
+ local keyType = redis.call('TYPE', key)['ok']
34
263
 
35
- if providerIndexKey ~= '' then
36
- redis.call('ZREM', providerIndexKey, sessionId)
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
- return 1
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 serialize = (value) => JSON.stringify(value);
75
- var parseJson = (value) => value ? JSON.parse(value) : null;
76
- var prefixKey = (keyPrefix, kind, id) => `${keyPrefix}:${kind}:${id}`;
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 toIndexScore = (expiresAt) => expiresAt ?? NON_EXPIRING_INDEX_SCORE;
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 (!options.client.sendCommand) {
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.keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX;
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.authorizationTransactionKey(input.state), input, input.expiresAt);
605
+ await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
100
606
  }
101
607
  async consumeAuthorizationTransaction(state) {
102
- return this.consumeJson(this.authorizationTransactionKey(state));
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.exchangeCodeKey(input.code), input, input.expiresAt);
615
+ await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
106
616
  }
107
617
  async consumeExchangeCode(code) {
108
- return this.consumeJson(this.exchangeCodeKey(code));
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.sessionKey(sessionId));
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
- const rotated = await this.rotateSessionRecord(previousSession, input.nextSession);
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
- return input.nextSession;
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.client.del(this.sessionKey(sessionId));
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.subjectIndexKey(resolved.subject), resolved);
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.providerSessionIndexKey(resolved.providerSessionId), resolved);
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
- return parseJson(await this.client.get(key));
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
- return typeof value === "string" ? parseJson(value) : null;
160
- }
161
- authorizationTransactionKey(state) {
162
- return prefixKey(this.keyPrefix, "txn", state);
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
- exchangeCodeKey(code) {
165
- return prefixKey(this.keyPrefix, "exchange", code);
725
+ async writeSessionRecord(session) {
726
+ const result = await this.runScript(buildWriteSessionCommand(this.keys, session));
727
+ return result === 1 || result === "1";
166
728
  }
167
- sessionKey(sessionId) {
168
- return prefixKey(this.keyPrefix, "session", sessionId);
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
- subjectIndexKey(subject) {
171
- return prefixKey(this.keyPrefix, "subject", subject);
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
- providerSessionIndexKey(providerSessionId) {
174
- return prefixKey(this.keyPrefix, "provider-session", providerSessionId);
740
+ async runScript(command) {
741
+ return this.scriptRunner.run(command);
175
742
  }
176
- async writeSessionRecord(session) {
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 deleteSessionRecord(session) {
191
- await this.sendCommand([
192
- "EVAL",
193
- DELETE_SESSION_SCRIPT,
194
- "2",
195
- this.sessionKey(session.sessionId),
196
- this.subjectIndexKey(session.subject),
197
- session.sessionId,
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 rotateSessionRecord(previousSession, nextSession) {
202
- const result = await this.sendCommand([
203
- "EVAL",
204
- ROTATE_SESSION_SCRIPT,
205
- "4",
206
- this.sessionKey(previousSession.sessionId),
207
- this.sessionKey(nextSession.sessionId),
208
- this.subjectIndexKey(previousSession.subject),
209
- this.subjectIndexKey(nextSession.subject),
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 cleanupExpiredIndexMembers(indexKey) {
221
- await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(this.now())]);
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
- for (const sessionId of sessionIds) {
238
- const session = await this.getSession(sessionId);
239
- if (!session) {
240
- await this.removeSessionIdFromIndex(indexKey, sessionId);
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
- if (!matchesProviderScope(session, scope)) {
244
- continue;
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
- await this.deleteSessionRecord(session);
247
- deleted += 1;
248
- }
878
+ } while (cursor !== "0");
249
879
  return deleted;
250
880
  }
251
- serializeExpiresAt(expiresAt) {
252
- return typeof expiresAt === "number" ? String(expiresAt) : "";
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
  };