ioredis-toolkit 0.0.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +645 -0
  3. package/dist/cache.d.ts +298 -0
  4. package/dist/cache.js +606 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +958 -0
  7. package/dist/cluster-slot.d.ts +4 -0
  8. package/dist/cluster-slot.js +31 -0
  9. package/dist/cluster.d.ts +79 -0
  10. package/dist/cluster.js +156 -0
  11. package/dist/errors.d.ts +30 -0
  12. package/dist/errors.js +63 -0
  13. package/dist/health.d.ts +39 -0
  14. package/dist/health.js +106 -0
  15. package/dist/index.d.ts +51 -0
  16. package/dist/index.js +44 -0
  17. package/dist/lock.d.ts +215 -0
  18. package/dist/lock.js +385 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/logger.js +40 -0
  21. package/dist/pubsub.d.ts +171 -0
  22. package/dist/pubsub.js +285 -0
  23. package/dist/ratelimiter.d.ts +162 -0
  24. package/dist/ratelimiter.js +289 -0
  25. package/dist/session/index.d.ts +23 -0
  26. package/dist/session/index.js +16 -0
  27. package/dist/session/revocation-store.d.ts +171 -0
  28. package/dist/session/revocation-store.js +310 -0
  29. package/dist/session/scripts/cleanup-index.lua +21 -0
  30. package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
  31. package/dist/session/scripts/conditional-update.lua +63 -0
  32. package/dist/session/scripts/create.lua +68 -0
  33. package/dist/session/scripts/delete-by-user.lua +29 -0
  34. package/dist/session/scripts/delete.lua +15 -0
  35. package/dist/session/scripts/enforce-limit.lua +38 -0
  36. package/dist/session/scripts/revoke.lua +61 -0
  37. package/dist/session/scripts/rotate-encrypted.lua +107 -0
  38. package/dist/session/scripts/rotate.lua +119 -0
  39. package/dist/session/scripts/touch-encrypted.lua +89 -0
  40. package/dist/session/scripts/touch.lua +72 -0
  41. package/dist/session/scripts/validate.lua +90 -0
  42. package/dist/session/session-circuit-breaker.d.ts +42 -0
  43. package/dist/session/session-circuit-breaker.js +129 -0
  44. package/dist/session/session-config.d.ts +335 -0
  45. package/dist/session/session-config.js +162 -0
  46. package/dist/session/session-cookie.d.ts +72 -0
  47. package/dist/session/session-cookie.js +101 -0
  48. package/dist/session/session-encryption.d.ts +87 -0
  49. package/dist/session/session-encryption.js +139 -0
  50. package/dist/session/session-errors.d.ts +85 -0
  51. package/dist/session/session-errors.js +145 -0
  52. package/dist/session/session-health.d.ts +38 -0
  53. package/dist/session/session-health.js +60 -0
  54. package/dist/session/session-keys.d.ts +51 -0
  55. package/dist/session/session-keys.js +113 -0
  56. package/dist/session/session-manager.d.ts +59 -0
  57. package/dist/session/session-manager.js +94 -0
  58. package/dist/session/session-metrics.d.ts +33 -0
  59. package/dist/session/session-metrics.js +112 -0
  60. package/dist/session/session-repository.d.ts +161 -0
  61. package/dist/session/session-repository.js +683 -0
  62. package/dist/session/session-scripts.d.ts +36 -0
  63. package/dist/session/session-scripts.js +130 -0
  64. package/dist/session/session-serializer.d.ts +42 -0
  65. package/dist/session/session-serializer.js +248 -0
  66. package/dist/session/session-service.d.ts +104 -0
  67. package/dist/session/session-service.js +611 -0
  68. package/dist/session/session-token.d.ts +38 -0
  69. package/dist/session/session-token.js +86 -0
  70. package/dist/session/session-types.d.ts +253 -0
  71. package/dist/session/session-types.js +16 -0
  72. package/dist/types.d.ts +782 -0
  73. package/dist/types.js +140 -0
  74. package/package.json +97 -0
