ioredis-toolkit 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +556 -1
- package/dist/cache.d.ts +39 -36
- package/dist/cache.js +77 -64
- package/dist/client.d.ts +4 -1
- package/dist/client.js +3 -4
- package/dist/lock.d.ts +1 -16
- package/dist/lock.js +43 -0
- package/dist/session/scripts/rotate-encrypted.lua +53 -14
- package/dist/session/scripts/rotate.lua +54 -9
- package/dist/session/session-config.d.ts +20 -0
- package/dist/session/session-config.js +9 -0
- package/dist/session/session-keys.d.ts +13 -0
- package/dist/session/session-keys.js +15 -0
- package/dist/session/session-metrics.d.ts +9 -1
- package/dist/session/session-metrics.js +23 -0
- package/dist/session/session-repository.d.ts +23 -0
- package/dist/session/session-repository.js +102 -22
- package/dist/session/session-serializer.d.ts +1 -1
- package/dist/session/session-serializer.js +19 -0
- package/dist/session/session-service.d.ts +20 -1
- package/dist/session/session-service.js +60 -1
- package/dist/session/session-types.d.ts +28 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
|
@@ -5,26 +5,40 @@
|
|
|
5
5
|
-- old record, builds the consumed + successor payloads (re-encrypted with
|
|
6
6
|
-- the current key) and passes them as ARGV. The script is the only
|
|
7
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
|
|
9
|
-
-- the written payloads are overwritten from the checked values so a
|
|
10
|
-
-- app payload can never misrepresent state.
|
|
8
|
+
-- mirrors (st/ver/exp/rn/rj/fam) and Redis server time; the header mirrors
|
|
9
|
+
-- of the written payloads are overwritten from the checked values so a
|
|
10
|
+
-- stale app payload can never misrepresent state.
|
|
11
|
+
--
|
|
12
|
+
-- Token-family reuse detection: mirrors rotate.lua (see that file for the
|
|
13
|
+
-- full rationale). The plaintext `fam` header mirror lets this script make
|
|
14
|
+
-- the same family-head decision the plain path makes, even though it can
|
|
15
|
+
-- never see or alter the ciphertext itself: the app is responsible for
|
|
16
|
+
-- baking the correct familyId into both the ciphertext and the `fam`
|
|
17
|
+
-- mirror before calling this script (see SessionRepository.rotate), and
|
|
18
|
+
-- this script cross-checks/forces the mirror the same way it already
|
|
19
|
+
-- forces st/ver/la/idle/exp/rn/rj - if the app ever got it wrong, the next
|
|
20
|
+
-- read's assertHeaderMatches() catches the ciphertext/header disagreement
|
|
21
|
+
-- and fails closed rather than silently establishing the wrong lineage.
|
|
11
22
|
--
|
|
12
23
|
-- KEYS[1] = old session record key
|
|
13
24
|
-- KEYS[2] = successor session record key
|
|
14
25
|
-- KEYS[3] = user session index key
|
|
15
26
|
--
|
|
16
|
-
-- ARGV[1]
|
|
17
|
-
-- ARGV[2]
|
|
18
|
-
-- ARGV[3]
|
|
19
|
-
-- ARGV[4]
|
|
20
|
-
-- ARGV[5]
|
|
21
|
-
-- ARGV[6]
|
|
22
|
-
-- ARGV[7]
|
|
23
|
-
-- ARGV[8]
|
|
24
|
-
-- ARGV[9]
|
|
27
|
+
-- ARGV[1] = consumed serialized envelope (app-built, re-encrypted)
|
|
28
|
+
-- ARGV[2] = successor serialized envelope (app-built, re-encrypted)
|
|
29
|
+
-- ARGV[3] = successor jti
|
|
30
|
+
-- ARGV[4] = expected version of the old session ('' = no check)
|
|
31
|
+
-- ARGV[5] = rotation nonce hash ('' = none)
|
|
32
|
+
-- ARGV[6] = retain consumed tombstone (1/0)
|
|
33
|
+
-- ARGV[7] = old jti
|
|
34
|
+
-- ARGV[8] = successor TTL (clamped >= 1)
|
|
35
|
+
-- ARGV[9] = consumed tombstone TTL (clamped >= 1)
|
|
36
|
+
-- ARGV[10] = session key prefix for this user (same hash tag => same slot)
|
|
37
|
+
-- ARGV[11] = family-head key prefix for this user (same hash tag)
|
|
38
|
+
-- ARGV[12] = revoke the family head on genuine replay (1/0)
|
|
25
39
|
--
|
|
26
|
-
-- Returns: same codes as rotate.lua (1/2/0/-1/-2/-3/-4/5),
|
|
27
|
-
-- envelope is plain (caller must use the plain path).
|
|
40
|
+
-- Returns: same codes as rotate.lua (1/2/0/-1/-2/-3/-4/5/{-6,familyId,headJti}),
|
|
41
|
+
-- plus 6 when the envelope is plain (caller must use the plain path).
|
|
28
42
|
local raw = redis.call('GET', KEYS[1])
|
|
29
43
|
|
|
30
44
|
if not raw then
|
|
@@ -40,6 +54,15 @@ end
|
|
|
40
54
|
local t = redis.call('TIME')
|
|
41
55
|
local now = tonumber(t[1])
|
|
42
56
|
|
|
57
|
+
-- Immutable identity field: unchanged across every rotation of this
|
|
58
|
+
-- lineage. Legacy pre-migration records lacking it adopt their own jti as
|
|
59
|
+
-- the familyId from this point forward (self-healing) - see rotate.lua.
|
|
60
|
+
local familyId = env.fam
|
|
61
|
+
if familyId == nil or familyId == cjson.null then
|
|
62
|
+
familyId = ARGV[7]
|
|
63
|
+
end
|
|
64
|
+
local familyHeadKey = ARGV[11] .. familyId
|
|
65
|
+
|
|
43
66
|
-- Retry-safe replay detection BEFORE rejecting consumed records.
|
|
44
67
|
-- The rotation nonce uniquely identifies the rotation: when the consumed
|
|
45
68
|
-- record carries the same nonce hash, this is a retry of an already-applied
|
|
@@ -49,6 +72,18 @@ if env.st == 'consumed' then
|
|
|
49
72
|
if ARGV[5] ~= '' and env.rn == ARGV[5] then
|
|
50
73
|
return { 2, env.rj }
|
|
51
74
|
end
|
|
75
|
+
|
|
76
|
+
-- Genuine reuse of an already-rotated-away token.
|
|
77
|
+
if ARGV[12] == '1' then
|
|
78
|
+
local headJti = redis.call('GET', familyHeadKey)
|
|
79
|
+
if headJti then
|
|
80
|
+
redis.call('DEL', ARGV[10] .. headJti)
|
|
81
|
+
redis.call('ZREM', KEYS[3], headJti)
|
|
82
|
+
end
|
|
83
|
+
redis.call('DEL', familyHeadKey)
|
|
84
|
+
return { -6, familyId, headJti or '' }
|
|
85
|
+
end
|
|
86
|
+
|
|
52
87
|
return -1
|
|
53
88
|
end
|
|
54
89
|
|
|
@@ -78,6 +113,7 @@ consumedEnv.idle = env.idle
|
|
|
78
113
|
consumedEnv.exp = env.exp
|
|
79
114
|
consumedEnv.rn = cjson.null
|
|
80
115
|
consumedEnv.rj = ARGV[3]
|
|
116
|
+
consumedEnv.fam = familyId
|
|
81
117
|
|
|
82
118
|
if ARGV[5] ~= '' then
|
|
83
119
|
consumedEnv.rn = ARGV[5]
|
|
@@ -92,6 +128,7 @@ if nextEnv.idle ~= cjson.null then
|
|
|
92
128
|
end
|
|
93
129
|
nextEnv.rn = cjson.null
|
|
94
130
|
nextEnv.rj = cjson.null
|
|
131
|
+
nextEnv.fam = familyId
|
|
95
132
|
|
|
96
133
|
local nextTtl = math.max(1, tonumber(ARGV[8]))
|
|
97
134
|
|
|
@@ -106,5 +143,7 @@ redis.call('ZREM', KEYS[3], ARGV[7])
|
|
|
106
143
|
-- Microsecond-resolution ordering score - see create.lua for why
|
|
107
144
|
-- second-granularity scores break oldest-first eviction ordering.
|
|
108
145
|
redis.call('ZADD', KEYS[3], tonumber(t[1]) + (tonumber(t[2]) / 1000000), ARGV[3])
|
|
146
|
+
-- Advance the family head to the new generation.
|
|
147
|
+
redis.call('SET', familyHeadKey, ARGV[3], 'EX', nextTtl)
|
|
109
148
|
|
|
110
149
|
return { 1, ARGV[3] }
|
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
-- and rotatedTo. If both match the retry, the script returns {2, jti} and
|
|
12
12
|
-- the caller returns the existing successor instead of a replay error.
|
|
13
13
|
--
|
|
14
|
+
-- Token-family reuse detection: every session carries a stable familyId
|
|
15
|
+
-- (the first generation's own jti), unchanged across rotations. A
|
|
16
|
+
-- {userId}-scoped "family-head" pointer key tracks the jti of the family's
|
|
17
|
+
-- current active generation, updated atomically on every successful
|
|
18
|
+
-- rotation. When a CONSUMED predecessor is replayed for real (not a
|
|
19
|
+
-- same-nonce retry), that is a strong signal the old token was stolen: if
|
|
20
|
+
-- ARGV[9] enables it, the script atomically revokes whatever the family
|
|
21
|
+
-- head currently points to (the live session an attacker could otherwise
|
|
22
|
+
-- keep using) and clears the head pointer, so the entire lineage dies
|
|
23
|
+
-- rather than just rejecting the one replayed request. The head pointer is
|
|
24
|
+
-- purely a defensive correlation key: per I7 it is never consulted by
|
|
25
|
+
-- validate() and can never itself grant authentication.
|
|
26
|
+
--
|
|
14
27
|
-- KEYS[1] = old session record key
|
|
15
28
|
-- KEYS[2] = successor session record key
|
|
16
29
|
-- KEYS[3] = user session index key
|
|
@@ -21,16 +34,24 @@
|
|
|
21
34
|
-- ARGV[4] = rotation nonce hash ('' = none)
|
|
22
35
|
-- ARGV[5] = retain consumed tombstone (1/0)
|
|
23
36
|
-- ARGV[6] = old jti
|
|
37
|
+
-- ARGV[7] = session key prefix for this user (same hash tag => same slot)
|
|
38
|
+
-- ARGV[8] = family-head key prefix for this user (same hash tag)
|
|
39
|
+
-- ARGV[9] = revoke the family head on genuine replay (1/0)
|
|
24
40
|
--
|
|
25
41
|
-- Returns:
|
|
26
|
-
-- {1, successorJti}
|
|
27
|
-
-- {2, successorJti}
|
|
28
|
-
-- 0
|
|
29
|
-
-- -1
|
|
30
|
-
--
|
|
31
|
-
--
|
|
32
|
-
-- -
|
|
33
|
-
--
|
|
42
|
+
-- {1, successorJti} success
|
|
43
|
+
-- {2, successorJti} idempotent replay of a rotation with the same nonce
|
|
44
|
+
-- 0 old session not found
|
|
45
|
+
-- -1 already consumed / revoked (replay; family not
|
|
46
|
+
-- touched - either ARGV[9] is off, or the status
|
|
47
|
+
-- was already 'revoked' rather than 'consumed')
|
|
48
|
+
-- {-6, familyId, headJti} genuine replay of a consumed predecessor: the
|
|
49
|
+
-- family head (headJti, or "" if none was set)
|
|
50
|
+
-- was revoked and the family head pointer cleared
|
|
51
|
+
-- -2 old session expired (record removed)
|
|
52
|
+
-- -3 version conflict
|
|
53
|
+
-- -4 successor jti collision
|
|
54
|
+
-- 5 envelope is encrypted (use the encrypted path)
|
|
34
55
|
local raw = redis.call('GET', KEYS[1])
|
|
35
56
|
|
|
36
57
|
if not raw then
|
|
@@ -47,6 +68,15 @@ local s = env.s
|
|
|
47
68
|
local t = redis.call('TIME')
|
|
48
69
|
local now = tonumber(t[1])
|
|
49
70
|
|
|
71
|
+
-- Immutable identity field: unchanged across every rotation of this
|
|
72
|
+
-- lineage. Legacy pre-migration records lacking it adopt their own jti as
|
|
73
|
+
-- the familyId from this point forward (self-healing).
|
|
74
|
+
local familyId = s.familyId
|
|
75
|
+
if familyId == nil or familyId == cjson.null then
|
|
76
|
+
familyId = s.jti
|
|
77
|
+
end
|
|
78
|
+
local familyHeadKey = ARGV[8] .. familyId
|
|
79
|
+
|
|
50
80
|
-- Retry-safe replay detection BEFORE rejecting consumed records.
|
|
51
81
|
-- The rotation nonce uniquely identifies the rotation: when the consumed
|
|
52
82
|
-- record carries the same nonce hash, this is a retry of an already-applied
|
|
@@ -56,6 +86,18 @@ if s.status == 'consumed' then
|
|
|
56
86
|
if ARGV[4] ~= '' and s.rotationNonceHash == ARGV[4] then
|
|
57
87
|
return { 2, s.rotatedTo }
|
|
58
88
|
end
|
|
89
|
+
|
|
90
|
+
-- Genuine reuse of an already-rotated-away token.
|
|
91
|
+
if ARGV[9] == '1' then
|
|
92
|
+
local headJti = redis.call('GET', familyHeadKey)
|
|
93
|
+
if headJti then
|
|
94
|
+
redis.call('DEL', ARGV[7] .. headJti)
|
|
95
|
+
redis.call('ZREM', KEYS[3], headJti)
|
|
96
|
+
end
|
|
97
|
+
redis.call('DEL', familyHeadKey)
|
|
98
|
+
return { -6, familyId, headJti or '' }
|
|
99
|
+
end
|
|
100
|
+
|
|
59
101
|
return -1
|
|
60
102
|
end
|
|
61
103
|
|
|
@@ -85,6 +127,7 @@ local ns = nextEnv.s
|
|
|
85
127
|
ns.createdAt = now
|
|
86
128
|
ns.lastAccessedAt = now
|
|
87
129
|
ns.rotatedFrom = s.jti
|
|
130
|
+
ns.familyId = familyId
|
|
88
131
|
ns.version = 1
|
|
89
132
|
|
|
90
133
|
if ns.idleExpiresAt and ns.idleExpiresAt ~= cjson.null then
|
|
@@ -118,5 +161,7 @@ redis.call('ZREM', KEYS[3], ARGV[6])
|
|
|
118
161
|
-- Microsecond-resolution ordering score - see create.lua for why
|
|
119
162
|
-- second-granularity scores break oldest-first eviction ordering.
|
|
120
163
|
redis.call('ZADD', KEYS[3], tonumber(t[1]) + (tonumber(t[2]) / 1000000), ARGV[2])
|
|
164
|
+
-- Advance the family head to the new generation.
|
|
165
|
+
redis.call('SET', familyHeadKey, ARGV[2], 'EX', nextTtl)
|
|
121
166
|
|
|
122
|
-
return { 1, ARGV[2] }
|
|
167
|
+
return { 1, ARGV[2] }
|
|
@@ -246,6 +246,25 @@ export interface SessionConfig {
|
|
|
246
246
|
* bounded by the remaining absolute lifetime. Default: true.
|
|
247
247
|
*/
|
|
248
248
|
retainConsumedTombstones: boolean;
|
|
249
|
+
/**
|
|
250
|
+
* On genuine reuse of an already-rotated-away (consumed) session token -
|
|
251
|
+
* not a same-nonce idempotent retry of an in-flight rotation - atomically
|
|
252
|
+
* revoke the entire rotation lineage's current active generation instead
|
|
253
|
+
* of only rejecting the replayed request. This is the strongest response
|
|
254
|
+
* to stolen refresh-token reuse (specification ยง5): if an attacker
|
|
255
|
+
* captured an old token and the legitimate client has since rotated past
|
|
256
|
+
* it, this kills the session the attacker could otherwise keep riding,
|
|
257
|
+
* rather than leaving it live while only the stale replay is rejected.
|
|
258
|
+
*
|
|
259
|
+
* Has no effect unless retainConsumedTombstones is also true: a replay
|
|
260
|
+
* can only be detected while a consumed tombstone still exists to be
|
|
261
|
+
* replayed against. Default: false (opt-in - a false positive here is a
|
|
262
|
+
* full lineage kill, so it should be turned on deliberately once the
|
|
263
|
+
* caller's retry semantics are understood; the resulting
|
|
264
|
+
* SessionReplayError with reason 'family_revoked' should be handled as
|
|
265
|
+
* a security event, e.g. forcing full re-authentication and logging).
|
|
266
|
+
*/
|
|
267
|
+
revokeFamilyOnReplay: boolean;
|
|
249
268
|
}
|
|
250
269
|
export declare const SessionConfigSchema: z.ZodObject<{
|
|
251
270
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -312,6 +331,7 @@ export declare const SessionConfigSchema: z.ZodObject<{
|
|
|
312
331
|
}, z.core.$strip>>;
|
|
313
332
|
enableCreateIdempotency: z.ZodDefault<z.ZodBoolean>;
|
|
314
333
|
retainConsumedTombstones: z.ZodDefault<z.ZodBoolean>;
|
|
334
|
+
revokeFamilyOnReplay: z.ZodDefault<z.ZodBoolean>;
|
|
315
335
|
}, z.core.$strip>;
|
|
316
336
|
/** Recursively makes every config field optional (matches the schema's input shape). */
|
|
317
337
|
export type DeepPartial<T> = {
|
|
@@ -101,6 +101,7 @@ export const SessionConfigSchema = z
|
|
|
101
101
|
limits: SessionLimitsConfigSchema.prefault({}),
|
|
102
102
|
enableCreateIdempotency: z.boolean().default(false),
|
|
103
103
|
retainConsumedTombstones: z.boolean().default(true),
|
|
104
|
+
revokeFamilyOnReplay: z.boolean().default(false),
|
|
104
105
|
})
|
|
105
106
|
.superRefine((data, ctx) => {
|
|
106
107
|
if (data.idleTimeout !== null && data.idleTimeout > data.ttl) {
|
|
@@ -117,6 +118,14 @@ export const SessionConfigSchema = z
|
|
|
117
118
|
path: ['touchInterval'],
|
|
118
119
|
});
|
|
119
120
|
}
|
|
121
|
+
if (data.revokeFamilyOnReplay && !data.retainConsumedTombstones) {
|
|
122
|
+
ctx.addIssue({
|
|
123
|
+
code: z.ZodIssueCode.custom,
|
|
124
|
+
message: 'revokeFamilyOnReplay requires retainConsumedTombstones: replay can only be ' +
|
|
125
|
+
'detected while a consumed tombstone still exists to be replayed against.',
|
|
126
|
+
path: ['revokeFamilyOnReplay'],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
120
129
|
});
|
|
121
130
|
/**
|
|
122
131
|
* Parses and validates session configuration.
|
|
@@ -46,6 +46,19 @@ export declare class SessionKeyStrategy {
|
|
|
46
46
|
* from jtis. The `{userId}` hash tag guarantees same-slot construction.
|
|
47
47
|
*/
|
|
48
48
|
sessionKeyPrefix(userId: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Key prefix for a user's rotation-family-head pointers, used by
|
|
51
|
+
* rotate.lua to construct `{prefix}{familyId}` at runtime (the familyId
|
|
52
|
+
* is only known once the script reads the old session record, so it
|
|
53
|
+
* cannot be precomputed as a literal key - see rotate.lua). Same
|
|
54
|
+
* `{userId}` hash tag as every other per-user key, so this is always
|
|
55
|
+
* same-slot with the session it may need to revoke.
|
|
56
|
+
*
|
|
57
|
+
* The family-head value is a correlation pointer used only to detect and
|
|
58
|
+
* respond to stolen-refresh-token reuse; per I7 it is never consulted by
|
|
59
|
+
* validate() and can never by itself grant authentication.
|
|
60
|
+
*/
|
|
61
|
+
familyHeadKeyPrefix(userId: string): string;
|
|
49
62
|
/** Index key prefix for namespace-scoped administration. */
|
|
50
63
|
namespacePrefix(): string;
|
|
51
64
|
}
|
|
@@ -106,6 +106,21 @@ export class SessionKeyStrategy {
|
|
|
106
106
|
sessionKeyPrefix(userId) {
|
|
107
107
|
return this.ns(`session:{${encodeUserId(userId)}}:session:`);
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Key prefix for a user's rotation-family-head pointers, used by
|
|
111
|
+
* rotate.lua to construct `{prefix}{familyId}` at runtime (the familyId
|
|
112
|
+
* is only known once the script reads the old session record, so it
|
|
113
|
+
* cannot be precomputed as a literal key - see rotate.lua). Same
|
|
114
|
+
* `{userId}` hash tag as every other per-user key, so this is always
|
|
115
|
+
* same-slot with the session it may need to revoke.
|
|
116
|
+
*
|
|
117
|
+
* The family-head value is a correlation pointer used only to detect and
|
|
118
|
+
* respond to stolen-refresh-token reuse; per I7 it is never consulted by
|
|
119
|
+
* validate() and can never by itself grant authentication.
|
|
120
|
+
*/
|
|
121
|
+
familyHeadKeyPrefix(userId) {
|
|
122
|
+
return this.ns(`family-head:{${encodeUserId(userId)}}:`);
|
|
123
|
+
}
|
|
109
124
|
/** Index key prefix for namespace-scoped administration. */
|
|
110
125
|
namespacePrefix() {
|
|
111
126
|
return `${this.namespace}:`;
|
|
@@ -7,7 +7,7 @@ export interface SessionMetricsAdapter {
|
|
|
7
7
|
/** Sets a gauge to a value. */
|
|
8
8
|
setGauge(name: string, value: number, attributes?: Record<string, string | number>): void;
|
|
9
9
|
}
|
|
10
|
-
export type SessionOperation = 'create' | 'validate' | 'touch' | 'rotate' | 'update' | 'destroy' | 'revoke' | 'revoke_all' | 'delete_by_user' | 'list' | 'find_by_user' | 'set_security_version' | 'health';
|
|
10
|
+
export type SessionOperation = 'create' | 'validate' | 'touch' | 'rotate' | 'update' | 'destroy' | 'revoke' | 'revoke_all' | 'delete_by_user' | 'list' | 'find_by_user' | 'set_security_version' | 'reconcile_user' | 'health';
|
|
11
11
|
/**
|
|
12
12
|
* Internal session metrics facade. Safe no-op without an adapter.
|
|
13
13
|
*/
|
|
@@ -29,5 +29,13 @@ export declare class SessionMetrics {
|
|
|
29
29
|
encryptionError(reason: string): void;
|
|
30
30
|
/** Records a failed best-effort jti index write (derived-state degradation). */
|
|
31
31
|
jtiIndexWriteFailure(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Records the outcome of one reconcileUser() repair pass: how many
|
|
34
|
+
* missing/stale jti-index entries it found and repaired or removed.
|
|
35
|
+
* A non-zero count indicates upstream partial-write drift (ยง67), not an
|
|
36
|
+
* authentication problem - the session record was authoritative and
|
|
37
|
+
* correct throughout.
|
|
38
|
+
*/
|
|
39
|
+
reconcileUser(repaired: number, removed: number): void;
|
|
32
40
|
}
|
|
33
41
|
export declare const SESSION_OPERATIONS: readonly SessionOperation[];
|
|
@@ -20,6 +20,7 @@ const OPERATIONS = [
|
|
|
20
20
|
'list',
|
|
21
21
|
'find_by_user',
|
|
22
22
|
'set_security_version',
|
|
23
|
+
'reconcile_user',
|
|
23
24
|
'health',
|
|
24
25
|
];
|
|
25
26
|
/**
|
|
@@ -108,5 +109,27 @@ export class SessionMetrics {
|
|
|
108
109
|
// Metrics must never break authentication.
|
|
109
110
|
}
|
|
110
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Records the outcome of one reconcileUser() repair pass: how many
|
|
114
|
+
* missing/stale jti-index entries it found and repaired or removed.
|
|
115
|
+
* A non-zero count indicates upstream partial-write drift (ยง67), not an
|
|
116
|
+
* authentication problem - the session record was authoritative and
|
|
117
|
+
* correct throughout.
|
|
118
|
+
*/
|
|
119
|
+
reconcileUser(repaired, removed) {
|
|
120
|
+
if (!this.adapter)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
if (repaired > 0) {
|
|
124
|
+
this.adapter.incCounter('session.jti_index.reconciled_repaired', repaired, this.topology !== null ? { topology: this.topology } : undefined);
|
|
125
|
+
}
|
|
126
|
+
if (removed > 0) {
|
|
127
|
+
this.adapter.incCounter('session.jti_index.reconciled_removed', removed, this.topology !== null ? { topology: this.topology } : undefined);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Metrics must never break authentication.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
111
134
|
}
|
|
112
135
|
export const SESSION_OPERATIONS = OPERATIONS;
|
|
@@ -87,10 +87,13 @@ export declare class SessionRepository {
|
|
|
87
87
|
expectedVersion?: number;
|
|
88
88
|
rotationNonceHash?: string;
|
|
89
89
|
retainTombstone: boolean;
|
|
90
|
+
revokeFamilyOnReplay: boolean;
|
|
90
91
|
}): Promise<{
|
|
91
92
|
code: number;
|
|
92
93
|
successorJti?: string;
|
|
93
94
|
status?: string;
|
|
95
|
+
familyId?: string;
|
|
96
|
+
headJtiRevoked?: string;
|
|
94
97
|
}>;
|
|
95
98
|
/**
|
|
96
99
|
* Optimistic-concurrency patch update. Returns the updated record, or
|
|
@@ -112,6 +115,26 @@ export declare class SessionRepository {
|
|
|
112
115
|
limit?: number;
|
|
113
116
|
offset?: number;
|
|
114
117
|
}): Promise<SessionRecord[]>;
|
|
118
|
+
/**
|
|
119
|
+
* Bounded per-user repair pass (ยง25/ยง67/ยง68): removes stale user-index
|
|
120
|
+
* entries (same mechanism as {@link listByUser}) and, when the global jti
|
|
121
|
+
* index is enabled, repairs any live active session whose jti-index entry
|
|
122
|
+
* is missing or stale (partial-write drift after create/rotate - the
|
|
123
|
+
* session record itself was authoritative and correct the whole time,
|
|
124
|
+
* only JTI-only lookup was degraded). Never touches consumed/revoked/
|
|
125
|
+
* expired sessions: repairing their index entry would make them
|
|
126
|
+
* JTI-lookupable again, which is unnecessary and works against prompt
|
|
127
|
+
* self-expiry.
|
|
128
|
+
*
|
|
129
|
+
* Bounded by `limit` (capped like every other list/admin operation);
|
|
130
|
+
* never scans the whole cluster and is safe to call from an
|
|
131
|
+
* administrative endpoint, not a hot auth path.
|
|
132
|
+
*/
|
|
133
|
+
reconcileUser(userId: string, limit: number, now: number): Promise<{
|
|
134
|
+
checked: number;
|
|
135
|
+
staleIndexRemoved: number;
|
|
136
|
+
jtiIndexRepaired: number;
|
|
137
|
+
}>;
|
|
115
138
|
/**
|
|
116
139
|
* Deletes all of a user's sessions in bounded same-slot batches.
|
|
117
140
|
* Returns the jtis whose records were deleted.
|
|
@@ -233,16 +233,26 @@ export class SessionRepository {
|
|
|
233
233
|
const expected = options.expectedVersion !== undefined ? String(options.expectedVersion) : '';
|
|
234
234
|
const nonce = options.rotationNonceHash ?? '';
|
|
235
235
|
const retain = options.retainTombstone ? '1' : '0';
|
|
236
|
+
const sessionPrefix = this.keys.sessionKeyPrefix(userId);
|
|
237
|
+
const familyHeadPrefix = this.keys.familyHeadKeyPrefix(userId);
|
|
238
|
+
const revokeFamily = options.revokeFamilyOnReplay ? '1' : '0';
|
|
236
239
|
if (!this.encrypted) {
|
|
237
240
|
const serialized = serializeSession(successor);
|
|
238
|
-
const result = await this.scripts.eval('rotate', 3, oldKey, newKey, indexKey, serialized, successor.jti, expected, nonce, retain, oldJti);
|
|
241
|
+
const result = await this.scripts.eval('rotate', 3, oldKey, newKey, indexKey, serialized, successor.jti, expected, nonce, retain, oldJti, sessionPrefix, familyHeadPrefix, revokeFamily);
|
|
239
242
|
const outcome = parseRotateResult(result, successor.jti);
|
|
240
243
|
if (outcome.code === 0) {
|
|
241
244
|
await this.cleanupJtiIndex(oldJti);
|
|
242
245
|
}
|
|
243
246
|
return outcome;
|
|
244
247
|
}
|
|
245
|
-
// Encrypted path.
|
|
248
|
+
// Encrypted path. The GET below is informational only (ยง18/ยง78): it
|
|
249
|
+
// lets us build well-formed re-encrypted payloads for the optimistic
|
|
250
|
+
// success path (ciphertext can't be built inside Lua), but every state
|
|
251
|
+
// decision - already consumed/revoked, expired, version conflict, and
|
|
252
|
+
// family-head reuse - is made authoritatively by the script from its
|
|
253
|
+
// own atomic read, never from this snapshot. A concurrent writer may
|
|
254
|
+
// have already invalidated it by the time the script actually runs;
|
|
255
|
+
// the script is the only party allowed to decide or write.
|
|
246
256
|
const raw = await this.client.get(oldKey);
|
|
247
257
|
if (raw === null) {
|
|
248
258
|
await this.cleanupJtiIndex(oldJti);
|
|
@@ -258,23 +268,7 @@ export class SessionRepository {
|
|
|
258
268
|
}
|
|
259
269
|
throw error;
|
|
260
270
|
}
|
|
261
|
-
if (current.status !== 'active') {
|
|
262
|
-
// Retry-safe replay: the stored rotatedTo jti is authoritative; the
|
|
263
|
-
// retry's freshly generated successor jti must never be compared
|
|
264
|
-
// against it (it is discarded, exactly like the plain path).
|
|
265
|
-
if (nonce !== '' && current.rotationNonceHash === nonce && current.rotatedTo !== null) {
|
|
266
|
-
return { code: 2, successorJti: current.rotatedTo };
|
|
267
|
-
}
|
|
268
|
-
return { code: -1, status: current.status };
|
|
269
|
-
}
|
|
270
271
|
const now = Math.floor(Date.now() / 1000);
|
|
271
|
-
if (current.absoluteExpiresAt <= now) {
|
|
272
|
-
await this.client.del(oldKey);
|
|
273
|
-
return { code: -2 };
|
|
274
|
-
}
|
|
275
|
-
if (options.expectedVersion !== undefined && current.version !== options.expectedVersion) {
|
|
276
|
-
return { code: -3 };
|
|
277
|
-
}
|
|
278
272
|
const consumed = {
|
|
279
273
|
...current,
|
|
280
274
|
status: 'consumed',
|
|
@@ -282,12 +276,25 @@ export class SessionRepository {
|
|
|
282
276
|
rotatedTo: successor.jti,
|
|
283
277
|
rotationNonceHash: nonce !== '' ? nonce : null,
|
|
284
278
|
};
|
|
279
|
+
// familyId is an identity field: self-heal from the (informational)
|
|
280
|
+
// old record, exactly like rotate.lua does authoritatively for the
|
|
281
|
+
// plain path. The Lua script can never rewrite ciphertext, so this is
|
|
282
|
+
// the only place the encrypted successor's real familyId can be set;
|
|
283
|
+
// the script still cross-checks the plaintext `fam` mirror it derives
|
|
284
|
+
// against this on every future read (assertHeaderMatches), so a bug
|
|
285
|
+
// here fails closed instead of silently mislabeling the lineage.
|
|
286
|
+
const familyId = current.familyId || current.jti;
|
|
287
|
+
const successorWithFamily = { ...successor, familyId };
|
|
285
288
|
const consumedSerialized = serializeEncryptedSession(consumed, this.keyProvider);
|
|
286
|
-
const successorSerialized = serializeEncryptedSession(
|
|
287
|
-
const successorTtl = Math.max(1,
|
|
289
|
+
const successorSerialized = serializeEncryptedSession(successorWithFamily, this.keyProvider);
|
|
290
|
+
const successorTtl = Math.max(1, successorWithFamily.absoluteExpiresAt - now);
|
|
288
291
|
const consumedTtl = Math.max(1, consumed.absoluteExpiresAt - now);
|
|
289
|
-
const result = await this.scripts.eval('rotateEncrypted', 3, oldKey, newKey, indexKey, consumedSerialized, successorSerialized,
|
|
290
|
-
|
|
292
|
+
const result = await this.scripts.eval('rotateEncrypted', 3, oldKey, newKey, indexKey, consumedSerialized, successorSerialized, successorWithFamily.jti, expected, nonce, retain, oldJti, String(successorTtl), String(consumedTtl), sessionPrefix, familyHeadPrefix, revokeFamily);
|
|
293
|
+
const outcome = parseRotateResult(result, successorWithFamily.jti);
|
|
294
|
+
if (outcome.code === 0) {
|
|
295
|
+
await this.cleanupJtiIndex(oldJti);
|
|
296
|
+
}
|
|
297
|
+
return outcome;
|
|
291
298
|
}
|
|
292
299
|
/* ------------------------------------------------------------------------ */
|
|
293
300
|
/* Update */
|
|
@@ -450,6 +457,65 @@ export class SessionRepository {
|
|
|
450
457
|
}
|
|
451
458
|
return sessions;
|
|
452
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Bounded per-user repair pass (ยง25/ยง67/ยง68): removes stale user-index
|
|
462
|
+
* entries (same mechanism as {@link listByUser}) and, when the global jti
|
|
463
|
+
* index is enabled, repairs any live active session whose jti-index entry
|
|
464
|
+
* is missing or stale (partial-write drift after create/rotate - the
|
|
465
|
+
* session record itself was authoritative and correct the whole time,
|
|
466
|
+
* only JTI-only lookup was degraded). Never touches consumed/revoked/
|
|
467
|
+
* expired sessions: repairing their index entry would make them
|
|
468
|
+
* JTI-lookupable again, which is unnecessary and works against prompt
|
|
469
|
+
* self-expiry.
|
|
470
|
+
*
|
|
471
|
+
* Bounded by `limit` (capped like every other list/admin operation);
|
|
472
|
+
* never scans the whole cluster and is safe to call from an
|
|
473
|
+
* administrative endpoint, not a hot auth path.
|
|
474
|
+
*/
|
|
475
|
+
async reconcileUser(userId, limit, now) {
|
|
476
|
+
const jtis = await this.listJtis(userId, limit);
|
|
477
|
+
if (jtis.length === 0)
|
|
478
|
+
return { checked: 0, staleIndexRemoved: 0, jtiIndexRepaired: 0 };
|
|
479
|
+
const sessionKeys = jtis.map((jti) => this.keys.sessionKey(userId, jti));
|
|
480
|
+
const values = await this.client.mget(...sessionKeys);
|
|
481
|
+
const sessions = [];
|
|
482
|
+
const stale = [];
|
|
483
|
+
for (let i = 0; i < jtis.length; i++) {
|
|
484
|
+
const raw = values[i];
|
|
485
|
+
if (raw === null || raw === undefined) {
|
|
486
|
+
stale.push(jtis[i]);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
sessions.push(deserializeSession(raw, this.keyProvider ?? undefined));
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (error instanceof SessionSerializationError) {
|
|
494
|
+
stale.push(jtis[i]);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (stale.length > 0) {
|
|
501
|
+
await this.cleanupIndexEntries(userId, stale);
|
|
502
|
+
}
|
|
503
|
+
let jtiIndexRepaired = 0;
|
|
504
|
+
if (this.jtiIndexEnabled) {
|
|
505
|
+
for (const session of sessions) {
|
|
506
|
+
if (session.status !== 'active')
|
|
507
|
+
continue;
|
|
508
|
+
const ttl = Math.max(1, session.absoluteExpiresAt - now);
|
|
509
|
+
const current = await this.readJtiIndex(session.jti);
|
|
510
|
+
if (current !== userId) {
|
|
511
|
+
const ok = await this.writeJtiIndex(session.jti, userId, ttl);
|
|
512
|
+
if (ok)
|
|
513
|
+
jtiIndexRepaired += 1;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return { checked: sessions.length, staleIndexRemoved: stale.length, jtiIndexRepaired };
|
|
518
|
+
}
|
|
453
519
|
/**
|
|
454
520
|
* Deletes all of a user's sessions in bounded same-slot batches.
|
|
455
521
|
* Returns the jtis whose records were deleted.
|
|
@@ -660,6 +726,20 @@ function parseRotateResult(result, successorJti) {
|
|
|
660
726
|
const jti = result[1] !== undefined ? String(result[1]) : successorJti;
|
|
661
727
|
return { code, successorJti: jti };
|
|
662
728
|
}
|
|
729
|
+
if (code === -6) {
|
|
730
|
+
// { -6, familyId, headJti } - genuine consumed-token replay: the
|
|
731
|
+
// family head (if any) was revoked and the pointer cleared. headJti
|
|
732
|
+
// is '' when no head was set (nothing to revoke, but replay itself
|
|
733
|
+
// is still reported).
|
|
734
|
+
const familyId = result[1] !== undefined ? String(result[1]) : undefined;
|
|
735
|
+
const headJtiRaw = result[2] !== undefined ? String(result[2]) : '';
|
|
736
|
+
const out = { code };
|
|
737
|
+
if (familyId !== undefined)
|
|
738
|
+
out.familyId = familyId;
|
|
739
|
+
if (headJtiRaw !== '')
|
|
740
|
+
out.headJtiRevoked = headJtiRaw;
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
663
743
|
const status = result[1] !== undefined ? String(result[1]) : undefined;
|
|
664
744
|
return status !== undefined ? { code, status } : { code };
|
|
665
745
|
}
|
|
@@ -31,7 +31,7 @@ export declare function deserializeSession(raw: string, keyProvider?: SessionKey
|
|
|
31
31
|
* Builds the plaintext header mirrors for an encrypted envelope from a
|
|
32
32
|
* record. Used by the repository when re-encrypting on touch/rotate/update.
|
|
33
33
|
*/
|
|
34
|
-
export declare function encryptedHeaderOf(record: SessionRecord): Pick<EncryptedSessionEnvelope, 'st' | 'ver' | 'la' | 'idle' | 'exp' | 'rn' | 'rj'>;
|
|
34
|
+
export declare function encryptedHeaderOf(record: SessionRecord): Pick<EncryptedSessionEnvelope, 'st' | 'ver' | 'la' | 'idle' | 'exp' | 'rn' | 'rj' | 'fam'>;
|
|
35
35
|
/**
|
|
36
36
|
* Verifies that a decrypted v2 record agrees with the envelope's plaintext
|
|
37
37
|
* header mirrors. The ciphertext is authoritative; a disagreement means the
|
|
@@ -99,6 +99,20 @@ export function validateSessionRecord(value) {
|
|
|
99
99
|
if (!isNullish(r.rotatedFrom) && !isString(r.rotatedFrom)) {
|
|
100
100
|
throw new SessionSerializationError({ reason: 'invalid_rotated_from' });
|
|
101
101
|
}
|
|
102
|
+
// familyId is an identity field, immutable across a lineage's rotations.
|
|
103
|
+
// Legacy records written before this field existed lack it: they adopt
|
|
104
|
+
// their own jti as the familyId (self-healing - see rotate.lua, which
|
|
105
|
+
// does the same fallback for records it reads directly).
|
|
106
|
+
let familyId;
|
|
107
|
+
if (isNullish(r.familyId)) {
|
|
108
|
+
familyId = jti;
|
|
109
|
+
}
|
|
110
|
+
else if (isString(r.familyId) && r.familyId.length >= 20 && r.familyId.length <= 100 && BASE64URL.test(r.familyId)) {
|
|
111
|
+
familyId = r.familyId;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new SessionSerializationError({ reason: 'invalid_family_id' });
|
|
115
|
+
}
|
|
102
116
|
if (!isNullish(r.rotatedTo) && !isString(r.rotatedTo)) {
|
|
103
117
|
throw new SessionSerializationError({ reason: 'invalid_rotated_to' });
|
|
104
118
|
}
|
|
@@ -123,6 +137,7 @@ export function validateSessionRecord(value) {
|
|
|
123
137
|
userAgent: r.userAgent ?? null,
|
|
124
138
|
metadata: metadata ?? null,
|
|
125
139
|
rotatedFrom: r.rotatedFrom ?? null,
|
|
140
|
+
familyId,
|
|
126
141
|
rotatedTo: r.rotatedTo ?? null,
|
|
127
142
|
consumedAt: r.consumedAt ?? null,
|
|
128
143
|
rotationNonceHash: r.rotationNonceHash ?? null,
|
|
@@ -151,6 +166,7 @@ export function serializeEncryptedSession(record, provider) {
|
|
|
151
166
|
exp: record.absoluteExpiresAt,
|
|
152
167
|
rn: record.rotationNonceHash,
|
|
153
168
|
rj: record.rotatedTo,
|
|
169
|
+
fam: record.familyId,
|
|
154
170
|
};
|
|
155
171
|
return JSON.stringify(envelope);
|
|
156
172
|
}
|
|
@@ -216,6 +232,7 @@ export function encryptedHeaderOf(record) {
|
|
|
216
232
|
exp: record.absoluteExpiresAt,
|
|
217
233
|
rn: record.rotationNonceHash,
|
|
218
234
|
rj: record.rotatedTo,
|
|
235
|
+
fam: record.familyId,
|
|
219
236
|
};
|
|
220
237
|
}
|
|
221
238
|
/**
|
|
@@ -242,6 +259,8 @@ export function assertHeaderMatches(envelope, record) {
|
|
|
242
259
|
mismatches.push('rn');
|
|
243
260
|
if (envelope.rj !== header.rj)
|
|
244
261
|
mismatches.push('rj');
|
|
262
|
+
if (envelope.fam !== header.fam)
|
|
263
|
+
mismatches.push('fam');
|
|
245
264
|
if (mismatches.length > 0) {
|
|
246
265
|
throw new SessionSerializationError({ reason: 'header_mismatch', fields: mismatches });
|
|
247
266
|
}
|