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.
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/dist/cache.d.ts +298 -0
- package/dist/cache.js +606 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +958 -0
- package/dist/cluster-slot.d.ts +4 -0
- package/dist/cluster-slot.js +31 -0
- package/dist/cluster.d.ts +79 -0
- package/dist/cluster.js +156 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +63 -0
- package/dist/health.d.ts +39 -0
- package/dist/health.js +106 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +44 -0
- package/dist/lock.d.ts +215 -0
- package/dist/lock.js +385 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +40 -0
- package/dist/pubsub.d.ts +171 -0
- package/dist/pubsub.js +285 -0
- package/dist/ratelimiter.d.ts +162 -0
- package/dist/ratelimiter.js +289 -0
- package/dist/session/index.d.ts +23 -0
- package/dist/session/index.js +16 -0
- package/dist/session/revocation-store.d.ts +171 -0
- package/dist/session/revocation-store.js +310 -0
- package/dist/session/scripts/cleanup-index.lua +21 -0
- package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
- package/dist/session/scripts/conditional-update.lua +63 -0
- package/dist/session/scripts/create.lua +68 -0
- package/dist/session/scripts/delete-by-user.lua +29 -0
- package/dist/session/scripts/delete.lua +15 -0
- package/dist/session/scripts/enforce-limit.lua +38 -0
- package/dist/session/scripts/revoke.lua +61 -0
- package/dist/session/scripts/rotate-encrypted.lua +107 -0
- package/dist/session/scripts/rotate.lua +119 -0
- package/dist/session/scripts/touch-encrypted.lua +89 -0
- package/dist/session/scripts/touch.lua +72 -0
- package/dist/session/scripts/validate.lua +90 -0
- package/dist/session/session-circuit-breaker.d.ts +42 -0
- package/dist/session/session-circuit-breaker.js +129 -0
- package/dist/session/session-config.d.ts +335 -0
- package/dist/session/session-config.js +162 -0
- package/dist/session/session-cookie.d.ts +72 -0
- package/dist/session/session-cookie.js +101 -0
- package/dist/session/session-encryption.d.ts +87 -0
- package/dist/session/session-encryption.js +139 -0
- package/dist/session/session-errors.d.ts +85 -0
- package/dist/session/session-errors.js +145 -0
- package/dist/session/session-health.d.ts +38 -0
- package/dist/session/session-health.js +60 -0
- package/dist/session/session-keys.d.ts +51 -0
- package/dist/session/session-keys.js +113 -0
- package/dist/session/session-manager.d.ts +59 -0
- package/dist/session/session-manager.js +94 -0
- package/dist/session/session-metrics.d.ts +33 -0
- package/dist/session/session-metrics.js +112 -0
- package/dist/session/session-repository.d.ts +161 -0
- package/dist/session/session-repository.js +683 -0
- package/dist/session/session-scripts.d.ts +36 -0
- package/dist/session/session-scripts.js +130 -0
- package/dist/session/session-serializer.d.ts +42 -0
- package/dist/session/session-serializer.js +248 -0
- package/dist/session/session-service.d.ts +104 -0
- package/dist/session/session-service.js +611 -0
- package/dist/session/session-token.d.ts +38 -0
- package/dist/session/session-token.js +86 -0
- package/dist/session/session-types.d.ts +253 -0
- package/dist/session/session-types.js +16 -0
- package/dist/types.d.ts +782 -0
- package/dist/types.js +140 -0
- 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
|