@web-ts-toolkit/express-oidc-vault-redis-store 0.43.0 → 0.48.0

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