ioredis-toolkit 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +556 -1
- package/dist/cache.d.ts +38 -37
- package/dist/cache.js +74 -79
- package/dist/client.d.ts +4 -1
- package/dist/client.js +3 -4
- package/dist/lock.d.ts +1 -16
- package/dist/lock.js +43 -0
- package/dist/session/scripts/rotate-encrypted.lua +53 -14
- package/dist/session/scripts/rotate.lua +54 -9
- package/dist/session/session-config.d.ts +20 -0
- package/dist/session/session-config.js +9 -0
- package/dist/session/session-keys.d.ts +13 -0
- package/dist/session/session-keys.js +15 -0
- package/dist/session/session-metrics.d.ts +9 -1
- package/dist/session/session-metrics.js +23 -0
- package/dist/session/session-repository.d.ts +23 -0
- package/dist/session/session-repository.js +102 -22
- package/dist/session/session-serializer.d.ts +1 -1
- package/dist/session/session-serializer.js +19 -0
- package/dist/session/session-service.d.ts +20 -1
- package/dist/session/session-service.js +60 -1
- package/dist/session/session-types.d.ts +28 -0
- package/dist/types.js +1 -1
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ import type { SessionKeyStrategy } from './session-keys.js';
|
|
|
7
7
|
import { SessionMetrics } from './session-metrics.js';
|
|
8
8
|
import { SessionRepository } from './session-repository.js';
|
|
9
9
|
import type { SessionTokenManager } from './session-token.js';
|
|
10
|
-
import type { CreatedSession, ListOptions, RotateOptions, RotatedSession, SessionCreateInput, SessionRecord, SessionUpdatePatch, SessionValidationResult, TouchOptions, TouchOutcome, UpdateOptions, ValidateOptions } from './session-types.js';
|
|
10
|
+
import type { CreatedSession, ListOptions, ReconcileUserResult, RotateOptions, RotatedSession, SessionCreateInput, SessionRecord, SessionUpdatePatch, SessionValidationResult, TouchOptions, TouchOutcome, UpdateOptions, ValidateOptions } from './session-types.js';
|
|
11
11
|
export interface SessionServiceDeps {
|
|
12
12
|
config: SessionConfig;
|
|
13
13
|
client: RedisClientWrapper;
|
|
@@ -85,6 +85,25 @@ export declare class SessionService {
|
|
|
85
85
|
*/
|
|
86
86
|
setSecurityVersion(userId: string, version?: number): Promise<number>;
|
|
87
87
|
getSecurityVersion(userId: string): Promise<number | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Bounded administrative repair pass for one user: prunes stale
|
|
90
|
+
* user-index entries and, when the global jti index is enabled, rewrites
|
|
91
|
+
* any missing/stale jti-index entry for that user's live active sessions.
|
|
92
|
+
*
|
|
93
|
+
* This is NOT required for authentication correctness - every read path
|
|
94
|
+
* (validate/touch/rotate) already treats the session record as
|
|
95
|
+
* authoritative and self-heals stale index entries lazily. This exists
|
|
96
|
+
* purely to shrink the window during which JTI-only lookup (`find(jti)`
|
|
97
|
+
* without a known userId) can miss a live session after a partial write
|
|
98
|
+
* (ยง67), and to give operators a way to proactively repair known drift
|
|
99
|
+
* (e.g. after a Redis incident) instead of waiting for it to be hit
|
|
100
|
+
* randomly. Safe to call repeatedly; every effect is idempotent.
|
|
101
|
+
*
|
|
102
|
+
* Bounded by config.limits.maxSessionsPerUserHardCap, same as
|
|
103
|
+
* revokeAll/deleteByUser - never scans the cluster and is not called
|
|
104
|
+
* from a hot auth path.
|
|
105
|
+
*/
|
|
106
|
+
reconcileUser(userId: string): Promise<ReconcileUserResult>;
|
|
88
107
|
/** Dependency health (PING latency + recent error rate). */
|
|
89
108
|
health(): Promise<ReturnType<SessionHealthChecker['check']>>;
|
|
90
109
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mapWithConcurrency } from '../cluster.js';
|
|
2
|
-
import { SessionConfigurationError, SessionConcurrencyError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, SessionRevokedError, SessionRotationError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
|
|
2
|
+
import { SessionConfigurationError, SessionConcurrencyError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, SessionReplayError, SessionRevokedError, SessionRotationError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
|
|
3
3
|
import { SessionMetrics } from './session-metrics.js';
|
|
4
4
|
import { assertHeaderMatches, deserializeSession } from './session-serializer.js';
|
|
5
5
|
const IDEMPOTENCY_MIN_LENGTH = 8;
|
|
@@ -150,6 +150,10 @@ export class SessionService {
|
|
|
150
150
|
rotatedTo: null,
|
|
151
151
|
consumedAt: null,
|
|
152
152
|
rotationNonceHash: null,
|
|
153
|
+
// First generation of a lineage: familyId equals its own jti (the
|
|
154
|
+
// convention rotate.lua's self-heal also falls back to for legacy
|
|
155
|
+
// records missing the field - see session-types.ts).
|
|
156
|
+
familyId: jti,
|
|
153
157
|
};
|
|
154
158
|
const ttl = Math.max(1, record.absoluteExpiresAt - now);
|
|
155
159
|
const result = await this.repository.create(record, ttl);
|
|
@@ -315,6 +319,14 @@ export class SessionService {
|
|
|
315
319
|
rotatedTo: null,
|
|
316
320
|
consumedAt: null,
|
|
317
321
|
rotationNonceHash: null,
|
|
322
|
+
// Placeholder only: familyId is an identity field decided
|
|
323
|
+
// authoritatively from the OLD session, not the app. The plain-path
|
|
324
|
+
// script (rotate.lua) always overwrites this before writing; the
|
|
325
|
+
// encrypted path resolves the real value from the just-decrypted
|
|
326
|
+
// predecessor in SessionRepository.rotate() (Lua can't rewrite
|
|
327
|
+
// ciphertext, so that's the only place it can be fixed up). Any
|
|
328
|
+
// syntactically valid jti-shaped string is fine here.
|
|
329
|
+
familyId: oldJti,
|
|
318
330
|
};
|
|
319
331
|
const result = await this.repository.rotate({
|
|
320
332
|
userId,
|
|
@@ -325,6 +337,7 @@ export class SessionService {
|
|
|
325
337
|
: {}),
|
|
326
338
|
...(rotationNonceHash !== undefined ? { rotationNonceHash } : {}),
|
|
327
339
|
retainTombstone: this.config.retainConsumedTombstones,
|
|
340
|
+
revokeFamilyOnReplay: this.config.revokeFamilyOnReplay,
|
|
328
341
|
});
|
|
329
342
|
if (result.code === 1 || result.code === 2) {
|
|
330
343
|
const replayed = result.code === 2;
|
|
@@ -346,6 +359,23 @@ export class SessionService {
|
|
|
346
359
|
// and re-authenticate rather than reusing the old token.
|
|
347
360
|
return replayed ? { session, replayed } : { token: successorToken, session, replayed };
|
|
348
361
|
}
|
|
362
|
+
if (result.code === -6) {
|
|
363
|
+
// Genuine reuse of an already-rotated-away token: the entire
|
|
364
|
+
// lineage's currently active generation was atomically revoked
|
|
365
|
+
// (this is a strong security signal, never an infra/storage
|
|
366
|
+
// failure - see SessionReplayError, not SessionStorageError, so it
|
|
367
|
+
// never trips the circuit breaker per guard()'s classification).
|
|
368
|
+
// The old jti-index entry (if any) no longer points anywhere
|
|
369
|
+
// useful; best-effort clean it up.
|
|
370
|
+
await this.repository.deleteJtiIndex(oldJti);
|
|
371
|
+
throw new SessionReplayError({
|
|
372
|
+
reason: 'family_revoked',
|
|
373
|
+
...(result.familyId !== undefined ? { familyId: result.familyId } : {}),
|
|
374
|
+
...(result.headJtiRevoked !== undefined
|
|
375
|
+
? { headJtiRevoked: result.headJtiRevoked }
|
|
376
|
+
: {}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
349
379
|
throw rotationError(result.code, result.status);
|
|
350
380
|
});
|
|
351
381
|
}
|
|
@@ -477,6 +507,35 @@ export class SessionService {
|
|
|
477
507
|
});
|
|
478
508
|
}
|
|
479
509
|
/* ------------------------------------------------------------------------ */
|
|
510
|
+
/* Reconciliation (ยง25 / ยง67 / ยง68) */
|
|
511
|
+
/* ------------------------------------------------------------------------ */
|
|
512
|
+
/**
|
|
513
|
+
* Bounded administrative repair pass for one user: prunes stale
|
|
514
|
+
* user-index entries and, when the global jti index is enabled, rewrites
|
|
515
|
+
* any missing/stale jti-index entry for that user's live active sessions.
|
|
516
|
+
*
|
|
517
|
+
* This is NOT required for authentication correctness - every read path
|
|
518
|
+
* (validate/touch/rotate) already treats the session record as
|
|
519
|
+
* authoritative and self-heals stale index entries lazily. This exists
|
|
520
|
+
* purely to shrink the window during which JTI-only lookup (`find(jti)`
|
|
521
|
+
* without a known userId) can miss a live session after a partial write
|
|
522
|
+
* (ยง67), and to give operators a way to proactively repair known drift
|
|
523
|
+
* (e.g. after a Redis incident) instead of waiting for it to be hit
|
|
524
|
+
* randomly. Safe to call repeatedly; every effect is idempotent.
|
|
525
|
+
*
|
|
526
|
+
* Bounded by config.limits.maxSessionsPerUserHardCap, same as
|
|
527
|
+
* revokeAll/deleteByUser - never scans the cluster and is not called
|
|
528
|
+
* from a hot auth path.
|
|
529
|
+
*/
|
|
530
|
+
reconcileUser(userId) {
|
|
531
|
+
return this.guard('reconcile_user', async () => {
|
|
532
|
+
validateUserId(userId);
|
|
533
|
+
const result = await this.repository.reconcileUser(userId, this.config.limits.maxSessionsPerUserHardCap, this.now());
|
|
534
|
+
this.metrics.reconcileUser(result.jtiIndexRepaired, result.staleIndexRemoved);
|
|
535
|
+
return { userId, ...result };
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/* ------------------------------------------------------------------------ */
|
|
480
539
|
/* Health */
|
|
481
540
|
/* ------------------------------------------------------------------------ */
|
|
482
541
|
/** Dependency health (PING latency + recent error rate). */
|
|
@@ -42,6 +42,15 @@ export type SessionRecord = {
|
|
|
42
42
|
metadata: Record<string, unknown> | null;
|
|
43
43
|
/** JTI this session was rotated from (rotation chains, reuse detection). */
|
|
44
44
|
rotatedFrom: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Stable identifier for this session's rotation lineage ("family"),
|
|
47
|
+
* unchanged across every generation produced by rotate(). Equal to the
|
|
48
|
+
* first generation's own jti (unguessable, 256-bit) - no separate
|
|
49
|
+
* randomness is generated for it. Used only to detect and respond to
|
|
50
|
+
* stolen-refresh-token reuse (see rotate.lua); it is never consulted by
|
|
51
|
+
* validate() and can never by itself grant authentication (I7).
|
|
52
|
+
*/
|
|
53
|
+
familyId: string;
|
|
45
54
|
/** JTI this session was rotated to (enables retry-safe rotation). */
|
|
46
55
|
rotatedTo: string | null;
|
|
47
56
|
/** When the session was consumed by a rotation (Unix seconds), or null. */
|
|
@@ -189,6 +198,20 @@ export type BindingMismatch = {
|
|
|
189
198
|
userAgent: boolean;
|
|
190
199
|
deviceId: boolean;
|
|
191
200
|
};
|
|
201
|
+
/**
|
|
202
|
+
* Result of a bounded {@link SessionService.reconcileUser} repair pass.
|
|
203
|
+
* `checked` is the number of live session records inspected (bounded by
|
|
204
|
+
* maxSessionsPerUserHardCap); `staleIndexRemoved` counts user-index entries
|
|
205
|
+
* removed because their session record no longer exists or was corrupt;
|
|
206
|
+
* `jtiIndexRepaired` counts global jti-index entries that were missing or
|
|
207
|
+
* stale for a live active session and were rewritten.
|
|
208
|
+
*/
|
|
209
|
+
export type ReconcileUserResult = {
|
|
210
|
+
userId: string;
|
|
211
|
+
checked: number;
|
|
212
|
+
staleIndexRemoved: number;
|
|
213
|
+
jtiIndexRepaired: number;
|
|
214
|
+
};
|
|
192
215
|
/** Schema version of the persisted envelope. */
|
|
193
216
|
export type SerializedSchemaVersion = 1 | 2;
|
|
194
217
|
/**
|
|
@@ -233,6 +256,11 @@ export type EncryptedSessionEnvelope = {
|
|
|
233
256
|
rn: string | null;
|
|
234
257
|
/** Plaintext rotated-to JTI mirror (script access). */
|
|
235
258
|
rj: string | null;
|
|
259
|
+
/**
|
|
260
|
+
* Plaintext rotation-family-id mirror (script access - see rotate.lua).
|
|
261
|
+
* Immutable identity field, unchanged across rotations of one lineage.
|
|
262
|
+
*/
|
|
263
|
+
fam: string;
|
|
236
264
|
};
|
|
237
265
|
export type SessionEnvelope = PlainSessionEnvelope | EncryptedSessionEnvelope;
|
|
238
266
|
export type RevocationRecord = {
|
package/dist/types.js
CHANGED
|
@@ -7,7 +7,7 @@ export const DistributedLockOptionsSchema = z.object({
|
|
|
7
7
|
export const CacheOptionsSchema = z.object({
|
|
8
8
|
defaultTTL: z.number().int().min(0).default(3_600),
|
|
9
9
|
compressionThreshold: z.number().int().min(1).default(1_024),
|
|
10
|
-
namespace: z.string().
|
|
10
|
+
namespace: z.string().default('')
|
|
11
11
|
});
|
|
12
12
|
// ============================================================================
|
|
13
13
|
// Rate Limiting
|
package/package.json
CHANGED