@web-ts-toolkit/express-oidc-vault-redis-store 0.42.2 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ import { createClient } from 'redis';
20
20
  import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
21
21
 
22
22
  const redis = createClient({ url: process.env.REDIS_URL });
23
+ redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
23
24
  await redis.connect();
24
25
 
25
26
  const storeProvider = createRedisOidcVaultStore({
@@ -38,6 +39,7 @@ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-re
38
39
 
39
40
  const app = express();
40
41
  const redis = createClient({ url: process.env.REDIS_URL });
42
+ redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
41
43
 
42
44
  await redis.connect();
43
45
 
@@ -69,14 +71,32 @@ app.use(
69
71
  - one-time records are consumed atomically through Redis commands instead of `get` plus `del`
70
72
  - session rotation and session indexes are updated atomically so concurrent refreshes do not fork multiple active sessions
71
73
  - rotation requires a distinct unused target session ID; missing-source, same-ID, and existing-target rotation conflicts throw `OidcVaultStoreConflictError` without changing source or target records
72
- - obsolete rotated session IDs are stored as aliases owned by the logical session; revoking the active logical session, or deleting through any obsolete ID, removes all aliases and the reverse alias index
74
+ - obsolete rotated session IDs are stored as aliases owned by the logical session; scoped/direct deletion preserves unexpired aliases while another live member survives, including another issuer/client scope. Deleting through an obsolete ID still revokes its logical lineage
75
+ - alias cleanup runs inside the delete script alongside the primary mutation. It checks keyed primary membership with Redis TTL authority before removing terminated-lineage aliases; empty logical logout uses the same liveness rule in an atomic cleanup script. Rotation cannot interleave between that check and alias removal. The overall indexed traversal is not a global logout snapshot, so later arrivals can survive
76
+ - **Alias-cleanup compatibility:** scoped/direct logout no longer discards surviving lineage handles. Successful delete scripts no longer repeat application-side alias cleanup. A fixed warmed workload of 12 rotated lineages deleted by subject used 52 client commands before and 16 after on both Redis 6.2 and 7.2 (36 fewer sequential round trips); this measures command count, not latency. Lua still materializes whole logical-member sets, probes primaries until finding a survivor, and materializes/deletes all aliases when inactive; large lineages/alias sets remain scale-dependent server work
73
77
  - creating or rotating into a previously obsolete session ID removes that stale alias ownership first, so a reused ID cannot invoke an old logical-session meaning
74
- - `createAuthorizationTransaction` and `createExchangeCode` are upserts; `createSession` is create-only and duplicate session IDs throw `OidcVaultStoreConflictError` without changing the existing record or indexes
75
- - session metadata should be JSON-compatible for portability across store providers
78
+ - `createAuthorizationTransaction` and `createExchangeCode` are upserts; `createSession` is create-only and duplicate session IDs throw `OidcVaultStoreConflictError` without changing the existing record or indexes. Portable callers must create sessions with a fresh unused ID and handle `OidcVaultStoreConflictError`: reusing a live ID rejects here but replaces on memory/MongoDB, so only fresh-ID creation is portable
79
+ - portable data consists of strings, finite numbers, booleans, null, arrays, and plain objects; exclude cycles, custom prototypes/serializers, functions, symbols, and undefined properties. This applies to nested provider/user/metadata data, not only top-level metadata
80
+ - portable plain-object/array inputs are captured before asynchronous work, including rotation source reads and scoped deletion scans. Returned session containers are independent from caller input, including nested provider/user/metadata arrays; later caller mutation cannot change the committed portable value or eventual result
81
+ - compatibility: session ownership snapshots do not add a JSON round-trip. The existing JSON serializer still handles native values and `toJSON` hooks (and still rejects unsupported values such as BigInt/cycles). Opaque non-plain objects are not recursively copied and have no portable mutation-isolation guarantee; create/rotate results retain their native values while reads reflect persisted JSON
76
82
  - backchannel logout token JTI records are consumed only when `expiresAt` is finite and greater than the store clock at consume time
77
83
  - the optional `now` hook controls store-domain timestamps and testable JTI validation only; Redis server time is the authority for Redis key expiry and revocation-index cleanup
78
84
  - the client must implement `sendCommand(args)`; official standalone `redis` clients already do, and a Sentinel-wrapped master client or any adapter implementing `OidcVaultRedisClient` does too
79
85
 
86
+ ## Rotation Aliases And Lifetime
87
+
88
+ Rotation preserves the source logical ID when the next session omits it. Each old ID becomes a revocation alias for its immediate successor's logical ID and `expiresAt`. With `A -> B (expiresAt=T1) -> C (expiresAt=T2)`, A stops working at T1 even if T2 is later or absent; B uses C's deadline. Later rotations never extend earlier aliases. `getSession(A)` returns `null`: an alias is never a readable/authentication session.
89
+
90
+ With `A/L1 -> B/L1 -> C/L2`, the new B alias revokes L2; earlier A keeps L1 and its original deadline, never C. Redis does not eagerly retire L1 aliases merely because rotation moves its last member elsewhere (memory does); they can remain until expiry or explicit lineage cleanup. Use distinct logical IDs for unrelated login families rather than relying on retained handles disappearing on reuse.
91
+
92
+ Without successor `expiresAt`, Redis and memory impose no alias time limit; MongoDB uses a finite fallback (default five minutes). Non-expiring Redis sessions and their aliases can grow without a fixed retention or size bound. Use explicit session lifetimes for portable finite windows. After an alias expires, use the live ID or the appropriate logical/subject/provider-session deletion method; core refresh preserves session expiry.
93
+
94
+ ## Deletion, Counts And Concurrent Arrivals
95
+
96
+ Deleting a live public ID removes that record; deleting an unexpired alias revokes its logical lineage. Logical deletion and aliases have no issuer/client filter. Subject/provider-session object inputs match each supplied `issuer` and `clientId`; string inputs omit both filters and match across providers. A retained member in another scope keeps its unexpired aliases.
97
+
98
+ Bulk deletion makes one cursor traversal, rechecks scope in each atomic delete script, and can follow a rotated-away source to matching same-lineage successors. Counts sum primary sessions actually deleted by those scripts, including such successors; expired/missing records, stale membership repair and alias cleanup do not contribute. Duplicate cursor visits or overlapping revocations do not double-count an already deleted primary. A concurrent arrival may survive, and `0` is not proof that the scope is empty. An error can follow earlier committed deletions; the complete traversal is not one transaction. MongoDB instead repeats scoped deletion until an empty query and can count expired documents awaiting TTL cleanup; memory counts live deletions during a synchronous map scan. Vault deletion does not revoke outstanding stateless access tokens or call upstream logout.
99
+
80
100
  ## Redis Version And Topology
81
101
 
82
102
  - **Minimum Redis version: 6.2.** One-time authorization transactions and exchange codes are consumed with `GETDEL`, which is unavailable before Redis 6.2. Versions 6.2 and 7.2 are exercised in integration tests.
@@ -89,7 +109,7 @@ app.use(
89
109
  - **Connect the client yourself.** The store never calls `client.connect()`, `client.quit()`, or `client.disconnect()`. Pass an already-connected client and reuse it across requests.
90
110
  - **Own `error` listeners, reconnects, and shutdown.** The store reads and writes commands but does not attach `error` listeners, suppress client errors, or close the client. Always register a client `error` listener on production connections; an unhandled client error can crash the process.
91
111
  - **Graceful shutdown.** Draining `createSession`/`rotateSession`/`deleteSessionsBy*` in-flight calls, then `client.quit()`, is the supported shutdown order. The store holds no background timers, so no store-side teardown is required beyond verifying in-flight operations have settled.
92
- - **Concurrency.** `OidcVaultStoreProvider` methods are safe to call concurrently from one or more processes. Atomic operations are protected by Redis server-side scripts; concurrent duplicate ID creation, concurrent indexed revocation of overlapping indexes, concurrent rotation into the same target ID, and concurrent same-ID rotation conflicting with indexed revocation are all bounded.
112
+ - **Startup and concurrency.** There is no store `ready()` handshake: connect before accepting traffic, and provision permission to execute the documented commands and cached Lua scripts. Server-side scripts protect individual mutations from interleaving; indexed traversal and post-commit maintenance span multiple commands. They do not have a fixed total-work or completion-time bound.
93
113
 
94
114
  ## Key Namespace And Migration
95
115
 
@@ -107,24 +127,32 @@ app.use(
107
127
  ## Expiry And Index Cleanup
108
128
 
109
129
  - expiring session records are stored with Redis `PXAT`, and revocation index scores use the same absolute expiration timestamp
130
+ - authorization transactions and exchange codes also use `PXAT`. Redis key expiry is authoritative for reads/consumes; payload `expiresAt` is structurally validated but is not independently compared to a clock on each read. Valid single-record reads use one `GET` or `GETDEL`, without a separate `TIME` round trip, preserving the split-clock policy above
131
+ - **TTL integrity assumption:** preserve the store-written key TTLs and matching index scores when restoring or migrating data. Externally removing/extending a TTL can make a past-dated payload readable/consumable; changing only a session TTL can also leave it live after its index membership is pruned. Read validation does not audit or repair this external expiry inconsistency. Shortening a key TTL makes the record unavailable as soon as Redis expires it, even if its payload expiry is later
110
132
  - stale revocation memberships are pruned with Redis `TIME`, not the application clock, so a skewed application clock cannot remove a still-live Redis session from subject, provider-session, or logical-session indexes
111
133
  - indexed revocation also checks the primary session key before deleting or returning a session; expired primary values are not returned and missing primary values remove the stale membership encountered during traversal
112
- - indexed revocation traverses Redis sorted sets with bounded cursor batches and batched record reads; it revokes sessions present in the cursor scan's view, while sessions added to the same index after the scan starts may be revoked by a later indexed revocation call
113
- - each successful session create or rotation scans up to 100 prefixed keys and removes Redis-expired members from subject, provider-session, and logical-session indexes; Redis deletes empty sorted sets, so unique expired logical-session index keys are eventually removed even without a logout for that logical session
114
- - rotated session aliases use the same expiry score as their successor session; non-expiring alias churn is bounded by cleanup on logical-session termination, and malformed stale aliases are deleted when their session ID is reused
115
- - stale index storage is therefore bounded by active session index entries plus expired entries waiting for Redis expiry and the incremental cleanup scan window; non-expiring sessions use a non-expiring index score and remain until revoked
134
+ - indexed revocation uses `ZSCAN COUNT 250` and one `MGET` for the IDs returned in each batch. `COUNT` is a hint, not a hard batch cap (compact sorted sets can return all members); the batch is not further chunked. Response memory and repair concurrency can grow with the returned batch, while deletion scripts run sequentially
135
+ - each successful session create or rotation attempts one `SCAN MATCH <prefix>:* COUNT 100` step, then `TYPE` and, for each recognized sorted-set index, `TIME`/`ZREMRANGEBYSCORE`. **COUNT 100 is a hint, not “up to 100 keys”**: Redis may return more or fewer keys, and the store processes the whole response. Cleanup includes subject, provider-session, logical-session and rotated-alias indexes. Redis removes empty sorted sets; continued successful writes advance the scan even without a logout for that logical session
136
+ - post-commit index maintenance is best-effort and never masquerades as a failed write: once the atomic create/rotate script commits, `createSession`/`rotateSession` resolve with the committed session even if the follow-up `SCAN`/`TYPE`/`TIME`/`ZREMRANGEBYSCORE` maintenance fails. The failure is reported once via a fixed-text `console.warn` identifying only `createSession` or `rotateSession`; adapter error names, messages, codes, causes, and arbitrary thrown values are never inspected or logged. The incremental scan retries on a later create/rotate. Mutation-script failures still reject, and a successful rotation is never retried merely because maintenance failed
137
+ - **Diagnostic compatibility note:** post-commit maintenance warnings no longer include a `Cause:` suffix or adapter error details, which may contain credentials. Log consumers should use the operation name and fixed maintenance-failure text
138
+ - aliases use their immediate successor's expiry score; malformed stale aliases are deleted when their session ID is reused. Alias/index cleanup can materialize a whole logical-member or alias set inside Lua; neither cursor COUNT value bounds that server work
139
+ - there is no fixed stale-index cleanup deadline or memory cap. Expired entries can remain while writes stop or maintenance fails; non-expiring entries and aliases remain until explicitly revoked/cleaned. The incremental scan is opportunistic, not a retention SLA
116
140
 
117
141
  ## Stored Data Validation And Corruption Handling
118
142
 
119
- - stored records are JSON payloads validated structurally on read; see _Stored Data Characteristics_ for the schema-migration and plaintext-storage posture
143
+ - stored records are JSON payloads validated structurally on read, with session `sessionId`, authorization transaction `state`, and exchange-code `code` required to match the requested key (including each indexed batch member); see _Stored Data Characteristics_ for the schema-migration and plaintext-storage posture
120
144
  - malformed one-time authorization transaction and exchange-code records are consumed atomically and return `null`, preventing reuse while failing closed
121
145
  - malformed session records are treated as unreadable, deleted when encountered through session reads or indexed revocation, and never returned as authenticated state
122
- - indexed revocation removes stale corrupt members and continues processing later valid sessions
146
+ - corruption repair deletes only the observed malformed payload through an atomic server-side compare-and-delete: a fresh same-ID record created after the stale read (session ID reuse is supported) is never destroyed by the repair, and the missing-session logout path applies the same guard instead of an unconditional delete
147
+ - **Identity-validation compatibility note:** shape-valid records stored under the wrong identity now follow the malformed-record policy and return `null`. Session repair targets only the observed lookup key, never the embedded session ID; indexed membership repair retains the same generation guard. One-time mismatches are consumed by the original atomic `GETDEL`, with no follow-up delete that could remove a replacement
148
+ - a stale repair observing corruption therefore preserves a concurrent replacement, while genuine logout of a live session (direct delete or scoped logical/subject/provider-session revocation) still deletes it; indexed revocation removes stale corrupt members and continues processing later valid sessions
123
149
  - Redis mutation scripts preflight expected key types before writing so wrong-type keys fail without deterministic partial mutation
124
- - validation errors and script errors are sanitized and do not include stored refresh tokens, ID tokens, authorization transactions, or exchange codes
150
+ - package-generated validation and deterministic script diagnostics use fixed text without stored records. Arbitrary client/adapter failures can still reject operations with sensitive original errors; they are not generally sanitized for application logs. Use fixed operation names and allowlisted categories, not raw errors, connection URLs, session IDs, state/PKCE values, exchange codes or token-valued metric labels. The secret-independent post-commit warning policy above applies specifically to that warning path
125
151
 
126
152
  ## Main Exports
127
153
 
154
+ Use named imports from the package root; there is no default export or public subpath API. Use Node.js `>=22.12.0` for the core dependency's CJS/ESM runtime support. TypeScript apps also need `@types/node` and `@types/express`.
155
+
128
156
  - `createRedisOidcVaultStore(...)`
129
157
  - `OidcVaultRedisStoreRecordError`
130
158
  - `type RedisOidcVaultStoreOptions`
package/index.d.mts CHANGED
@@ -2,7 +2,8 @@ import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  /**
4
4
  * Thrown when a stored Redis value cannot be parsed or fails structural
5
- * validation. The message never includes stored token or refresh values.
5
+ * validation (including identity mismatch with the requested key). The message
6
+ * never includes stored token or refresh values.
6
7
  * Malformed one-time records are consumed atomically and return `null`; they
7
8
  * fail closed without throwing this error. Malformed sessions are deleted when
8
9
  * encountered through reads or indexed revocation.
package/index.d.ts CHANGED
@@ -2,7 +2,8 @@ import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  /**
4
4
  * Thrown when a stored Redis value cannot be parsed or fails structural
5
- * validation. The message never includes stored token or refresh values.
5
+ * validation (including identity mismatch with the requested key). The message
6
+ * never includes stored token or refresh values.
6
7
  * Malformed one-time records are consumed atomically and return `null`; they
7
8
  * fail closed without throwing this error. Malformed sessions are deleted when
8
9
  * encountered through reads or indexed revocation.
package/index.js CHANGED
@@ -98,10 +98,10 @@ var isOptionalString = (value) => value === void 0 || isString(value);
98
98
  var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
99
99
  var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
100
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);
101
+ var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === 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, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
103
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));
104
+ var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === 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
105
  var parseStoredJson = (value, recordKind, validate) => {
106
106
  if (value === null) {
107
107
  return null;
@@ -267,7 +267,42 @@ end
267
267
 
268
268
  return 1
269
269
  `;
270
+ var LIVE_LINEAGE_HELPER = `
271
+ local function hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, excludedId)
272
+ local members = redis.call('ZRANGE', logicalIndexKey, 0, -1)
273
+ for _, memberId in ipairs(members) do
274
+ if memberId ~= excludedId then
275
+ local value = redis.call('GET', sessionKeyPrefix .. memberId)
276
+ if value ~= false then
277
+ local ok, member = pcall(cjson.decode, value)
278
+ if ok and type(member) == 'table' and member['sessionId'] == memberId
279
+ and (member['logicalSessionId'] or memberId) == logicalSessionId then
280
+ return true
281
+ end
282
+ end
283
+ end
284
+ end
285
+ return false
286
+ end
287
+ `;
288
+ var CLEANUP_INACTIVE_ALIASES_SCRIPT = `
289
+ ${LIVE_LINEAGE_HELPER}
290
+ if hasLiveMember(KEYS[1], ARGV[1], ARGV[3], nil) then
291
+ return 0
292
+ end
293
+ local aliases = redis.call('ZRANGE', KEYS[2], 0, -1)
294
+ for _, aliasId in ipairs(aliases) do
295
+ redis.call('DEL', ARGV[2] .. aliasId)
296
+ end
297
+ return redis.call('DEL', KEYS[2])
298
+ `;
299
+ var buildCleanupInactiveAliasesCommand = (keys, logicalSessionId) => evalCommand(
300
+ CLEANUP_INACTIVE_ALIASES_SCRIPT,
301
+ [keys.logicalSessionIndex(logicalSessionId), keys.rotatedSessionAliasIndex(logicalSessionId)],
302
+ [keys.sessionPrefix(), keys.rotatedSessionAliasPrefix(), logicalSessionId]
303
+ );
270
304
  var DELETE_SESSION_SCRIPT = `
305
+ ${LIVE_LINEAGE_HELPER}
271
306
  local expected = cjson.decode(ARGV[1])
272
307
  local scopeKind = ARGV[2]
273
308
  local scopeValue = ARGV[3]
@@ -347,7 +382,16 @@ local function deleteRecord(sessionKey, session)
347
382
  assertZsetOrMissing(providerIndexKeyPrefix .. session['providerSessionId'])
348
383
  end
349
384
 
350
- local aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
385
+ -- Check actual primaries, not just scores: TTL is authoritative and stale
386
+ -- logical memberships must not keep a terminated lineage's aliases alive.
387
+ -- This check and deletion share the script's atomic boundary with rotation.
388
+ local logicalIndexKey = logicalIndexKeyPrefix .. logicalSessionId
389
+ local hasSurvivor = hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, sessionId)
390
+
391
+ local aliasSessionIds = {}
392
+ if not hasSurvivor then
393
+ aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
394
+ end
351
395
 
352
396
  if redis.call('DEL', sessionKey) == 0 then
353
397
  return 0
@@ -364,7 +408,9 @@ local function deleteRecord(sessionKey, session)
364
408
  redis.call('DEL', aliasKeyPrefix .. aliasSessionId)
365
409
  end
366
410
 
367
- redis.call('DEL', aliasIndexKey)
411
+ if not hasSurvivor then
412
+ redis.call('DEL', aliasIndexKey)
413
+ end
368
414
 
369
415
  return 1
370
416
  end
@@ -526,6 +572,30 @@ redis.call('ZADD', aliasIndexKey, newScore, oldSessionId)
526
572
 
527
573
  return 1
528
574
  `;
575
+ var COMPARE_AND_DELETE_SCRIPT = `
576
+ local current = redis.call('GET', KEYS[1])
577
+
578
+ if current == ARGV[1] then
579
+ return redis.call('DEL', KEYS[1])
580
+ else
581
+ return 0
582
+ end
583
+ `;
584
+ var buildCompareAndDeleteCommand = (key, observedValue) => evalCommand(COMPARE_AND_DELETE_SCRIPT, [key], [observedValue]);
585
+ var REPAIR_SESSION_INDEX_SCRIPT = `
586
+ local current = redis.call('GET', KEYS[1])
587
+
588
+ if current == false or (ARGV[3] == '1' and current == ARGV[2]) then
589
+ return redis.call('ZREM', KEYS[2], ARGV[1])
590
+ end
591
+
592
+ return 0
593
+ `;
594
+ var buildRepairSessionIndexCommand = (sessionKey, indexKey, sessionId, observedValue) => evalCommand(
595
+ REPAIR_SESSION_INDEX_SCRIPT,
596
+ [sessionKey, indexKey],
597
+ [sessionId, observedValue ?? "", observedValue === null ? "0" : "1"]
598
+ );
529
599
  var buildWriteSessionCommand = (keys, session) => evalCommand(
530
600
  WRITE_SESSION_SCRIPT,
531
601
  [
@@ -590,11 +660,30 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
590
660
  // src/index.ts
591
661
  var INDEX_CLEANUP_SCAN_COUNT = 100;
592
662
  var INDEX_REVOCATION_SCAN_COUNT = 250;
663
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
664
+ if (input === null || typeof input !== "object") return input;
665
+ const prototype = Object.getPrototypeOf(input);
666
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
667
+ if (seen.has(input)) return seen.get(input);
668
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
669
+ seen.set(input, copy);
670
+ for (const key of Reflect.ownKeys(input)) {
671
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
672
+ Object.defineProperty(copy, key, {
673
+ value: snapshotInput(Reflect.get(input, key), seen),
674
+ enumerable: true,
675
+ configurable: true,
676
+ writable: true
677
+ });
678
+ }
679
+ }
680
+ return copy;
681
+ };
593
682
  var isRecord = (value) => typeof value === "object" && value !== null;
594
683
  var isUnsupportedClusterClient = (client) => isRecord(client) && typeof client.getSlotMaster === "function" && typeof client.nodeClient === "function" && Array.isArray(client.masters) && Array.isArray(client.slots);
595
- var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
596
- var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
597
- var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
684
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
685
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
686
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
598
687
  var matchesProviderScope = (session, input) => {
599
688
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
600
689
  return false;
@@ -631,16 +720,35 @@ var RedisOidcVaultStore = class {
631
720
  return this.consumeJson(
632
721
  this.keys.authorizationTransaction(state),
633
722
  "authorization transaction",
634
- validateAuthorizationTransaction
723
+ (value) => validateAuthorizationTransaction(value, state)
635
724
  );
636
725
  }
637
726
  async createExchangeCode(input) {
638
727
  await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
639
728
  }
640
729
  async consumeExchangeCode(code) {
641
- return this.consumeJson(this.keys.exchangeCode(code), "exchange code", validateExchangeCodeRecord);
730
+ return this.consumeJson(
731
+ this.keys.exchangeCode(code),
732
+ "exchange code",
733
+ (value) => validateExchangeCodeRecord(value, code)
734
+ );
642
735
  }
736
+ /**
737
+ * Creates a session record atomically, then runs best-effort index
738
+ * maintenance.
739
+ *
740
+ * Result policy (SVH-02): once `writeSessionRecord` reports success the
741
+ * session is committed and this method resolves with the session even if
742
+ * post-commit index maintenance (`SCAN`/`TYPE`/`TIME`/`ZREMRANGEBYSCORE`)
743
+ * fails. A maintenance failure is reported once via a fixed-text
744
+ * `console.warn` identifying only the operation (no adapter error details,
745
+ * session IDs, keys, or token material) and is retried opportunistically by
746
+ * a later `createSession`/`rotateSession` call. Mutation-command failures (the
747
+ * atomic write script) still reject, and this method never retries a
748
+ * committed mutation.
749
+ */
643
750
  async createSession(input) {
751
+ input = snapshotInput({ ...input });
644
752
  const timestamp = this.now();
645
753
  const session = {
646
754
  ...input,
@@ -652,13 +760,16 @@ var RedisOidcVaultStore = class {
652
760
  if (!written) {
653
761
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session already exists.");
654
762
  }
655
- await this.cleanupStaleIndexKeys();
763
+ await this.runPostCommitIndexMaintenance("createSession");
656
764
  return session;
657
765
  }
658
766
  async getSession(sessionId) {
659
- return this.getJson(this.keys.session(sessionId), "session", validateSession, { deleteMalformed: true });
767
+ return this.getJson(this.keys.session(sessionId), "session", (value) => validateSession(value, sessionId), {
768
+ deleteMalformed: true
769
+ });
660
770
  }
661
771
  async rotateSession(input) {
772
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
662
773
  const previousSession = await this.getSession(input.sessionId);
663
774
  if (!previousSession) {
664
775
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
@@ -674,7 +785,7 @@ var RedisOidcVaultStore = class {
674
785
  if (!rotated) {
675
786
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
676
787
  }
677
- await this.cleanupStaleIndexKeys();
788
+ await this.runPostCommitIndexMaintenance("rotateSession");
678
789
  return nextSession;
679
790
  }
680
791
  async deleteSession(sessionId) {
@@ -688,13 +799,11 @@ var RedisOidcVaultStore = class {
688
799
  );
689
800
  if (logicalSessionId) {
690
801
  await this.deleteSessionsByLogicalSessionId(logicalSessionId);
691
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
692
802
  }
693
- await this.client.del(this.keys.session(sessionId));
803
+ await this.deleteSessionKeyIfMalformed(sessionId);
694
804
  return;
695
805
  }
696
806
  await this.deleteSessionRecord(session, { kind: "single" });
697
- await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
698
807
  }
699
808
  async deleteSessionsByLogicalSessionId(input) {
700
809
  const resolved = toLogicalSessionDeleteInput(input);
@@ -724,11 +833,12 @@ var RedisOidcVaultStore = class {
724
833
  await this.client.set(key, serialize(value), options);
725
834
  }
726
835
  async getJson(key, recordKind, validate, options) {
836
+ const raw = await this.client.get(key);
727
837
  try {
728
- return parseStoredJson(await this.client.get(key), recordKind, validate);
838
+ return parseStoredJson(raw, recordKind, validate);
729
839
  } catch (error) {
730
- if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
731
- await this.client.del(key);
840
+ if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
841
+ await this.deleteMalformedValueIfUnchanged(key, raw);
732
842
  return null;
733
843
  }
734
844
  throw error;
@@ -753,6 +863,43 @@ var RedisOidcVaultStore = class {
753
863
  const result = await this.runScript(buildDeleteSessionCommand(this.keys, session, scope));
754
864
  return typeof result === "number" ? result : Number(result);
755
865
  }
866
+ /**
867
+ * Removes a stored value only when it still equals the malformed payload
868
+ * observed by a prior read (SVH-03). The equality check runs inside the Lua
869
+ * script, so a concurrent `createSession` reusing the same ID is never
870
+ * destroyed by a stale repair.
871
+ */
872
+ async deleteMalformedValueIfUnchanged(key, observedRaw) {
873
+ await this.runScript(buildCompareAndDeleteCommand(key, observedRaw));
874
+ }
875
+ /**
876
+ * Best-effort cleanup for the missing-session branch of `deleteSession`.
877
+ * Re-reads the session key and removes it only when it still holds malformed
878
+ * data. A missing key needs no cleanup and a freshly created valid session
879
+ * (same-ID reuse after the earlier read) is preserved; genuine logout of a
880
+ * live session never reaches this branch.
881
+ */
882
+ async deleteSessionKeyIfMalformed(sessionId) {
883
+ const sessionKey = this.keys.session(sessionId);
884
+ let raw;
885
+ try {
886
+ raw = await this.client.get(sessionKey);
887
+ } catch {
888
+ return;
889
+ }
890
+ if (raw === null) {
891
+ return;
892
+ }
893
+ try {
894
+ parseStoredJson(raw, "session", (value) => validateSession(value, sessionId));
895
+ } catch (error) {
896
+ if (error instanceof OidcVaultRedisStoreRecordError) {
897
+ await this.deleteMalformedValueIfUnchanged(sessionKey, raw);
898
+ return;
899
+ }
900
+ throw error;
901
+ }
902
+ }
756
903
  async rotateSessionRecord(previousSession, nextSession) {
757
904
  const result = await this.runScript(buildRotateSessionCommand(this.keys, previousSession, nextSession));
758
905
  if (result === 2 || result === "2") {
@@ -766,6 +913,20 @@ var RedisOidcVaultStore = class {
766
913
  async cleanupExpiredIndexMembers(indexKey) {
767
914
  await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(await this.redisServerTime())]);
768
915
  }
916
+ /**
917
+ * Runs optional post-commit index maintenance without letting it masquerade
918
+ * as a failed mutation. Any failure is reported once via a fixed-text
919
+ * warning and swallowed so the already-committed session result stands; the
920
+ * next successful create/rotate retries the incremental scan from the
921
+ * retained cursor. Never retries the committed mutation itself.
922
+ */
923
+ async runPostCommitIndexMaintenance(operation) {
924
+ try {
925
+ await this.cleanupStaleIndexKeys();
926
+ } catch {
927
+ console.warn(`OIDC vault Redis index maintenance failed after ${operation} and will retry on a later write.`);
928
+ }
929
+ }
769
930
  async cleanupStaleIndexKeys() {
770
931
  const response = await this.sendCommand([
771
932
  "SCAN",
@@ -816,10 +977,6 @@ var RedisOidcVaultStore = class {
816
977
  }
817
978
  return seconds * 1e3 + Math.floor(microseconds / 1e3);
818
979
  }
819
- async getSessionIdsFromIndex(indexKey) {
820
- const response = await this.sendCommand(["ZRANGE", indexKey, "0", "-1"]);
821
- return Array.isArray(response) ? response.filter((value) => typeof value === "string") : [];
822
- }
823
980
  async scanSessionIdsFromIndex(indexKey, cursor) {
824
981
  const response = await this.sendCommand(["ZSCAN", indexKey, cursor, "COUNT", String(INDEX_REVOCATION_SCAN_COUNT)]);
825
982
  if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
@@ -844,21 +1001,25 @@ var RedisOidcVaultStore = class {
844
1001
  throw new Error("OIDC vault Redis store received an unexpected MGET response.");
845
1002
  }
846
1003
  return Promise.all(
847
- response.map(async (value, index) => {
1004
+ sessionIds.map(async (sessionId, index) => {
1005
+ const value = response[index];
1006
+ const raw = typeof value === "string" ? value : null;
848
1007
  try {
849
- return typeof value === "string" ? parseStoredJson(value, "session", validateSession) : null;
1008
+ return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
850
1009
  } catch (error) {
851
- if (error instanceof OidcVaultRedisStoreRecordError) {
852
- await this.client.del(this.keys.session(sessionIds[index]));
853
- return null;
1010
+ if (error instanceof OidcVaultRedisStoreRecordError && typeof value === "string") {
1011
+ await this.deleteMalformedValueIfUnchanged(this.keys.session(sessionId), value);
1012
+ return { session: null, raw };
854
1013
  }
855
1014
  throw error;
856
1015
  }
857
1016
  })
858
1017
  );
859
1018
  }
860
- async removeSessionIdFromIndex(indexKey, sessionId) {
861
- await this.sendCommand(["ZREM", indexKey, sessionId]);
1019
+ async removeSessionIdFromIndex(indexKey, sessionId, observedRaw) {
1020
+ await this.runScript(
1021
+ buildRepairSessionIndexCommand(this.keys.session(sessionId), indexKey, sessionId, observedRaw)
1022
+ );
862
1023
  }
863
1024
  async deleteSessionsFromIndex(indexKey, scope) {
864
1025
  await this.cleanupExpiredIndexMembers(indexKey);
@@ -872,21 +1033,21 @@ var RedisOidcVaultStore = class {
872
1033
  }
873
1034
  const sessions = await this.getSessionRecords(batch.sessionIds);
874
1035
  for (const [index, sessionId] of batch.sessionIds.entries()) {
875
- const session = sessions[index];
1036
+ const { session, raw } = sessions[index] ?? { session: null, raw: null };
876
1037
  if (!session) {
877
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1038
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
878
1039
  continue;
879
1040
  }
880
1041
  if ("logicalSessionId" in scope && (session.logicalSessionId ?? session.sessionId) !== scope.logicalSessionId) {
881
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1042
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
882
1043
  continue;
883
1044
  }
884
1045
  if ("subject" in scope && session.subject !== scope.subject) {
885
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1046
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
886
1047
  continue;
887
1048
  }
888
1049
  if ("providerSessionId" in scope && session.providerSessionId !== scope.providerSessionId) {
889
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1050
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
890
1051
  continue;
891
1052
  }
892
1053
  if (!("logicalSessionId" in scope) && !matchesProviderScope(session, scope)) {
@@ -894,11 +1055,13 @@ var RedisOidcVaultStore = class {
894
1055
  }
895
1056
  const revoked = await this.deleteSessionRecord(session, this.toDeleteScriptScope(scope));
896
1057
  if (revoked > 0) {
897
- await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
898
1058
  deleted += revoked;
899
1059
  }
900
1060
  }
901
1061
  } while (cursor !== "0");
1062
+ if (deleted === 0 && "logicalSessionId" in scope) {
1063
+ await this.runScript(buildCleanupInactiveAliasesCommand(this.keys, scope.logicalSessionId));
1064
+ }
902
1065
  return deleted;
903
1066
  }
904
1067
  toDeleteScriptScope(scope) {
@@ -915,16 +1078,6 @@ var RedisOidcVaultStore = class {
915
1078
  clientId: scope.clientId
916
1079
  };
917
1080
  }
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);
927
- }
928
1081
  async sendCommand(args) {
929
1082
  return this.client.sendCommand(args);
930
1083
  }
package/index.mjs CHANGED
@@ -75,10 +75,10 @@ var isOptionalString = (value) => value === void 0 || isString(value);
75
75
  var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
76
76
  var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
77
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);
78
+ var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === 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, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
80
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));
81
+ var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === 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
82
  var parseStoredJson = (value, recordKind, validate) => {
83
83
  if (value === null) {
84
84
  return null;
@@ -244,7 +244,42 @@ end
244
244
 
245
245
  return 1
246
246
  `;
247
+ var LIVE_LINEAGE_HELPER = `
248
+ local function hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, excludedId)
249
+ local members = redis.call('ZRANGE', logicalIndexKey, 0, -1)
250
+ for _, memberId in ipairs(members) do
251
+ if memberId ~= excludedId then
252
+ local value = redis.call('GET', sessionKeyPrefix .. memberId)
253
+ if value ~= false then
254
+ local ok, member = pcall(cjson.decode, value)
255
+ if ok and type(member) == 'table' and member['sessionId'] == memberId
256
+ and (member['logicalSessionId'] or memberId) == logicalSessionId then
257
+ return true
258
+ end
259
+ end
260
+ end
261
+ end
262
+ return false
263
+ end
264
+ `;
265
+ var CLEANUP_INACTIVE_ALIASES_SCRIPT = `
266
+ ${LIVE_LINEAGE_HELPER}
267
+ if hasLiveMember(KEYS[1], ARGV[1], ARGV[3], nil) then
268
+ return 0
269
+ end
270
+ local aliases = redis.call('ZRANGE', KEYS[2], 0, -1)
271
+ for _, aliasId in ipairs(aliases) do
272
+ redis.call('DEL', ARGV[2] .. aliasId)
273
+ end
274
+ return redis.call('DEL', KEYS[2])
275
+ `;
276
+ var buildCleanupInactiveAliasesCommand = (keys, logicalSessionId) => evalCommand(
277
+ CLEANUP_INACTIVE_ALIASES_SCRIPT,
278
+ [keys.logicalSessionIndex(logicalSessionId), keys.rotatedSessionAliasIndex(logicalSessionId)],
279
+ [keys.sessionPrefix(), keys.rotatedSessionAliasPrefix(), logicalSessionId]
280
+ );
247
281
  var DELETE_SESSION_SCRIPT = `
282
+ ${LIVE_LINEAGE_HELPER}
248
283
  local expected = cjson.decode(ARGV[1])
249
284
  local scopeKind = ARGV[2]
250
285
  local scopeValue = ARGV[3]
@@ -324,7 +359,16 @@ local function deleteRecord(sessionKey, session)
324
359
  assertZsetOrMissing(providerIndexKeyPrefix .. session['providerSessionId'])
325
360
  end
326
361
 
327
- local aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
362
+ -- Check actual primaries, not just scores: TTL is authoritative and stale
363
+ -- logical memberships must not keep a terminated lineage's aliases alive.
364
+ -- This check and deletion share the script's atomic boundary with rotation.
365
+ local logicalIndexKey = logicalIndexKeyPrefix .. logicalSessionId
366
+ local hasSurvivor = hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, sessionId)
367
+
368
+ local aliasSessionIds = {}
369
+ if not hasSurvivor then
370
+ aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
371
+ end
328
372
 
329
373
  if redis.call('DEL', sessionKey) == 0 then
330
374
  return 0
@@ -341,7 +385,9 @@ local function deleteRecord(sessionKey, session)
341
385
  redis.call('DEL', aliasKeyPrefix .. aliasSessionId)
342
386
  end
343
387
 
344
- redis.call('DEL', aliasIndexKey)
388
+ if not hasSurvivor then
389
+ redis.call('DEL', aliasIndexKey)
390
+ end
345
391
 
346
392
  return 1
347
393
  end
@@ -503,6 +549,30 @@ redis.call('ZADD', aliasIndexKey, newScore, oldSessionId)
503
549
 
504
550
  return 1
505
551
  `;
552
+ var COMPARE_AND_DELETE_SCRIPT = `
553
+ local current = redis.call('GET', KEYS[1])
554
+
555
+ if current == ARGV[1] then
556
+ return redis.call('DEL', KEYS[1])
557
+ else
558
+ return 0
559
+ end
560
+ `;
561
+ var buildCompareAndDeleteCommand = (key, observedValue) => evalCommand(COMPARE_AND_DELETE_SCRIPT, [key], [observedValue]);
562
+ var REPAIR_SESSION_INDEX_SCRIPT = `
563
+ local current = redis.call('GET', KEYS[1])
564
+
565
+ if current == false or (ARGV[3] == '1' and current == ARGV[2]) then
566
+ return redis.call('ZREM', KEYS[2], ARGV[1])
567
+ end
568
+
569
+ return 0
570
+ `;
571
+ var buildRepairSessionIndexCommand = (sessionKey, indexKey, sessionId, observedValue) => evalCommand(
572
+ REPAIR_SESSION_INDEX_SCRIPT,
573
+ [sessionKey, indexKey],
574
+ [sessionId, observedValue ?? "", observedValue === null ? "0" : "1"]
575
+ );
506
576
  var buildWriteSessionCommand = (keys, session) => evalCommand(
507
577
  WRITE_SESSION_SCRIPT,
508
578
  [
@@ -567,11 +637,30 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
567
637
  // src/index.ts
568
638
  var INDEX_CLEANUP_SCAN_COUNT = 100;
569
639
  var INDEX_REVOCATION_SCAN_COUNT = 250;
640
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
641
+ if (input === null || typeof input !== "object") return input;
642
+ const prototype = Object.getPrototypeOf(input);
643
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
644
+ if (seen.has(input)) return seen.get(input);
645
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
646
+ seen.set(input, copy);
647
+ for (const key of Reflect.ownKeys(input)) {
648
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
649
+ Object.defineProperty(copy, key, {
650
+ value: snapshotInput(Reflect.get(input, key), seen),
651
+ enumerable: true,
652
+ configurable: true,
653
+ writable: true
654
+ });
655
+ }
656
+ }
657
+ return copy;
658
+ };
570
659
  var isRecord = (value) => typeof value === "object" && value !== null;
571
660
  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;
661
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
662
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
663
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
575
664
  var matchesProviderScope = (session, input) => {
576
665
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
577
666
  return false;
@@ -608,16 +697,35 @@ var RedisOidcVaultStore = class {
608
697
  return this.consumeJson(
609
698
  this.keys.authorizationTransaction(state),
610
699
  "authorization transaction",
611
- validateAuthorizationTransaction
700
+ (value) => validateAuthorizationTransaction(value, state)
612
701
  );
613
702
  }
614
703
  async createExchangeCode(input) {
615
704
  await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
616
705
  }
617
706
  async consumeExchangeCode(code) {
618
- return this.consumeJson(this.keys.exchangeCode(code), "exchange code", validateExchangeCodeRecord);
707
+ return this.consumeJson(
708
+ this.keys.exchangeCode(code),
709
+ "exchange code",
710
+ (value) => validateExchangeCodeRecord(value, code)
711
+ );
619
712
  }
713
+ /**
714
+ * Creates a session record atomically, then runs best-effort index
715
+ * maintenance.
716
+ *
717
+ * Result policy (SVH-02): once `writeSessionRecord` reports success the
718
+ * session is committed and this method resolves with the session even if
719
+ * post-commit index maintenance (`SCAN`/`TYPE`/`TIME`/`ZREMRANGEBYSCORE`)
720
+ * fails. A maintenance failure is reported once via a fixed-text
721
+ * `console.warn` identifying only the operation (no adapter error details,
722
+ * session IDs, keys, or token material) and is retried opportunistically by
723
+ * a later `createSession`/`rotateSession` call. Mutation-command failures (the
724
+ * atomic write script) still reject, and this method never retries a
725
+ * committed mutation.
726
+ */
620
727
  async createSession(input) {
728
+ input = snapshotInput({ ...input });
621
729
  const timestamp = this.now();
622
730
  const session = {
623
731
  ...input,
@@ -629,13 +737,16 @@ var RedisOidcVaultStore = class {
629
737
  if (!written) {
630
738
  throw new OidcVaultStoreConflictError("OIDC vault session already exists.");
631
739
  }
632
- await this.cleanupStaleIndexKeys();
740
+ await this.runPostCommitIndexMaintenance("createSession");
633
741
  return session;
634
742
  }
635
743
  async getSession(sessionId) {
636
- return this.getJson(this.keys.session(sessionId), "session", validateSession, { deleteMalformed: true });
744
+ return this.getJson(this.keys.session(sessionId), "session", (value) => validateSession(value, sessionId), {
745
+ deleteMalformed: true
746
+ });
637
747
  }
638
748
  async rotateSession(input) {
749
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
639
750
  const previousSession = await this.getSession(input.sessionId);
640
751
  if (!previousSession) {
641
752
  throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
@@ -651,7 +762,7 @@ var RedisOidcVaultStore = class {
651
762
  if (!rotated) {
652
763
  throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
653
764
  }
654
- await this.cleanupStaleIndexKeys();
765
+ await this.runPostCommitIndexMaintenance("rotateSession");
655
766
  return nextSession;
656
767
  }
657
768
  async deleteSession(sessionId) {
@@ -665,13 +776,11 @@ var RedisOidcVaultStore = class {
665
776
  );
666
777
  if (logicalSessionId) {
667
778
  await this.deleteSessionsByLogicalSessionId(logicalSessionId);
668
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
669
779
  }
670
- await this.client.del(this.keys.session(sessionId));
780
+ await this.deleteSessionKeyIfMalformed(sessionId);
671
781
  return;
672
782
  }
673
783
  await this.deleteSessionRecord(session, { kind: "single" });
674
- await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
675
784
  }
676
785
  async deleteSessionsByLogicalSessionId(input) {
677
786
  const resolved = toLogicalSessionDeleteInput(input);
@@ -701,11 +810,12 @@ var RedisOidcVaultStore = class {
701
810
  await this.client.set(key, serialize(value), options);
702
811
  }
703
812
  async getJson(key, recordKind, validate, options) {
813
+ const raw = await this.client.get(key);
704
814
  try {
705
- return parseStoredJson(await this.client.get(key), recordKind, validate);
815
+ return parseStoredJson(raw, recordKind, validate);
706
816
  } catch (error) {
707
- if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
708
- await this.client.del(key);
817
+ if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
818
+ await this.deleteMalformedValueIfUnchanged(key, raw);
709
819
  return null;
710
820
  }
711
821
  throw error;
@@ -730,6 +840,43 @@ var RedisOidcVaultStore = class {
730
840
  const result = await this.runScript(buildDeleteSessionCommand(this.keys, session, scope));
731
841
  return typeof result === "number" ? result : Number(result);
732
842
  }
843
+ /**
844
+ * Removes a stored value only when it still equals the malformed payload
845
+ * observed by a prior read (SVH-03). The equality check runs inside the Lua
846
+ * script, so a concurrent `createSession` reusing the same ID is never
847
+ * destroyed by a stale repair.
848
+ */
849
+ async deleteMalformedValueIfUnchanged(key, observedRaw) {
850
+ await this.runScript(buildCompareAndDeleteCommand(key, observedRaw));
851
+ }
852
+ /**
853
+ * Best-effort cleanup for the missing-session branch of `deleteSession`.
854
+ * Re-reads the session key and removes it only when it still holds malformed
855
+ * data. A missing key needs no cleanup and a freshly created valid session
856
+ * (same-ID reuse after the earlier read) is preserved; genuine logout of a
857
+ * live session never reaches this branch.
858
+ */
859
+ async deleteSessionKeyIfMalformed(sessionId) {
860
+ const sessionKey = this.keys.session(sessionId);
861
+ let raw;
862
+ try {
863
+ raw = await this.client.get(sessionKey);
864
+ } catch {
865
+ return;
866
+ }
867
+ if (raw === null) {
868
+ return;
869
+ }
870
+ try {
871
+ parseStoredJson(raw, "session", (value) => validateSession(value, sessionId));
872
+ } catch (error) {
873
+ if (error instanceof OidcVaultRedisStoreRecordError) {
874
+ await this.deleteMalformedValueIfUnchanged(sessionKey, raw);
875
+ return;
876
+ }
877
+ throw error;
878
+ }
879
+ }
733
880
  async rotateSessionRecord(previousSession, nextSession) {
734
881
  const result = await this.runScript(buildRotateSessionCommand(this.keys, previousSession, nextSession));
735
882
  if (result === 2 || result === "2") {
@@ -743,6 +890,20 @@ var RedisOidcVaultStore = class {
743
890
  async cleanupExpiredIndexMembers(indexKey) {
744
891
  await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(await this.redisServerTime())]);
745
892
  }
893
+ /**
894
+ * Runs optional post-commit index maintenance without letting it masquerade
895
+ * as a failed mutation. Any failure is reported once via a fixed-text
896
+ * warning and swallowed so the already-committed session result stands; the
897
+ * next successful create/rotate retries the incremental scan from the
898
+ * retained cursor. Never retries the committed mutation itself.
899
+ */
900
+ async runPostCommitIndexMaintenance(operation) {
901
+ try {
902
+ await this.cleanupStaleIndexKeys();
903
+ } catch {
904
+ console.warn(`OIDC vault Redis index maintenance failed after ${operation} and will retry on a later write.`);
905
+ }
906
+ }
746
907
  async cleanupStaleIndexKeys() {
747
908
  const response = await this.sendCommand([
748
909
  "SCAN",
@@ -793,10 +954,6 @@ var RedisOidcVaultStore = class {
793
954
  }
794
955
  return seconds * 1e3 + Math.floor(microseconds / 1e3);
795
956
  }
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
957
  async scanSessionIdsFromIndex(indexKey, cursor) {
801
958
  const response = await this.sendCommand(["ZSCAN", indexKey, cursor, "COUNT", String(INDEX_REVOCATION_SCAN_COUNT)]);
802
959
  if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
@@ -821,21 +978,25 @@ var RedisOidcVaultStore = class {
821
978
  throw new Error("OIDC vault Redis store received an unexpected MGET response.");
822
979
  }
823
980
  return Promise.all(
824
- response.map(async (value, index) => {
981
+ sessionIds.map(async (sessionId, index) => {
982
+ const value = response[index];
983
+ const raw = typeof value === "string" ? value : null;
825
984
  try {
826
- return typeof value === "string" ? parseStoredJson(value, "session", validateSession) : null;
985
+ return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
827
986
  } catch (error) {
828
- if (error instanceof OidcVaultRedisStoreRecordError) {
829
- await this.client.del(this.keys.session(sessionIds[index]));
830
- return null;
987
+ if (error instanceof OidcVaultRedisStoreRecordError && typeof value === "string") {
988
+ await this.deleteMalformedValueIfUnchanged(this.keys.session(sessionId), value);
989
+ return { session: null, raw };
831
990
  }
832
991
  throw error;
833
992
  }
834
993
  })
835
994
  );
836
995
  }
837
- async removeSessionIdFromIndex(indexKey, sessionId) {
838
- await this.sendCommand(["ZREM", indexKey, sessionId]);
996
+ async removeSessionIdFromIndex(indexKey, sessionId, observedRaw) {
997
+ await this.runScript(
998
+ buildRepairSessionIndexCommand(this.keys.session(sessionId), indexKey, sessionId, observedRaw)
999
+ );
839
1000
  }
840
1001
  async deleteSessionsFromIndex(indexKey, scope) {
841
1002
  await this.cleanupExpiredIndexMembers(indexKey);
@@ -849,21 +1010,21 @@ var RedisOidcVaultStore = class {
849
1010
  }
850
1011
  const sessions = await this.getSessionRecords(batch.sessionIds);
851
1012
  for (const [index, sessionId] of batch.sessionIds.entries()) {
852
- const session = sessions[index];
1013
+ const { session, raw } = sessions[index] ?? { session: null, raw: null };
853
1014
  if (!session) {
854
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1015
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
855
1016
  continue;
856
1017
  }
857
1018
  if ("logicalSessionId" in scope && (session.logicalSessionId ?? session.sessionId) !== scope.logicalSessionId) {
858
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1019
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
859
1020
  continue;
860
1021
  }
861
1022
  if ("subject" in scope && session.subject !== scope.subject) {
862
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1023
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
863
1024
  continue;
864
1025
  }
865
1026
  if ("providerSessionId" in scope && session.providerSessionId !== scope.providerSessionId) {
866
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1027
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
867
1028
  continue;
868
1029
  }
869
1030
  if (!("logicalSessionId" in scope) && !matchesProviderScope(session, scope)) {
@@ -871,11 +1032,13 @@ var RedisOidcVaultStore = class {
871
1032
  }
872
1033
  const revoked = await this.deleteSessionRecord(session, this.toDeleteScriptScope(scope));
873
1034
  if (revoked > 0) {
874
- await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
875
1035
  deleted += revoked;
876
1036
  }
877
1037
  }
878
1038
  } while (cursor !== "0");
1039
+ if (deleted === 0 && "logicalSessionId" in scope) {
1040
+ await this.runScript(buildCleanupInactiveAliasesCommand(this.keys, scope.logicalSessionId));
1041
+ }
879
1042
  return deleted;
880
1043
  }
881
1044
  toDeleteScriptScope(scope) {
@@ -892,16 +1055,6 @@ var RedisOidcVaultStore = class {
892
1055
  clientId: scope.clientId
893
1056
  };
894
1057
  }
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
1058
  async sendCommand(args) {
906
1059
  return this.client.sendCommand(args);
907
1060
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-redis-store",
3
3
  "description": "Redis store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-redis-store",
5
- "version": "0.42.2",
5
+ "version": "0.47.1",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -29,7 +29,7 @@
29
29
  "node": ">=22"
30
30
  },
31
31
  "dependencies": {
32
- "@web-ts-toolkit/express-oidc-vault": "0.42.2"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.47.1"
33
33
  },
34
34
  "files": [
35
35
  "**/*",