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