@web-ts-toolkit/express-oidc-vault-redis-store 0.43.0 → 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 +39 -11
- package/index.d.mts +2 -1
- package/index.d.ts +2 -1
- package/index.js +199 -46
- package/index.mjs +199 -46
- package/package.json +2 -2
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;
|
|
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
|
-
-
|
|
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
|
-
- **
|
|
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
|
|
113
|
-
- each successful session create or rotation
|
|
114
|
-
-
|
|
115
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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, {
|
|
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.
|
|
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.
|
|
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(
|
|
838
|
+
return parseStoredJson(raw, recordKind, validate);
|
|
729
839
|
} catch (error) {
|
|
730
|
-
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
|
|
731
|
-
await this.
|
|
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
|
-
|
|
1004
|
+
sessionIds.map(async (sessionId, index) => {
|
|
1005
|
+
const value = response[index];
|
|
1006
|
+
const raw = typeof value === "string" ? value : null;
|
|
848
1007
|
try {
|
|
849
|
-
return
|
|
1008
|
+
return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
|
|
850
1009
|
} catch (error) {
|
|
851
|
-
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
852
|
-
await this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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, {
|
|
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.
|
|
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.
|
|
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(
|
|
815
|
+
return parseStoredJson(raw, recordKind, validate);
|
|
706
816
|
} catch (error) {
|
|
707
|
-
if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
|
|
708
|
-
await this.
|
|
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
|
-
|
|
981
|
+
sessionIds.map(async (sessionId, index) => {
|
|
982
|
+
const value = response[index];
|
|
983
|
+
const raw = typeof value === "string" ? value : null;
|
|
825
984
|
try {
|
|
826
|
-
return
|
|
985
|
+
return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
|
|
827
986
|
} catch (error) {
|
|
828
|
-
if (error instanceof OidcVaultRedisStoreRecordError) {
|
|
829
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
32
|
+
"@web-ts-toolkit/express-oidc-vault": "0.47.1"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"**/*",
|