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,611 @@
|
|
|
1
|
+
import { mapWithConcurrency } from '../cluster.js';
|
|
2
|
+
import { SessionConfigurationError, SessionConcurrencyError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, SessionRevokedError, SessionRotationError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
|
|
3
|
+
import { SessionMetrics } from './session-metrics.js';
|
|
4
|
+
import { assertHeaderMatches, deserializeSession } from './session-serializer.js';
|
|
5
|
+
const IDEMPOTENCY_MIN_LENGTH = 8;
|
|
6
|
+
const IDEMPOTENCY_MAX_LENGTH = 256;
|
|
7
|
+
const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
|
|
8
|
+
const MAX_THROTTLE_ENTRIES = 10_000;
|
|
9
|
+
export class SessionService {
|
|
10
|
+
deps;
|
|
11
|
+
throttle = new Map();
|
|
12
|
+
constructor(deps) {
|
|
13
|
+
this.deps = deps;
|
|
14
|
+
}
|
|
15
|
+
get config() {
|
|
16
|
+
return this.deps.config;
|
|
17
|
+
}
|
|
18
|
+
get repository() {
|
|
19
|
+
return this.deps.repository;
|
|
20
|
+
}
|
|
21
|
+
get metrics() {
|
|
22
|
+
return this.deps.metrics ?? new SessionMetrics();
|
|
23
|
+
}
|
|
24
|
+
get breaker() {
|
|
25
|
+
return this.deps.circuitBreaker ?? null;
|
|
26
|
+
}
|
|
27
|
+
get healthChecker() {
|
|
28
|
+
return this.deps.health ?? null;
|
|
29
|
+
}
|
|
30
|
+
now() {
|
|
31
|
+
return this.deps.now ? this.deps.now() : Math.floor(Date.now() / 1000);
|
|
32
|
+
}
|
|
33
|
+
/* ------------------------------------------------------------------------ */
|
|
34
|
+
/* Guard: metrics + circuit breaker + error normalization per operation. */
|
|
35
|
+
/* ------------------------------------------------------------------------ */
|
|
36
|
+
async guard(op, fn, classify) {
|
|
37
|
+
const breaker = this.breaker;
|
|
38
|
+
const started = performance.now();
|
|
39
|
+
const outcome = () => this.metrics.latency(op, Math.round(performance.now() - started));
|
|
40
|
+
const run = async () => {
|
|
41
|
+
try {
|
|
42
|
+
const result = await fn();
|
|
43
|
+
outcome();
|
|
44
|
+
this.metrics.operation(op, classify ? classify(result) : 'ok');
|
|
45
|
+
this.healthChecker?.recordOp(true);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
outcome();
|
|
50
|
+
if (error instanceof SessionError) {
|
|
51
|
+
this.metrics.operation(op, 'error');
|
|
52
|
+
this.healthChecker?.recordOp(false);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
// Unknown errors are infrastructure failures: fail closed, typed.
|
|
56
|
+
this.healthChecker?.recordOp(false);
|
|
57
|
+
this.metrics.operation(op, 'error', 'storage');
|
|
58
|
+
throw new SessionStorageError(undefined, { operation: op, cause: String(error) });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (!breaker)
|
|
62
|
+
return run();
|
|
63
|
+
if (!breaker.tryAcquire()) {
|
|
64
|
+
outcome();
|
|
65
|
+
this.metrics.operation(op, 'error', 'circuit_open');
|
|
66
|
+
this.healthChecker?.recordOp(false);
|
|
67
|
+
throw new SessionStorageError(undefined, { operation: op, reason: 'circuit_open' });
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const result = await run();
|
|
71
|
+
breaker.recordSuccess();
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// Only infrastructure (storage) failures trip the breaker: business
|
|
76
|
+
// errors (not_found, invalid, revoked, concurrency, ...) are expected
|
|
77
|
+
// outcomes of bad input and must never open the circuit.
|
|
78
|
+
if (error instanceof SessionStorageError) {
|
|
79
|
+
breaker.recordFailure();
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/* ------------------------------------------------------------------------ */
|
|
85
|
+
/* Create */
|
|
86
|
+
/* ------------------------------------------------------------------------ */
|
|
87
|
+
/**
|
|
88
|
+
* Creates a session and returns the raw token exactly once.
|
|
89
|
+
*
|
|
90
|
+
* Idempotent creation: when `input.idempotencyKey` is provided (and
|
|
91
|
+
* config.enableCreateIdempotency is on), the idempotencyKey IS the token.
|
|
92
|
+
* A retry with the same key returns the existing session with
|
|
93
|
+
* `replayed: true` instead of creating a duplicate.
|
|
94
|
+
*/
|
|
95
|
+
create(input) {
|
|
96
|
+
return this.guard('create', async () => {
|
|
97
|
+
validateUserId(input.userId);
|
|
98
|
+
let token;
|
|
99
|
+
let jti;
|
|
100
|
+
if (input.idempotencyKey !== undefined) {
|
|
101
|
+
if (!this.config.enableCreateIdempotency) {
|
|
102
|
+
throw new SessionConfigurationError('idempotencyKey requires enableCreateIdempotency.');
|
|
103
|
+
}
|
|
104
|
+
validateIdempotencyToken(input.idempotencyKey);
|
|
105
|
+
token = input.idempotencyKey;
|
|
106
|
+
jti = this.deps.token.hash(token);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
token = this.deps.token.generate();
|
|
110
|
+
jti = this.deps.token.hash(token);
|
|
111
|
+
}
|
|
112
|
+
if (input.metadata !== undefined) {
|
|
113
|
+
let serialized;
|
|
114
|
+
try {
|
|
115
|
+
serialized = JSON.stringify(input.metadata);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new SessionInvalidError({ reason: 'metadata_cyclic' });
|
|
119
|
+
}
|
|
120
|
+
const size = Buffer.byteLength(serialized);
|
|
121
|
+
if (size > this.config.limits.maxMetadataSize) {
|
|
122
|
+
throw new SessionInvalidError({
|
|
123
|
+
reason: 'metadata_too_large',
|
|
124
|
+
size,
|
|
125
|
+
max: this.config.limits.maxMetadataSize,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const now = this.now();
|
|
130
|
+
const securityVersion = this.config.securityVersion.enabled
|
|
131
|
+
? await this.repository.getSecurityVersion(input.userId)
|
|
132
|
+
: null;
|
|
133
|
+
const record = {
|
|
134
|
+
jti,
|
|
135
|
+
userId: input.userId,
|
|
136
|
+
createdAt: now,
|
|
137
|
+
lastAccessedAt: now,
|
|
138
|
+
absoluteExpiresAt: now + this.config.ttl,
|
|
139
|
+
idleExpiresAt: this.config.idleTimeout !== null
|
|
140
|
+
? Math.min(now + this.config.idleTimeout, now + this.config.ttl)
|
|
141
|
+
: null,
|
|
142
|
+
status: 'active',
|
|
143
|
+
version: 1,
|
|
144
|
+
securityVersion,
|
|
145
|
+
deviceId: this.config.storeDeviceId ? (input.deviceId ?? null) : null,
|
|
146
|
+
ipAddress: this.config.storeIpAddress ? (input.ipAddress ?? null) : null,
|
|
147
|
+
userAgent: this.config.storeUserAgent ? (input.userAgent ?? null) : null,
|
|
148
|
+
metadata: input.metadata ?? null,
|
|
149
|
+
rotatedFrom: null,
|
|
150
|
+
rotatedTo: null,
|
|
151
|
+
consumedAt: null,
|
|
152
|
+
rotationNonceHash: null,
|
|
153
|
+
};
|
|
154
|
+
const ttl = Math.max(1, record.absoluteExpiresAt - now);
|
|
155
|
+
const result = await this.repository.create(record, ttl);
|
|
156
|
+
if (result.status === 'replayed') {
|
|
157
|
+
const existing = await this.repository.get(input.userId, result.jti);
|
|
158
|
+
if (!existing) {
|
|
159
|
+
// Claim exists but the record vanished (TTL race); create afresh.
|
|
160
|
+
const retry = { userId: input.userId };
|
|
161
|
+
if (input.deviceId !== undefined)
|
|
162
|
+
retry.deviceId = input.deviceId;
|
|
163
|
+
if (input.ipAddress !== undefined)
|
|
164
|
+
retry.ipAddress = input.ipAddress;
|
|
165
|
+
if (input.userAgent !== undefined)
|
|
166
|
+
retry.userAgent = input.userAgent;
|
|
167
|
+
if (input.metadata !== undefined)
|
|
168
|
+
retry.metadata = input.metadata;
|
|
169
|
+
return this.create(retry);
|
|
170
|
+
}
|
|
171
|
+
return { token, session: existing, replayed: true };
|
|
172
|
+
}
|
|
173
|
+
const indexed = await this.repository.writeJtiIndex(jti, input.userId, ttl);
|
|
174
|
+
if (!indexed)
|
|
175
|
+
this.metrics.jtiIndexWriteFailure();
|
|
176
|
+
return { token, session: record };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/* ------------------------------------------------------------------------ */
|
|
180
|
+
/* Validate */
|
|
181
|
+
/* ------------------------------------------------------------------------ */
|
|
182
|
+
/**
|
|
183
|
+
* Validates a session token. Single Redis round trip when userId is known.
|
|
184
|
+
* Never throws for invalid sessions; throws only for infrastructure
|
|
185
|
+
* failures (fail closed) and configuration errors.
|
|
186
|
+
*/
|
|
187
|
+
validate(token, options = {}) {
|
|
188
|
+
return this.guard('validate', async () => {
|
|
189
|
+
if (!this.isAcceptableToken(token)) {
|
|
190
|
+
return { valid: false, reason: 'invalid' };
|
|
191
|
+
}
|
|
192
|
+
const jti = this.deps.token.hash(token);
|
|
193
|
+
const userId = await this.resolveUserId(jti, options.userId);
|
|
194
|
+
if (userId === null) {
|
|
195
|
+
return { valid: false, reason: 'not_found' };
|
|
196
|
+
}
|
|
197
|
+
const result = await this.repository.validateRead(userId, jti);
|
|
198
|
+
if (!result.found) {
|
|
199
|
+
return { valid: false, reason: 'not_found' };
|
|
200
|
+
}
|
|
201
|
+
if ('code' in result) {
|
|
202
|
+
if (result.code === -1) {
|
|
203
|
+
return { valid: false, reason: result.status === 'revoked' ? 'revoked' : 'invalid' };
|
|
204
|
+
}
|
|
205
|
+
if (result.code === -2)
|
|
206
|
+
return { valid: false, reason: 'expired' };
|
|
207
|
+
if (result.code === -3)
|
|
208
|
+
return { valid: false, reason: 'idle_timeout' };
|
|
209
|
+
if (result.code === -4)
|
|
210
|
+
return { valid: false, reason: 'revoked' };
|
|
211
|
+
}
|
|
212
|
+
// Found and passed script checks. App-side checks on the payload.
|
|
213
|
+
let session;
|
|
214
|
+
try {
|
|
215
|
+
session = deserializeSession(result.raw, this.repository.keyProvider ?? undefined);
|
|
216
|
+
if (this.config.encryption.enabled) {
|
|
217
|
+
assertHeaderMatches(parseEncryptedHeader(result.raw), session);
|
|
218
|
+
// Security version (plain path is checked inside the script).
|
|
219
|
+
if (result.currentSecurityVersion !== null &&
|
|
220
|
+
session.securityVersion !== result.currentSecurityVersion) {
|
|
221
|
+
return { valid: false, reason: 'revoked' };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof SessionSerializationError) {
|
|
227
|
+
await this.bestEffortCleanup(userId, jti);
|
|
228
|
+
return { valid: false, reason: 'invalid' };
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
// Binding policy.
|
|
233
|
+
const binding = this.checkBinding(session, options);
|
|
234
|
+
if (binding && this.config.bindingPolicy === 'strict') {
|
|
235
|
+
return { valid: false, reason: 'binding_mismatch' };
|
|
236
|
+
}
|
|
237
|
+
// External revocation store (JWT jti denylists etc.).
|
|
238
|
+
if (this.config.checkRevocationStore && this.deps.revocationStore) {
|
|
239
|
+
const revoked = await this.deps.revocationStore.isRevoked(jti);
|
|
240
|
+
if (revoked)
|
|
241
|
+
return { valid: false, reason: 'revoked' };
|
|
242
|
+
}
|
|
243
|
+
return binding ? { valid: true, session, binding } : { valid: true, session };
|
|
244
|
+
}, (result) => (result.valid ? 'ok' : 'invalid'));
|
|
245
|
+
}
|
|
246
|
+
/* ------------------------------------------------------------------------ */
|
|
247
|
+
/* Touch */
|
|
248
|
+
/* ------------------------------------------------------------------------ */
|
|
249
|
+
/**
|
|
250
|
+
* Refreshes activity. Throttled by touchInterval (in-script + in-memory
|
|
251
|
+
* optimizations). Never resurrects an idle-expired session.
|
|
252
|
+
*/
|
|
253
|
+
touch(token, options = {}) {
|
|
254
|
+
return this.guard('touch', async () => {
|
|
255
|
+
if (!this.isAcceptableToken(token))
|
|
256
|
+
return 'not_found';
|
|
257
|
+
const jti = this.deps.token.hash(token);
|
|
258
|
+
const userId = await this.resolveUserId(jti, options.userId);
|
|
259
|
+
if (userId === null)
|
|
260
|
+
return 'not_found';
|
|
261
|
+
if (!options.force) {
|
|
262
|
+
const last = this.throttle.get(jti);
|
|
263
|
+
if (last !== undefined && this.now() - last < this.config.touchInterval) {
|
|
264
|
+
return 'skipped_throttled';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const outcome = await this.repository.touch(userId, jti, options.force ?? false);
|
|
268
|
+
if (outcome === 'touched') {
|
|
269
|
+
if (this.throttle.size >= MAX_THROTTLE_ENTRIES)
|
|
270
|
+
this.throttle.clear();
|
|
271
|
+
this.throttle.set(jti, this.now());
|
|
272
|
+
}
|
|
273
|
+
return outcome;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/* ------------------------------------------------------------------------ */
|
|
277
|
+
/* Rotate */
|
|
278
|
+
/* ------------------------------------------------------------------------ */
|
|
279
|
+
/**
|
|
280
|
+
* Single-use atomic rotation with retry-safe idempotency (rotationNonce).
|
|
281
|
+
*/
|
|
282
|
+
rotate(token, options = {}) {
|
|
283
|
+
return this.guard('rotate', async () => {
|
|
284
|
+
if (!this.isAcceptableToken(token)) {
|
|
285
|
+
throw new SessionNotFoundError({ reason: 'invalid_token' });
|
|
286
|
+
}
|
|
287
|
+
const oldJti = this.deps.token.hash(token);
|
|
288
|
+
const userId = await this.resolveUserId(oldJti, options.userId);
|
|
289
|
+
if (userId === null) {
|
|
290
|
+
throw new SessionNotFoundError({ reason: 'jti_index_miss' });
|
|
291
|
+
}
|
|
292
|
+
const rotationNonceHash = options.rotationNonce
|
|
293
|
+
? this.deps.token.hash(options.rotationNonce)
|
|
294
|
+
: undefined;
|
|
295
|
+
const now = this.now();
|
|
296
|
+
const successorToken = this.deps.token.generate();
|
|
297
|
+
const successorJti = this.deps.token.hash(successorToken);
|
|
298
|
+
const successor = {
|
|
299
|
+
jti: successorJti,
|
|
300
|
+
userId,
|
|
301
|
+
createdAt: now,
|
|
302
|
+
lastAccessedAt: now,
|
|
303
|
+
absoluteExpiresAt: now + this.config.ttl,
|
|
304
|
+
idleExpiresAt: this.config.idleTimeout !== null
|
|
305
|
+
? Math.min(now + this.config.idleTimeout, now + this.config.ttl)
|
|
306
|
+
: null,
|
|
307
|
+
status: 'active',
|
|
308
|
+
version: 1,
|
|
309
|
+
securityVersion: null,
|
|
310
|
+
deviceId: null,
|
|
311
|
+
ipAddress: null,
|
|
312
|
+
userAgent: null,
|
|
313
|
+
metadata: null,
|
|
314
|
+
rotatedFrom: oldJti,
|
|
315
|
+
rotatedTo: null,
|
|
316
|
+
consumedAt: null,
|
|
317
|
+
rotationNonceHash: null,
|
|
318
|
+
};
|
|
319
|
+
const result = await this.repository.rotate({
|
|
320
|
+
userId,
|
|
321
|
+
oldJti,
|
|
322
|
+
successor,
|
|
323
|
+
...(options.expectedVersion !== undefined
|
|
324
|
+
? { expectedVersion: options.expectedVersion }
|
|
325
|
+
: {}),
|
|
326
|
+
...(rotationNonceHash !== undefined ? { rotationNonceHash } : {}),
|
|
327
|
+
retainTombstone: this.config.retainConsumedTombstones,
|
|
328
|
+
});
|
|
329
|
+
if (result.code === 1 || result.code === 2) {
|
|
330
|
+
const replayed = result.code === 2;
|
|
331
|
+
const session = replayed
|
|
332
|
+
? await this.repository.get(userId, result.successorJti)
|
|
333
|
+
: successor;
|
|
334
|
+
if (!session) {
|
|
335
|
+
throw new SessionRotationError({ reason: 'successor_unavailable', replayed });
|
|
336
|
+
}
|
|
337
|
+
// The old index entry is intentionally kept: it is derived state
|
|
338
|
+
// with its own TTL, and keeping it lets retry-safe rotation replays
|
|
339
|
+
// resolve the consumed jti without a userId for the tombstone
|
|
340
|
+
// window. Validation of a consumed session fails regardless.
|
|
341
|
+
const indexed = await this.repository.writeJtiIndex(session.jti, userId, Math.max(1, session.absoluteExpiresAt - this.now()));
|
|
342
|
+
if (!indexed)
|
|
343
|
+
this.metrics.jtiIndexWriteFailure();
|
|
344
|
+
// On replay the successor's raw token is unrecoverable (only its
|
|
345
|
+
// hash is stored): the caller must treat the outcome as ambiguous
|
|
346
|
+
// and re-authenticate rather than reusing the old token.
|
|
347
|
+
return replayed ? { session, replayed } : { token: successorToken, session, replayed };
|
|
348
|
+
}
|
|
349
|
+
throw rotationError(result.code, result.status);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/* ------------------------------------------------------------------------ */
|
|
353
|
+
/* Update */
|
|
354
|
+
/* ------------------------------------------------------------------------ */
|
|
355
|
+
/**
|
|
356
|
+
* Patch update of non-security fields (device/ip/ua/metadata) with
|
|
357
|
+
* optimistic concurrency.
|
|
358
|
+
*/
|
|
359
|
+
update(token, patch, options = {}) {
|
|
360
|
+
return this.guard('update', async () => {
|
|
361
|
+
if (!this.isAcceptableToken(token)) {
|
|
362
|
+
throw new SessionNotFoundError({ reason: 'invalid_token' });
|
|
363
|
+
}
|
|
364
|
+
const jti = this.deps.token.hash(token);
|
|
365
|
+
const userId = await this.resolveUserId(jti, options.userId);
|
|
366
|
+
if (userId === null) {
|
|
367
|
+
throw new SessionNotFoundError({ reason: 'jti_index_miss' });
|
|
368
|
+
}
|
|
369
|
+
validatePatch(patch, this.config.limits.maxMetadataSize);
|
|
370
|
+
const record = await this.repository.update(userId, jti, patch, options.expectedVersion);
|
|
371
|
+
if (!record) {
|
|
372
|
+
throw new SessionNotFoundError({});
|
|
373
|
+
}
|
|
374
|
+
return record;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/* ------------------------------------------------------------------------ */
|
|
378
|
+
/* Destroy / revoke */
|
|
379
|
+
/* ------------------------------------------------------------------------ */
|
|
380
|
+
/** Physically deletes a session (idempotent). */
|
|
381
|
+
destroy(token, options = {}) {
|
|
382
|
+
return this.guard('destroy', async () => {
|
|
383
|
+
if (!this.isAcceptableToken(token))
|
|
384
|
+
return false;
|
|
385
|
+
const jti = this.deps.token.hash(token);
|
|
386
|
+
const userId = await this.resolveUserId(jti, options.userId);
|
|
387
|
+
if (userId === null)
|
|
388
|
+
return false;
|
|
389
|
+
const deleted = await this.repository.destroy(userId, jti);
|
|
390
|
+
if (deleted)
|
|
391
|
+
await this.repository.deleteJtiIndex(jti);
|
|
392
|
+
return deleted;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Logically revokes a session (keeps a bounded tombstone).
|
|
397
|
+
* Returns 'revoked' | 'already_revoked' | 'not_found'.
|
|
398
|
+
*/
|
|
399
|
+
revoke(token, options = {}) {
|
|
400
|
+
return this.guard('revoke', async () => {
|
|
401
|
+
if (!this.isAcceptableToken(token))
|
|
402
|
+
return 'not_found';
|
|
403
|
+
const jti = this.deps.token.hash(token);
|
|
404
|
+
const userId = await this.resolveUserId(jti, options.userId);
|
|
405
|
+
if (userId === null)
|
|
406
|
+
return 'not_found';
|
|
407
|
+
const outcome = await this.repository.revoke(userId, jti, this.config.ttl);
|
|
408
|
+
await this.repository.deleteJtiIndex(jti);
|
|
409
|
+
return outcome;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/** Revokes every session of a user (bounded, fail-closed on partial). */
|
|
413
|
+
async revokeAll(userId) {
|
|
414
|
+
return this.guard('revoke_all', async () => {
|
|
415
|
+
validateUserId(userId);
|
|
416
|
+
const jtis = await this.repository.listJtis(userId, this.config.limits.maxSessionsPerUserHardCap);
|
|
417
|
+
let revoked = 0;
|
|
418
|
+
await mapWithConcurrency(jtis, this.config.limits.maxFanOutConcurrency, async (jti) => {
|
|
419
|
+
await this.repository.revoke(userId, jti, this.config.ttl);
|
|
420
|
+
revoked += 1;
|
|
421
|
+
});
|
|
422
|
+
return revoked;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/** Deletes every session of a user (physical, bounded). */
|
|
426
|
+
async deleteByUser(userId) {
|
|
427
|
+
return this.guard('delete_by_user', async () => {
|
|
428
|
+
validateUserId(userId);
|
|
429
|
+
const jtis = await this.repository.listJtis(userId, this.config.limits.maxSessionsPerUserHardCap);
|
|
430
|
+
const deleted = await this.repository.deleteByUser(userId);
|
|
431
|
+
await this.repository.deleteJtiIndexMany(jtis);
|
|
432
|
+
return deleted;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/* ------------------------------------------------------------------------ */
|
|
436
|
+
/* Listing */
|
|
437
|
+
/* ------------------------------------------------------------------------ */
|
|
438
|
+
/** Lists a user's sessions (oldest first). */
|
|
439
|
+
findByUser(userId, options = {}) {
|
|
440
|
+
return this.guard('find_by_user', async () => {
|
|
441
|
+
validateUserId(userId);
|
|
442
|
+
const includeInactive = options.includeInactive ?? false;
|
|
443
|
+
const sessions = await this.repository.listByUser(userId, {
|
|
444
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
445
|
+
...(options.offset !== undefined ? { offset: options.offset } : {}),
|
|
446
|
+
});
|
|
447
|
+
if (includeInactive)
|
|
448
|
+
return sessions;
|
|
449
|
+
return sessions.filter((s) => s.status === 'active');
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/** Alias of {@link findByUser} for listing. */
|
|
453
|
+
list(userId, options = {}) {
|
|
454
|
+
return this.findByUser(userId, options);
|
|
455
|
+
}
|
|
456
|
+
/* ------------------------------------------------------------------------ */
|
|
457
|
+
/* Security version */
|
|
458
|
+
/* ------------------------------------------------------------------------ */
|
|
459
|
+
/**
|
|
460
|
+
* Sets (or bumps) the user's security version, invalidating every session
|
|
461
|
+
* captured at an older version. Use after password/MFA changes.
|
|
462
|
+
*/
|
|
463
|
+
setSecurityVersion(userId, version) {
|
|
464
|
+
return this.guard('set_security_version', async () => {
|
|
465
|
+
validateUserId(userId);
|
|
466
|
+
const next = version !== undefined
|
|
467
|
+
? version
|
|
468
|
+
: ((await this.repository.getSecurityVersion(userId)) ?? 0) + 1;
|
|
469
|
+
await this.repository.setSecurityVersion(userId, next);
|
|
470
|
+
return next;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
getSecurityVersion(userId) {
|
|
474
|
+
return this.guard('set_security_version', async () => {
|
|
475
|
+
validateUserId(userId);
|
|
476
|
+
return this.repository.getSecurityVersion(userId);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/* ------------------------------------------------------------------------ */
|
|
480
|
+
/* Health */
|
|
481
|
+
/* ------------------------------------------------------------------------ */
|
|
482
|
+
/** Dependency health (PING latency + recent error rate). */
|
|
483
|
+
async health() {
|
|
484
|
+
if (!this.deps.health) {
|
|
485
|
+
throw new SessionConfigurationError('Session health checker is not configured.');
|
|
486
|
+
}
|
|
487
|
+
return this.deps.health.check();
|
|
488
|
+
}
|
|
489
|
+
/* ------------------------------------------------------------------------ */
|
|
490
|
+
/* Internals */
|
|
491
|
+
/* ------------------------------------------------------------------------ */
|
|
492
|
+
/**
|
|
493
|
+
* Resolves the userId for a jti: explicit when provided (fast path), via
|
|
494
|
+
* the JTI index otherwise. Returns null when the index has no entry.
|
|
495
|
+
*/
|
|
496
|
+
async resolveUserId(jti, explicitUserId) {
|
|
497
|
+
if (explicitUserId !== undefined && explicitUserId !== '') {
|
|
498
|
+
return explicitUserId;
|
|
499
|
+
}
|
|
500
|
+
if (!this.config.jtiIndex.enabled) {
|
|
501
|
+
throw new SessionConfigurationError('Operation requires userId (jtiIndex is disabled and no userId was provided).');
|
|
502
|
+
}
|
|
503
|
+
return this.repository.readJtiIndex(jti);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Accepts tokens in the strict issued format (base64url of the configured
|
|
507
|
+
* entropy) and caller-supplied idempotency keys (bounded printable ASCII),
|
|
508
|
+
* which are used as tokens for idempotent creation. Rejects everything
|
|
509
|
+
* else (DoS guard: bounded length, bounded alphabet).
|
|
510
|
+
*/
|
|
511
|
+
isAcceptableToken(token) {
|
|
512
|
+
if (this.deps.token.validateFormat(token))
|
|
513
|
+
return true;
|
|
514
|
+
return (token.length >= IDEMPOTENCY_MIN_LENGTH &&
|
|
515
|
+
token.length <= IDEMPOTENCY_MAX_LENGTH &&
|
|
516
|
+
PRINTABLE_ASCII.test(token));
|
|
517
|
+
}
|
|
518
|
+
checkBinding(session, options) {
|
|
519
|
+
if (this.config.bindingPolicy === 'disabled')
|
|
520
|
+
return null;
|
|
521
|
+
const mismatch = {
|
|
522
|
+
ipAddress: session.ipAddress !== null &&
|
|
523
|
+
options.ipAddress !== undefined &&
|
|
524
|
+
session.ipAddress !== options.ipAddress,
|
|
525
|
+
userAgent: session.userAgent !== null &&
|
|
526
|
+
options.userAgent !== undefined &&
|
|
527
|
+
session.userAgent !== options.userAgent,
|
|
528
|
+
deviceId: session.deviceId !== null &&
|
|
529
|
+
options.deviceId !== undefined &&
|
|
530
|
+
session.deviceId !== options.deviceId,
|
|
531
|
+
};
|
|
532
|
+
if (!mismatch.ipAddress && !mismatch.userAgent && !mismatch.deviceId)
|
|
533
|
+
return null;
|
|
534
|
+
return mismatch;
|
|
535
|
+
}
|
|
536
|
+
async bestEffortCleanup(userId, jti) {
|
|
537
|
+
try {
|
|
538
|
+
await this.repository.destroy(userId, jti);
|
|
539
|
+
await this.repository.deleteJtiIndex(jti);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
// Best-effort: a failed cleanup must not mask the invalid result.
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/* -------------------------------------------------------------------------- */
|
|
547
|
+
/* Helpers */
|
|
548
|
+
/* -------------------------------------------------------------------------- */
|
|
549
|
+
function rotationError(code, status) {
|
|
550
|
+
switch (code) {
|
|
551
|
+
case 0:
|
|
552
|
+
return new SessionNotFoundError({});
|
|
553
|
+
case -1:
|
|
554
|
+
return new SessionRevokedError({ status });
|
|
555
|
+
case -2:
|
|
556
|
+
return new SessionExpiredError({});
|
|
557
|
+
case -3:
|
|
558
|
+
return new SessionConcurrencyError({ reason: 'version_conflict' });
|
|
559
|
+
case -4:
|
|
560
|
+
return new SessionRotationError({ reason: 'successor_collision' });
|
|
561
|
+
case 5:
|
|
562
|
+
case 6:
|
|
563
|
+
return new SessionSerializationError({ reason: 'envelope_mode_mismatch' });
|
|
564
|
+
default:
|
|
565
|
+
return new SessionStorageError(undefined, { code });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function validateUserId(userId) {
|
|
569
|
+
if (typeof userId !== 'string' || userId.length === 0 || userId.length > 512) {
|
|
570
|
+
throw new SessionConfigurationError('userId must be a non-empty string of at most 512 chars.');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function validateIdempotencyToken(token) {
|
|
574
|
+
if (token.length < IDEMPOTENCY_MIN_LENGTH ||
|
|
575
|
+
token.length > IDEMPOTENCY_MAX_LENGTH ||
|
|
576
|
+
!PRINTABLE_ASCII.test(token)) {
|
|
577
|
+
throw new SessionConfigurationError(`idempotencyKey must be ${IDEMPOTENCY_MIN_LENGTH}-${IDEMPOTENCY_MAX_LENGTH} printable ASCII chars.`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function validatePatch(patch, maxMetadataSize) {
|
|
581
|
+
if (patch.deviceId !== undefined && patch.deviceId !== null && patch.deviceId.length > 1024) {
|
|
582
|
+
throw new SessionInvalidError({ reason: 'device_id_too_long' });
|
|
583
|
+
}
|
|
584
|
+
if (patch.ipAddress !== undefined && patch.ipAddress !== null && patch.ipAddress.length > 64) {
|
|
585
|
+
throw new SessionInvalidError({ reason: 'ip_address_too_long' });
|
|
586
|
+
}
|
|
587
|
+
if (patch.userAgent !== undefined &&
|
|
588
|
+
patch.userAgent !== null &&
|
|
589
|
+
patch.userAgent.length > 1024) {
|
|
590
|
+
throw new SessionInvalidError({ reason: 'user_agent_too_long' });
|
|
591
|
+
}
|
|
592
|
+
if (patch.metadata !== undefined && patch.metadata !== null) {
|
|
593
|
+
let serialized;
|
|
594
|
+
try {
|
|
595
|
+
serialized = JSON.stringify(patch.metadata);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
throw new SessionInvalidError({ reason: 'metadata_cyclic' });
|
|
599
|
+
}
|
|
600
|
+
const size = Buffer.byteLength(serialized);
|
|
601
|
+
if (size > maxMetadataSize) {
|
|
602
|
+
throw new SessionInvalidError({ reason: 'metadata_too_large', size, max: maxMetadataSize });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function parseEncryptedHeader(raw) {
|
|
607
|
+
const parsed = JSON.parse(raw);
|
|
608
|
+
if (parsed.v !== 2)
|
|
609
|
+
throw new SessionSerializationError({ reason: 'envelope_mode_mismatch' });
|
|
610
|
+
return parsed;
|
|
611
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates and hashes session tokens.
|
|
3
|
+
*
|
|
4
|
+
* All functions are synchronous; entropy comes from the OS CSPRNG.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SessionTokenManager {
|
|
7
|
+
private readonly tokenBytes;
|
|
8
|
+
/**
|
|
9
|
+
* @param tokenBytes - Raw entropy in bytes (min 16 = 128 bits). 32 is
|
|
10
|
+
* recommended (256 bits).
|
|
11
|
+
*/
|
|
12
|
+
constructor(tokenBytes?: number);
|
|
13
|
+
/** Generates a new raw session token (base64url, no padding). */
|
|
14
|
+
generate(): string;
|
|
15
|
+
/** Generates a caller-supplied rotation nonce of the same strength. */
|
|
16
|
+
generateNonce(): string;
|
|
17
|
+
/**
|
|
18
|
+
* SHA-256 hashes a raw token into its persisted jti (base64url).
|
|
19
|
+
* The token itself is never stored; only this digest is.
|
|
20
|
+
*/
|
|
21
|
+
hash(token: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Validates that a value looks like a well-formed raw token of the
|
|
24
|
+
* configured length. Used to reject garbage before hashing/lookup.
|
|
25
|
+
*/
|
|
26
|
+
validateFormat(token: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Constant-time comparison of two strings (uses byte length, then
|
|
29
|
+
* timingSafeEqual). Returns false when lengths differ without leaking
|
|
30
|
+
* the difference.
|
|
31
|
+
*/
|
|
32
|
+
safeEquals(a: string, b: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Validates a token and returns its jti, or throws SessionInvalidError
|
|
35
|
+
* for malformed input (so a malformed token is never looked up).
|
|
36
|
+
*/
|
|
37
|
+
tokenToJti(token: string): string;
|
|
38
|
+
}
|