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,107 @@
|
|
|
1
|
+
-- rotate-encrypted.lua (version 1) - encrypted (v2) envelopes
|
|
2
|
+
-- Atomic, single-use session rotation for encrypted sessions.
|
|
3
|
+
--
|
|
4
|
+
-- The ciphertext cannot be decoded inside Lua, so the app decrypts the
|
|
5
|
+
-- old record, builds the consumed + successor payloads (re-encrypted with
|
|
6
|
+
-- the current key) and passes them as ARGV. The script is the only
|
|
7
|
+
-- authority for the state transition, decided from the plaintext header
|
|
8
|
+
-- mirrors (st/ver/exp/rn/rj) and Redis server time; the header mirrors of
|
|
9
|
+
-- the written payloads are overwritten from the checked values so a stale
|
|
10
|
+
-- app payload can never misrepresent state.
|
|
11
|
+
--
|
|
12
|
+
-- KEYS[1] = old session record key
|
|
13
|
+
-- KEYS[2] = successor session record key
|
|
14
|
+
-- KEYS[3] = user session index key
|
|
15
|
+
--
|
|
16
|
+
-- ARGV[1] = consumed serialized envelope (app-built, re-encrypted)
|
|
17
|
+
-- ARGV[2] = successor serialized envelope (app-built, re-encrypted)
|
|
18
|
+
-- ARGV[3] = successor jti
|
|
19
|
+
-- ARGV[4] = expected version of the old session ('' = no check)
|
|
20
|
+
-- ARGV[5] = rotation nonce hash ('' = none)
|
|
21
|
+
-- ARGV[6] = retain consumed tombstone (1/0)
|
|
22
|
+
-- ARGV[7] = old jti
|
|
23
|
+
-- ARGV[8] = successor TTL (clamped >= 1)
|
|
24
|
+
-- ARGV[9] = consumed tombstone TTL (clamped >= 1)
|
|
25
|
+
--
|
|
26
|
+
-- Returns: same codes as rotate.lua (1/2/0/-1/-2/-3/-4/5), plus 6 when the
|
|
27
|
+
-- envelope is plain (caller must use the plain path).
|
|
28
|
+
local raw = redis.call('GET', KEYS[1])
|
|
29
|
+
|
|
30
|
+
if not raw then
|
|
31
|
+
return 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local env = cjson.decode(raw)
|
|
35
|
+
|
|
36
|
+
if env.v ~= 2 then
|
|
37
|
+
return 6
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local now = tonumber(redis.call('TIME')[1])
|
|
41
|
+
|
|
42
|
+
-- Retry-safe replay detection BEFORE rejecting consumed records.
|
|
43
|
+
-- The rotation nonce uniquely identifies the rotation: when the consumed
|
|
44
|
+
-- record carries the same nonce hash, this is a retry of an already-applied
|
|
45
|
+
-- rotation. The stored rotatedTo jti is returned (the retry's own freshly
|
|
46
|
+
-- generated successor jti is discarded - it can never match).
|
|
47
|
+
if env.st == 'consumed' then
|
|
48
|
+
if ARGV[5] ~= '' and env.rn == ARGV[5] then
|
|
49
|
+
return { 2, env.rj }
|
|
50
|
+
end
|
|
51
|
+
return -1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if env.st ~= 'active' then
|
|
55
|
+
return -1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if tonumber(env.exp) <= now then
|
|
59
|
+
redis.call('DEL', KEYS[1])
|
|
60
|
+
redis.call('ZREM', KEYS[3], ARGV[7])
|
|
61
|
+
return -2
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if ARGV[4] ~= '' and tostring(env.ver) ~= ARGV[4] then
|
|
65
|
+
return -3
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
69
|
+
return -4
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
local consumedEnv = cjson.decode(ARGV[1])
|
|
73
|
+
consumedEnv.st = 'consumed'
|
|
74
|
+
consumedEnv.ver = env.ver
|
|
75
|
+
consumedEnv.la = env.la
|
|
76
|
+
consumedEnv.idle = env.idle
|
|
77
|
+
consumedEnv.exp = env.exp
|
|
78
|
+
consumedEnv.rn = cjson.null
|
|
79
|
+
consumedEnv.rj = ARGV[3]
|
|
80
|
+
|
|
81
|
+
if ARGV[5] ~= '' then
|
|
82
|
+
consumedEnv.rn = ARGV[5]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
local nextEnv = cjson.decode(ARGV[2])
|
|
86
|
+
nextEnv.st = 'active'
|
|
87
|
+
nextEnv.ver = 1
|
|
88
|
+
nextEnv.la = now
|
|
89
|
+
if nextEnv.idle ~= cjson.null then
|
|
90
|
+
nextEnv.idle = math.min(tonumber(nextEnv.idle), tonumber(nextEnv.exp))
|
|
91
|
+
end
|
|
92
|
+
nextEnv.rn = cjson.null
|
|
93
|
+
nextEnv.rj = cjson.null
|
|
94
|
+
|
|
95
|
+
local nextTtl = math.max(1, tonumber(ARGV[8]))
|
|
96
|
+
|
|
97
|
+
if ARGV[6] == '1' then
|
|
98
|
+
redis.call('SET', KEYS[1], cjson.encode(consumedEnv), 'EX', math.max(1, tonumber(ARGV[9])))
|
|
99
|
+
else
|
|
100
|
+
redis.call('DEL', KEYS[1])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
redis.call('SET', KEYS[2], cjson.encode(nextEnv), 'EX', nextTtl)
|
|
104
|
+
redis.call('ZREM', KEYS[3], ARGV[7])
|
|
105
|
+
redis.call('ZADD', KEYS[3], now, ARGV[3])
|
|
106
|
+
|
|
107
|
+
return { 1, ARGV[3] }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
-- rotate.lua (version 1) - plain (v1) envelopes
|
|
2
|
+
-- Atomic, single-use session rotation.
|
|
3
|
+
--
|
|
4
|
+
-- The old session is consumed (status=consumed, consumedAt, rotatedTo,
|
|
5
|
+
-- rotationNonceHash) and the successor is created, all in one same-slot
|
|
6
|
+
-- script. No preliminary GET can make this safe; the script is the only
|
|
7
|
+
-- authority. Timestamps are stamped from Redis server time.
|
|
8
|
+
--
|
|
9
|
+
-- Retry-safe idempotency: when the caller retries a rotation that already
|
|
10
|
+
-- succeeded (response lost), the consumed record retains rotationNonceHash
|
|
11
|
+
-- and rotatedTo. If both match the retry, the script returns {2, jti} and
|
|
12
|
+
-- the caller returns the existing successor instead of a replay error.
|
|
13
|
+
--
|
|
14
|
+
-- KEYS[1] = old session record key
|
|
15
|
+
-- KEYS[2] = successor session record key
|
|
16
|
+
-- KEYS[3] = user session index key
|
|
17
|
+
--
|
|
18
|
+
-- ARGV[1] = successor serialized envelope (app-built static fields)
|
|
19
|
+
-- ARGV[2] = successor jti
|
|
20
|
+
-- ARGV[3] = expected version of the old session ('' = no check)
|
|
21
|
+
-- ARGV[4] = rotation nonce hash ('' = none)
|
|
22
|
+
-- ARGV[5] = retain consumed tombstone (1/0)
|
|
23
|
+
-- ARGV[6] = old jti
|
|
24
|
+
--
|
|
25
|
+
-- Returns:
|
|
26
|
+
-- {1, successorJti} success
|
|
27
|
+
-- {2, successorJti} idempotent replay of a rotation with the same nonce
|
|
28
|
+
-- 0 old session not found
|
|
29
|
+
-- -1 already consumed / revoked (replay)
|
|
30
|
+
-- -2 old session expired (record removed)
|
|
31
|
+
-- -3 version conflict
|
|
32
|
+
-- -4 successor jti collision
|
|
33
|
+
-- 5 envelope is encrypted (use the encrypted path)
|
|
34
|
+
local raw = redis.call('GET', KEYS[1])
|
|
35
|
+
|
|
36
|
+
if not raw then
|
|
37
|
+
return 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local env = cjson.decode(raw)
|
|
41
|
+
|
|
42
|
+
if env.v ~= 1 then
|
|
43
|
+
return 5
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local s = env.s
|
|
47
|
+
local now = tonumber(redis.call('TIME')[1])
|
|
48
|
+
|
|
49
|
+
-- Retry-safe replay detection BEFORE rejecting consumed records.
|
|
50
|
+
-- The rotation nonce uniquely identifies the rotation: when the consumed
|
|
51
|
+
-- record carries the same nonce hash, this is a retry of an already-applied
|
|
52
|
+
-- rotation. The stored rotatedTo jti is returned (the retry's own freshly
|
|
53
|
+
-- generated successor jti is discarded - it can never match).
|
|
54
|
+
if s.status == 'consumed' then
|
|
55
|
+
if ARGV[4] ~= '' and s.rotationNonceHash == ARGV[4] then
|
|
56
|
+
return { 2, s.rotatedTo }
|
|
57
|
+
end
|
|
58
|
+
return -1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if s.status ~= 'active' then
|
|
62
|
+
return -1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if tonumber(s.absoluteExpiresAt) <= now then
|
|
66
|
+
redis.call('DEL', KEYS[1])
|
|
67
|
+
redis.call('ZREM', KEYS[3], ARGV[6])
|
|
68
|
+
return -2
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if ARGV[3] ~= '' and tostring(s.version) ~= ARGV[3] then
|
|
72
|
+
return -3
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
-- Successor collision: cryptographically improbable, but never overwrite.
|
|
76
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
77
|
+
return -4
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
-- Build the successor record; server time is authoritative for ordering.
|
|
81
|
+
local nextEnv = cjson.decode(ARGV[1])
|
|
82
|
+
local ns = nextEnv.s
|
|
83
|
+
|
|
84
|
+
ns.createdAt = now
|
|
85
|
+
ns.lastAccessedAt = now
|
|
86
|
+
ns.rotatedFrom = s.jti
|
|
87
|
+
ns.version = 1
|
|
88
|
+
|
|
89
|
+
if ns.idleExpiresAt and ns.idleExpiresAt ~= cjson.null then
|
|
90
|
+
ns.idleExpiresAt = math.min(tonumber(ns.idleExpiresAt), tonumber(ns.absoluteExpiresAt))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
local nextTtl = math.max(1, tonumber(ns.absoluteExpiresAt) - now)
|
|
94
|
+
|
|
95
|
+
-- Consume the old session.
|
|
96
|
+
s.status = 'consumed'
|
|
97
|
+
s.consumedAt = now
|
|
98
|
+
s.rotatedTo = ARGV[2]
|
|
99
|
+
|
|
100
|
+
if ARGV[4] ~= '' then
|
|
101
|
+
s.rotationNonceHash = ARGV[4]
|
|
102
|
+
else
|
|
103
|
+
s.rotationNonceHash = cjson.null
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
local oldTtl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
|
|
107
|
+
|
|
108
|
+
if ARGV[5] == '1' then
|
|
109
|
+
-- Short-lived consumed tombstone for replay detection (bounded TTL).
|
|
110
|
+
redis.call('SET', KEYS[1], cjson.encode(env), 'EX', oldTtl)
|
|
111
|
+
else
|
|
112
|
+
redis.call('DEL', KEYS[1])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
redis.call('SET', KEYS[2], cjson.encode(nextEnv), 'EX', nextTtl)
|
|
116
|
+
redis.call('ZREM', KEYS[3], ARGV[6])
|
|
117
|
+
redis.call('ZADD', KEYS[3], now, ARGV[2])
|
|
118
|
+
|
|
119
|
+
return { 1, ARGV[2] }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
-- touch-encrypted.lua (version 1) - encrypted (v2) envelopes
|
|
2
|
+
-- Throttled, monotonic activity refresh for encrypted sessions.
|
|
3
|
+
--
|
|
4
|
+
-- The ciphertext cannot be decoded inside Lua, so the app re-encrypts the
|
|
5
|
+
-- record (with its own clock) and passes the new envelope as ARGV[4]. The
|
|
6
|
+
-- script validates state via the plaintext header mirrors and enforces
|
|
7
|
+
-- monotonicity: a stale request can never move lastAccessedAt backwards.
|
|
8
|
+
--
|
|
9
|
+
-- KEYS[1] = session record key
|
|
10
|
+
--
|
|
11
|
+
-- ARGV[1] = touchInterval (seconds)
|
|
12
|
+
-- ARGV[2] = idleTimeout (seconds, '' when disabled)
|
|
13
|
+
-- ARGV[3] = force (1 = ignore throttle)
|
|
14
|
+
-- ARGV[4] = new serialized envelope (re-encrypted with the current key)
|
|
15
|
+
-- ARGV[5] = new lastAccessedAt (app clock, seconds)
|
|
16
|
+
-- ARGV[6] = new idleExpiresAt (app clock, seconds, '' when disabled)
|
|
17
|
+
-- ARGV[7] = new TTL (absoluteExpiresAt - now, clamped >= 1)
|
|
18
|
+
--
|
|
19
|
+
-- Returns:
|
|
20
|
+
-- 1 touched (write performed)
|
|
21
|
+
-- 2 skipped: inside touchInterval
|
|
22
|
+
-- 3 skipped: request older than recorded activity
|
|
23
|
+
-- 0 not found
|
|
24
|
+
-- -1 consumed or revoked
|
|
25
|
+
-- -2 absolute expiry passed; record deleted
|
|
26
|
+
-- -3 idle expired; NOT resurrected
|
|
27
|
+
-- 4 envelope is plain (caller must use the plain touch path)
|
|
28
|
+
-- 5 stale request: ARGV[5] older than recorded activity
|
|
29
|
+
local raw = redis.call('GET', KEYS[1])
|
|
30
|
+
|
|
31
|
+
if not raw then
|
|
32
|
+
return 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
local env = cjson.decode(raw)
|
|
36
|
+
|
|
37
|
+
if env.v ~= 2 then
|
|
38
|
+
return 4
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if env.st ~= 'active' then
|
|
42
|
+
return -1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
local now = tonumber(redis.call('TIME')[1])
|
|
46
|
+
|
|
47
|
+
if tonumber(env.exp) <= now then
|
|
48
|
+
redis.call('DEL', KEYS[1])
|
|
49
|
+
return -2
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if env.idle ~= cjson.null and tonumber(env.idle) <= now then
|
|
53
|
+
return -3
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
local interval = tonumber(ARGV[1])
|
|
57
|
+
|
|
58
|
+
if tonumber(ARGV[3]) ~= 1 and now - tonumber(env.la) < interval then
|
|
59
|
+
return 2
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Monotonic guard: a stale app request must never regress activity.
|
|
63
|
+
-- Equal-second requests are allowed (the app clock is second-granular and
|
|
64
|
+
-- cannot advance within the same second); only strictly older writes are
|
|
65
|
+
-- rejected.
|
|
66
|
+
local newLa = tonumber(ARGV[5])
|
|
67
|
+
|
|
68
|
+
if newLa < tonumber(env.la) then
|
|
69
|
+
return 5
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
local newEnv = cjson.decode(ARGV[4])
|
|
73
|
+
|
|
74
|
+
-- Keep the plaintext header mirrors in sync with the checked values.
|
|
75
|
+
newEnv.st = env.st
|
|
76
|
+
newEnv.la = newLa
|
|
77
|
+
if ARGV[6] ~= '' then
|
|
78
|
+
newEnv.idle = tonumber(ARGV[6])
|
|
79
|
+
else
|
|
80
|
+
newEnv.idle = cjson.null
|
|
81
|
+
end
|
|
82
|
+
newEnv.ver = env.ver
|
|
83
|
+
newEnv.exp = env.exp
|
|
84
|
+
newEnv.rn = env.rn
|
|
85
|
+
newEnv.rj = env.rj
|
|
86
|
+
|
|
87
|
+
redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', tonumber(ARGV[7]))
|
|
88
|
+
|
|
89
|
+
return 1
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- touch.lua (version 1) - plain (v1) envelopes
|
|
2
|
+
-- Throttled, monotonic activity refresh for plain sessions.
|
|
3
|
+
--
|
|
4
|
+
-- The script is authoritative for all state checks (exists, status,
|
|
5
|
+
-- absolute expiry, idle expiry, throttle). Timestamps come from Redis
|
|
6
|
+
-- server time, so distributed application clocks cannot race each other.
|
|
7
|
+
--
|
|
8
|
+
-- KEYS[1] = session record key
|
|
9
|
+
--
|
|
10
|
+
-- ARGV[1] = touchInterval (seconds)
|
|
11
|
+
-- ARGV[2] = idleTimeout (seconds, '' when idle timeout disabled)
|
|
12
|
+
-- ARGV[3] = force (1 = ignore throttle)
|
|
13
|
+
--
|
|
14
|
+
-- Returns:
|
|
15
|
+
-- 1 touched (write performed)
|
|
16
|
+
-- 2 skipped: inside touchInterval
|
|
17
|
+
-- 3 skipped: request older than recorded activity (not possible with
|
|
18
|
+
-- server time; retained for parity with the encrypted path)
|
|
19
|
+
-- 0 not found
|
|
20
|
+
-- -1 consumed or revoked
|
|
21
|
+
-- -2 absolute expiry passed; record deleted
|
|
22
|
+
-- -3 idle expired; NOT resurrected
|
|
23
|
+
-- 4 envelope is encrypted (caller must use the encrypted touch path)
|
|
24
|
+
local raw = redis.call('GET', KEYS[1])
|
|
25
|
+
|
|
26
|
+
if not raw then
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
local env = cjson.decode(raw)
|
|
31
|
+
|
|
32
|
+
if env.v ~= 1 then
|
|
33
|
+
return 4
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local s = env.s
|
|
37
|
+
|
|
38
|
+
if s.status ~= 'active' then
|
|
39
|
+
return -1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
local now = tonumber(redis.call('TIME')[1])
|
|
43
|
+
|
|
44
|
+
if tonumber(s.absoluteExpiresAt) <= now then
|
|
45
|
+
redis.call('DEL', KEYS[1])
|
|
46
|
+
return -2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if s.idleExpiresAt and s.idleExpiresAt ~= cjson.null and tonumber(s.idleExpiresAt) <= now then
|
|
50
|
+
return -3
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
local interval = tonumber(ARGV[1])
|
|
54
|
+
|
|
55
|
+
if tonumber(ARGV[3]) ~= 1 and now - tonumber(s.lastAccessedAt) < interval then
|
|
56
|
+
return 2
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
s.lastAccessedAt = now
|
|
60
|
+
|
|
61
|
+
if ARGV[2] ~= '' then
|
|
62
|
+
local idle = now + tonumber(ARGV[2])
|
|
63
|
+
local abs = tonumber(s.absoluteExpiresAt)
|
|
64
|
+
-- Rolling extends the idle boundary but NEVER the absolute boundary.
|
|
65
|
+
s.idleExpiresAt = math.min(idle, abs)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
local ttl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
|
|
69
|
+
|
|
70
|
+
redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
|
|
71
|
+
|
|
72
|
+
return 1
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- validate.lua (version 1)
|
|
2
|
+
-- Single-round-trip validation read: fetches the session record, the
|
|
3
|
+
-- user's security version (when the key exists) and lazily cleans up
|
|
4
|
+
-- expired records and their index members, all in one same-slot script.
|
|
5
|
+
--
|
|
6
|
+
-- The script performs the cheap, non-cryptographic state checks; binding
|
|
7
|
+
-- checks and (for encrypted sessions) decryption happen app-side on the
|
|
8
|
+
-- returned payload. A missing security-version key means the check is
|
|
9
|
+
-- skipped (the version is only enforced once setSecurityVersion has run).
|
|
10
|
+
--
|
|
11
|
+
-- KEYS[1] = session record key
|
|
12
|
+
-- KEYS[2] = user security version key (same slot)
|
|
13
|
+
-- KEYS[3] = user session index key (same slot)
|
|
14
|
+
--
|
|
15
|
+
-- ARGV[1] = jti (used for the lazy index cleanup of encrypted records,
|
|
16
|
+
-- whose headers do not carry their own jti)
|
|
17
|
+
--
|
|
18
|
+
-- Returns:
|
|
19
|
+
-- {1, raw, securityVersion?} record exists and passed script checks;
|
|
20
|
+
-- raw is the stored envelope; securityVersion
|
|
21
|
+
-- is the current user version when set
|
|
22
|
+
-- {0} not found
|
|
23
|
+
-- {-1, status} not active (consumed/revoked)
|
|
24
|
+
-- {-2} expired (absolute); record + index entry
|
|
25
|
+
-- removed
|
|
26
|
+
-- {-3} idle expired; record + index entry removed
|
|
27
|
+
-- {-4} security version mismatch (plain records)
|
|
28
|
+
local raw = redis.call('GET', KEYS[1])
|
|
29
|
+
|
|
30
|
+
if not raw then
|
|
31
|
+
return { 0 }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local currentRaw = redis.call('GET', KEYS[2])
|
|
35
|
+
local currentVersion = nil
|
|
36
|
+
|
|
37
|
+
if currentRaw then
|
|
38
|
+
currentVersion = tonumber(currentRaw)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
local env = cjson.decode(raw)
|
|
42
|
+
local now = tonumber(redis.call('TIME')[1])
|
|
43
|
+
|
|
44
|
+
if env.v == 1 then
|
|
45
|
+
local s = env.s
|
|
46
|
+
|
|
47
|
+
if s.status ~= 'active' then
|
|
48
|
+
return { -1, s.status }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if tonumber(s.absoluteExpiresAt) <= now then
|
|
52
|
+
redis.call('DEL', KEYS[1])
|
|
53
|
+
redis.call('ZREM', KEYS[3], s.jti)
|
|
54
|
+
return { -2 }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if s.idleExpiresAt and s.idleExpiresAt ~= cjson.null and tonumber(s.idleExpiresAt) <= now then
|
|
58
|
+
redis.call('DEL', KEYS[1])
|
|
59
|
+
redis.call('ZREM', KEYS[3], s.jti)
|
|
60
|
+
return { -3 }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if currentVersion and (s.securityVersion == nil or tonumber(s.securityVersion) ~= currentVersion) then
|
|
64
|
+
return { -4 }
|
|
65
|
+
end
|
|
66
|
+
elseif env.v == 2 then
|
|
67
|
+
-- Encrypted envelope: only the plaintext header mirrors are readable in
|
|
68
|
+
-- Lua. They are the script-level authority for state decisions; the
|
|
69
|
+
-- ciphertext remains authoritative for the payload (assertHeaderMatches).
|
|
70
|
+
if env.st ~= 'active' then
|
|
71
|
+
return { -1, env.st }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if tonumber(env.exp) <= now then
|
|
75
|
+
redis.call('DEL', KEYS[1])
|
|
76
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
77
|
+
return { -2 }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if env.idle ~= cjson.null and tonumber(env.idle) <= now then
|
|
81
|
+
redis.call('DEL', KEYS[1])
|
|
82
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
83
|
+
return { -3 }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
-- Security version is checked app-side for encrypted records (it lives
|
|
87
|
+
-- only inside the ciphertext).
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return { 1, raw, currentVersion }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionCircuitBreakerConfig } from './session-config.js';
|
|
2
|
+
export type CircuitBreakerState = 'closed' | 'open' | 'half_open';
|
|
3
|
+
/**
|
|
4
|
+
* Fail-closed circuit breaker around session storage operations.
|
|
5
|
+
*
|
|
6
|
+
* `run()` executes a function while the circuit is closed or a half-open
|
|
7
|
+
* probe is allowed, and throws {@link CircuitBreakerOpenError} when open.
|
|
8
|
+
* Callers may instead use {@link tryAcquire} / {@link recordSuccess} /
|
|
9
|
+
* {@link recordFailure} explicitly.
|
|
10
|
+
*/
|
|
11
|
+
export declare class SessionCircuitBreaker {
|
|
12
|
+
private readonly config;
|
|
13
|
+
private readonly circuit;
|
|
14
|
+
private readonly now;
|
|
15
|
+
private readonly onTransition;
|
|
16
|
+
constructor(config: SessionCircuitBreakerConfig, options?: {
|
|
17
|
+
now?: () => number;
|
|
18
|
+
onTransition?: (state: CircuitBreakerState) => void;
|
|
19
|
+
});
|
|
20
|
+
get state(): CircuitBreakerState;
|
|
21
|
+
/**
|
|
22
|
+
* Runs an operation under circuit protection. Records success/failure
|
|
23
|
+
* based on the promise outcome.
|
|
24
|
+
*
|
|
25
|
+
* @throws {CircuitBreakerOpenError} when the circuit is open.
|
|
26
|
+
*/
|
|
27
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
28
|
+
/**
|
|
29
|
+
* Attempts to acquire a call slot synchronously. Returns false (fail
|
|
30
|
+
* closed) when the circuit is open and not yet ready for probes.
|
|
31
|
+
*/
|
|
32
|
+
tryAcquire(): boolean;
|
|
33
|
+
/** Records a successful operation (closes a half-open circuit). */
|
|
34
|
+
recordSuccess(): void;
|
|
35
|
+
/** Records a failed operation (may open the circuit). */
|
|
36
|
+
recordFailure(): void;
|
|
37
|
+
/** Evaluates whether the open timer has elapsed, rolling to half-open. */
|
|
38
|
+
private rollHalfOpen;
|
|
39
|
+
private transitionTo;
|
|
40
|
+
/** Resets the breaker to closed (admin/repair). */
|
|
41
|
+
reset(): void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { CircuitBreakerOpenError } from './session-errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fail-closed circuit breaker around session storage operations.
|
|
4
|
+
*
|
|
5
|
+
* `run()` executes a function while the circuit is closed or a half-open
|
|
6
|
+
* probe is allowed, and throws {@link CircuitBreakerOpenError} when open.
|
|
7
|
+
* Callers may instead use {@link tryAcquire} / {@link recordSuccess} /
|
|
8
|
+
* {@link recordFailure} explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export class SessionCircuitBreaker {
|
|
11
|
+
config;
|
|
12
|
+
circuit;
|
|
13
|
+
now;
|
|
14
|
+
onTransition;
|
|
15
|
+
constructor(config, options = {}) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.now = options.now ?? (() => Date.now());
|
|
18
|
+
this.onTransition = options.onTransition ?? null;
|
|
19
|
+
this.circuit = {
|
|
20
|
+
state: 'closed',
|
|
21
|
+
consecutiveFailures: 0,
|
|
22
|
+
openedAt: 0,
|
|
23
|
+
halfOpenInFlight: 0,
|
|
24
|
+
halfOpenSucceeded: 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
get state() {
|
|
28
|
+
return this.circuit.state;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Runs an operation under circuit protection. Records success/failure
|
|
32
|
+
* based on the promise outcome.
|
|
33
|
+
*
|
|
34
|
+
* @throws {CircuitBreakerOpenError} when the circuit is open.
|
|
35
|
+
*/
|
|
36
|
+
async run(fn) {
|
|
37
|
+
if (!this.tryAcquire()) {
|
|
38
|
+
throw new CircuitBreakerOpenError();
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const result = await fn();
|
|
42
|
+
this.recordSuccess();
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
this.recordFailure();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Attempts to acquire a call slot synchronously. Returns false (fail
|
|
52
|
+
* closed) when the circuit is open and not yet ready for probes.
|
|
53
|
+
*/
|
|
54
|
+
tryAcquire() {
|
|
55
|
+
this.rollHalfOpen();
|
|
56
|
+
if (this.circuit.state === 'closed')
|
|
57
|
+
return true;
|
|
58
|
+
if (this.circuit.state === 'half_open') {
|
|
59
|
+
if (this.circuit.halfOpenInFlight >= this.config.halfOpenMaxRequests) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
this.circuit.halfOpenInFlight += 1;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Records a successful operation (closes a half-open circuit). */
|
|
68
|
+
recordSuccess() {
|
|
69
|
+
if (this.circuit.state === 'closed') {
|
|
70
|
+
this.circuit.consecutiveFailures = 0;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (this.circuit.state !== 'half_open')
|
|
74
|
+
return;
|
|
75
|
+
this.circuit.halfOpenInFlight = Math.max(0, this.circuit.halfOpenInFlight - 1);
|
|
76
|
+
this.circuit.halfOpenSucceeded += 1;
|
|
77
|
+
if (this.circuit.halfOpenInFlight === 0 && this.circuit.halfOpenSucceeded >= 1) {
|
|
78
|
+
this.transitionTo('closed');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Records a failed operation (may open the circuit). */
|
|
82
|
+
recordFailure() {
|
|
83
|
+
if (this.circuit.state === 'closed') {
|
|
84
|
+
this.circuit.consecutiveFailures += 1;
|
|
85
|
+
if (this.circuit.consecutiveFailures >= this.config.failureThreshold) {
|
|
86
|
+
this.transitionTo('open');
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.circuit.state === 'half_open') {
|
|
91
|
+
this.circuit.halfOpenInFlight = Math.max(0, this.circuit.halfOpenInFlight - 1);
|
|
92
|
+
this.transitionTo('open');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Evaluates whether the open timer has elapsed, rolling to half-open. */
|
|
96
|
+
rollHalfOpen() {
|
|
97
|
+
if (this.circuit.state === 'open' &&
|
|
98
|
+
this.now() - this.circuit.openedAt >= this.config.resetTimeoutMs) {
|
|
99
|
+
this.circuit.state = 'half_open';
|
|
100
|
+
this.circuit.halfOpenInFlight = 0;
|
|
101
|
+
this.circuit.halfOpenSucceeded = 0;
|
|
102
|
+
this.onTransition?.('half_open');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
transitionTo(state) {
|
|
106
|
+
if (this.circuit.state === state)
|
|
107
|
+
return;
|
|
108
|
+
this.circuit.state = state;
|
|
109
|
+
if (state === 'open') {
|
|
110
|
+
this.circuit.openedAt = this.now();
|
|
111
|
+
this.circuit.halfOpenInFlight = 0;
|
|
112
|
+
this.circuit.halfOpenSucceeded = 0;
|
|
113
|
+
}
|
|
114
|
+
if (state === 'half_open') {
|
|
115
|
+
this.circuit.halfOpenInFlight = 0;
|
|
116
|
+
this.circuit.halfOpenSucceeded = 0;
|
|
117
|
+
}
|
|
118
|
+
if (state === 'closed') {
|
|
119
|
+
this.circuit.consecutiveFailures = 0;
|
|
120
|
+
this.circuit.halfOpenInFlight = 0;
|
|
121
|
+
this.circuit.halfOpenSucceeded = 0;
|
|
122
|
+
}
|
|
123
|
+
this.onTransition?.(state);
|
|
124
|
+
}
|
|
125
|
+
/** Resets the breaker to closed (admin/repair). */
|
|
126
|
+
reset() {
|
|
127
|
+
this.transitionTo('closed');
|
|
128
|
+
}
|
|
129
|
+
}
|