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,113 @@
|
|
|
1
|
+
import { SessionConfigurationError } from './session-errors.js';
|
|
2
|
+
/* -------------------------------------------------------------------------- */
|
|
3
|
+
/* Redis key design for Redis Cluster. */
|
|
4
|
+
/* */
|
|
5
|
+
/* session record : {ns}:session:{userId}:session:{jti} */
|
|
6
|
+
/* user index : {ns}:user-sessions:{userId} (ZSET) */
|
|
7
|
+
/* security ver : {ns}:security-version:{userId} (string) */
|
|
8
|
+
/* jti index (opt): {ns}:jti-index:{jti} (cross-slot, global) */
|
|
9
|
+
/* revoked (opt) : {ns}:revoked:{jti} (cross-slot, single) */
|
|
10
|
+
/* */
|
|
11
|
+
/* The literal text `{userId}` is the Redis Cluster hash tag: every key of */
|
|
12
|
+
/* one user shares one slot, enabling atomic Lua scripts per user. No global */
|
|
13
|
+
/* hash tag is used, so sessions are spread across slots. */
|
|
14
|
+
/* */
|
|
15
|
+
/* userId values are percent-encoded before embedding: values containing */
|
|
16
|
+
/* `{`, `}`, `:`, `*`, `?`, `[`, `]` etc. would otherwise break hash tags, */
|
|
17
|
+
/* glob patterns and key parsing. The encoding is deterministic and */
|
|
18
|
+
/* collision-free. */
|
|
19
|
+
/* -------------------------------------------------------------------------- */
|
|
20
|
+
/** Characters kept verbatim in the encoded user id. */
|
|
21
|
+
const SAFE = /^[A-Za-z0-9._-]$/;
|
|
22
|
+
/**
|
|
23
|
+
* Deterministically encodes a userId for safe embedding in Redis keys.
|
|
24
|
+
*
|
|
25
|
+
* Every byte not in [A-Za-z0-9._-] is hex-encoded as %XX (UTF-8 aware),
|
|
26
|
+
* so `{`, `}`, `:` and glob metacharacters can never appear. The encoding
|
|
27
|
+
* is injective: distinct userIds always produce distinct encodings.
|
|
28
|
+
*/
|
|
29
|
+
export function encodeUserId(userId) {
|
|
30
|
+
if (typeof userId !== 'string' || userId.length === 0 || userId.length > 512) {
|
|
31
|
+
throw new SessionConfigurationError('userId must be a non-empty string of at most 512 chars.');
|
|
32
|
+
}
|
|
33
|
+
let out = '';
|
|
34
|
+
for (let i = 0; i < userId.length; i++) {
|
|
35
|
+
const ch = userId[i];
|
|
36
|
+
if (SAFE.test(ch)) {
|
|
37
|
+
out += ch;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
for (const byte of Buffer.from(ch, 'utf8')) {
|
|
41
|
+
out += `%${byte.toString(16).padStart(2, '0')}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Deterministic, Cluster-safe key strategy.
|
|
49
|
+
*
|
|
50
|
+
* Every key derived here is stable for the lifetime of the process and
|
|
51
|
+
* identical across horizontally scaled instances (no randomness).
|
|
52
|
+
*/
|
|
53
|
+
export class SessionKeyStrategy {
|
|
54
|
+
namespace;
|
|
55
|
+
/**
|
|
56
|
+
* @param namespace - Key namespace, e.g. `'authcore'`.
|
|
57
|
+
*/
|
|
58
|
+
constructor(namespace) {
|
|
59
|
+
const trimmed = namespace.trim();
|
|
60
|
+
if (!trimmed || trimmed.length > 64 || /[\s{}/:*?[\]]/.test(trimmed)) {
|
|
61
|
+
throw new SessionConfigurationError('namespace must be 1-64 chars without whitespace or glob/hash-tag metacharacters.');
|
|
62
|
+
}
|
|
63
|
+
this.namespace = trimmed;
|
|
64
|
+
}
|
|
65
|
+
ns(part) {
|
|
66
|
+
return `${this.namespace}:${part}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Key of a single session record. Hash-tagged by userId, so all of one
|
|
70
|
+
* user's session keys share a slot.
|
|
71
|
+
*/
|
|
72
|
+
sessionKey(userId, jti) {
|
|
73
|
+
return this.ns(`session:{${encodeUserId(userId)}}:session:${jti}`);
|
|
74
|
+
}
|
|
75
|
+
/** Key of the user's session index (ZSET, member = jti, score = createdAt). */
|
|
76
|
+
userIndexKey(userId) {
|
|
77
|
+
return this.ns(`user-sessions:{${encodeUserId(userId)}}`);
|
|
78
|
+
}
|
|
79
|
+
/** Key of the user's security version counter. Same user slot. */
|
|
80
|
+
securityVersionKey(userId) {
|
|
81
|
+
return this.ns(`security-version:{${encodeUserId(userId)}}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Short-lived idempotent-creation claim key (user slot). Bounded TTL is
|
|
85
|
+
* set by the create script; a claim only ever suppresses a duplicate.
|
|
86
|
+
*/
|
|
87
|
+
createClaimKey(userId, jti) {
|
|
88
|
+
return this.ns(`create-claim:{${encodeUserId(userId)}}:${jti}`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Key of the optional global JTI -> userId index.
|
|
92
|
+
* Deliberately NOT hash-tagged: it is cross-slot from the session record
|
|
93
|
+
* and treated as derived state (see docs/architecture).
|
|
94
|
+
*/
|
|
95
|
+
jtiIndexKey(jti) {
|
|
96
|
+
return this.ns(`jti-index:${jti}`);
|
|
97
|
+
}
|
|
98
|
+
/** Key of a revocation entry (single-key, cluster-safe, no tag needed). */
|
|
99
|
+
revokedKey(jti) {
|
|
100
|
+
return this.ns(`revoked:${jti}`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Session key prefix for a user, used by Lua eviction to construct keys
|
|
104
|
+
* from jtis. The `{userId}` hash tag guarantees same-slot construction.
|
|
105
|
+
*/
|
|
106
|
+
sessionKeyPrefix(userId) {
|
|
107
|
+
return this.ns(`session:{${encodeUserId(userId)}}:session:`);
|
|
108
|
+
}
|
|
109
|
+
/** Index key prefix for namespace-scoped administration. */
|
|
110
|
+
namespacePrefix() {
|
|
111
|
+
return `${this.namespace}:`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RedisClientWrapper } from '../client.js';
|
|
2
|
+
import type { RevocationStore } from './session-types.js';
|
|
3
|
+
import { SessionCircuitBreaker } from './session-circuit-breaker.js';
|
|
4
|
+
import type { PartialSessionConfig, SessionConfig } from './session-config.js';
|
|
5
|
+
import { SessionCookieManager } from './session-cookie.js';
|
|
6
|
+
import { SessionHealthChecker } from './session-health.js';
|
|
7
|
+
import type { SessionKeyProvider } from './session-encryption.js';
|
|
8
|
+
import { SessionKeyStrategy } from './session-keys.js';
|
|
9
|
+
import { SessionMetrics } from './session-metrics.js';
|
|
10
|
+
import type { SessionMetricsAdapter } from './session-metrics.js';
|
|
11
|
+
import { SessionRepository } from './session-repository.js';
|
|
12
|
+
import { SessionService } from './session-service.js';
|
|
13
|
+
import { SessionTokenManager } from './session-token.js';
|
|
14
|
+
export type SessionManagerOptions = {
|
|
15
|
+
/** Redis client (standalone, sentinel or cluster - all supported). */
|
|
16
|
+
client: RedisClientWrapper;
|
|
17
|
+
/** Session configuration (defaults applied; see SessionConfigSchema). */
|
|
18
|
+
config?: PartialSessionConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Encryption key provider. REQUIRED when config.encryption.enabled is
|
|
21
|
+
* true (fail at construction rather than at first write). The provider
|
|
22
|
+
* should be backed by a KMS/vault in production.
|
|
23
|
+
*/
|
|
24
|
+
encryptionKeyProvider?: SessionKeyProvider;
|
|
25
|
+
/** External revocation store (JWT jti denylists etc.). */
|
|
26
|
+
revocationStore?: RevocationStore;
|
|
27
|
+
/** Metrics adapter (no-op without it). */
|
|
28
|
+
metricsAdapter?: SessionMetricsAdapter;
|
|
29
|
+
/** Optional circuit breaker (enabled via config.circuitBreaker.enabled). */
|
|
30
|
+
circuitBreaker?: SessionCircuitBreaker;
|
|
31
|
+
/** Injectable clock for tests. */
|
|
32
|
+
now?: () => number;
|
|
33
|
+
};
|
|
34
|
+
export declare class SessionManager {
|
|
35
|
+
readonly config: SessionConfig;
|
|
36
|
+
readonly service: SessionService;
|
|
37
|
+
readonly repository: SessionRepository;
|
|
38
|
+
readonly metrics: SessionMetrics;
|
|
39
|
+
readonly circuitBreaker: SessionCircuitBreaker | null;
|
|
40
|
+
readonly health: SessionHealthChecker;
|
|
41
|
+
readonly cookies: SessionCookieManager;
|
|
42
|
+
readonly token: SessionTokenManager;
|
|
43
|
+
readonly keys: SessionKeyStrategy;
|
|
44
|
+
private readonly scripts;
|
|
45
|
+
private readonly client;
|
|
46
|
+
constructor(options: SessionManagerOptions);
|
|
47
|
+
/** Preloads Lua scripts now (awaits SCRIPT LOAD on all nodes). */
|
|
48
|
+
init(): Promise<void>;
|
|
49
|
+
/** No-op for symmetry: the client is owned by the application. */
|
|
50
|
+
close(): void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Creates a session manager. Synchronous: use `await manager.init()` when
|
|
54
|
+
* eager script preloading matters (first call latency).
|
|
55
|
+
*
|
|
56
|
+
* @throws {SessionConfigurationError} when the config is invalid, encryption
|
|
57
|
+
* is enabled without a key provider, or sessions are not explicitly enabled.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createSessionManager(options: SessionManagerOptions): SessionManager;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SessionConfigurationError } from './session-errors.js';
|
|
2
|
+
import { SessionCircuitBreaker } from './session-circuit-breaker.js';
|
|
3
|
+
import { parseSessionConfig } from './session-config.js';
|
|
4
|
+
import { SessionCookieManager } from './session-cookie.js';
|
|
5
|
+
import { SessionHealthChecker } from './session-health.js';
|
|
6
|
+
import { SessionKeyStrategy } from './session-keys.js';
|
|
7
|
+
import { SessionMetrics } from './session-metrics.js';
|
|
8
|
+
import { SessionRepository } from './session-repository.js';
|
|
9
|
+
import { SessionScriptRegistry } from './session-scripts.js';
|
|
10
|
+
import { SessionService } from './session-service.js';
|
|
11
|
+
import { SessionTokenManager } from './session-token.js';
|
|
12
|
+
export class SessionManager {
|
|
13
|
+
config;
|
|
14
|
+
service;
|
|
15
|
+
repository;
|
|
16
|
+
metrics;
|
|
17
|
+
circuitBreaker;
|
|
18
|
+
health;
|
|
19
|
+
cookies;
|
|
20
|
+
token;
|
|
21
|
+
keys;
|
|
22
|
+
scripts;
|
|
23
|
+
client;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
const config = parseSessionConfig(options.config);
|
|
26
|
+
if (config.encryption.enabled && !options.encryptionKeyProvider) {
|
|
27
|
+
throw new SessionConfigurationError('encryptionKeyProvider is required when config.encryption.enabled is true.');
|
|
28
|
+
}
|
|
29
|
+
if (!config.enabled) {
|
|
30
|
+
// Constructing an enabled-by-default subsystem would surprise; the
|
|
31
|
+
// manager is inert until config.enabled is explicitly set.
|
|
32
|
+
throw new SessionConfigurationError('Session subsystem is not enabled: set config.enabled = true to opt in.');
|
|
33
|
+
}
|
|
34
|
+
this.client = options.client;
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.token = new SessionTokenManager(config.tokenBytes);
|
|
37
|
+
this.keys = new SessionKeyStrategy(config.namespace);
|
|
38
|
+
this.scripts = new SessionScriptRegistry(options.client);
|
|
39
|
+
this.repository = new SessionRepository({
|
|
40
|
+
client: options.client,
|
|
41
|
+
keys: this.keys,
|
|
42
|
+
config,
|
|
43
|
+
scripts: this.scripts,
|
|
44
|
+
keyProvider: options.encryptionKeyProvider ?? null,
|
|
45
|
+
});
|
|
46
|
+
this.metrics = new SessionMetrics(options.metricsAdapter, options.client.mode);
|
|
47
|
+
this.circuitBreaker =
|
|
48
|
+
options.circuitBreaker ??
|
|
49
|
+
(config.circuitBreaker.enabled
|
|
50
|
+
? new SessionCircuitBreaker(config.circuitBreaker, {
|
|
51
|
+
onTransition: (state) => this.metrics.breakerState(state),
|
|
52
|
+
})
|
|
53
|
+
: null);
|
|
54
|
+
this.health = new SessionHealthChecker(options.client, config.health, {
|
|
55
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
56
|
+
});
|
|
57
|
+
this.cookies = new SessionCookieManager(config.cookie);
|
|
58
|
+
this.service = new SessionService({
|
|
59
|
+
config,
|
|
60
|
+
client: options.client,
|
|
61
|
+
repository: this.repository,
|
|
62
|
+
token: this.token,
|
|
63
|
+
keys: this.keys,
|
|
64
|
+
...(options.revocationStore !== undefined
|
|
65
|
+
? { revocationStore: options.revocationStore }
|
|
66
|
+
: {}),
|
|
67
|
+
metrics: this.metrics,
|
|
68
|
+
...(this.circuitBreaker !== null ? { circuitBreaker: this.circuitBreaker } : {}),
|
|
69
|
+
health: this.health,
|
|
70
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
71
|
+
});
|
|
72
|
+
// Preload scripts in the background; the EVALSHA + NOSCRIPT fallback
|
|
73
|
+
// keeps working until (and after) the preload finishes.
|
|
74
|
+
void this.scripts.preload();
|
|
75
|
+
}
|
|
76
|
+
/** Preloads Lua scripts now (awaits SCRIPT LOAD on all nodes). */
|
|
77
|
+
async init() {
|
|
78
|
+
await this.scripts.preload();
|
|
79
|
+
}
|
|
80
|
+
/** No-op for symmetry: the client is owned by the application. */
|
|
81
|
+
close() {
|
|
82
|
+
this.scripts.invalidate();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Creates a session manager. Synchronous: use `await manager.init()` when
|
|
87
|
+
* eager script preloading matters (first call latency).
|
|
88
|
+
*
|
|
89
|
+
* @throws {SessionConfigurationError} when the config is invalid, encryption
|
|
90
|
+
* is enabled without a key provider, or sessions are not explicitly enabled.
|
|
91
|
+
*/
|
|
92
|
+
export function createSessionManager(options) {
|
|
93
|
+
return new SessionManager(options);
|
|
94
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Application-provided metrics sink. Implementations must be non-throwing. */
|
|
2
|
+
export interface SessionMetricsAdapter {
|
|
3
|
+
/** Increments a counter by delta (default 1). */
|
|
4
|
+
incCounter(name: string, delta?: number, attributes?: Record<string, string | number>): void;
|
|
5
|
+
/** Records a duration sample (milliseconds). */
|
|
6
|
+
recordHistogram(name: string, value: number, attributes?: Record<string, string | number>): void;
|
|
7
|
+
/** Sets a gauge to a value. */
|
|
8
|
+
setGauge(name: string, value: number, attributes?: Record<string, string | number>): void;
|
|
9
|
+
}
|
|
10
|
+
export type SessionOperation = 'create' | 'validate' | 'touch' | 'rotate' | 'update' | 'destroy' | 'revoke' | 'revoke_all' | 'delete_by_user' | 'list' | 'find_by_user' | 'set_security_version' | 'health';
|
|
11
|
+
/**
|
|
12
|
+
* Internal session metrics facade. Safe no-op without an adapter.
|
|
13
|
+
*/
|
|
14
|
+
export declare class SessionMetrics {
|
|
15
|
+
private readonly adapter;
|
|
16
|
+
private readonly topology;
|
|
17
|
+
constructor(adapter?: SessionMetricsAdapter | null, topology?: string);
|
|
18
|
+
/** Adds the constant topology label to an attribute set. */
|
|
19
|
+
private withTopology;
|
|
20
|
+
/** Counts a completed session operation, with its outcome. */
|
|
21
|
+
operation(op: SessionOperation, outcome: 'ok' | 'error' | 'invalid', code?: string): void;
|
|
22
|
+
/** Records operation latency in milliseconds. */
|
|
23
|
+
latency(op: SessionOperation, ms: number): void;
|
|
24
|
+
/** Records the circuit breaker state transition. */
|
|
25
|
+
breakerState(state: 'closed' | 'open' | 'half_open'): void;
|
|
26
|
+
/** Records a fail-closed revocation-store failure (a security-relevant miss). */
|
|
27
|
+
revocationMiss(): void;
|
|
28
|
+
/** Records encryption failures (key rotation issues, corruption). */
|
|
29
|
+
encryptionError(reason: string): void;
|
|
30
|
+
/** Records a failed best-effort jti index write (derived-state degradation). */
|
|
31
|
+
jtiIndexWriteFailure(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare const SESSION_OPERATIONS: readonly SessionOperation[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* -------------------------------------------------------------------------- */
|
|
2
|
+
/* Session metrics: minimal, dependency-free observability. */
|
|
3
|
+
/* */
|
|
4
|
+
/* An adapter is injected by the application (Prometheus, StatsD, OpenTelemetry*/
|
|
5
|
+
/* - whichever the app already uses). Without an adapter every call is a */
|
|
6
|
+
/* no-op; metrics never throw and never affect the hot path. */
|
|
7
|
+
/* */
|
|
8
|
+
/* No session tokens or raw identifiers ever reach a metric label. */
|
|
9
|
+
/* -------------------------------------------------------------------------- */
|
|
10
|
+
const OPERATIONS = [
|
|
11
|
+
'create',
|
|
12
|
+
'validate',
|
|
13
|
+
'touch',
|
|
14
|
+
'rotate',
|
|
15
|
+
'update',
|
|
16
|
+
'destroy',
|
|
17
|
+
'revoke',
|
|
18
|
+
'revoke_all',
|
|
19
|
+
'delete_by_user',
|
|
20
|
+
'list',
|
|
21
|
+
'find_by_user',
|
|
22
|
+
'set_security_version',
|
|
23
|
+
'health',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Internal session metrics facade. Safe no-op without an adapter.
|
|
27
|
+
*/
|
|
28
|
+
export class SessionMetrics {
|
|
29
|
+
adapter;
|
|
30
|
+
topology;
|
|
31
|
+
constructor(adapter, topology) {
|
|
32
|
+
this.adapter = adapter ?? null;
|
|
33
|
+
this.topology = topology ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** Adds the constant topology label to an attribute set. */
|
|
36
|
+
withTopology(attributes) {
|
|
37
|
+
if (this.topology !== null)
|
|
38
|
+
attributes.topology = this.topology;
|
|
39
|
+
return attributes;
|
|
40
|
+
}
|
|
41
|
+
/** Counts a completed session operation, with its outcome. */
|
|
42
|
+
operation(op, outcome, code) {
|
|
43
|
+
if (!this.adapter)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
const attributes = { outcome };
|
|
47
|
+
if (code)
|
|
48
|
+
attributes.code = code;
|
|
49
|
+
this.adapter.incCounter(`session.${op}.total`, 1, this.withTopology(attributes));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Metrics must never break authentication.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Records operation latency in milliseconds. */
|
|
56
|
+
latency(op, ms) {
|
|
57
|
+
if (!this.adapter)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
this.adapter.recordHistogram(`session.${op}.duration_ms`, ms, this.topology !== null ? { topology: this.topology } : undefined);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Metrics must never break authentication.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Records the circuit breaker state transition. */
|
|
67
|
+
breakerState(state) {
|
|
68
|
+
if (!this.adapter)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
this.adapter.setGauge('session.circuit_breaker.state', state === 'open' ? 2 : state === 'half_open' ? 1 : 0, this.topology !== null ? { topology: this.topology } : undefined);
|
|
72
|
+
this.adapter.incCounter(`session.circuit_breaker.${state}`, 1, this.withTopology({}));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Metrics must never break authentication.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Records a fail-closed revocation-store failure (a security-relevant miss). */
|
|
79
|
+
revocationMiss() {
|
|
80
|
+
if (!this.adapter)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
this.adapter.incCounter('session.revocation_store.fail_closed', 1, this.topology !== null ? { topology: this.topology } : undefined);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Metrics must never break authentication.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Records encryption failures (key rotation issues, corruption). */
|
|
90
|
+
encryptionError(reason) {
|
|
91
|
+
if (!this.adapter)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
this.adapter.incCounter('session.encryption.errors', 1, this.withTopology({ reason }));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Metrics must never break authentication.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Records a failed best-effort jti index write (derived-state degradation). */
|
|
101
|
+
jtiIndexWriteFailure() {
|
|
102
|
+
if (!this.adapter)
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
this.adapter.incCounter('session.jti_index.write_failures', 1, this.topology !== null ? { topology: this.topology } : undefined);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Metrics must never break authentication.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export const SESSION_OPERATIONS = OPERATIONS;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { RedisClientWrapper } from '../client.js';
|
|
2
|
+
import type { SessionConfig } from './session-config.js';
|
|
3
|
+
import type { SessionKeyProvider } from './session-encryption.js';
|
|
4
|
+
import type { SessionKeyStrategy } from './session-keys.js';
|
|
5
|
+
import type { SessionScriptRegistry } from './session-scripts.js';
|
|
6
|
+
import type { SessionRecord, SessionUpdatePatch, TouchOutcome } from './session-types.js';
|
|
7
|
+
export declare class SessionRepository {
|
|
8
|
+
private readonly client;
|
|
9
|
+
private readonly keys;
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly scripts;
|
|
12
|
+
/** Encryption key provider, or null when encryption is disabled. */
|
|
13
|
+
readonly keyProvider: SessionKeyProvider | null;
|
|
14
|
+
constructor(options: {
|
|
15
|
+
client: RedisClientWrapper;
|
|
16
|
+
keys: SessionKeyStrategy;
|
|
17
|
+
config: SessionConfig;
|
|
18
|
+
scripts: SessionScriptRegistry;
|
|
19
|
+
keyProvider?: SessionKeyProvider | null;
|
|
20
|
+
});
|
|
21
|
+
/** True when the repository can decrypt stored sessions. */
|
|
22
|
+
hasKeyProvider(): boolean;
|
|
23
|
+
private get encrypted();
|
|
24
|
+
private get jtiIndexEnabled();
|
|
25
|
+
private get maxBatchSize();
|
|
26
|
+
private get maxFanOut();
|
|
27
|
+
/**
|
|
28
|
+
* Stores a session atomically with its user index entry and (bounded)
|
|
29
|
+
* max-session eviction. Returns the outcome; on idempotent replays the
|
|
30
|
+
* claim's jti identifies the pre-existing session.
|
|
31
|
+
*/
|
|
32
|
+
create(record: SessionRecord, ttl: number): Promise<{
|
|
33
|
+
status: 'created';
|
|
34
|
+
} | {
|
|
35
|
+
status: 'replayed';
|
|
36
|
+
jti: string;
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Reads and deserializes a session. Returns null when missing.
|
|
40
|
+
* Throws SessionSerializationError when the stored payload is corrupt.
|
|
41
|
+
*/
|
|
42
|
+
get(userId: string, jti: string): Promise<SessionRecord | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Single-round-trip validation read (session + security version).
|
|
45
|
+
* Returns { found: false } for missing records, or the raw envelope plus
|
|
46
|
+
* the current security version for further app-side checks.
|
|
47
|
+
*/
|
|
48
|
+
validateRead(userId: string, jti: string): Promise<{
|
|
49
|
+
found: false;
|
|
50
|
+
} | {
|
|
51
|
+
found: true;
|
|
52
|
+
raw: string;
|
|
53
|
+
currentSecurityVersion: number | null;
|
|
54
|
+
} | {
|
|
55
|
+
found: true;
|
|
56
|
+
code: -1;
|
|
57
|
+
status: string;
|
|
58
|
+
} | {
|
|
59
|
+
found: true;
|
|
60
|
+
code: -2;
|
|
61
|
+
} | {
|
|
62
|
+
found: true;
|
|
63
|
+
code: -3;
|
|
64
|
+
} | {
|
|
65
|
+
found: true;
|
|
66
|
+
code: -4;
|
|
67
|
+
}>;
|
|
68
|
+
/** Best-effort removal of a stale jti index entry (never throws). */
|
|
69
|
+
private cleanupJtiIndex;
|
|
70
|
+
/**
|
|
71
|
+
* Throttled monotonic activity refresh.
|
|
72
|
+
*
|
|
73
|
+
* Plain sessions: one atomic script (server time). Encrypted sessions:
|
|
74
|
+
* read + decrypt + re-encrypt + atomic CAS (two round trips).
|
|
75
|
+
*/
|
|
76
|
+
touch(userId: string, jti: string, force: boolean): Promise<TouchOutcome>;
|
|
77
|
+
/**
|
|
78
|
+
* Atomic single-use rotation. Returns the successor jti on success.
|
|
79
|
+
*
|
|
80
|
+
* Plain sessions: one script (server time). Encrypted sessions: read +
|
|
81
|
+
* decrypt + build + atomic script (two round trips).
|
|
82
|
+
*/
|
|
83
|
+
rotate(options: {
|
|
84
|
+
userId: string;
|
|
85
|
+
oldJti: string;
|
|
86
|
+
successor: SessionRecord;
|
|
87
|
+
expectedVersion?: number;
|
|
88
|
+
rotationNonceHash?: string;
|
|
89
|
+
retainTombstone: boolean;
|
|
90
|
+
}): Promise<{
|
|
91
|
+
code: number;
|
|
92
|
+
successorJti?: string;
|
|
93
|
+
status?: string;
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* Optimistic-concurrency patch update. Returns the updated record, or
|
|
97
|
+
* null when missing. Throws SessionConcurrencyError on version conflict.
|
|
98
|
+
*/
|
|
99
|
+
update(userId: string, jti: string, patch: SessionUpdatePatch, expectedVersion?: number): Promise<SessionRecord | null>;
|
|
100
|
+
/** Physically deletes a session and its index entry. Idempotent. */
|
|
101
|
+
destroy(userId: string, jti: string): Promise<boolean>;
|
|
102
|
+
/**
|
|
103
|
+
* Logically revokes a session with a bounded tombstone TTL.
|
|
104
|
+
* Returns 'revoked' | 'already_revoked' | 'not_found'.
|
|
105
|
+
*/
|
|
106
|
+
revoke(userId: string, jti: string, tombstoneTtl: number): Promise<string>;
|
|
107
|
+
/**
|
|
108
|
+
* Lists a user's sessions (oldest first), lazily cleaning stale index
|
|
109
|
+
* members. Bounded: fetches at most `limit` members plus cleanup batches.
|
|
110
|
+
*/
|
|
111
|
+
listByUser(userId: string, options?: {
|
|
112
|
+
limit?: number;
|
|
113
|
+
offset?: number;
|
|
114
|
+
}): Promise<SessionRecord[]>;
|
|
115
|
+
/**
|
|
116
|
+
* Deletes all of a user's sessions in bounded same-slot batches.
|
|
117
|
+
* Returns the jtis whose records were deleted.
|
|
118
|
+
*/
|
|
119
|
+
deleteByUser(userId: string): Promise<string[]>;
|
|
120
|
+
/**
|
|
121
|
+
* Removes stale (missing) members from a user's index. Bounded, safe to
|
|
122
|
+
* run repeatedly.
|
|
123
|
+
*/
|
|
124
|
+
cleanupUserIndex(userId: string): Promise<number>;
|
|
125
|
+
/** Removes specific stale entries from a user's index (bounded). */
|
|
126
|
+
cleanupIndexEntries(userId: string, jtis: string[]): Promise<string[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Standalone max-session enforcement (used after revokeAll-style bulk
|
|
129
|
+
* operations and by admin repair). Loops in bounded steps until the
|
|
130
|
+
* index fits the limit or a hard cap is reached.
|
|
131
|
+
*/
|
|
132
|
+
enforceLimit(userId: string): Promise<number>;
|
|
133
|
+
/** Sets the current security version for a user (invalidates older sessions). */
|
|
134
|
+
setSecurityVersion(userId: string, version: number): Promise<void>;
|
|
135
|
+
/** Reads the current security version for a user, or null when unset. */
|
|
136
|
+
getSecurityVersion(userId: string): Promise<number | null>;
|
|
137
|
+
/** Best-effort write of the JTI lookup index (cross-slot, self-healing). */
|
|
138
|
+
writeJtiIndex(jti: string, userId: string, ttl: number): Promise<boolean>;
|
|
139
|
+
/** Reads the JTI lookup index (only valid when the index is enabled). */
|
|
140
|
+
readJtiIndex(jti: string): Promise<string | null>;
|
|
141
|
+
/** Deletes one JTI index entry (idempotent, cross-slot). */
|
|
142
|
+
deleteJtiIndex(jti: string): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Deletes JTI index entries through bounded slot-grouped pipelines
|
|
145
|
+
* (cross-slot fan-out lives here, not in the session layer).
|
|
146
|
+
*/
|
|
147
|
+
deleteJtiIndexMany(jtis: string[]): Promise<void>;
|
|
148
|
+
/** Returns the user id behind a jti via the index, or null. */
|
|
149
|
+
resolveUserIdByJti(jti: string): Promise<string | null>;
|
|
150
|
+
/** Server time in seconds (authoritative clock). */
|
|
151
|
+
serverTime(): Promise<number>;
|
|
152
|
+
/** Number of sessions currently in a user's index. */
|
|
153
|
+
countByUser(userId: string): Promise<number>;
|
|
154
|
+
/**
|
|
155
|
+
* Lists the jtis of a user's sessions (oldest first), bounded to `max`.
|
|
156
|
+
* Used by bulk operations (revokeAll, deleteByUser).
|
|
157
|
+
*/
|
|
158
|
+
listJtis(userId: string, max: number): Promise<string[]>;
|
|
159
|
+
}
|
|
160
|
+
/** Validates and normalizes an externally provided record (defense in depth). */
|
|
161
|
+
export declare function normalizeRecord(value: unknown): SessionRecord;
|