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,87 @@
|
|
|
1
|
+
import type { EncryptedSessionEnvelope } from './session-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Key management abstraction. Applications provide their own implementation
|
|
4
|
+
* (KMS, secret store, env-based rotation), or a simple in-memory map of
|
|
5
|
+
* versions to 32-byte keys for single-process deployments.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionKeyProvider {
|
|
8
|
+
/**
|
|
9
|
+
* Returns the encryption key used for all WRITES (new sessions, updates,
|
|
10
|
+
* re-encryption on touch). Called on every encryption.
|
|
11
|
+
*
|
|
12
|
+
* The version labels which key produced the ciphertext: it is stored in
|
|
13
|
+
* the session envelope (`k` field) so the corresponding key can be looked
|
|
14
|
+
* up later on read. Always return the current key here — never an
|
|
15
|
+
* arbitrary one.
|
|
16
|
+
*
|
|
17
|
+
* The key may be a 32-byte Buffer or a string (see {@link toKeyBuffer} for
|
|
18
|
+
* the accepted encodings). Whatever form is returned here must also be
|
|
19
|
+
* resolvable via {@link getKey} for the same version.
|
|
20
|
+
*/
|
|
21
|
+
getCurrentKey(): {
|
|
22
|
+
keyVersion: number;
|
|
23
|
+
key: Buffer | string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Returns the key for a specific version, or null when that version is no
|
|
27
|
+
* longer available. Called on every READ (decryption).
|
|
28
|
+
*
|
|
29
|
+
* Every session records the version it was encrypted with; this lookup
|
|
30
|
+
* resolves it. Returning null makes sessions encrypted with that version
|
|
31
|
+
* undecryptable — they surface as {@link SessionSerializationError}.
|
|
32
|
+
*
|
|
33
|
+
* This is what enables safe key rotation: when the current key changes,
|
|
34
|
+
* old keys must stay available here so previously written sessions keep
|
|
35
|
+
* decrypting. Only drop a version once every session using it has expired
|
|
36
|
+
* or been re-encrypted with the current key.
|
|
37
|
+
*/
|
|
38
|
+
getKey(keyVersion: number): Buffer | string | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Normalizes a key to a Buffer for use with AES-256-GCM.
|
|
42
|
+
*
|
|
43
|
+
* Buffers are returned as-is. Strings are decoded in priority order:
|
|
44
|
+
* - 64 hex characters -> hex
|
|
45
|
+
* - base64 / base64url text that decodes to exactly 32 bytes -> base64
|
|
46
|
+
* - anything else -> utf8 (a 32-character passphrase)
|
|
47
|
+
*
|
|
48
|
+
* The decoded key must be 32 bytes (AES-256) for Node's crypto to accept
|
|
49
|
+
* it; {@link StaticSessionKeyProvider} validates this at construction.
|
|
50
|
+
*/
|
|
51
|
+
export declare function toKeyBuffer(key: Buffer | string): Buffer;
|
|
52
|
+
/** A simple key provider for single-process deployments (env/CLI injection). */
|
|
53
|
+
export declare class StaticSessionKeyProvider implements SessionKeyProvider {
|
|
54
|
+
private readonly currentVersion;
|
|
55
|
+
private readonly keys;
|
|
56
|
+
constructor(keys: ReadonlyMap<number, Buffer | string>, currentVersion: number);
|
|
57
|
+
getCurrentKey(): {
|
|
58
|
+
keyVersion: number;
|
|
59
|
+
key: Buffer;
|
|
60
|
+
};
|
|
61
|
+
getKey(keyVersion: number): Buffer | null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Creates a {@link StaticSessionKeyProvider} with one freshly generated
|
|
65
|
+
* 32-byte key. Convenience for local development and tests; production
|
|
66
|
+
* deployments should derive keys from a KMS/vault instead.
|
|
67
|
+
*
|
|
68
|
+
* @param keyVersion - Version label for the generated key (default 1).
|
|
69
|
+
*/
|
|
70
|
+
export declare function createRandomSessionKeyProvider(keyVersion?: number): StaticSessionKeyProvider;
|
|
71
|
+
/**
|
|
72
|
+
* Encrypts a plaintext payload with AES-256-GCM using the current key.
|
|
73
|
+
* Returns a fresh random IV + auth tag per call.
|
|
74
|
+
*/
|
|
75
|
+
export declare function encryptPayload(plaintext: Buffer, provider: SessionKeyProvider): Pick<EncryptedSessionEnvelope, 'k' | 'i' | 't' | 'c'>;
|
|
76
|
+
/**
|
|
77
|
+
* Decrypts an envelope, verifying the GCM auth tag.
|
|
78
|
+
*
|
|
79
|
+
* @throws {SessionSerializationError} when the key version is unknown, the
|
|
80
|
+
* IV/auth tag/ciphertext are malformed, or authentication fails (tampered
|
|
81
|
+
* or corrupt data).
|
|
82
|
+
*/
|
|
83
|
+
export declare function decryptPayload(envelope: Pick<EncryptedSessionEnvelope, 'k' | 'i' | 't' | 'c'>, provider: SessionKeyProvider): Buffer;
|
|
84
|
+
/** Encrypts a JSON string into an encrypted envelope body. */
|
|
85
|
+
export declare function encryptJson(json: string, provider: SessionKeyProvider): Pick<EncryptedSessionEnvelope, 'k' | 'i' | 't' | 'c'>;
|
|
86
|
+
/** Decrypts an envelope body and parses the JSON inside. */
|
|
87
|
+
export declare function decryptJson<T>(envelope: Pick<EncryptedSessionEnvelope, 'k' | 'i' | 't' | 'c'>, provider: SessionKeyProvider): T;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, } from 'node:crypto';
|
|
2
|
+
import { SessionConfigurationError, SessionSerializationError } from './session-errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes a key to a Buffer for use with AES-256-GCM.
|
|
5
|
+
*
|
|
6
|
+
* Buffers are returned as-is. Strings are decoded in priority order:
|
|
7
|
+
* - 64 hex characters -> hex
|
|
8
|
+
* - base64 / base64url text that decodes to exactly 32 bytes -> base64
|
|
9
|
+
* - anything else -> utf8 (a 32-character passphrase)
|
|
10
|
+
*
|
|
11
|
+
* The decoded key must be 32 bytes (AES-256) for Node's crypto to accept
|
|
12
|
+
* it; {@link StaticSessionKeyProvider} validates this at construction.
|
|
13
|
+
*/
|
|
14
|
+
export function toKeyBuffer(key) {
|
|
15
|
+
if (Buffer.isBuffer(key))
|
|
16
|
+
return key;
|
|
17
|
+
const trimmed = key.trim();
|
|
18
|
+
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
|
19
|
+
return Buffer.from(trimmed, 'hex');
|
|
20
|
+
}
|
|
21
|
+
if (/^[A-Za-z0-9+/_-]+={0,2}$/.test(trimmed)) {
|
|
22
|
+
const decoded = Buffer.from(trimmed.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
23
|
+
if (decoded.length === 32)
|
|
24
|
+
return decoded;
|
|
25
|
+
}
|
|
26
|
+
return Buffer.from(key, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
/** A simple key provider for single-process deployments (env/CLI injection). */
|
|
29
|
+
export class StaticSessionKeyProvider {
|
|
30
|
+
currentVersion;
|
|
31
|
+
keys;
|
|
32
|
+
constructor(keys, currentVersion) {
|
|
33
|
+
this.currentVersion = currentVersion;
|
|
34
|
+
if (keys.size === 0) {
|
|
35
|
+
throw new SessionConfigurationError('At least one encryption key is required.');
|
|
36
|
+
}
|
|
37
|
+
if (!keys.has(currentVersion)) {
|
|
38
|
+
throw new SessionConfigurationError('currentVersion must be present in the key map.');
|
|
39
|
+
}
|
|
40
|
+
const normalized = new Map();
|
|
41
|
+
for (const [version, raw] of keys) {
|
|
42
|
+
const key = toKeyBuffer(raw);
|
|
43
|
+
if (key.length !== 32) {
|
|
44
|
+
throw new SessionConfigurationError(`Encryption key version ${version} must be exactly 32 bytes (AES-256).`);
|
|
45
|
+
}
|
|
46
|
+
normalized.set(version, key);
|
|
47
|
+
}
|
|
48
|
+
this.keys = normalized;
|
|
49
|
+
}
|
|
50
|
+
getCurrentKey() {
|
|
51
|
+
return { keyVersion: this.currentVersion, key: this.keys.get(this.currentVersion) };
|
|
52
|
+
}
|
|
53
|
+
getKey(keyVersion) {
|
|
54
|
+
return this.keys.get(keyVersion) ?? null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Creates a {@link StaticSessionKeyProvider} with one freshly generated
|
|
59
|
+
* 32-byte key. Convenience for local development and tests; production
|
|
60
|
+
* deployments should derive keys from a KMS/vault instead.
|
|
61
|
+
*
|
|
62
|
+
* @param keyVersion - Version label for the generated key (default 1).
|
|
63
|
+
*/
|
|
64
|
+
export function createRandomSessionKeyProvider(keyVersion = 1) {
|
|
65
|
+
return new StaticSessionKeyProvider(new Map([[keyVersion, randomBytes(32)]]), keyVersion);
|
|
66
|
+
}
|
|
67
|
+
const IV_BYTES = 12;
|
|
68
|
+
const TAG_BYTES = 16;
|
|
69
|
+
/**
|
|
70
|
+
* Encrypts a plaintext payload with AES-256-GCM using the current key.
|
|
71
|
+
* Returns a fresh random IV + auth tag per call.
|
|
72
|
+
*/
|
|
73
|
+
export function encryptPayload(plaintext, provider) {
|
|
74
|
+
const { keyVersion, key } = provider.getCurrentKey();
|
|
75
|
+
const iv = randomBytes(IV_BYTES);
|
|
76
|
+
const cipher = createCipheriv('aes-256-gcm', toKeyBuffer(key), iv);
|
|
77
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
78
|
+
const authTag = cipher.getAuthTag();
|
|
79
|
+
return {
|
|
80
|
+
k: keyVersion,
|
|
81
|
+
i: iv.toString('base64url'),
|
|
82
|
+
t: authTag.toString('base64url'),
|
|
83
|
+
c: ciphertext.toString('base64url'),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Decrypts an envelope, verifying the GCM auth tag.
|
|
88
|
+
*
|
|
89
|
+
* @throws {SessionSerializationError} when the key version is unknown, the
|
|
90
|
+
* IV/auth tag/ciphertext are malformed, or authentication fails (tampered
|
|
91
|
+
* or corrupt data).
|
|
92
|
+
*/
|
|
93
|
+
export function decryptPayload(envelope, provider) {
|
|
94
|
+
const raw = provider.getKey(envelope.k);
|
|
95
|
+
if (!raw) {
|
|
96
|
+
throw new SessionSerializationError({
|
|
97
|
+
reason: 'unknown_key_version',
|
|
98
|
+
keyVersion: envelope.k,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const key = toKeyBuffer(raw);
|
|
102
|
+
let iv;
|
|
103
|
+
let tag;
|
|
104
|
+
let ciphertext;
|
|
105
|
+
try {
|
|
106
|
+
iv = Buffer.from(envelope.i, 'base64url');
|
|
107
|
+
tag = Buffer.from(envelope.t, 'base64url');
|
|
108
|
+
ciphertext = Buffer.from(envelope.c, 'base64url');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new SessionSerializationError({ reason: 'malformed_encrypted_fields' });
|
|
112
|
+
}
|
|
113
|
+
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
|
114
|
+
throw new SessionSerializationError({ reason: 'malformed_encrypted_fields' });
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
118
|
+
decipher.setAuthTag(tag);
|
|
119
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// GCM auth failure = tampered or corrupted data. Treat as invalid.
|
|
123
|
+
throw new SessionSerializationError({ reason: 'authentication_failed' });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Encrypts a JSON string into an encrypted envelope body. */
|
|
127
|
+
export function encryptJson(json, provider) {
|
|
128
|
+
return encryptPayload(Buffer.from(json, 'utf8'), provider);
|
|
129
|
+
}
|
|
130
|
+
/** Decrypts an envelope body and parses the JSON inside. */
|
|
131
|
+
export function decryptJson(envelope, provider) {
|
|
132
|
+
const plaintext = decryptPayload(envelope, provider);
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(plaintext.toString('utf8'));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new SessionSerializationError({ reason: 'malformed_plaintext' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { RedisError } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Base class for every session subsystem error.
|
|
4
|
+
* Extends {@link RedisError} so existing Redis error handling keeps working.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SessionError extends RedisError {
|
|
7
|
+
constructor(message: string, code?: string, details?: Record<string, unknown>);
|
|
8
|
+
}
|
|
9
|
+
/** The session does not exist (or no longer exists). */
|
|
10
|
+
export declare class SessionNotFoundError extends SessionError {
|
|
11
|
+
constructor(details?: Record<string, unknown>);
|
|
12
|
+
}
|
|
13
|
+
/** The session expired (absolute or idle timeout), or was created in the past. */
|
|
14
|
+
export declare class SessionExpiredError extends SessionError {
|
|
15
|
+
constructor(details?: Record<string, unknown>);
|
|
16
|
+
}
|
|
17
|
+
/** The session was revoked or consumed by a rotation (replay detected). */
|
|
18
|
+
export declare class SessionRevokedError extends SessionError {
|
|
19
|
+
constructor(details?: Record<string, unknown>);
|
|
20
|
+
}
|
|
21
|
+
/** The session record exists but is invalid (corrupt, tampered, mismatched). */
|
|
22
|
+
export declare class SessionInvalidError extends SessionError {
|
|
23
|
+
constructor(details?: Record<string, unknown>);
|
|
24
|
+
}
|
|
25
|
+
/** A security-sensitive session transition (rotation) failed. */
|
|
26
|
+
export declare class SessionRotationError extends SessionError {
|
|
27
|
+
constructor(details?: Record<string, unknown>);
|
|
28
|
+
}
|
|
29
|
+
/** Reuse of an already-consumed session token was detected. */
|
|
30
|
+
export declare class SessionReplayError extends SessionError {
|
|
31
|
+
constructor(details?: Record<string, unknown>);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Redis (or the session storage backend) is unavailable.
|
|
35
|
+
*
|
|
36
|
+
* Authentication MUST fail closed on this error: never treat it as an
|
|
37
|
+
* invalid session, and never fall back to assuming the session is valid.
|
|
38
|
+
*/
|
|
39
|
+
export declare class SessionStorageError extends SessionError {
|
|
40
|
+
constructor(message?: string, details?: Record<string, unknown>);
|
|
41
|
+
}
|
|
42
|
+
/** Stored session data could not be deserialized/decrypted. */
|
|
43
|
+
export declare class SessionSerializationError extends SessionError {
|
|
44
|
+
constructor(details?: Record<string, unknown>);
|
|
45
|
+
}
|
|
46
|
+
/** Session configuration is invalid (fails at manager construction). */
|
|
47
|
+
export declare class SessionConfigurationError extends SessionError {
|
|
48
|
+
constructor(message: string, details?: Record<string, unknown>);
|
|
49
|
+
}
|
|
50
|
+
/** Optimistic-concurrency conflict on a session update. */
|
|
51
|
+
export declare class SessionConcurrencyError extends SessionError {
|
|
52
|
+
constructor(details?: Record<string, unknown>);
|
|
53
|
+
}
|
|
54
|
+
/** A revocation could not be persisted (fail closed, do not swallow). */
|
|
55
|
+
export declare class RevocationError extends SessionError {
|
|
56
|
+
constructor(details?: Record<string, unknown>);
|
|
57
|
+
}
|
|
58
|
+
/** A batch revocation partially failed; check `failures` for details. */
|
|
59
|
+
export declare class RevocationBatchError extends SessionError {
|
|
60
|
+
/** Safe identifiers of the entries whose pipeline command failed. */
|
|
61
|
+
readonly failures: Array<{
|
|
62
|
+
jti: string;
|
|
63
|
+
error: unknown;
|
|
64
|
+
}>;
|
|
65
|
+
constructor(failures: Array<{
|
|
66
|
+
jti: string;
|
|
67
|
+
error: unknown;
|
|
68
|
+
}>);
|
|
69
|
+
/** Compatibility alias: the affected jtis. */
|
|
70
|
+
get ids(): string[];
|
|
71
|
+
}
|
|
72
|
+
/** The circuit breaker is open; requests fail closed without touching Redis. */
|
|
73
|
+
export declare class CircuitBreakerOpenError extends SessionError {
|
|
74
|
+
constructor(details?: Record<string, unknown>);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Redacts an identifier for safe inclusion in logs/errors/metrics labels.
|
|
78
|
+
*
|
|
79
|
+
* Only the length and a short opaque suffix are revealed; never the full
|
|
80
|
+
* value. Use for jti/userId/deviceId/ipAddress in structured details.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* redactIdentifier('dG9rZW5oYXNo...') // => 'token#c3V'
|
|
84
|
+
*/
|
|
85
|
+
export declare function redactIdentifier(value: string | null | undefined): string;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { RedisError } from '../errors.js';
|
|
2
|
+
/* -------------------------------------------------------------------------- */
|
|
3
|
+
/* Typed, safe session errors. */
|
|
4
|
+
/* */
|
|
5
|
+
/* Rules: */
|
|
6
|
+
/* - Never embed raw session tokens, cookies, passwords, encryption keys, */
|
|
7
|
+
/* or full session payloads in messages. */
|
|
8
|
+
/* - JTI/userId are only included in `details` (structured metadata) after */
|
|
9
|
+
/* redaction, never as plain strings in `message`. */
|
|
10
|
+
/* - Authentication layers map these errors to HTTP semantics: */
|
|
11
|
+
/* SessionStorageError -> 503 (infrastructure unavailable) */
|
|
12
|
+
/* SessionNotFoundError -> 401 */
|
|
13
|
+
/* SessionExpiredError -> 401 */
|
|
14
|
+
/* SessionRevokedError -> 401 */
|
|
15
|
+
/* CircuitBreakerOpenError -> 503 */
|
|
16
|
+
/* -------------------------------------------------------------------------- */
|
|
17
|
+
/**
|
|
18
|
+
* Base class for every session subsystem error.
|
|
19
|
+
* Extends {@link RedisError} so existing Redis error handling keeps working.
|
|
20
|
+
*/
|
|
21
|
+
export class SessionError extends RedisError {
|
|
22
|
+
constructor(message, code = 'SESSION_ERROR', details) {
|
|
23
|
+
super(message, code, details);
|
|
24
|
+
this.name = 'SessionError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The session does not exist (or no longer exists). */
|
|
28
|
+
export class SessionNotFoundError extends SessionError {
|
|
29
|
+
constructor(details) {
|
|
30
|
+
super('Session not found.', 'SESSION_NOT_FOUND', details);
|
|
31
|
+
this.name = 'SessionNotFoundError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The session expired (absolute or idle timeout), or was created in the past. */
|
|
35
|
+
export class SessionExpiredError extends SessionError {
|
|
36
|
+
constructor(details) {
|
|
37
|
+
super('Session has expired.', 'SESSION_EXPIRED', details);
|
|
38
|
+
this.name = 'SessionExpiredError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The session was revoked or consumed by a rotation (replay detected). */
|
|
42
|
+
export class SessionRevokedError extends SessionError {
|
|
43
|
+
constructor(details) {
|
|
44
|
+
super('Session is no longer valid.', 'SESSION_REVOKED', details);
|
|
45
|
+
this.name = 'SessionRevokedError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The session record exists but is invalid (corrupt, tampered, mismatched). */
|
|
49
|
+
export class SessionInvalidError extends SessionError {
|
|
50
|
+
constructor(details) {
|
|
51
|
+
super('Session is invalid.', 'SESSION_INVALID', details);
|
|
52
|
+
this.name = 'SessionInvalidError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A security-sensitive session transition (rotation) failed. */
|
|
56
|
+
export class SessionRotationError extends SessionError {
|
|
57
|
+
constructor(details) {
|
|
58
|
+
super('Session rotation failed.', 'SESSION_ROTATION_FAILED', details);
|
|
59
|
+
this.name = 'SessionRotationError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Reuse of an already-consumed session token was detected. */
|
|
63
|
+
export class SessionReplayError extends SessionError {
|
|
64
|
+
constructor(details) {
|
|
65
|
+
super('Session reuse detected.', 'SESSION_REPLAY', details);
|
|
66
|
+
this.name = 'SessionReplayError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Redis (or the session storage backend) is unavailable.
|
|
71
|
+
*
|
|
72
|
+
* Authentication MUST fail closed on this error: never treat it as an
|
|
73
|
+
* invalid session, and never fall back to assuming the session is valid.
|
|
74
|
+
*/
|
|
75
|
+
export class SessionStorageError extends SessionError {
|
|
76
|
+
constructor(message = 'Session storage unavailable.', details) {
|
|
77
|
+
super(message, 'SESSION_STORAGE_UNAVAILABLE', details);
|
|
78
|
+
this.name = 'SessionStorageError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Stored session data could not be deserialized/decrypted. */
|
|
82
|
+
export class SessionSerializationError extends SessionError {
|
|
83
|
+
constructor(details) {
|
|
84
|
+
super('Stored session data is malformed.', 'SESSION_SERIALIZATION_ERROR', details);
|
|
85
|
+
this.name = 'SessionSerializationError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Session configuration is invalid (fails at manager construction). */
|
|
89
|
+
export class SessionConfigurationError extends SessionError {
|
|
90
|
+
constructor(message, details) {
|
|
91
|
+
super(message, 'SESSION_CONFIGURATION_ERROR', details);
|
|
92
|
+
this.name = 'SessionConfigurationError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Optimistic-concurrency conflict on a session update. */
|
|
96
|
+
export class SessionConcurrencyError extends SessionError {
|
|
97
|
+
constructor(details) {
|
|
98
|
+
super('Session was modified concurrently.', 'SESSION_CONCURRENCY', details);
|
|
99
|
+
this.name = 'SessionConcurrencyError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** A revocation could not be persisted (fail closed, do not swallow). */
|
|
103
|
+
export class RevocationError extends SessionError {
|
|
104
|
+
constructor(details) {
|
|
105
|
+
super('Revocation could not be persisted.', 'REVOCATION_ERROR', details);
|
|
106
|
+
this.name = 'RevocationError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** A batch revocation partially failed; check `failures` for details. */
|
|
110
|
+
export class RevocationBatchError extends SessionError {
|
|
111
|
+
/** Safe identifiers of the entries whose pipeline command failed. */
|
|
112
|
+
failures;
|
|
113
|
+
constructor(failures) {
|
|
114
|
+
super(`Revocation batch failed for ${failures.length} entr${failures.length === 1 ? 'y' : 'ies'}.`, 'REVOCATION_BATCH_ERROR', { failureCount: failures.length });
|
|
115
|
+
this.name = 'RevocationBatchError';
|
|
116
|
+
this.failures = failures;
|
|
117
|
+
}
|
|
118
|
+
/** Compatibility alias: the affected jtis. */
|
|
119
|
+
get ids() {
|
|
120
|
+
return this.failures.map((f) => f.jti);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** The circuit breaker is open; requests fail closed without touching Redis. */
|
|
124
|
+
export class CircuitBreakerOpenError extends SessionError {
|
|
125
|
+
constructor(details) {
|
|
126
|
+
super('Authentication infrastructure is temporarily unavailable.', 'CIRCUIT_OPEN', details);
|
|
127
|
+
this.name = 'CircuitBreakerOpenError';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Redacts an identifier for safe inclusion in logs/errors/metrics labels.
|
|
132
|
+
*
|
|
133
|
+
* Only the length and a short opaque suffix are revealed; never the full
|
|
134
|
+
* value. Use for jti/userId/deviceId/ipAddress in structured details.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* redactIdentifier('dG9rZW5oYXNo...') // => 'token#c3V'
|
|
138
|
+
*/
|
|
139
|
+
export function redactIdentifier(value) {
|
|
140
|
+
if (!value)
|
|
141
|
+
return 'none';
|
|
142
|
+
if (value.length <= 6)
|
|
143
|
+
return `#${'*'.repeat(value.length)}`;
|
|
144
|
+
return `${value.slice(0, 3)}…${value.slice(-3)}`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RedisClientWrapper } from '../client.js';
|
|
2
|
+
import type { SessionHealthConfig } from './session-config.js';
|
|
3
|
+
export interface SessionHealthStatus {
|
|
4
|
+
/** True when PING latency and the recent error rate are within thresholds. */
|
|
5
|
+
healthy: boolean;
|
|
6
|
+
/** PING round trip in milliseconds, or null when the probe itself failed. */
|
|
7
|
+
latencyMs: number | null;
|
|
8
|
+
/** Recent operation error rate (0..1) from the sliding window. */
|
|
9
|
+
errorRate: number;
|
|
10
|
+
/** True when Redis is reachable at all (PING succeeded). */
|
|
11
|
+
reachable: boolean;
|
|
12
|
+
/** Unix ms of the last probe. */
|
|
13
|
+
checkedAt: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Periodic PING probe + sliding-window error rate for the session dependency.
|
|
17
|
+
*
|
|
18
|
+
* Error-rate sampling is fed by {@link recordOp}; a PING probe runs on every
|
|
19
|
+
* {@link check}. Both signals must be healthy for the overall status to be
|
|
20
|
+
* healthy.
|
|
21
|
+
*/
|
|
22
|
+
export declare class SessionHealthChecker {
|
|
23
|
+
private readonly client;
|
|
24
|
+
private readonly config;
|
|
25
|
+
private readonly now;
|
|
26
|
+
private readonly results;
|
|
27
|
+
constructor(client: RedisClientWrapper, config: SessionHealthConfig, options?: {
|
|
28
|
+
now?: () => number;
|
|
29
|
+
});
|
|
30
|
+
/** Feeds one operation outcome into the sliding window. */
|
|
31
|
+
recordOp(success: boolean): void;
|
|
32
|
+
private errorRate;
|
|
33
|
+
/**
|
|
34
|
+
* Runs a PING probe and returns the current health status.
|
|
35
|
+
* Never throws: probe failures surface as `reachable: false`.
|
|
36
|
+
*/
|
|
37
|
+
check(): Promise<SessionHealthStatus>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic PING probe + sliding-window error rate for the session dependency.
|
|
3
|
+
*
|
|
4
|
+
* Error-rate sampling is fed by {@link recordOp}; a PING probe runs on every
|
|
5
|
+
* {@link check}. Both signals must be healthy for the overall status to be
|
|
6
|
+
* healthy.
|
|
7
|
+
*/
|
|
8
|
+
export class SessionHealthChecker {
|
|
9
|
+
client;
|
|
10
|
+
config;
|
|
11
|
+
now;
|
|
12
|
+
results = [];
|
|
13
|
+
constructor(client, config, options = {}) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.now = options.now ?? (() => Date.now());
|
|
17
|
+
}
|
|
18
|
+
/** Feeds one operation outcome into the sliding window. */
|
|
19
|
+
recordOp(success) {
|
|
20
|
+
this.results.push(success);
|
|
21
|
+
const max = this.config.errorWindowSize;
|
|
22
|
+
if (this.results.length > max) {
|
|
23
|
+
this.results.splice(0, this.results.length - max);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
errorRate() {
|
|
27
|
+
if (this.results.length === 0)
|
|
28
|
+
return 0;
|
|
29
|
+
let failures = 0;
|
|
30
|
+
for (const ok of this.results) {
|
|
31
|
+
if (!ok)
|
|
32
|
+
failures += 1;
|
|
33
|
+
}
|
|
34
|
+
return failures / this.results.length;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Runs a PING probe and returns the current health status.
|
|
38
|
+
* Never throws: probe failures surface as `reachable: false`.
|
|
39
|
+
*/
|
|
40
|
+
async check() {
|
|
41
|
+
const checkedAt = this.now();
|
|
42
|
+
const started = performance.now();
|
|
43
|
+
let reachable = true;
|
|
44
|
+
let latencyMs = null;
|
|
45
|
+
try {
|
|
46
|
+
const pong = await this.client.raw.ping();
|
|
47
|
+
reachable = pong === 'PONG' || pong === true || pong === 'pong';
|
|
48
|
+
latencyMs = Math.round(performance.now() - started);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
reachable = false;
|
|
52
|
+
latencyMs = null;
|
|
53
|
+
}
|
|
54
|
+
const errorRate = this.errorRate();
|
|
55
|
+
const healthy = reachable &&
|
|
56
|
+
(latencyMs === null || latencyMs <= this.config.latencyThresholdMs) &&
|
|
57
|
+
errorRate <= this.config.errorRateThreshold;
|
|
58
|
+
return { healthy, latencyMs, errorRate, reachable, checkedAt };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministically encodes a userId for safe embedding in Redis keys.
|
|
3
|
+
*
|
|
4
|
+
* Every byte not in [A-Za-z0-9._-] is hex-encoded as %XX (UTF-8 aware),
|
|
5
|
+
* so `{`, `}`, `:` and glob metacharacters can never appear. The encoding
|
|
6
|
+
* is injective: distinct userIds always produce distinct encodings.
|
|
7
|
+
*/
|
|
8
|
+
export declare function encodeUserId(userId: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic, Cluster-safe key strategy.
|
|
11
|
+
*
|
|
12
|
+
* Every key derived here is stable for the lifetime of the process and
|
|
13
|
+
* identical across horizontally scaled instances (no randomness).
|
|
14
|
+
*/
|
|
15
|
+
export declare class SessionKeyStrategy {
|
|
16
|
+
private readonly namespace;
|
|
17
|
+
/**
|
|
18
|
+
* @param namespace - Key namespace, e.g. `'authcore'`.
|
|
19
|
+
*/
|
|
20
|
+
constructor(namespace: string);
|
|
21
|
+
private ns;
|
|
22
|
+
/**
|
|
23
|
+
* Key of a single session record. Hash-tagged by userId, so all of one
|
|
24
|
+
* user's session keys share a slot.
|
|
25
|
+
*/
|
|
26
|
+
sessionKey(userId: string, jti: string): string;
|
|
27
|
+
/** Key of the user's session index (ZSET, member = jti, score = createdAt). */
|
|
28
|
+
userIndexKey(userId: string): string;
|
|
29
|
+
/** Key of the user's security version counter. Same user slot. */
|
|
30
|
+
securityVersionKey(userId: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Short-lived idempotent-creation claim key (user slot). Bounded TTL is
|
|
33
|
+
* set by the create script; a claim only ever suppresses a duplicate.
|
|
34
|
+
*/
|
|
35
|
+
createClaimKey(userId: string, jti: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Key of the optional global JTI -> userId index.
|
|
38
|
+
* Deliberately NOT hash-tagged: it is cross-slot from the session record
|
|
39
|
+
* and treated as derived state (see docs/architecture).
|
|
40
|
+
*/
|
|
41
|
+
jtiIndexKey(jti: string): string;
|
|
42
|
+
/** Key of a revocation entry (single-key, cluster-safe, no tag needed). */
|
|
43
|
+
revokedKey(jti: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Session key prefix for a user, used by Lua eviction to construct keys
|
|
46
|
+
* from jtis. The `{userId}` hash tag guarantees same-slot construction.
|
|
47
|
+
*/
|
|
48
|
+
sessionKeyPrefix(userId: string): string;
|
|
49
|
+
/** Index key prefix for namespace-scoped administration. */
|
|
50
|
+
namespacePrefix(): string;
|
|
51
|
+
}
|