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.
@@ -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 of
9
- -- the written payloads are overwritten from the checked values so a stale
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] = 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)
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), plus 6 when the
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} 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)
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(successor, this.keyProvider);
287
- const successorTtl = Math.max(1, successor.absoluteExpiresAt - now);
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, successor.jti, expected, nonce, retain, oldJti, String(successorTtl), String(consumedTtl));
290
- return parseRotateResult(result, successor.jti);
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
  }