@@ -0,0 +1,310 @@
1
+ import { RevocationError, RevocationBatchError, redactIdentifier } from './session-errors.js';
2
+ /**
3
+ * Redis-backed revocation store. Each revoked jti is stored as
4
+ * `{prefix}{jti} -> reason`, with the Redis key TTL itself set to the
5
+ * token's remaining lifetime — expired entries are reclaimed automatically
6
+ * by Redis, no sweep job required.
7
+ *
8
+ * Every operation here is a single-key command, so this store works
9
+ * identically on standalone, Sentinel, and Cluster with no hash tags
10
+ * required (unlike the session store, there's no multi-key atomicity
11
+ * requirement to satisfy).
12
+ *
13
+ * Batched operations (`revokeMany`, `isRevokedMany`) group their commands
14
+ * by hash slot and issue one pipeline per slot, so they never trigger
15
+ * `CROSSSLOT` errors on Redis Cluster. Pipeline failures are surfaced via
16
+ * {@link RevocationBatchError} instead of being silently swallowed —
17
+ * a missed revocation is a security bug.
18
+ *
19
+ * Validation fails fast and typed: invalid records throw
20
+ * {@link RevocationError} before any network call, and reads fail closed
21
+ * (an infra error is never treated as "not revoked").
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const revocations = new RedisRevocationStore({ client });
26
+ *
27
+ * await revocations.revoke({
28
+ * jti: 'a1b2c3d4',
29
+ * reason: 'password-change',
30
+ * expiresAt: Math.floor(Date.now() / 1000) + 3600,
31
+ * });
32
+ *
33
+ * if (await revocations.isRevoked('a1b2c3d4')) {
34
+ * // token was rotated or revoked - reject it
35
+ * }
36
+ * ```
37
+ */
38
+ export class RedisRevocationStore {
39
+ client;
40
+ keyPrefix;
41
+ /**
42
+ * Creates a Redis-backed revocation store.
43
+ *
44
+ * @param options - Client connection and key-prefix configuration.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const store = new RedisRevocationStore({
49
+ * client,
50
+ * keyPrefix: 'myapp:revoked:',
51
+ * });
52
+ * ```
53
+ */
54
+ constructor(options) {
55
+ this.client = options.client;
56
+ this.keyPrefix = options.keyPrefix ?? 'authcore:revoked:';
57
+ }
58
+ /* ------------------------------------------------------------------------ */
59
+ /* Revoke */
60
+ /* ------------------------------------------------------------------------ */
61
+ /**
62
+ * Marks a jti as revoked for the remainder of its lifetime.
63
+ *
64
+ * Stores `{prefix}{jti} -> reason` with a Redis TTL equal to the
65
+ * record's remaining lifetime (`expiresAt - now`), so the entry is
66
+ * garbage-collected automatically once the original token would have
67
+ * expired anyway. Overwriting an existing entry extends/refreshes its
68
+ * TTL to the new expiry.
69
+ *
70
+ * @param record - The revocation entry (`jti`, `expiresAt`, optional
71
+ * `reason`). `expiresAt` must be a finite Unix-seconds timestamp in
72
+ * the future.
73
+ * @throws {RevocationError} when `record.expiresAt` is missing, not a
74
+ * finite number, or not in the future (fails fast instead of sending
75
+ * an invalid `EX` to Redis).
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * await revocations.revoke({
80
+ * jti: 'a1b2c3d4',
81
+ * reason: 'logout',
82
+ * expiresAt: Math.floor(Date.now() / 1000) + 86400,
83
+ * });
84
+ * ```
85
+ */
86
+ async revoke(record) {
87
+ const ttl = computeTtl(record);
88
+ try {
89
+ await this.client.set(this.key(record.jti), record.reason ?? '1', ttl);
90
+ }
91
+ catch (error) {
92
+ throw wrapStorageError(error, { jti: record.jti });
93
+ }
94
+ }
95
+ /**
96
+ * Revokes many jtis in one batched call.
97
+ *
98
+ * All records are validated up front — an invalid `expiresAt` fails
99
+ * before any network call is issued, rather than partway through a
100
+ * batch. Commands are grouped by hash slot (one pipeline per slot) so
101
+ * the batch stays Cluster-safe, and every pipeline result is inspected:
102
+ * any failed command throws {@link RevocationBatchError} listing the
103
+ * affected jtis, because a silently-missed revocation is a security bug.
104
+ *
105
+ * @param records - The revocation entries to create/refresh.
106
+ * @throws {RevocationError} when any record is invalid (validation is
107
+ * all-or-nothing, before any network call).
108
+ * @throws {RevocationBatchError} when one or more pipeline commands
109
+ * fail; carries the exact jtis that were not revoked.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * await revocations.revokeMany([
114
+ * { jti: 'a1', reason: 'logout-all', expiresAt: expiry },
115
+ * { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
116
+ * ]);
117
+ * ```
118
+ */
119
+ async revokeMany(records) {
120
+ if (records.length === 0)
121
+ return;
122
+ // Validate every record up front - fail before issuing any network
123
+ // calls rather than partway through a batch.
124
+ const ttls = records.map((record) => computeTtl(record));
125
+ // Group by hash slot: the jti keys are not hash-tagged, so they may
126
+ // scatter across Cluster slots. One pipeline per slot avoids
127
+ // CROSSSLOT errors.
128
+ const groups = new Map();
129
+ for (let i = 0; i < records.length; i++) {
130
+ const record = records[i];
131
+ const key = this.key(record.jti);
132
+ const slot = this.client.calculateSlot(key);
133
+ const entry = { jti: record.jti, value: record.reason ?? '1', ttl: ttls[i] };
134
+ const group = groups.get(slot);
135
+ if (group) {
136
+ group.push(entry);
137
+ }
138
+ else {
139
+ groups.set(slot, [entry]);
140
+ }
141
+ }
142
+ const failures = [];
143
+ for (const entries of groups.values()) {
144
+ const pipeline = this.client.pipeline();
145
+ for (const entry of entries) {
146
+ pipeline.set(this.key(entry.jti), entry.value, 'EX', entry.ttl);
147
+ }
148
+ const results = await pipeline.exec();
149
+ // pipeline.exec() resolves to [error, result][] - a failed command
150
+ // does NOT reject the pipeline promise. Check every result.
151
+ for (let i = 0; i < entries.length; i++) {
152
+ const result = results?.[i];
153
+ const error = Array.isArray(result) ? result[0] : undefined;
154
+ if (error) {
155
+ failures.push({ jti: entries[i].jti, error });
156
+ }
157
+ }
158
+ }
159
+ if (failures.length > 0) {
160
+ throw new RevocationBatchError(failures);
161
+ }
162
+ }
163
+ /* ------------------------------------------------------------------------ */
164
+ /* Check */
165
+ /* ------------------------------------------------------------------------ */
166
+ /**
167
+ * Checks whether a jti is currently revoked.
168
+ *
169
+ * Fail-closed: infrastructure errors are wrapped in a typed error and
170
+ * must NOT be treated as "not revoked".
171
+ *
172
+ * @param jti - The token/session id to check.
173
+ * @returns `true` when the jti has a live revocation entry.
174
+ * @throws {RevocationError} when the check itself fails (caller must
175
+ * treat the outcome as unknown).
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * if (await revocations.isRevoked(token.jti)) {
180
+ * return 401; // token was rotated away or explicitly revoked
181
+ * }
182
+ * ```
183
+ */
184
+ async isRevoked(jti) {
185
+ try {
186
+ const exists = await this.client.exists(this.key(jti));
187
+ return exists === 1;
188
+ }
189
+ catch (error) {
190
+ throw wrapStorageError(error, { jti });
191
+ }
192
+ }
193
+ /**
194
+ * Batched revocation check - one network round trip instead of N.
195
+ *
196
+ * Useful for validating a whole family of rotated tokens, or a batch
197
+ * of refresh attempts, at once. Commands are grouped by hash slot
198
+ * (one pipeline per slot) to stay Cluster-safe, and the check fails
199
+ * closed: if any command errors, {@link RevocationBatchError} is thrown
200
+ * rather than silently treating the jti as "not revoked".
201
+ *
202
+ * @param jtis - The token/session ids to check.
203
+ * @returns A `Set` containing exactly the revoked jtis.
204
+ * @throws {RevocationBatchError} when a pipeline command fails -
205
+ * the caller must treat the outcome as unknown, not as "valid".
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * const revoked = await revocations.isRevokedMany(['a1', 'b2', 'c3']);
210
+ * if (revoked.has('b2')) {
211
+ * // b2 must not be accepted
212
+ * }
213
+ * ```
214
+ */
215
+ async isRevokedMany(jtis) {
216
+ if (jtis.length === 0)
217
+ return new Set();
218
+ const groups = new Map();
219
+ for (const jti of jtis) {
220
+ const key = this.key(jti);
221
+ const slot = this.client.calculateSlot(key);
222
+ const group = groups.get(slot);
223
+ if (group) {
224
+ group.push(jti);
225
+ }
226
+ else {
227
+ groups.set(slot, [jti]);
228
+ }
229
+ }
230
+ const revoked = new Set();
231
+ for (const groupJtis of groups.values()) {
232
+ const pipeline = this.client.pipeline();
233
+ for (const jti of groupJtis) {
234
+ pipeline.exists(this.key(jti));
235
+ }
236
+ const results = await pipeline.exec();
237
+ for (let i = 0; i < groupJtis.length; i++) {
238
+ const result = results?.[i];
239
+ const error = Array.isArray(result) ? result[0] : undefined;
240
+ const value = Array.isArray(result) ? result[1] : undefined;
241
+ if (error) {
242
+ // Fail closed: if we can't confirm a jti's status, don't silently
243
+ // treat it as "not revoked".
244
+ throw new RevocationBatchError([{ jti: groupJtis[i], error }]);
245
+ }
246
+ if (value === 1) {
247
+ revoked.add(groupJtis[i]);
248
+ }
249
+ }
250
+ }
251
+ return revoked;
252
+ }
253
+ key(jti) {
254
+ return `${this.keyPrefix}${jti}`;
255
+ }
256
+ }
257
+ /* -------------------------------------------------------------------------- */
258
+ /* Helpers */
259
+ /* -------------------------------------------------------------------------- */
260
+ function nowSeconds() {
261
+ return Math.floor(Date.now() / 1000);
262
+ }
263
+ /**
264
+ * Validates and computes the Redis TTL for a revocation record.
265
+ *
266
+ * Throws a typed {@link RevocationError} early with a redacted message
267
+ * instead of letting a malformed `expiresAt` (undefined/NaN/past) turn
268
+ * into `Math.max(1, NaN) === NaN`, which would otherwise reach Redis as
269
+ * an invalid `EX` argument and fail with an opaque "value is not an
270
+ * integer" error deep inside the client.
271
+ *
272
+ * @param record - The revocation record to validate.
273
+ * @param now - Reference timestamp (Unix seconds); overridable for tests.
274
+ * @returns The TTL in seconds, at least 1.
275
+ * @throws {RevocationError} on invalid records.
276
+ */
277
+ function computeTtl(record, now = nowSeconds()) {
278
+ const safe = redactIdentifier(record.jti);
279
+ if (!record.jti || typeof record.jti !== 'string') {
280
+ throw new RevocationError({ reason: 'missing_jti' });
281
+ }
282
+ if (!Number.isFinite(record.expiresAt)) {
283
+ throw new RevocationError({
284
+ reason: 'invalid_expires_at',
285
+ jti: safe,
286
+ detail: 'expiresAt must be a finite number',
287
+ });
288
+ }
289
+ if (record.expiresAt <= now) {
290
+ throw new RevocationError({
291
+ reason: 'expires_at_in_past',
292
+ jti: safe,
293
+ detail: 'expiresAt must be in the future',
294
+ });
295
+ }
296
+ return Math.max(1, record.expiresAt - now);
297
+ }
298
+ /**
299
+ * Wraps an underlying storage failure in a typed, redacted error so the
300
+ * caller can fail closed without losing the root cause.
301
+ */
302
+ function wrapStorageError(error, context) {
303
+ if (error instanceof RevocationError)
304
+ return error;
305
+ return new RevocationError({
306
+ reason: 'storage_failure',
307
+ jti: redactIdentifier(context.jti),
308
+ cause: error instanceof Error ? error.message : String(error),
309
+ });
310
+ }
@@ -0,0 +1,21 @@
1
+ -- cleanup-index.lua (version 1)
2
+ -- Lazily removes stale members (whose session records no longer exist,
3
+ -- e.g. expired and naturally reclaimed by Redis TTL) from the user index.
4
+ -- Bounded: the caller chunks the user's jtis into batches.
5
+ --
6
+ -- KEYS[1] = user session index key
7
+ -- KEYS[2..] = session record keys (same slot as KEYS[1])
8
+ --
9
+ -- ARGV[1..] = matching jtis (index i pairs with KEYS[i + 1])
10
+ --
11
+ -- Returns: the jtis removed from the index.
12
+ local removed = {}
13
+
14
+ for i = 2, #KEYS do
15
+ if redis.call('EXISTS', KEYS[i]) == 0 then
16
+ redis.call('ZREM', KEYS[1], ARGV[i - 1])
17
+ table.insert(removed, ARGV[i - 1])
18
+ end
19
+ end
20
+
21
+ return removed
@@ -0,0 +1,60 @@
1
+ -- conditional-update-encrypted.lua (version 1) - encrypted (v2) envelopes
2
+ -- Optimistic-concurrency patch update for encrypted sessions.
3
+ --
4
+ -- The app decrypts the record, applies the whitelisted patch, re-encrypts
5
+ -- and passes the new envelope. The script CAS's the plaintext version
6
+ -- mirror and enforces status/expiry; the absolute expiry mirror is
7
+ -- preserved from the stored record (identity/security fields can never be
8
+ -- changed through update).
9
+ --
10
+ -- KEYS[1] = session record key
11
+ --
12
+ -- ARGV[1] = expected version ('' = no check)
13
+ -- ARGV[2] = new serialized envelope (app-built, re-encrypted)
14
+ -- ARGV[3] = new version (expected + 1)
15
+ -- ARGV[4] = new TTL (clamped >= 1)
16
+ --
17
+ -- Returns:
18
+ -- {1, newVersion} applied
19
+ -- 0 not found
20
+ -- -1 consumed or revoked
21
+ -- -2 expired (record removed)
22
+ -- -3 version conflict
23
+ -- 6 envelope is plain (use the plain path)
24
+ local raw = redis.call('GET', KEYS[1])
25
+
26
+ if not raw then
27
+ return 0
28
+ end
29
+
30
+ local env = cjson.decode(raw)
31
+
32
+ if env.v ~= 2 then
33
+ return 6
34
+ end
35
+
36
+ if env.st ~= 'active' then
37
+ return -1
38
+ end
39
+
40
+ local now = tonumber(redis.call('TIME')[1])
41
+
42
+ if tonumber(env.exp) <= now then
43
+ redis.call('DEL', KEYS[1])
44
+ return -2
45
+ end
46
+
47
+ if ARGV[1] ~= '' and tostring(env.ver) ~= ARGV[1] then
48
+ return -3
49
+ end
50
+
51
+ local newEnv = cjson.decode(ARGV[2])
52
+ newEnv.st = 'active'
53
+ newEnv.ver = tonumber(ARGV[3])
54
+ newEnv.exp = env.exp
55
+ newEnv.rn = env.rn
56
+ newEnv.rj = env.rj
57
+
58
+ redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', tonumber(ARGV[4]))
59
+
60
+ return { 1, ARGV[3] }
@@ -0,0 +1,63 @@
1
+ -- conditional-update.lua (version 1) - plain (v1) envelopes
2
+ -- Optimistic-concurrency patch update. The script is the authority for:
3
+ -- - existence / status / expiry checks
4
+ -- - the version compare-and-swap
5
+ -- - the mutable-field whitelist (identity and security-critical fields
6
+ -- are never touched, regardless of what the caller sends)
7
+ --
8
+ -- KEYS[1] = session record key
9
+ --
10
+ -- ARGV[1] = expected version ('' = no check)
11
+ -- ARGV[2] = patch JSON: only { deviceId?, ipAddress?, userAgent?, metadata? }
12
+ -- may be present; other keys are ignored (never applied)
13
+ --
14
+ -- Returns:
15
+ -- {1, newVersion} applied
16
+ -- 0 not found
17
+ -- -1 consumed or revoked
18
+ -- -2 expired (record removed)
19
+ -- -3 version conflict
20
+ -- 5 envelope is encrypted (use the encrypted path)
21
+ local raw = redis.call('GET', KEYS[1])
22
+
23
+ if not raw then
24
+ return 0
25
+ end
26
+
27
+ local env = cjson.decode(raw)
28
+
29
+ if env.v ~= 1 then
30
+ return 5
31
+ end
32
+
33
+ local s = env.s
34
+
35
+ if s.status ~= 'active' then
36
+ return -1
37
+ end
38
+
39
+ local now = tonumber(redis.call('TIME')[1])
40
+
41
+ if tonumber(s.absoluteExpiresAt) <= now then
42
+ redis.call('DEL', KEYS[1])
43
+ return -2
44
+ end
45
+
46
+ if ARGV[1] ~= '' and tostring(s.version) ~= ARGV[1] then
47
+ return -3
48
+ end
49
+
50
+ local patch = cjson.decode(ARGV[2])
51
+
52
+ if patch.deviceId ~= nil then s.deviceId = patch.deviceId end
53
+ if patch.ipAddress ~= nil then s.ipAddress = patch.ipAddress end
54
+ if patch.userAgent ~= nil then s.userAgent = patch.userAgent end
55
+ if patch.metadata ~= nil then s.metadata = patch.metadata end
56
+
57
+ s.version = tonumber(s.version) + 1
58
+
59
+ local ttl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
60
+
61
+ redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
62
+
63
+ return { 1, tostring(s.version) }
@@ -0,0 +1,68 @@
1
+ -- create.lua (version 1)
2
+ -- Atomically creates a session record, registers it in the user index
3
+ -- (ZSET, score = createdAt), optionally claims an idempotency key, and
4
+ -- enforces maxSessionsPerUser with bounded oldest-first eviction.
5
+ --
6
+ -- KEYS[1] = session record key
7
+ -- KEYS[2] = user session index key (ZSET)
8
+ -- KEYS[3] = idempotency claim key (only passed when idempotency is enabled)
9
+ --
10
+ -- ARGV[1] = serialized session envelope (plain v1 or encrypted v2)
11
+ -- ARGV[2] = jti
12
+ -- ARGV[3] = createdAt (Unix seconds)
13
+ -- ARGV[4] = session TTL (absoluteExpiresAt - now, clamped >= 1)
14
+ -- ARGV[5] = maxSessionsPerUser (0 disables)
15
+ -- ARGV[6] = session key prefix for this user (same hash tag => same slot)
16
+ -- ARGV[7] = max evictions per call (bounded Lua work)
17
+ -- ARGV[8] = idempotency claim TTL ('' when idempotency is disabled)
18
+ --
19
+ -- Returns:
20
+ -- {1, {evictedJtis...}} success (evicted list may be empty)
21
+ -- {3, jti} idempotent replay: a previous attempt created
22
+ -- the session; jti identifies it
23
+ -- -5 jti collision: a session with this id exists
24
+ --
25
+ -- All keys share the {userId} hash tag, so this script is atomic on a
26
+ -- single Cluster slot. No cross-slot keys are touched.
27
+ local key = KEYS[1]
28
+ local index = KEYS[2]
29
+
30
+ -- Idempotent replay wins over collision detection: a retry with the same
31
+ -- idempotencyKey must return the original session, even though the session
32
+ -- record already exists.
33
+ if ARGV[8] ~= '' then
34
+ local claimKey = KEYS[3]
35
+ local claimJti = redis.call('GET', claimKey)
36
+ if claimJti then
37
+ return { 3, claimJti }
38
+ end
39
+ redis.call('SET', claimKey, ARGV[2], 'EX', tonumber(ARGV[8]))
40
+ end
41
+
42
+ if redis.call('EXISTS', key) == 1 then
43
+ return -5
44
+ end
45
+
46
+ redis.call('SET', key, ARGV[1], 'EX', tonumber(ARGV[4]))
47
+ redis.call('ZADD', index, tonumber(ARGV[3]), ARGV[2])
48
+
49
+ local evicted = {}
50
+ local limit = tonumber(ARGV[5])
51
+
52
+ if limit and limit > 0 then
53
+ local card = redis.call('ZCARD', index)
54
+ if card > limit then
55
+ local excess = card - limit
56
+ local n = math.min(excess, tonumber(ARGV[7]))
57
+ local members = redis.call('ZRANGE', index, 0, n - 1)
58
+ local prefix = ARGV[6]
59
+
60
+ for _, member in ipairs(members) do
61
+ redis.call('DEL', prefix .. member)
62
+ redis.call('ZREM', index, member)
63
+ table.insert(evicted, member)
64
+ end
65
+ end
66
+ end
67
+
68
+ return { 1, evicted }
@@ -0,0 +1,29 @@
1
+ -- delete-by-user.lua (version 2)
2
+ -- Deletes a bounded batch of a user's sessions and removes them from the
3
+ -- user index, in one same-slot script. The caller chunks the user's jtis
4
+ -- into batches (limits.maxBatchSize), so a user with an arbitrarily large
5
+ -- session count never produces one giant script.
6
+ --
7
+ -- KEYS[1] = user session index key
8
+ -- KEYS[2..] = session record keys (same slot as KEYS[1])
9
+ --
10
+ -- ARGV[1..] = matching jtis (index i pairs with KEYS[i + 1])
11
+ --
12
+ -- Returns: the jtis whose records were actually deleted. The index key is
13
+ -- deleted at the end (the whole-user operation is complete when the caller
14
+ -- has processed every batch).
15
+ local deleted = {}
16
+
17
+ for i = 2, #KEYS do
18
+ local jti = ARGV[i - 1]
19
+
20
+ if redis.call('DEL', KEYS[i]) == 1 then
21
+ table.insert(deleted, jti)
22
+ end
23
+
24
+ redis.call('ZREM', KEYS[1], jti)
25
+ end
26
+
27
+ redis.call('DEL', KEYS[1])
28
+
29
+ return deleted
@@ -0,0 +1,15 @@
1
+ -- delete.lua (version 1)
2
+ -- Physically deletes a session record and removes it from the user index.
3
+ -- Idempotent: deleting a missing session returns 0 and is not an error.
4
+ --
5
+ -- KEYS[1] = session record key
6
+ -- KEYS[2] = user session index key
7
+ --
8
+ -- ARGV[1] = jti
9
+ --
10
+ -- Returns: 1 if the record existed, 0 otherwise.
11
+ local existed = redis.call('DEL', KEYS[1])
12
+
13
+ redis.call('ZREM', KEYS[2], ARGV[1])
14
+
15
+ return existed
@@ -0,0 +1,38 @@
1
+ -- enforce-limit.lua (version 1)
2
+ -- Standalone max-session enforcement (admin/repair paths). Evicts the
3
+ -- oldest excess sessions from a user's index using ZSET ordering and
4
+ -- bounded work (never loads the whole index).
5
+ --
6
+ -- KEYS[1] = user session index key
7
+ --
8
+ -- ARGV[1] = maxSessionsPerUser (0 disables)
9
+ -- ARGV[2] = max evictions per call (bounded Lua work; callers loop to
10
+ -- converge when the excess exceeds this bound)
11
+ -- ARGV[3] = session key prefix for this user (same hash tag => same slot)
12
+ --
13
+ -- Returns: { evictedCount, {evictedJtis...} }
14
+ local card = redis.call('ZCARD', KEYS[1])
15
+ local limit = tonumber(ARGV[1])
16
+
17
+ if limit <= 0 then
18
+ return { 0, {} }
19
+ end
20
+
21
+ local excess = card - limit
22
+
23
+ if excess <= 0 then
24
+ return { 0, {} }
25
+ end
26
+
27
+ local n = math.min(excess, tonumber(ARGV[2]))
28
+ local members = redis.call('ZRANGE', KEYS[1], 0, n - 1)
29
+ local prefix = ARGV[3]
30
+ local evicted = {}
31
+
32
+ for _, member in ipairs(members) do
33
+ redis.call('DEL', prefix .. member)
34
+ redis.call('ZREM', KEYS[1], member)
35
+ table.insert(evicted, member)
36
+ end
37
+
38
+ return { #evicted, evicted }
@@ -0,0 +1,61 @@
1
+ -- revoke.lua (version 1)
2
+ -- Logically revokes a session: status -> 'revoked' with a bounded tombstone
3
+ -- TTL (remaining absolute lifetime). Revoked sessions never authenticate,
4
+ -- even if a stale copy of the record survives somewhere. Idempotent: a
5
+ -- second revoke returns 2.
6
+ --
7
+ -- KEYS[1] = session record key
8
+ --
9
+ -- ARGV[1] = new serialized envelope for encrypted (v2) sessions,
10
+ -- '' for plain (v1) sessions (the script builds it itself)
11
+ -- ARGV[2] = tombstone TTL (remaining absolute lifetime, clamped >= 1)
12
+ --
13
+ -- Returns:
14
+ -- 1 revoked
15
+ -- 2 already revoked (idempotent)
16
+ -- 0 not found
17
+ -- 3 unknown envelope (neither v1 nor v2)
18
+ local raw = redis.call('GET', KEYS[1])
19
+
20
+ if not raw then
21
+ return 0
22
+ end
23
+
24
+ local env = cjson.decode(raw)
25
+ local ttl = tonumber(ARGV[2])
26
+
27
+ if env.v == 1 then
28
+ local s = env.s
29
+
30
+ if s.status == 'revoked' then
31
+ return 2
32
+ end
33
+
34
+ s.status = 'revoked'
35
+ redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
36
+ return 1
37
+ end
38
+
39
+ if env.v == 2 then
40
+ if env.st == 'revoked' then
41
+ return 2
42
+ end
43
+
44
+ if ARGV[1] == '' then
45
+ return 4
46
+ end
47
+
48
+ local newEnv = cjson.decode(ARGV[1])
49
+ newEnv.st = 'revoked'
50
+ newEnv.ver = env.ver
51
+ newEnv.la = env.la
52
+ newEnv.idle = env.idle
53
+ newEnv.exp = env.exp
54
+ newEnv.rn = env.rn
55
+ newEnv.rj = env.rj
56
+
57
+ redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', ttl)
58
+ return 1
59
+ end
60
+
61
+ return 3