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,289 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { defaultLogger } from './logger.js';
|
|
3
|
+
const CONSUME_SCRIPT = `
|
|
4
|
+
local key = KEYS[1]
|
|
5
|
+
local now = tonumber(ARGV[1])
|
|
6
|
+
local window = tonumber(ARGV[2])
|
|
7
|
+
local limit = tonumber(ARGV[3])
|
|
8
|
+
local member = ARGV[4]
|
|
9
|
+
|
|
10
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
|
|
11
|
+
|
|
12
|
+
local count = redis.call('ZCARD', key)
|
|
13
|
+
if count >= limit then
|
|
14
|
+
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
15
|
+
local retryAfter = 0
|
|
16
|
+
if oldest[2] then
|
|
17
|
+
retryAfter = math.max(1, math.ceil((tonumber(oldest[2]) + window - now) / 1000))
|
|
18
|
+
end
|
|
19
|
+
return { 0, count, -1, retryAfter }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
redis.call('ZADD', key, now, member)
|
|
23
|
+
redis.call('PEXPIRE', key, window)
|
|
24
|
+
return { 1, count + 1, limit - count - 1, 0 }
|
|
25
|
+
`;
|
|
26
|
+
const PEEK_SCRIPT = `
|
|
27
|
+
local key = KEYS[1]
|
|
28
|
+
local now = tonumber(ARGV[1])
|
|
29
|
+
local window = tonumber(ARGV[2])
|
|
30
|
+
local limit = tonumber(ARGV[3])
|
|
31
|
+
|
|
32
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
|
|
33
|
+
|
|
34
|
+
local count = redis.call('ZCARD', key)
|
|
35
|
+
local retryAfter = 0
|
|
36
|
+
if count >= limit then
|
|
37
|
+
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
38
|
+
if oldest[2] then
|
|
39
|
+
retryAfter = math.max(1, math.ceil((tonumber(oldest[2]) + window - now) / 1000))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
return { count, retryAfter }
|
|
43
|
+
`;
|
|
44
|
+
function failOpenResult(limit, duration) {
|
|
45
|
+
return {
|
|
46
|
+
allowed: true,
|
|
47
|
+
limit,
|
|
48
|
+
used: 0,
|
|
49
|
+
remaining: limit,
|
|
50
|
+
resetAt: Date.now() + duration * 1000,
|
|
51
|
+
retryAfter: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generic Redis-backed rate limiter that works for any resource: routes, API
|
|
56
|
+
* endpoints, users, IPs, databases, email sending, etc.
|
|
57
|
+
*
|
|
58
|
+
* Keys are namespaced as `ratelimit:{namespace}:{resource}:{identifier}` so each
|
|
59
|
+
* resource + identifier combination is tracked independently. Supports fixed-window
|
|
60
|
+
* (`INCR`/`EXPIRE`) and sliding-window (atomic Lua over a sorted set) algorithms.
|
|
61
|
+
* Fails open when Redis is unavailable.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
|
|
66
|
+
*
|
|
67
|
+
* const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
|
|
68
|
+
* if (!result.allowed) {
|
|
69
|
+
* throw new Error(`Slow down, retry in ${result.retryAfter}s`);
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export class RateLimiter {
|
|
74
|
+
client;
|
|
75
|
+
logger;
|
|
76
|
+
defaultLimit;
|
|
77
|
+
defaultDuration;
|
|
78
|
+
defaultAlgorithm;
|
|
79
|
+
defaultNamespace;
|
|
80
|
+
/**
|
|
81
|
+
* Creates a rate limiter bound to a Redis client.
|
|
82
|
+
*
|
|
83
|
+
* @param client - The underlying {@link RedisClientWrapper}.
|
|
84
|
+
* @param options - Defaults applied when a call does not override them:
|
|
85
|
+
* `limit` (default `100`), `duration` in seconds (default `60`),
|
|
86
|
+
* `algorithm` (default `'sliding'`), `namespace` (default `'ratelimit'`).
|
|
87
|
+
* @param logger - Optional pino-compatible logger; defaults to `console`.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const limiter = new RateLimiter(client, { limit: 10, duration: 1, algorithm: 'fixed' });
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
constructor(client, options = {}, logger = defaultLogger) {
|
|
95
|
+
this.client = client;
|
|
96
|
+
this.logger = logger.child({ component: 'RateLimiter' });
|
|
97
|
+
this.defaultLimit = options.limit ?? 100;
|
|
98
|
+
this.defaultDuration = options.duration ?? 60;
|
|
99
|
+
this.defaultAlgorithm = options.algorithm ?? 'sliding';
|
|
100
|
+
this.defaultNamespace = options.namespace ?? 'ratelimit';
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Builds the Redis key for a resource + identifier combination.
|
|
104
|
+
*
|
|
105
|
+
* @param resource - The rate-limited resource, e.g. a route `'/api/login'` or
|
|
106
|
+
* a resource name `'email:send'`.
|
|
107
|
+
* @param identifier - The caller identity, e.g. an IP, user id or API key.
|
|
108
|
+
* @param namespace - Key prefix (defaults to the limiter's namespace).
|
|
109
|
+
* @returns The full key, e.g. `'ratelimit:/api/login:ip-10.0.0.1'`.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* limiter.makeKey('/api/login', 'ip-10.0.0.1');
|
|
114
|
+
* // 'ratelimit:/api/login:ip-10.0.0.1'
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
makeKey(resource, identifier, namespace = this.defaultNamespace) {
|
|
118
|
+
return `${namespace}:${resource}:${identifier}`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Consumes one unit of capacity for a resource + identifier and returns the
|
|
122
|
+
* resulting limit state.
|
|
123
|
+
*
|
|
124
|
+
* When the limit is reached the request is not recorded and `allowed` is
|
|
125
|
+
* `false` with `retryAfter` (seconds) and `resetAt` (epoch ms) hints.
|
|
126
|
+
* Fails open (allows the request) if Redis errors.
|
|
127
|
+
*
|
|
128
|
+
* @param resource - The rate-limited resource, e.g. a route `'/api/login'` or
|
|
129
|
+
* a resource name `'db:write'`.
|
|
130
|
+
* @param identifier - The caller identity, e.g. an IP, user id or API key.
|
|
131
|
+
* @param options - Per-call overrides for `limit`, `duration`, `algorithm`,
|
|
132
|
+
* and `namespace`.
|
|
133
|
+
* @returns The limit state: `allowed`, `limit`, `used`, `remaining`,
|
|
134
|
+
* `resetAt` (epoch ms), `retryAfter` (seconds).
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* const result = await limiter.consume('/api/orders', 'user-7', { limit: 5, duration: 60 });
|
|
139
|
+
* if (!result.allowed) {
|
|
140
|
+
* res.setHeader('Retry-After', String(result.retryAfter));
|
|
141
|
+
* return res.status(429).json({ error: 'Too many requests' });
|
|
142
|
+
* }
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
async consume(resource, identifier, options = {}) {
|
|
146
|
+
const limit = options.limit ?? this.defaultLimit;
|
|
147
|
+
const duration = options.duration ?? this.defaultDuration;
|
|
148
|
+
const algorithm = options.algorithm ?? this.defaultAlgorithm;
|
|
149
|
+
const namespace = options.namespace ?? this.defaultNamespace;
|
|
150
|
+
const key = this.makeKey(resource, identifier, namespace);
|
|
151
|
+
try {
|
|
152
|
+
if (algorithm === 'fixed') {
|
|
153
|
+
return await this.consumeFixed(key, limit, duration);
|
|
154
|
+
}
|
|
155
|
+
return await this.consumeSliding(key, limit, duration);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
this.logger.error('Rate limit consume failed, failing open', { key, error });
|
|
159
|
+
return failOpenResult(limit, duration);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Peeks at the current limit state without consuming capacity.
|
|
164
|
+
*
|
|
165
|
+
* Useful for pre-flight checks (e.g. showing "limit reached" in a UI before
|
|
166
|
+
* the actual request). Also fails open on Redis errors.
|
|
167
|
+
*
|
|
168
|
+
* @param resource - The rate-limited resource.
|
|
169
|
+
* @param identifier - The caller identity.
|
|
170
|
+
* @param options - Per-call overrides for `limit`, `duration`, `algorithm`,
|
|
171
|
+
* and `namespace`.
|
|
172
|
+
* @returns The current limit state; `used` is not incremented.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* const state = await limiter.check('/api/search', 'user-1');
|
|
177
|
+
* if (state.remaining === 0) {
|
|
178
|
+
* // disable the search button
|
|
179
|
+
* }
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
async check(resource, identifier, options = {}) {
|
|
183
|
+
const limit = options.limit ?? this.defaultLimit;
|
|
184
|
+
const duration = options.duration ?? this.defaultDuration;
|
|
185
|
+
const algorithm = options.algorithm ?? this.defaultAlgorithm;
|
|
186
|
+
const namespace = options.namespace ?? this.defaultNamespace;
|
|
187
|
+
const key = this.makeKey(resource, identifier, namespace);
|
|
188
|
+
try {
|
|
189
|
+
if (algorithm === 'fixed') {
|
|
190
|
+
return await this.checkFixed(key, limit, duration);
|
|
191
|
+
}
|
|
192
|
+
return await this.checkSliding(key, limit, duration);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
this.logger.error('Rate limit check failed, failing open', { key, error });
|
|
196
|
+
return failOpenResult(limit, duration);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resets the counter for a resource + identifier, granting full capacity again.
|
|
201
|
+
*
|
|
202
|
+
* @param resource - The rate-limited resource.
|
|
203
|
+
* @param identifier - The caller identity.
|
|
204
|
+
* @param namespace - Key prefix (defaults to the limiter's namespace).
|
|
205
|
+
* @returns `true` if a counter existed and was removed.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* // user upgraded to a premium plan, lift their limits
|
|
210
|
+
* await limiter.reset('/api/export', 'user-7');
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
async reset(resource, identifier, namespace = this.defaultNamespace) {
|
|
214
|
+
const key = this.makeKey(resource, identifier, namespace);
|
|
215
|
+
try {
|
|
216
|
+
const deleted = await this.client.del(key);
|
|
217
|
+
return deleted > 0;
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
this.logger.error('Rate limit reset failed', { key, error });
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async consumeFixed(key, limit, duration) {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
const count = await this.client.incr(key);
|
|
227
|
+
if (count === 1) {
|
|
228
|
+
await this.client.expire(key, duration);
|
|
229
|
+
}
|
|
230
|
+
const ttl = await this.client.ttl(key);
|
|
231
|
+
const ttlSeconds = ttl > 0 ? ttl : duration;
|
|
232
|
+
const allowed = count <= limit;
|
|
233
|
+
return {
|
|
234
|
+
allowed,
|
|
235
|
+
limit,
|
|
236
|
+
used: count,
|
|
237
|
+
remaining: Math.max(0, limit - count),
|
|
238
|
+
resetAt: now + ttlSeconds * 1000,
|
|
239
|
+
retryAfter: allowed ? 0 : ttlSeconds,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async consumeSliding(key, limit, duration) {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
const member = `${now}:${randomUUID()}`;
|
|
245
|
+
const result = (await this.client.raw.eval(CONSUME_SCRIPT, 1, key, now, duration * 1000, limit, member));
|
|
246
|
+
const allowed = result[0] === 1;
|
|
247
|
+
const used = result[1] ?? 0;
|
|
248
|
+
const remaining = result[2] ?? 0;
|
|
249
|
+
const retryAfter = result[3] ?? 0;
|
|
250
|
+
return {
|
|
251
|
+
allowed,
|
|
252
|
+
limit,
|
|
253
|
+
used,
|
|
254
|
+
remaining: allowed ? remaining : 0,
|
|
255
|
+
resetAt: now + retryAfter * 1000,
|
|
256
|
+
retryAfter,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async checkFixed(key, limit, duration) {
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
const raw = await this.client.get(key);
|
|
262
|
+
const used = raw === null || raw === undefined ? 0 : Number(raw) || 0;
|
|
263
|
+
const ttl = await this.client.ttl(key);
|
|
264
|
+
const ttlSeconds = ttl > 0 ? ttl : duration;
|
|
265
|
+
const allowed = used < limit;
|
|
266
|
+
return {
|
|
267
|
+
allowed,
|
|
268
|
+
limit,
|
|
269
|
+
used,
|
|
270
|
+
remaining: Math.max(0, limit - used),
|
|
271
|
+
resetAt: now + ttlSeconds * 1000,
|
|
272
|
+
retryAfter: allowed ? 0 : ttlSeconds,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async checkSliding(key, limit, duration) {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const result = (await this.client.raw.eval(PEEK_SCRIPT, 1, key, now, duration * 1000, limit));
|
|
278
|
+
const used = result[0] ?? 0;
|
|
279
|
+
const retryAfter = result[1] ?? 0;
|
|
280
|
+
return {
|
|
281
|
+
allowed: used < limit,
|
|
282
|
+
limit,
|
|
283
|
+
used,
|
|
284
|
+
remaining: Math.max(0, limit - used),
|
|
285
|
+
resetAt: now + retryAfter * 1000,
|
|
286
|
+
retryAfter,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { RedisRevocationStore } from './revocation-store.js';
|
|
2
|
+
export type { RedisRevocationStoreOptions } from './revocation-store.js';
|
|
3
|
+
export { RevocationBatchError, RevocationError } from './session-errors.js';
|
|
4
|
+
export { createSessionManager, SessionManager } from './session-manager.js';
|
|
5
|
+
export type { SessionManagerOptions } from './session-manager.js';
|
|
6
|
+
export { SessionService } from './session-service.js';
|
|
7
|
+
export type { SessionServiceDeps } from './session-service.js';
|
|
8
|
+
export { SessionRepository } from './session-repository.js';
|
|
9
|
+
export type { SessionScriptRegistryOptions } from './session-scripts.js';
|
|
10
|
+
export { SessionScriptRegistry, SCRIPT_NAMES } from './session-scripts.js';
|
|
11
|
+
export type { ScriptName } from './session-scripts.js';
|
|
12
|
+
export { SessionKeyStrategy, encodeUserId } from './session-keys.js';
|
|
13
|
+
export { SessionTokenManager } from './session-token.js';
|
|
14
|
+
export { StaticSessionKeyProvider, createRandomSessionKeyProvider, toKeyBuffer, } from './session-encryption.js';
|
|
15
|
+
export { serializeSession, serializeEncryptedSession, deserializeSession, validateSessionRecord, envelopeKind, encryptedHeaderOf, } from './session-serializer.js';
|
|
16
|
+
export { parseSessionConfig, redactSessionConfig, SessionConfigSchema, TTL, IDLE_TIMEOUT, TOUCH_INTERVAL, } from './session-config.js';
|
|
17
|
+
export type { SessionConfig, SessionConfigInput, PartialSessionConfig, } from './session-config.js';
|
|
18
|
+
export { SessionError, SessionNotFoundError, SessionExpiredError, SessionRevokedError, SessionInvalidError, SessionRotationError, SessionReplayError, SessionStorageError, SessionSerializationError, SessionConfigurationError, SessionConcurrencyError, CircuitBreakerOpenError, redactIdentifier, } from './session-errors.js';
|
|
19
|
+
export type { SessionRecord, SessionCreateInput, SessionUpdatePatch, CreatedSession, RotatedSession, SessionValidationResult, SessionInvalidReason, TouchOutcome, SessionEnvelope, } from './session-types.js';
|
|
20
|
+
export { SessionMetrics, type SessionMetricsAdapter } from './session-metrics.js';
|
|
21
|
+
export { SessionCircuitBreaker } from './session-circuit-breaker.js';
|
|
22
|
+
export { SessionHealthChecker } from './session-health.js';
|
|
23
|
+
export { SessionCookieManager, type SerializeCookieOptions, type SerializedCookie, type SerializedCookieAttributes, } from './session-cookie.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { RedisRevocationStore } from './revocation-store.js';
|
|
2
|
+
export { RevocationBatchError, RevocationError } from './session-errors.js';
|
|
3
|
+
export { createSessionManager, SessionManager } from './session-manager.js';
|
|
4
|
+
export { SessionService } from './session-service.js';
|
|
5
|
+
export { SessionRepository } from './session-repository.js';
|
|
6
|
+
export { SessionScriptRegistry, SCRIPT_NAMES } from './session-scripts.js';
|
|
7
|
+
export { SessionKeyStrategy, encodeUserId } from './session-keys.js';
|
|
8
|
+
export { SessionTokenManager } from './session-token.js';
|
|
9
|
+
export { StaticSessionKeyProvider, createRandomSessionKeyProvider, toKeyBuffer, } from './session-encryption.js';
|
|
10
|
+
export { serializeSession, serializeEncryptedSession, deserializeSession, validateSessionRecord, envelopeKind, encryptedHeaderOf, } from './session-serializer.js';
|
|
11
|
+
export { parseSessionConfig, redactSessionConfig, SessionConfigSchema, TTL, IDLE_TIMEOUT, TOUCH_INTERVAL, } from './session-config.js';
|
|
12
|
+
export { SessionError, SessionNotFoundError, SessionExpiredError, SessionRevokedError, SessionInvalidError, SessionRotationError, SessionReplayError, SessionStorageError, SessionSerializationError, SessionConfigurationError, SessionConcurrencyError, CircuitBreakerOpenError, redactIdentifier, } from './session-errors.js';
|
|
13
|
+
export { SessionMetrics } from './session-metrics.js';
|
|
14
|
+
export { SessionCircuitBreaker } from './session-circuit-breaker.js';
|
|
15
|
+
export { SessionHealthChecker } from './session-health.js';
|
|
16
|
+
export { SessionCookieManager, } from './session-cookie.js';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { RevocationRecord, RevocationStore } from './session-types.js';
|
|
2
|
+
import type { RedisClientWrapper } from '../client.js';
|
|
3
|
+
export interface RedisRevocationStoreOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Redis client.
|
|
6
|
+
*
|
|
7
|
+
* Compatible with:
|
|
8
|
+
* - ioredis standalone
|
|
9
|
+
* - ioredis Sentinel
|
|
10
|
+
* - ioredis Cluster
|
|
11
|
+
*/
|
|
12
|
+
client: RedisClientWrapper;
|
|
13
|
+
/**
|
|
14
|
+
* Key prefix, so multiple apps can share a Redis instance safely.
|
|
15
|
+
* Default: `authcore:revoked:`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* new RedisRevocationStore({ client, keyPrefix: 'auth:revoked:' });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
keyPrefix?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Redis-backed revocation store. Each revoked jti is stored as
|
|
26
|
+
* `{prefix}{jti} -> reason`, with the Redis key TTL itself set to the
|
|
27
|
+
* token's remaining lifetime — expired entries are reclaimed automatically
|
|
28
|
+
* by Redis, no sweep job required.
|
|
29
|
+
*
|
|
30
|
+
* Every operation here is a single-key command, so this store works
|
|
31
|
+
* identically on standalone, Sentinel, and Cluster with no hash tags
|
|
32
|
+
* required (unlike the session store, there's no multi-key atomicity
|
|
33
|
+
* requirement to satisfy).
|
|
34
|
+
*
|
|
35
|
+
* Batched operations (`revokeMany`, `isRevokedMany`) group their commands
|
|
36
|
+
* by hash slot and issue one pipeline per slot, so they never trigger
|
|
37
|
+
* `CROSSSLOT` errors on Redis Cluster. Pipeline failures are surfaced via
|
|
38
|
+
* {@link RevocationBatchError} instead of being silently swallowed —
|
|
39
|
+
* a missed revocation is a security bug.
|
|
40
|
+
*
|
|
41
|
+
* Validation fails fast and typed: invalid records throw
|
|
42
|
+
* {@link RevocationError} before any network call, and reads fail closed
|
|
43
|
+
* (an infra error is never treated as "not revoked").
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const revocations = new RedisRevocationStore({ client });
|
|
48
|
+
*
|
|
49
|
+
* await revocations.revoke({
|
|
50
|
+
* jti: 'a1b2c3d4',
|
|
51
|
+
* reason: 'password-change',
|
|
52
|
+
* expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* if (await revocations.isRevoked('a1b2c3d4')) {
|
|
56
|
+
* // token was rotated or revoked - reject it
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare class RedisRevocationStore implements RevocationStore {
|
|
61
|
+
private readonly client;
|
|
62
|
+
private readonly keyPrefix;
|
|
63
|
+
/**
|
|
64
|
+
* Creates a Redis-backed revocation store.
|
|
65
|
+
*
|
|
66
|
+
* @param options - Client connection and key-prefix configuration.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* const store = new RedisRevocationStore({
|
|
71
|
+
* client,
|
|
72
|
+
* keyPrefix: 'myapp:revoked:',
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
constructor(options: RedisRevocationStoreOptions);
|
|
77
|
+
/**
|
|
78
|
+
* Marks a jti as revoked for the remainder of its lifetime.
|
|
79
|
+
*
|
|
80
|
+
* Stores `{prefix}{jti} -> reason` with a Redis TTL equal to the
|
|
81
|
+
* record's remaining lifetime (`expiresAt - now`), so the entry is
|
|
82
|
+
* garbage-collected automatically once the original token would have
|
|
83
|
+
* expired anyway. Overwriting an existing entry extends/refreshes its
|
|
84
|
+
* TTL to the new expiry.
|
|
85
|
+
*
|
|
86
|
+
* @param record - The revocation entry (`jti`, `expiresAt`, optional
|
|
87
|
+
* `reason`). `expiresAt` must be a finite Unix-seconds timestamp in
|
|
88
|
+
* the future.
|
|
89
|
+
* @throws {RevocationError} when `record.expiresAt` is missing, not a
|
|
90
|
+
* finite number, or not in the future (fails fast instead of sending
|
|
91
|
+
* an invalid `EX` to Redis).
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* await revocations.revoke({
|
|
96
|
+
* jti: 'a1b2c3d4',
|
|
97
|
+
* reason: 'logout',
|
|
98
|
+
* expiresAt: Math.floor(Date.now() / 1000) + 86400,
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
revoke(record: RevocationRecord): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Revokes many jtis in one batched call.
|
|
105
|
+
*
|
|
106
|
+
* All records are validated up front — an invalid `expiresAt` fails
|
|
107
|
+
* before any network call is issued, rather than partway through a
|
|
108
|
+
* batch. Commands are grouped by hash slot (one pipeline per slot) so
|
|
109
|
+
* the batch stays Cluster-safe, and every pipeline result is inspected:
|
|
110
|
+
* any failed command throws {@link RevocationBatchError} listing the
|
|
111
|
+
* affected jtis, because a silently-missed revocation is a security bug.
|
|
112
|
+
*
|
|
113
|
+
* @param records - The revocation entries to create/refresh.
|
|
114
|
+
* @throws {RevocationError} when any record is invalid (validation is
|
|
115
|
+
* all-or-nothing, before any network call).
|
|
116
|
+
* @throws {RevocationBatchError} when one or more pipeline commands
|
|
117
|
+
* fail; carries the exact jtis that were not revoked.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* await revocations.revokeMany([
|
|
122
|
+
* { jti: 'a1', reason: 'logout-all', expiresAt: expiry },
|
|
123
|
+
* { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
|
|
124
|
+
* ]);
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
revokeMany(records: RevocationRecord[]): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Checks whether a jti is currently revoked.
|
|
130
|
+
*
|
|
131
|
+
* Fail-closed: infrastructure errors are wrapped in a typed error and
|
|
132
|
+
* must NOT be treated as "not revoked".
|
|
133
|
+
*
|
|
134
|
+
* @param jti - The token/session id to check.
|
|
135
|
+
* @returns `true` when the jti has a live revocation entry.
|
|
136
|
+
* @throws {RevocationError} when the check itself fails (caller must
|
|
137
|
+
* treat the outcome as unknown).
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* if (await revocations.isRevoked(token.jti)) {
|
|
142
|
+
* return 401; // token was rotated away or explicitly revoked
|
|
143
|
+
* }
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
isRevoked(jti: string): Promise<boolean>;
|
|
147
|
+
/**
|
|
148
|
+
* Batched revocation check - one network round trip instead of N.
|
|
149
|
+
*
|
|
150
|
+
* Useful for validating a whole family of rotated tokens, or a batch
|
|
151
|
+
* of refresh attempts, at once. Commands are grouped by hash slot
|
|
152
|
+
* (one pipeline per slot) to stay Cluster-safe, and the check fails
|
|
153
|
+
* closed: if any command errors, {@link RevocationBatchError} is thrown
|
|
154
|
+
* rather than silently treating the jti as "not revoked".
|
|
155
|
+
*
|
|
156
|
+
* @param jtis - The token/session ids to check.
|
|
157
|
+
* @returns A `Set` containing exactly the revoked jtis.
|
|
158
|
+
* @throws {RevocationBatchError} when a pipeline command fails -
|
|
159
|
+
* the caller must treat the outcome as unknown, not as "valid".
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* const revoked = await revocations.isRevokedMany(['a1', 'b2', 'c3']);
|
|
164
|
+
* if (revoked.has('b2')) {
|
|
165
|
+
* // b2 must not be accepted
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
isRevokedMany(jtis: string[]): Promise<Set<string>>;
|
|
170
|
+
private key;
|
|
171
|
+
}
|