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,36 @@
|
|
|
1
|
+
import type { RedisClientWrapper } from '../client.js';
|
|
2
|
+
export declare const SCRIPT_NAMES: readonly ['create', 'touch', 'touchEncrypted', 'rotate', 'rotateEncrypted', 'delete', 'revoke', 'conditionalUpdate', 'conditionalUpdateEncrypted', 'deleteByUser', 'cleanupIndex', 'enforceLimit', 'validate'];
|
|
3
|
+
export type ScriptName = (typeof SCRIPT_NAMES)[number];
|
|
4
|
+
/** Loads a script source from disk relative to this module. */
|
|
5
|
+
export declare function loadScriptSource(name: ScriptName): string;
|
|
6
|
+
export interface SessionScriptRegistryOptions {
|
|
7
|
+
/** Override script sources (used by tests to inject fixtures). */
|
|
8
|
+
sources?: Partial<Record<ScriptName, string>>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Loads and executes the session Lua scripts through a single client.
|
|
12
|
+
* Stateless besides the script sources and SHA digests.
|
|
13
|
+
*/
|
|
14
|
+
export declare class SessionScriptRegistry {
|
|
15
|
+
private readonly sources;
|
|
16
|
+
private readonly client;
|
|
17
|
+
private shas;
|
|
18
|
+
private loaded;
|
|
19
|
+
constructor(client: RedisClientWrapper, options?: SessionScriptRegistryOptions);
|
|
20
|
+
/** Returns the raw source of a script (for tests and audits). */
|
|
21
|
+
source(name: ScriptName): string;
|
|
22
|
+
/**
|
|
23
|
+
* Pre-loads every script with SCRIPT LOAD. Best-effort: when loading
|
|
24
|
+
* fails (e.g. Redis briefly unavailable at startup), the EVALSHA +
|
|
25
|
+
* NOSCRIPT fallback keeps working, so authentication is never blocked
|
|
26
|
+
* by a failed preload.
|
|
27
|
+
*/
|
|
28
|
+
preload(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Executes a script by name with EVALSHA, falling back to EVAL on
|
|
31
|
+
* NOSCRIPT (script cache evicted / node restarted).
|
|
32
|
+
*/
|
|
33
|
+
eval(name: ScriptName, numKeys: number, ...args: Array<string | number | Buffer>): Promise<unknown>;
|
|
34
|
+
/** Marks the registry dirty (e.g. after tests replaced the client). */
|
|
35
|
+
invalidate(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { SessionConfigurationError } from './session-errors.js';
|
|
3
|
+
/* -------------------------------------------------------------------------- */
|
|
4
|
+
/* Lua script registry. */
|
|
5
|
+
/* */
|
|
6
|
+
/* Scripts live as versioned .lua files (src/session/scripts/) and are */
|
|
7
|
+
/* loaded at construction. The registry handles SCRIPT LOAD + EVALSHA with */
|
|
8
|
+
/* NOSCRIPT fallback, so scripts survive server script-cache eviction and */
|
|
9
|
+
/* cluster node restarts. */
|
|
10
|
+
/* */
|
|
11
|
+
/* Script contract (enforced by review, not by machinery): */
|
|
12
|
+
/* - every key is declared in KEYS and shares the user's hash slot */
|
|
13
|
+
/* - no cross-slot keys, no KEYS/SCAN, no dynamic Lua construction */
|
|
14
|
+
/* - stable integer result codes (documented in each script header) */
|
|
15
|
+
/* - bounded loops (batch sizes passed in ARGV) */
|
|
16
|
+
/* -------------------------------------------------------------------------- */
|
|
17
|
+
export const SCRIPT_NAMES = [
|
|
18
|
+
'create',
|
|
19
|
+
'touch',
|
|
20
|
+
'touchEncrypted',
|
|
21
|
+
'rotate',
|
|
22
|
+
'rotateEncrypted',
|
|
23
|
+
'delete',
|
|
24
|
+
'revoke',
|
|
25
|
+
'conditionalUpdate',
|
|
26
|
+
'conditionalUpdateEncrypted',
|
|
27
|
+
'deleteByUser',
|
|
28
|
+
'cleanupIndex',
|
|
29
|
+
'enforceLimit',
|
|
30
|
+
'validate',
|
|
31
|
+
];
|
|
32
|
+
const SCRIPT_FILE = {
|
|
33
|
+
create: 'create.lua',
|
|
34
|
+
touch: 'touch.lua',
|
|
35
|
+
touchEncrypted: 'touch-encrypted.lua',
|
|
36
|
+
rotate: 'rotate.lua',
|
|
37
|
+
rotateEncrypted: 'rotate-encrypted.lua',
|
|
38
|
+
delete: 'delete.lua',
|
|
39
|
+
revoke: 'revoke.lua',
|
|
40
|
+
conditionalUpdate: 'conditional-update.lua',
|
|
41
|
+
conditionalUpdateEncrypted: 'conditional-update-encrypted.lua',
|
|
42
|
+
deleteByUser: 'delete-by-user.lua',
|
|
43
|
+
cleanupIndex: 'cleanup-index.lua',
|
|
44
|
+
enforceLimit: 'enforce-limit.lua',
|
|
45
|
+
validate: 'validate.lua',
|
|
46
|
+
};
|
|
47
|
+
/** Loads a script source from disk relative to this module. */
|
|
48
|
+
export function loadScriptSource(name) {
|
|
49
|
+
const url = new URL(`./scripts/${SCRIPT_FILE[name]}`, import.meta.url);
|
|
50
|
+
try {
|
|
51
|
+
return readFileSync(url, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new SessionConfigurationError(`Failed to load Lua script "${SCRIPT_FILE[name]}" (${url.pathname}): ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Loads and executes the session Lua scripts through a single client.
|
|
59
|
+
* Stateless besides the script sources and SHA digests.
|
|
60
|
+
*/
|
|
61
|
+
export class SessionScriptRegistry {
|
|
62
|
+
sources;
|
|
63
|
+
client;
|
|
64
|
+
shas = {};
|
|
65
|
+
loaded = false;
|
|
66
|
+
constructor(client, options = {}) {
|
|
67
|
+
this.client = client;
|
|
68
|
+
const sources = {};
|
|
69
|
+
for (const name of SCRIPT_NAMES) {
|
|
70
|
+
sources[name] = options.sources?.[name] ?? loadScriptSource(name);
|
|
71
|
+
}
|
|
72
|
+
this.sources = sources;
|
|
73
|
+
}
|
|
74
|
+
/** Returns the raw source of a script (for tests and audits). */
|
|
75
|
+
source(name) {
|
|
76
|
+
return this.sources[name];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Pre-loads every script with SCRIPT LOAD. Best-effort: when loading
|
|
80
|
+
* fails (e.g. Redis briefly unavailable at startup), the EVALSHA +
|
|
81
|
+
* NOSCRIPT fallback keeps working, so authentication is never blocked
|
|
82
|
+
* by a failed preload.
|
|
83
|
+
*/
|
|
84
|
+
async preload() {
|
|
85
|
+
if (this.loaded)
|
|
86
|
+
return;
|
|
87
|
+
const results = await Promise.allSettled(SCRIPT_NAMES.map((name) => this.client
|
|
88
|
+
.scriptLoad(this.sources[name])
|
|
89
|
+
.then((sha) => {
|
|
90
|
+
this.shas[name] = sha;
|
|
91
|
+
})));
|
|
92
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
93
|
+
if (failed > 0) {
|
|
94
|
+
// Scripts will still work via the EVAL fallback path.
|
|
95
|
+
this.loaded = false;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.loaded = true;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Executes a script by name with EVALSHA, falling back to EVAL on
|
|
102
|
+
* NOSCRIPT (script cache evicted / node restarted).
|
|
103
|
+
*/
|
|
104
|
+
async eval(name, numKeys, ...args) {
|
|
105
|
+
const sha = this.shas[name];
|
|
106
|
+
const source = this.sources[name];
|
|
107
|
+
if (sha) {
|
|
108
|
+
try {
|
|
109
|
+
return await this.client.evalsha(sha, source, numKeys, ...args);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
if (!message.includes('NOSCRIPT'))
|
|
114
|
+
throw error;
|
|
115
|
+
// Fall through to EVAL and refresh the cached SHA.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const result = await this.client.eval(source, numKeys, ...args);
|
|
119
|
+
if (sha) {
|
|
120
|
+
// EVAL succeeds only when the server has the script; update the SHA.
|
|
121
|
+
this.shas[name] = sha;
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
/** Marks the registry dirty (e.g. after tests replaced the client). */
|
|
126
|
+
invalidate() {
|
|
127
|
+
this.loaded = false;
|
|
128
|
+
this.shas = {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionKeyProvider } from './session-encryption.js';
|
|
2
|
+
import type { EncryptedSessionEnvelope, SessionRecord } from './session-types.js';
|
|
3
|
+
export type EnvelopeKind = 'plain' | 'encrypted' | 'unknown';
|
|
4
|
+
/**
|
|
5
|
+
* Runtime validation of an arbitrary parsed value against the SessionRecord
|
|
6
|
+
* shape. Returns the validated record or throws SessionSerializationError
|
|
7
|
+
* with a machine-readable reason. Never casts blindly.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateSessionRecord(value: unknown): SessionRecord;
|
|
10
|
+
/**
|
|
11
|
+
* Serializes a record into the plain v1 envelope.
|
|
12
|
+
*/
|
|
13
|
+
export declare function serializeSession(record: SessionRecord): string;
|
|
14
|
+
/**
|
|
15
|
+
* Serializes a record into the encrypted v2 envelope, mirroring the
|
|
16
|
+
* script-readable plaintext header from the record.
|
|
17
|
+
*/
|
|
18
|
+
export declare function serializeEncryptedSession(record: SessionRecord, provider: SessionKeyProvider): string;
|
|
19
|
+
/** Cheap kind detection without full parsing (for script mode selection). */
|
|
20
|
+
export declare function envelopeKind(raw: string): EnvelopeKind;
|
|
21
|
+
/**
|
|
22
|
+
* Deserializes a stored envelope into a validated SessionRecord.
|
|
23
|
+
*
|
|
24
|
+
* @throws {SessionSerializationError} for unknown schema versions, malformed
|
|
25
|
+
* JSON, malformed records, and encryption failures (auth tag, unknown key
|
|
26
|
+
* version). The caller decides how to handle the corrupt record (invalidate
|
|
27
|
+
* + clean up); this never crashes the process.
|
|
28
|
+
*/
|
|
29
|
+
export declare function deserializeSession(raw: string, keyProvider?: SessionKeyProvider): SessionRecord;
|
|
30
|
+
/**
|
|
31
|
+
* Builds the plaintext header mirrors for an encrypted envelope from a
|
|
32
|
+
* record. Used by the repository when re-encrypting on touch/rotate/update.
|
|
33
|
+
*/
|
|
34
|
+
export declare function encryptedHeaderOf(record: SessionRecord): Pick<EncryptedSessionEnvelope, 'st' | 'ver' | 'la' | 'idle' | 'exp' | 'rn' | 'rj'>;
|
|
35
|
+
/**
|
|
36
|
+
* Verifies that a decrypted v2 record agrees with the envelope's plaintext
|
|
37
|
+
* header mirrors. The ciphertext is authoritative; a disagreement means the
|
|
38
|
+
* envelope was built from stale or inconsistent state and MUST fail closed.
|
|
39
|
+
*
|
|
40
|
+
* @throws {SessionSerializationError} on any mismatch.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertHeaderMatches(envelope: EncryptedSessionEnvelope, record: SessionRecord): void;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { SessionSerializationError } from './session-errors.js';
|
|
2
|
+
import { decryptJson, encryptJson } from './session-encryption.js';
|
|
3
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
4
|
+
function isString(v) {
|
|
5
|
+
return typeof v === 'string';
|
|
6
|
+
}
|
|
7
|
+
function isNullish(v) {
|
|
8
|
+
return v === null || v === undefined;
|
|
9
|
+
}
|
|
10
|
+
function isSecondsTimestamp(v) {
|
|
11
|
+
return typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
|
|
12
|
+
}
|
|
13
|
+
function isOptionalSecondsTimestamp(v) {
|
|
14
|
+
return isNullish(v) || isSecondsTimestamp(v);
|
|
15
|
+
}
|
|
16
|
+
function isStatus(v) {
|
|
17
|
+
return v === 'active' || v === 'consumed' || v === 'revoked';
|
|
18
|
+
}
|
|
19
|
+
function isOptionalString(v, maxLength = 1024) {
|
|
20
|
+
return isNullish(v) || (typeof v === 'string' && v.length <= maxLength);
|
|
21
|
+
}
|
|
22
|
+
const MAX_METADATA_DEPTH = 5;
|
|
23
|
+
const MAX_METADATA_KEYS = 128;
|
|
24
|
+
function isPlainMetadata(v, depth = 0) {
|
|
25
|
+
if (v === null || v === undefined)
|
|
26
|
+
return true;
|
|
27
|
+
if (typeof v === 'string' || typeof v === 'boolean')
|
|
28
|
+
return true;
|
|
29
|
+
if (typeof v === 'number')
|
|
30
|
+
return Number.isFinite(v);
|
|
31
|
+
if (Array.isArray(v)) {
|
|
32
|
+
if (depth >= MAX_METADATA_DEPTH)
|
|
33
|
+
return false;
|
|
34
|
+
return v.every((item) => isPlainMetadata(item, depth + 1));
|
|
35
|
+
}
|
|
36
|
+
if (typeof v === 'object') {
|
|
37
|
+
if (depth >= MAX_METADATA_DEPTH)
|
|
38
|
+
return false;
|
|
39
|
+
const keys = Object.keys(v);
|
|
40
|
+
if (keys.length > MAX_METADATA_KEYS)
|
|
41
|
+
return false;
|
|
42
|
+
return keys.every((key) => key.length <= 128 && isPlainMetadata(v[key], depth + 1));
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Runtime validation of an arbitrary parsed value against the SessionRecord
|
|
48
|
+
* shape. Returns the validated record or throws SessionSerializationError
|
|
49
|
+
* with a machine-readable reason. Never casts blindly.
|
|
50
|
+
*/
|
|
51
|
+
export function validateSessionRecord(value) {
|
|
52
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
53
|
+
throw new SessionSerializationError({ reason: 'not_an_object' });
|
|
54
|
+
}
|
|
55
|
+
const r = value;
|
|
56
|
+
const jti = r.jti;
|
|
57
|
+
if (!isString(jti) || jti.length < 20 || jti.length > 100 || !BASE64URL.test(jti)) {
|
|
58
|
+
throw new SessionSerializationError({ reason: 'invalid_jti' });
|
|
59
|
+
}
|
|
60
|
+
const userId = r.userId;
|
|
61
|
+
if (!isString(userId) || userId.length === 0 || userId.length > 512) {
|
|
62
|
+
throw new SessionSerializationError({ reason: 'invalid_user_id' });
|
|
63
|
+
}
|
|
64
|
+
const createdAt = r.createdAt;
|
|
65
|
+
const lastAccessedAt = r.lastAccessedAt;
|
|
66
|
+
const absoluteExpiresAt = r.absoluteExpiresAt;
|
|
67
|
+
if (!isSecondsTimestamp(createdAt))
|
|
68
|
+
throw new SessionSerializationError({ reason: 'invalid_created_at' });
|
|
69
|
+
if (!isSecondsTimestamp(lastAccessedAt))
|
|
70
|
+
throw new SessionSerializationError({ reason: 'invalid_last_accessed_at' });
|
|
71
|
+
if (!isSecondsTimestamp(absoluteExpiresAt))
|
|
72
|
+
throw new SessionSerializationError({ reason: 'invalid_absolute_expiry' });
|
|
73
|
+
const idleExpiresAt = r.idleExpiresAt;
|
|
74
|
+
if (!isOptionalSecondsTimestamp(idleExpiresAt)) {
|
|
75
|
+
throw new SessionSerializationError({ reason: 'invalid_idle_expiry' });
|
|
76
|
+
}
|
|
77
|
+
const status = r.status;
|
|
78
|
+
if (!isStatus(status))
|
|
79
|
+
throw new SessionSerializationError({ reason: 'invalid_status' });
|
|
80
|
+
const version = r.version;
|
|
81
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) {
|
|
82
|
+
throw new SessionSerializationError({ reason: 'invalid_version' });
|
|
83
|
+
}
|
|
84
|
+
const securityVersion = r.securityVersion;
|
|
85
|
+
if (!isNullish(securityVersion) && (typeof securityVersion !== 'number' || !Number.isSafeInteger(securityVersion) || securityVersion < 0)) {
|
|
86
|
+
throw new SessionSerializationError({ reason: 'invalid_security_version' });
|
|
87
|
+
}
|
|
88
|
+
const validatedSecurityVersion = securityVersion ?? null;
|
|
89
|
+
if (!isOptionalString(r.deviceId))
|
|
90
|
+
throw new SessionSerializationError({ reason: 'invalid_device_id' });
|
|
91
|
+
if (!isOptionalString(r.ipAddress))
|
|
92
|
+
throw new SessionSerializationError({ reason: 'invalid_ip_address' });
|
|
93
|
+
if (!isOptionalString(r.userAgent))
|
|
94
|
+
throw new SessionSerializationError({ reason: 'invalid_user_agent' });
|
|
95
|
+
const metadata = r.metadata;
|
|
96
|
+
if (!isNullish(metadata) && !isPlainMetadata(metadata)) {
|
|
97
|
+
throw new SessionSerializationError({ reason: 'invalid_metadata' });
|
|
98
|
+
}
|
|
99
|
+
if (!isNullish(r.rotatedFrom) && !isString(r.rotatedFrom)) {
|
|
100
|
+
throw new SessionSerializationError({ reason: 'invalid_rotated_from' });
|
|
101
|
+
}
|
|
102
|
+
if (!isNullish(r.rotatedTo) && !isString(r.rotatedTo)) {
|
|
103
|
+
throw new SessionSerializationError({ reason: 'invalid_rotated_to' });
|
|
104
|
+
}
|
|
105
|
+
if (!isNullish(r.consumedAt) && !isSecondsTimestamp(r.consumedAt)) {
|
|
106
|
+
throw new SessionSerializationError({ reason: 'invalid_consumed_at' });
|
|
107
|
+
}
|
|
108
|
+
if (!isNullish(r.rotationNonceHash) && !isString(r.rotationNonceHash)) {
|
|
109
|
+
throw new SessionSerializationError({ reason: 'invalid_rotation_nonce_hash' });
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
jti,
|
|
113
|
+
userId,
|
|
114
|
+
createdAt,
|
|
115
|
+
lastAccessedAt,
|
|
116
|
+
absoluteExpiresAt,
|
|
117
|
+
idleExpiresAt: idleExpiresAt ?? null,
|
|
118
|
+
status,
|
|
119
|
+
version,
|
|
120
|
+
securityVersion: validatedSecurityVersion,
|
|
121
|
+
deviceId: r.deviceId ?? null,
|
|
122
|
+
ipAddress: r.ipAddress ?? null,
|
|
123
|
+
userAgent: r.userAgent ?? null,
|
|
124
|
+
metadata: metadata ?? null,
|
|
125
|
+
rotatedFrom: r.rotatedFrom ?? null,
|
|
126
|
+
rotatedTo: r.rotatedTo ?? null,
|
|
127
|
+
consumedAt: r.consumedAt ?? null,
|
|
128
|
+
rotationNonceHash: r.rotationNonceHash ?? null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Serializes a record into the plain v1 envelope.
|
|
133
|
+
*/
|
|
134
|
+
export function serializeSession(record) {
|
|
135
|
+
return JSON.stringify({ v: 1, s: record });
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Serializes a record into the encrypted v2 envelope, mirroring the
|
|
139
|
+
* script-readable plaintext header from the record.
|
|
140
|
+
*/
|
|
141
|
+
export function serializeEncryptedSession(record, provider) {
|
|
142
|
+
const body = encryptJson(JSON.stringify(record), provider);
|
|
143
|
+
const envelope = {
|
|
144
|
+
v: 2,
|
|
145
|
+
e: 1,
|
|
146
|
+
...body,
|
|
147
|
+
st: record.status,
|
|
148
|
+
ver: record.version,
|
|
149
|
+
la: record.lastAccessedAt,
|
|
150
|
+
idle: record.idleExpiresAt,
|
|
151
|
+
exp: record.absoluteExpiresAt,
|
|
152
|
+
rn: record.rotationNonceHash,
|
|
153
|
+
rj: record.rotatedTo,
|
|
154
|
+
};
|
|
155
|
+
return JSON.stringify(envelope);
|
|
156
|
+
}
|
|
157
|
+
/** Cheap kind detection without full parsing (for script mode selection). */
|
|
158
|
+
export function envelopeKind(raw) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
if (parsed && typeof parsed === 'object' && parsed.v === 1)
|
|
162
|
+
return 'plain';
|
|
163
|
+
if (parsed && typeof parsed === 'object' && parsed.v === 2 && parsed.e === 1)
|
|
164
|
+
return 'encrypted';
|
|
165
|
+
return 'unknown';
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return 'unknown';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Deserializes a stored envelope into a validated SessionRecord.
|
|
173
|
+
*
|
|
174
|
+
* @throws {SessionSerializationError} for unknown schema versions, malformed
|
|
175
|
+
* JSON, malformed records, and encryption failures (auth tag, unknown key
|
|
176
|
+
* version). The caller decides how to handle the corrupt record (invalidate
|
|
177
|
+
* + clean up); this never crashes the process.
|
|
178
|
+
*/
|
|
179
|
+
export function deserializeSession(raw, keyProvider) {
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(raw);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
throw new SessionSerializationError({ reason: 'invalid_json' });
|
|
186
|
+
}
|
|
187
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
188
|
+
throw new SessionSerializationError({ reason: 'not_an_object' });
|
|
189
|
+
}
|
|
190
|
+
const envelope = parsed;
|
|
191
|
+
if (envelope.v === 1) {
|
|
192
|
+
return validateSessionRecord(envelope.s);
|
|
193
|
+
}
|
|
194
|
+
if (envelope.v === 2) {
|
|
195
|
+
if (!keyProvider) {
|
|
196
|
+
throw new SessionSerializationError({ reason: 'encrypted_without_provider' });
|
|
197
|
+
}
|
|
198
|
+
const body = envelope;
|
|
199
|
+
if (body.e !== 1) {
|
|
200
|
+
throw new SessionSerializationError({ reason: 'unknown_encryption_version' });
|
|
201
|
+
}
|
|
202
|
+
return validateSessionRecord(decryptJson(body, keyProvider));
|
|
203
|
+
}
|
|
204
|
+
throw new SessionSerializationError({ reason: 'unsupported_schema_version' });
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Builds the plaintext header mirrors for an encrypted envelope from a
|
|
208
|
+
* record. Used by the repository when re-encrypting on touch/rotate/update.
|
|
209
|
+
*/
|
|
210
|
+
export function encryptedHeaderOf(record) {
|
|
211
|
+
return {
|
|
212
|
+
st: record.status,
|
|
213
|
+
ver: record.version,
|
|
214
|
+
la: record.lastAccessedAt,
|
|
215
|
+
idle: record.idleExpiresAt,
|
|
216
|
+
exp: record.absoluteExpiresAt,
|
|
217
|
+
rn: record.rotationNonceHash,
|
|
218
|
+
rj: record.rotatedTo,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Verifies that a decrypted v2 record agrees with the envelope's plaintext
|
|
223
|
+
* header mirrors. The ciphertext is authoritative; a disagreement means the
|
|
224
|
+
* envelope was built from stale or inconsistent state and MUST fail closed.
|
|
225
|
+
*
|
|
226
|
+
* @throws {SessionSerializationError} on any mismatch.
|
|
227
|
+
*/
|
|
228
|
+
export function assertHeaderMatches(envelope, record) {
|
|
229
|
+
const header = encryptedHeaderOf(record);
|
|
230
|
+
const mismatches = [];
|
|
231
|
+
if (envelope.st !== header.st)
|
|
232
|
+
mismatches.push('st');
|
|
233
|
+
if (envelope.ver !== header.ver)
|
|
234
|
+
mismatches.push('ver');
|
|
235
|
+
if (envelope.la !== header.la)
|
|
236
|
+
mismatches.push('la');
|
|
237
|
+
if (envelope.idle !== header.idle)
|
|
238
|
+
mismatches.push('idle');
|
|
239
|
+
if (envelope.exp !== header.exp)
|
|
240
|
+
mismatches.push('exp');
|
|
241
|
+
if (envelope.rn !== header.rn)
|
|
242
|
+
mismatches.push('rn');
|
|
243
|
+
if (envelope.rj !== header.rj)
|
|
244
|
+
mismatches.push('rj');
|
|
245
|
+
if (mismatches.length > 0) {
|
|
246
|
+
throw new SessionSerializationError({ reason: 'header_mismatch', fields: mismatches });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { SessionConfig } from './session-config.js';
|
|
5
|
+
import { SessionHealthChecker } from './session-health.js';
|
|
6
|
+
import type { SessionKeyStrategy } from './session-keys.js';
|
|
7
|
+
import { SessionMetrics } from './session-metrics.js';
|
|
8
|
+
import { SessionRepository } from './session-repository.js';
|
|
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';
|
|
11
|
+
export interface SessionServiceDeps {
|
|
12
|
+
config: SessionConfig;
|
|
13
|
+
client: RedisClientWrapper;
|
|
14
|
+
repository: SessionRepository;
|
|
15
|
+
token: SessionTokenManager;
|
|
16
|
+
keys: SessionKeyStrategy;
|
|
17
|
+
revocationStore?: RevocationStore;
|
|
18
|
+
metrics?: SessionMetrics;
|
|
19
|
+
circuitBreaker?: SessionCircuitBreaker;
|
|
20
|
+
health?: SessionHealthChecker;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
export declare class SessionService {
|
|
24
|
+
private readonly deps;
|
|
25
|
+
private readonly throttle;
|
|
26
|
+
constructor(deps: SessionServiceDeps);
|
|
27
|
+
private get config();
|
|
28
|
+
private get repository();
|
|
29
|
+
private get metrics();
|
|
30
|
+
private get breaker();
|
|
31
|
+
private get healthChecker();
|
|
32
|
+
private now;
|
|
33
|
+
private guard;
|
|
34
|
+
/**
|
|
35
|
+
* Creates a session and returns the raw token exactly once.
|
|
36
|
+
*
|
|
37
|
+
* Idempotent creation: when `input.idempotencyKey` is provided (and
|
|
38
|
+
* config.enableCreateIdempotency is on), the idempotencyKey IS the token.
|
|
39
|
+
* A retry with the same key returns the existing session with
|
|
40
|
+
* `replayed: true` instead of creating a duplicate.
|
|
41
|
+
*/
|
|
42
|
+
create(input: SessionCreateInput): Promise<CreatedSession>;
|
|
43
|
+
/**
|
|
44
|
+
* Validates a session token. Single Redis round trip when userId is known.
|
|
45
|
+
* Never throws for invalid sessions; throws only for infrastructure
|
|
46
|
+
* failures (fail closed) and configuration errors.
|
|
47
|
+
*/
|
|
48
|
+
validate(token: string, options?: ValidateOptions): Promise<SessionValidationResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Refreshes activity. Throttled by touchInterval (in-script + in-memory
|
|
51
|
+
* optimizations). Never resurrects an idle-expired session.
|
|
52
|
+
*/
|
|
53
|
+
touch(token: string, options?: TouchOptions): Promise<TouchOutcome>;
|
|
54
|
+
/**
|
|
55
|
+
* Single-use atomic rotation with retry-safe idempotency (rotationNonce).
|
|
56
|
+
*/
|
|
57
|
+
rotate(token: string, options?: RotateOptions): Promise<RotatedSession>;
|
|
58
|
+
/**
|
|
59
|
+
* Patch update of non-security fields (device/ip/ua/metadata) with
|
|
60
|
+
* optimistic concurrency.
|
|
61
|
+
*/
|
|
62
|
+
update(token: string, patch: SessionUpdatePatch, options?: UpdateOptions): Promise<SessionRecord>;
|
|
63
|
+
/** Physically deletes a session (idempotent). */
|
|
64
|
+
destroy(token: string, options?: {
|
|
65
|
+
userId?: string;
|
|
66
|
+
}): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Logically revokes a session (keeps a bounded tombstone).
|
|
69
|
+
* Returns 'revoked' | 'already_revoked' | 'not_found'.
|
|
70
|
+
*/
|
|
71
|
+
revoke(token: string, options?: {
|
|
72
|
+
userId?: string;
|
|
73
|
+
}): Promise<string>;
|
|
74
|
+
/** Revokes every session of a user (bounded, fail-closed on partial). */
|
|
75
|
+
revokeAll(userId: string): Promise<number>;
|
|
76
|
+
/** Deletes every session of a user (physical, bounded). */
|
|
77
|
+
deleteByUser(userId: string): Promise<string[]>;
|
|
78
|
+
/** Lists a user's sessions (oldest first). */
|
|
79
|
+
findByUser(userId: string, options?: ListOptions): Promise<SessionRecord[]>;
|
|
80
|
+
/** Alias of {@link findByUser} for listing. */
|
|
81
|
+
list(userId: string, options?: ListOptions): Promise<SessionRecord[]>;
|
|
82
|
+
/**
|
|
83
|
+
* Sets (or bumps) the user's security version, invalidating every session
|
|
84
|
+
* captured at an older version. Use after password/MFA changes.
|
|
85
|
+
*/
|
|
86
|
+
setSecurityVersion(userId: string, version?: number): Promise<number>;
|
|
87
|
+
getSecurityVersion(userId: string): Promise<number | null>;
|
|
88
|
+
/** Dependency health (PING latency + recent error rate). */
|
|
89
|
+
health(): Promise<ReturnType<SessionHealthChecker['check']>>;
|
|
90
|
+
/**
|
|
91
|
+
* Resolves the userId for a jti: explicit when provided (fast path), via
|
|
92
|
+
* the JTI index otherwise. Returns null when the index has no entry.
|
|
93
|
+
*/
|
|
94
|
+
private resolveUserId;
|
|
95
|
+
/**
|
|
96
|
+
* Accepts tokens in the strict issued format (base64url of the configured
|
|
97
|
+
* entropy) and caller-supplied idempotency keys (bounded printable ASCII),
|
|
98
|
+
* which are used as tokens for idempotent creation. Rejects everything
|
|
99
|
+
* else (DoS guard: bounded length, bounded alphabet).
|
|
100
|
+
*/
|
|
101
|
+
private isAcceptableToken;
|
|
102
|
+
private checkBinding;
|
|
103
|
+
private bestEffortCleanup;
|
|
104
|
+
}
|