ioredis-toolkit 0.0.9 → 0.5.0

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.
Files changed (267) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +1 -1
  3. package/README.md +68 -1058
  4. package/dist/cache/cache.d.ts +30 -0
  5. package/dist/cache/cache.d.ts.map +1 -0
  6. package/dist/cache/cache.js +59 -0
  7. package/dist/cache/cache.js.map +1 -0
  8. package/dist/cache/config.d.ts +12 -0
  9. package/dist/cache/config.d.ts.map +1 -0
  10. package/dist/cache/config.js +13 -0
  11. package/dist/cache/config.js.map +1 -0
  12. package/dist/cache/types.d.ts +32 -0
  13. package/dist/cache/types.d.ts.map +1 -0
  14. package/dist/cache/types.js +5 -0
  15. package/dist/cache/types.js.map +1 -0
  16. package/dist/index.d.ts +43 -51
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +32 -44
  19. package/dist/index.js.map +1 -0
  20. package/dist/lock/config.d.ts +12 -0
  21. package/dist/lock/config.d.ts.map +1 -0
  22. package/dist/lock/config.js +8 -0
  23. package/dist/lock/config.js.map +1 -0
  24. package/dist/lock/lock.d.ts +20 -0
  25. package/dist/lock/lock.d.ts.map +1 -0
  26. package/dist/lock/lock.js +44 -0
  27. package/dist/lock/lock.js.map +1 -0
  28. package/dist/lock/types.d.ts +19 -0
  29. package/dist/lock/types.d.ts.map +1 -0
  30. package/dist/lock/types.js +2 -0
  31. package/dist/lock/types.js.map +1 -0
  32. package/dist/modules-config.d.ts +3 -0
  33. package/dist/modules-config.d.ts.map +1 -0
  34. package/dist/modules-config.js +2 -0
  35. package/dist/modules-config.js.map +1 -0
  36. package/dist/pubsub/config.d.ts +11 -0
  37. package/dist/pubsub/config.d.ts.map +1 -0
  38. package/dist/pubsub/config.js +6 -0
  39. package/dist/pubsub/config.js.map +1 -0
  40. package/dist/pubsub/pubsub.d.ts +20 -0
  41. package/dist/pubsub/pubsub.d.ts.map +1 -0
  42. package/dist/pubsub/pubsub.js +54 -0
  43. package/dist/pubsub/pubsub.js.map +1 -0
  44. package/dist/pubsub/types.d.ts +24 -0
  45. package/dist/pubsub/types.d.ts.map +1 -0
  46. package/dist/pubsub/types.js +2 -0
  47. package/dist/pubsub/types.js.map +1 -0
  48. package/dist/rate-limit/config.d.ts +12 -0
  49. package/dist/rate-limit/config.d.ts.map +1 -0
  50. package/dist/rate-limit/config.js +6 -0
  51. package/dist/rate-limit/config.js.map +1 -0
  52. package/dist/rate-limit/rate-limiter.d.ts +18 -0
  53. package/dist/rate-limit/rate-limiter.d.ts.map +1 -0
  54. package/dist/rate-limit/rate-limiter.js +37 -0
  55. package/dist/rate-limit/rate-limiter.js.map +1 -0
  56. package/dist/rate-limit/types.d.ts +27 -0
  57. package/dist/rate-limit/types.d.ts.map +1 -0
  58. package/dist/rate-limit/types.js +2 -0
  59. package/dist/rate-limit/types.js.map +1 -0
  60. package/dist/redis/client-facade.d.ts +77 -0
  61. package/dist/redis/client-facade.d.ts.map +1 -0
  62. package/dist/redis/client-facade.js +102 -0
  63. package/dist/redis/client-facade.js.map +1 -0
  64. package/dist/redis/client.d.ts +10 -0
  65. package/dist/redis/client.d.ts.map +1 -0
  66. package/dist/redis/client.js +29 -0
  67. package/dist/redis/client.js.map +1 -0
  68. package/dist/redis/cluster.d.ts +7 -0
  69. package/dist/redis/cluster.d.ts.map +1 -0
  70. package/dist/redis/cluster.js +47 -0
  71. package/dist/redis/cluster.js.map +1 -0
  72. package/dist/redis/config.d.ts +39 -0
  73. package/dist/redis/config.d.ts.map +1 -0
  74. package/dist/redis/config.js +52 -0
  75. package/dist/redis/config.js.map +1 -0
  76. package/dist/redis/errors.d.ts +5 -0
  77. package/dist/redis/errors.d.ts.map +1 -0
  78. package/dist/redis/errors.js +5 -0
  79. package/dist/redis/errors.js.map +1 -0
  80. package/dist/redis/types.d.ts +135 -0
  81. package/dist/redis/types.d.ts.map +1 -0
  82. package/dist/redis/types.js +2 -0
  83. package/dist/redis/types.js.map +1 -0
  84. package/dist/redis/wrapper.d.ts +88 -0
  85. package/dist/redis/wrapper.d.ts.map +1 -0
  86. package/dist/redis/wrapper.js +206 -0
  87. package/dist/redis/wrapper.js.map +1 -0
  88. package/dist/session/config.d.ts +47 -0
  89. package/dist/session/config.d.ts.map +1 -0
  90. package/dist/session/config.js +101 -0
  91. package/dist/session/config.js.map +1 -0
  92. package/dist/session/cookie.d.ts +16 -0
  93. package/dist/session/cookie.d.ts.map +1 -0
  94. package/dist/session/cookie.js +28 -0
  95. package/dist/session/cookie.js.map +1 -0
  96. package/dist/session/errors.d.ts +56 -0
  97. package/dist/session/errors.d.ts.map +1 -0
  98. package/dist/session/errors.js +58 -0
  99. package/dist/session/errors.js.map +1 -0
  100. package/dist/session/factory.d.ts +21 -0
  101. package/dist/session/factory.d.ts.map +1 -0
  102. package/dist/session/factory.js +30 -0
  103. package/dist/session/factory.js.map +1 -0
  104. package/dist/session/health.d.ts +12 -0
  105. package/dist/session/health.d.ts.map +1 -0
  106. package/dist/session/health.js +23 -0
  107. package/dist/session/health.js.map +1 -0
  108. package/dist/session/keys.d.ts +23 -0
  109. package/dist/session/keys.d.ts.map +1 -0
  110. package/dist/session/keys.js +27 -0
  111. package/dist/session/keys.js.map +1 -0
  112. package/dist/session/manager.d.ts +34 -0
  113. package/dist/session/manager.d.ts.map +1 -0
  114. package/dist/session/manager.js +31 -0
  115. package/dist/session/manager.js.map +1 -0
  116. package/dist/session/metrics.d.ts +11 -0
  117. package/dist/session/metrics.d.ts.map +1 -0
  118. package/dist/session/metrics.js +10 -0
  119. package/dist/session/metrics.js.map +1 -0
  120. package/dist/session/repository.d.ts +49 -0
  121. package/dist/session/repository.d.ts.map +1 -0
  122. package/dist/session/repository.js +203 -0
  123. package/dist/session/repository.js.map +1 -0
  124. package/dist/session/revocation.d.ts +22 -0
  125. package/dist/session/revocation.d.ts.map +1 -0
  126. package/dist/session/revocation.js +41 -0
  127. package/dist/session/revocation.js.map +1 -0
  128. package/dist/session/script-sources.d.ts +11 -0
  129. package/dist/session/script-sources.d.ts.map +1 -0
  130. package/dist/session/script-sources.js +140 -0
  131. package/dist/session/script-sources.js.map +1 -0
  132. package/dist/session/scripts.d.ts +15 -0
  133. package/dist/session/scripts.d.ts.map +1 -0
  134. package/dist/session/scripts.js +41 -0
  135. package/dist/session/scripts.js.map +1 -0
  136. package/dist/session/serializer.d.ts +12 -0
  137. package/dist/session/serializer.d.ts.map +1 -0
  138. package/dist/session/serializer.js +77 -0
  139. package/dist/session/serializer.js.map +1 -0
  140. package/dist/session/service.d.ts +48 -0
  141. package/dist/session/service.d.ts.map +1 -0
  142. package/dist/session/service.js +235 -0
  143. package/dist/session/service.js.map +1 -0
  144. package/dist/session/token.d.ts +16 -0
  145. package/dist/session/token.d.ts.map +1 -0
  146. package/dist/session/token.js +32 -0
  147. package/dist/session/token.js.map +1 -0
  148. package/dist/session/types.d.ts +134 -0
  149. package/dist/session/types.d.ts.map +1 -0
  150. package/dist/session/types.js +2 -0
  151. package/dist/session/types.js.map +1 -0
  152. package/dist/streams/config.d.ts +12 -0
  153. package/dist/streams/config.d.ts.map +1 -0
  154. package/dist/streams/config.js +6 -0
  155. package/dist/streams/config.js.map +1 -0
  156. package/dist/streams/streams.d.ts +24 -0
  157. package/dist/streams/streams.d.ts.map +1 -0
  158. package/dist/streams/streams.js +55 -0
  159. package/dist/streams/streams.js.map +1 -0
  160. package/dist/streams/types.d.ts +32 -0
  161. package/dist/streams/types.d.ts.map +1 -0
  162. package/dist/streams/types.js +2 -0
  163. package/dist/streams/types.js.map +1 -0
  164. package/docs/ACCEPTANCE-REPORT.md +70 -0
  165. package/docs/ARCHITECTURE.md +61 -0
  166. package/docs/CAPACITY.md +33 -0
  167. package/docs/DEPLOYMENT.md +22 -0
  168. package/docs/README-API.md +15 -0
  169. package/docs/STATE-MACHINE.md +38 -0
  170. package/docs/TESTING.md +37 -0
  171. package/docs/THREAT-MODEL.md +23 -0
  172. package/docs/TYPE-SAFETY.md +34 -0
  173. package/docs/modules/cache/README.md +7 -0
  174. package/docs/modules/cache/usage.md +156 -0
  175. package/docs/modules/lock/README.md +7 -0
  176. package/docs/modules/lock/usage.md +105 -0
  177. package/docs/modules/pubsub/README.md +7 -0
  178. package/docs/modules/pubsub/usage.md +106 -0
  179. package/docs/modules/rate-limit/README.md +7 -0
  180. package/docs/modules/rate-limit/usage.md +100 -0
  181. package/docs/modules/sessions/README.md +7 -0
  182. package/docs/modules/sessions/usage.md +262 -0
  183. package/docs/modules/streams/README.md +7 -0
  184. package/docs/modules/streams/usage.md +141 -0
  185. package/package.json +50 -60
  186. package/src/scripts/cleanup-index.lua +4 -0
  187. package/src/scripts/conditional-update.lua +21 -0
  188. package/src/scripts/consume-session.lua +21 -0
  189. package/src/scripts/create-session.lua +28 -0
  190. package/src/scripts/delete.lua +2 -0
  191. package/src/scripts/destroy-user.lua +13 -0
  192. package/src/scripts/enforce-limit.lua +17 -0
  193. package/src/scripts/revoke-session.lua +13 -0
  194. package/src/scripts/rotate.lua +24 -0
  195. package/src/scripts/touch-session.lua +28 -0
  196. package/src/scripts/update-session.lua +18 -0
  197. package/dist/cache.d.ts +0 -796
  198. package/dist/cache.js +0 -1120
  199. package/dist/client.d.ts +0 -284
  200. package/dist/client.js +0 -1114
  201. package/dist/cluster-slot.d.ts +0 -4
  202. package/dist/cluster-slot.js +0 -31
  203. package/dist/cluster.d.ts +0 -79
  204. package/dist/cluster.js +0 -156
  205. package/dist/errors.d.ts +0 -30
  206. package/dist/errors.js +0 -63
  207. package/dist/health.d.ts +0 -180
  208. package/dist/health.js +0 -239
  209. package/dist/lock.d.ts +0 -248
  210. package/dist/lock.js +0 -397
  211. package/dist/logger.d.ts +0 -12
  212. package/dist/logger.js +0 -40
  213. package/dist/pubsub.d.ts +0 -423
  214. package/dist/pubsub.js +0 -537
  215. package/dist/ratelimiter.d.ts +0 -441
  216. package/dist/ratelimiter.js +0 -539
  217. package/dist/session/index.d.ts +0 -23
  218. package/dist/session/index.js +0 -16
  219. package/dist/session/revocation-store.d.ts +0 -176
  220. package/dist/session/revocation-store.js +0 -318
  221. package/dist/session/scripts/cleanup-index.lua +0 -21
  222. package/dist/session/scripts/conditional-update-encrypted.lua +0 -60
  223. package/dist/session/scripts/conditional-update.lua +0 -63
  224. package/dist/session/scripts/create.lua +0 -83
  225. package/dist/session/scripts/delete-by-user.lua +0 -29
  226. package/dist/session/scripts/delete.lua +0 -15
  227. package/dist/session/scripts/enforce-limit.lua +0 -38
  228. package/dist/session/scripts/revoke.lua +0 -61
  229. package/dist/session/scripts/rotate-encrypted.lua +0 -110
  230. package/dist/session/scripts/rotate.lua +0 -122
  231. package/dist/session/scripts/touch-encrypted.lua +0 -89
  232. package/dist/session/scripts/touch.lua +0 -72
  233. package/dist/session/scripts/validate.lua +0 -90
  234. package/dist/session/session-circuit-breaker.d.ts +0 -42
  235. package/dist/session/session-circuit-breaker.js +0 -129
  236. package/dist/session/session-config.d.ts +0 -335
  237. package/dist/session/session-config.js +0 -162
  238. package/dist/session/session-cookie.d.ts +0 -72
  239. package/dist/session/session-cookie.js +0 -101
  240. package/dist/session/session-encryption.d.ts +0 -87
  241. package/dist/session/session-encryption.js +0 -139
  242. package/dist/session/session-errors.d.ts +0 -85
  243. package/dist/session/session-errors.js +0 -145
  244. package/dist/session/session-health.d.ts +0 -38
  245. package/dist/session/session-health.js +0 -60
  246. package/dist/session/session-keys.d.ts +0 -51
  247. package/dist/session/session-keys.js +0 -113
  248. package/dist/session/session-manager.d.ts +0 -73
  249. package/dist/session/session-manager.js +0 -94
  250. package/dist/session/session-metrics.d.ts +0 -33
  251. package/dist/session/session-metrics.js +0 -112
  252. package/dist/session/session-repository.d.ts +0 -161
  253. package/dist/session/session-repository.js +0 -683
  254. package/dist/session/session-scripts.d.ts +0 -36
  255. package/dist/session/session-scripts.js +0 -130
  256. package/dist/session/session-serializer.d.ts +0 -42
  257. package/dist/session/session-serializer.js +0 -248
  258. package/dist/session/session-service.d.ts +0 -104
  259. package/dist/session/session-service.js +0 -611
  260. package/dist/session/session-token.d.ts +0 -38
  261. package/dist/session/session-token.js +0 -86
  262. package/dist/session/session-types.d.ts +0 -253
  263. package/dist/session/session-types.js +0 -16
  264. package/dist/types.d.ts +0 -924
  265. package/dist/types.js +0 -151
  266. package/dist/utils/deepmerge.d.ts +0 -9
  267. package/dist/utils/deepmerge.js +0 -61
@@ -1,176 +0,0 @@
1
- import type { RevocationRecord, RevocationStore } from './session-types.js';
2
- import type { RedisClientWrapper } from '../client.js';
3
- import z from 'zod';
4
- export interface RedisRevocationStoreOptions {
5
- /**
6
- * Redis client.
7
- *
8
- * Compatible with:
9
- * - ioredis standalone
10
- * - ioredis Sentinel
11
- * - ioredis Cluster
12
- */
13
- client: RedisClientWrapper;
14
- /**
15
- * Key prefix, so multiple apps can share a Redis instance safely.
16
- * Default: `authcore:revoked:`.
17
- *
18
- * @example
19
- * ```ts
20
- * new RedisRevocationStore({ client, keyPrefix: 'auth:revoked:' });
21
- * ```
22
- */
23
- keyPrefix?: string | undefined;
24
- }
25
- export declare const RedisRevocationStoreOptionsSchema: z.ZodObject<{
26
- keyPrefix: z.ZodOptional<z.ZodString>;
27
- }, z.z.core.$strip>;
28
- export type RedisRevocationStoreOptionsInput = z.input<typeof RedisRevocationStoreOptionsSchema>;
29
- /**
30
- * Redis-backed revocation store. Each revoked jti is stored as
31
- * `{prefix}{jti} -> reason`, with the Redis key TTL itself set to the
32
- * token's remaining lifetime — expired entries are reclaimed automatically
33
- * by Redis, no sweep job required.
34
- *
35
- * Every operation here is a single-key command, so this store works
36
- * identically on standalone, Sentinel, and Cluster with no hash tags
37
- * required (unlike the session store, there's no multi-key atomicity
38
- * requirement to satisfy).
39
- *
40
- * Batched operations (`revokeMany`, `isRevokedMany`) group their commands
41
- * by hash slot and issue one pipeline per slot, so they never trigger
42
- * `CROSSSLOT` errors on Redis Cluster. Pipeline failures are surfaced via
43
- * {@link RevocationBatchError} instead of being silently swallowed —
44
- * a missed revocation is a security bug.
45
- *
46
- * Validation fails fast and typed: invalid records throw
47
- * {@link RevocationError} before any network call, and reads fail closed
48
- * (an infra error is never treated as "not revoked").
49
- *
50
- * @example
51
- * ```ts
52
- * const revocations = new RedisRevocationStore({ client });
53
- *
54
- * await revocations.revoke({
55
- * jti: 'a1b2c3d4',
56
- * reason: 'password-change',
57
- * expiresAt: Math.floor(Date.now() / 1000) + 3600,
58
- * });
59
- *
60
- * if (await revocations.isRevoked('a1b2c3d4')) {
61
- * // token was rotated or revoked - reject it
62
- * }
63
- * ```
64
- */
65
- export declare class RedisRevocationStore implements RevocationStore {
66
- private readonly client;
67
- private readonly keyPrefix;
68
- /**
69
- * Creates a Redis-backed revocation store.
70
- *
71
- * @param options - Client connection and key-prefix configuration.
72
- *
73
- * @example
74
- * ```ts
75
- * const store = new RedisRevocationStore({
76
- * client,
77
- * keyPrefix: 'myapp:revoked:',
78
- * });
79
- * ```
80
- */
81
- constructor(options: RedisRevocationStoreOptions);
82
- /**
83
- * Marks a jti as revoked for the remainder of its lifetime.
84
- *
85
- * Stores `{prefix}{jti} -> reason` with a Redis TTL equal to the
86
- * record's remaining lifetime (`expiresAt - now`), so the entry is
87
- * garbage-collected automatically once the original token would have
88
- * expired anyway. Overwriting an existing entry extends/refreshes its
89
- * TTL to the new expiry.
90
- *
91
- * @param record - The revocation entry (`jti`, `expiresAt`, optional
92
- * `reason`). `expiresAt` must be a finite Unix-seconds timestamp in
93
- * the future.
94
- * @throws {RevocationError} when `record.expiresAt` is missing, not a
95
- * finite number, or not in the future (fails fast instead of sending
96
- * an invalid `EX` to Redis).
97
- *
98
- * @example
99
- * ```ts
100
- * await revocations.revoke({
101
- * jti: 'a1b2c3d4',
102
- * reason: 'logout',
103
- * expiresAt: Math.floor(Date.now() / 1000) + 86400,
104
- * });
105
- * ```
106
- */
107
- revoke(record: RevocationRecord): Promise<void>;
108
- /**
109
- * Revokes many jtis in one batched call.
110
- *
111
- * All records are validated up front — an invalid `expiresAt` fails
112
- * before any network call is issued, rather than partway through a
113
- * batch. Commands are grouped by hash slot (one pipeline per slot) so
114
- * the batch stays Cluster-safe, and every pipeline result is inspected:
115
- * any failed command throws {@link RevocationBatchError} listing the
116
- * affected jtis, because a silently-missed revocation is a security bug.
117
- *
118
- * @param records - The revocation entries to create/refresh.
119
- * @throws {RevocationError} when any record is invalid (validation is
120
- * all-or-nothing, before any network call).
121
- * @throws {RevocationBatchError} when one or more pipeline commands
122
- * fail; carries the exact jtis that were not revoked.
123
- *
124
- * @example
125
- * ```ts
126
- * await revocations.revokeMany([
127
- * { jti: 'a1', reason: 'logout-all', expiresAt: expiry },
128
- * { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
129
- * ]);
130
- * ```
131
- */
132
- revokeMany(records: RevocationRecord[]): Promise<void>;
133
- /**
134
- * Checks whether a jti is currently revoked.
135
- *
136
- * Fail-closed: infrastructure errors are wrapped in a typed error and
137
- * must NOT be treated as "not revoked".
138
- *
139
- * @param jti - The token/session id to check.
140
- * @returns `true` when the jti has a live revocation entry.
141
- * @throws {RevocationError} when the check itself fails (caller must
142
- * treat the outcome as unknown).
143
- *
144
- * @example
145
- * ```ts
146
- * if (await revocations.isRevoked(token.jti)) {
147
- * return 401; // token was rotated away or explicitly revoked
148
- * }
149
- * ```
150
- */
151
- isRevoked(jti: string): Promise<boolean>;
152
- /**
153
- * Batched revocation check - one network round trip instead of N.
154
- *
155
- * Useful for validating a whole family of rotated tokens, or a batch
156
- * of refresh attempts, at once. Commands are grouped by hash slot
157
- * (one pipeline per slot) to stay Cluster-safe, and the check fails
158
- * closed: if any command errors, {@link RevocationBatchError} is thrown
159
- * rather than silently treating the jti as "not revoked".
160
- *
161
- * @param jtis - The token/session ids to check.
162
- * @returns A `Set` containing exactly the revoked jtis.
163
- * @throws {RevocationBatchError} when a pipeline command fails -
164
- * the caller must treat the outcome as unknown, not as "valid".
165
- *
166
- * @example
167
- * ```ts
168
- * const revoked = await revocations.isRevokedMany(['a1', 'b2', 'c3']);
169
- * if (revoked.has('b2')) {
170
- * // b2 must not be accepted
171
- * }
172
- * ```
173
- */
174
- isRevokedMany(jtis: string[]): Promise<Set<string>>;
175
- private key;
176
- }
@@ -1,318 +0,0 @@
1
- import { RevocationError, RevocationBatchError, redactIdentifier } from './session-errors.js';
2
- import z from 'zod';
3
- // ============================================================================
4
- // Redis Revocations config
5
- // ============================================================================
6
- //
7
- export const RedisRevocationStoreOptionsSchema = z.object({
8
- keyPrefix: z.string().optional(),
9
- });
10
- /**
11
- * Redis-backed revocation store. Each revoked jti is stored as
12
- * `{prefix}{jti} -> reason`, with the Redis key TTL itself set to the
13
- * token's remaining lifetime — expired entries are reclaimed automatically
14
- * by Redis, no sweep job required.
15
- *
16
- * Every operation here is a single-key command, so this store works
17
- * identically on standalone, Sentinel, and Cluster with no hash tags
18
- * required (unlike the session store, there's no multi-key atomicity
19
- * requirement to satisfy).
20
- *
21
- * Batched operations (`revokeMany`, `isRevokedMany`) group their commands
22
- * by hash slot and issue one pipeline per slot, so they never trigger
23
- * `CROSSSLOT` errors on Redis Cluster. Pipeline failures are surfaced via
24
- * {@link RevocationBatchError} instead of being silently swallowed —
25
- * a missed revocation is a security bug.
26
- *
27
- * Validation fails fast and typed: invalid records throw
28
- * {@link RevocationError} before any network call, and reads fail closed
29
- * (an infra error is never treated as "not revoked").
30
- *
31
- * @example
32
- * ```ts
33
- * const revocations = new RedisRevocationStore({ client });
34
- *
35
- * await revocations.revoke({
36
- * jti: 'a1b2c3d4',
37
- * reason: 'password-change',
38
- * expiresAt: Math.floor(Date.now() / 1000) + 3600,
39
- * });
40
- *
41
- * if (await revocations.isRevoked('a1b2c3d4')) {
42
- * // token was rotated or revoked - reject it
43
- * }
44
- * ```
45
- */
46
- export class RedisRevocationStore {
47
- client;
48
- keyPrefix;
49
- /**
50
- * Creates a Redis-backed revocation store.
51
- *
52
- * @param options - Client connection and key-prefix configuration.
53
- *
54
- * @example
55
- * ```ts
56
- * const store = new RedisRevocationStore({
57
- * client,
58
- * keyPrefix: 'myapp:revoked:',
59
- * });
60
- * ```
61
- */
62
- constructor(options) {
63
- this.client = options.client;
64
- this.keyPrefix = options.keyPrefix ?? 'cache:revoked:';
65
- }
66
- /* ------------------------------------------------------------------------ */
67
- /* Revoke */
68
- /* ------------------------------------------------------------------------ */
69
- /**
70
- * Marks a jti as revoked for the remainder of its lifetime.
71
- *
72
- * Stores `{prefix}{jti} -> reason` with a Redis TTL equal to the
73
- * record's remaining lifetime (`expiresAt - now`), so the entry is
74
- * garbage-collected automatically once the original token would have
75
- * expired anyway. Overwriting an existing entry extends/refreshes its
76
- * TTL to the new expiry.
77
- *
78
- * @param record - The revocation entry (`jti`, `expiresAt`, optional
79
- * `reason`). `expiresAt` must be a finite Unix-seconds timestamp in
80
- * the future.
81
- * @throws {RevocationError} when `record.expiresAt` is missing, not a
82
- * finite number, or not in the future (fails fast instead of sending
83
- * an invalid `EX` to Redis).
84
- *
85
- * @example
86
- * ```ts
87
- * await revocations.revoke({
88
- * jti: 'a1b2c3d4',
89
- * reason: 'logout',
90
- * expiresAt: Math.floor(Date.now() / 1000) + 86400,
91
- * });
92
- * ```
93
- */
94
- async revoke(record) {
95
- const ttl = computeTtl(record);
96
- try {
97
- await this.client.set(this.key(record.jti), record.reason ?? '1', ttl);
98
- }
99
- catch (error) {
100
- throw wrapStorageError(error, { jti: record.jti });
101
- }
102
- }
103
- /**
104
- * Revokes many jtis in one batched call.
105
- *
106
- * All records are validated up front — an invalid `expiresAt` fails
107
- * before any network call is issued, rather than partway through a
108
- * batch. Commands are grouped by hash slot (one pipeline per slot) so
109
- * the batch stays Cluster-safe, and every pipeline result is inspected:
110
- * any failed command throws {@link RevocationBatchError} listing the
111
- * affected jtis, because a silently-missed revocation is a security bug.
112
- *
113
- * @param records - The revocation entries to create/refresh.
114
- * @throws {RevocationError} when any record is invalid (validation is
115
- * all-or-nothing, before any network call).
116
- * @throws {RevocationBatchError} when one or more pipeline commands
117
- * fail; carries the exact jtis that were not revoked.
118
- *
119
- * @example
120
- * ```ts
121
- * await revocations.revokeMany([
122
- * { jti: 'a1', reason: 'logout-all', expiresAt: expiry },
123
- * { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
124
- * ]);
125
- * ```
126
- */
127
- async revokeMany(records) {
128
- if (records.length === 0)
129
- return;
130
- // Validate every record up front - fail before issuing any network
131
- // calls rather than partway through a batch.
132
- const ttls = records.map((record) => computeTtl(record));
133
- // Group by hash slot: the jti keys are not hash-tagged, so they may
134
- // scatter across Cluster slots. One pipeline per slot avoids
135
- // CROSSSLOT errors.
136
- const groups = new Map();
137
- for (let i = 0; i < records.length; i++) {
138
- const record = records[i];
139
- const key = this.key(record.jti);
140
- const slot = this.client.calculateSlot(key);
141
- const entry = { jti: record.jti, value: record.reason ?? '1', ttl: ttls[i] };
142
- const group = groups.get(slot);
143
- if (group) {
144
- group.push(entry);
145
- }
146
- else {
147
- groups.set(slot, [entry]);
148
- }
149
- }
150
- const failures = [];
151
- for (const entries of groups.values()) {
152
- const pipeline = this.client.pipeline();
153
- for (const entry of entries) {
154
- pipeline.set(this.key(entry.jti), entry.value, 'EX', entry.ttl);
155
- }
156
- const results = await pipeline.exec();
157
- // pipeline.exec() resolves to [error, result][] - a failed command
158
- // does NOT reject the pipeline promise. Check every result.
159
- for (let i = 0; i < entries.length; i++) {
160
- const result = results?.[i];
161
- const error = Array.isArray(result) ? result[0] : undefined;
162
- if (error) {
163
- failures.push({ jti: entries[i].jti, error });
164
- }
165
- }
166
- }
167
- if (failures.length > 0) {
168
- throw new RevocationBatchError(failures);
169
- }
170
- }
171
- /* ------------------------------------------------------------------------ */
172
- /* Check */
173
- /* ------------------------------------------------------------------------ */
174
- /**
175
- * Checks whether a jti is currently revoked.
176
- *
177
- * Fail-closed: infrastructure errors are wrapped in a typed error and
178
- * must NOT be treated as "not revoked".
179
- *
180
- * @param jti - The token/session id to check.
181
- * @returns `true` when the jti has a live revocation entry.
182
- * @throws {RevocationError} when the check itself fails (caller must
183
- * treat the outcome as unknown).
184
- *
185
- * @example
186
- * ```ts
187
- * if (await revocations.isRevoked(token.jti)) {
188
- * return 401; // token was rotated away or explicitly revoked
189
- * }
190
- * ```
191
- */
192
- async isRevoked(jti) {
193
- try {
194
- const exists = await this.client.exists(this.key(jti));
195
- return exists === 1;
196
- }
197
- catch (error) {
198
- throw wrapStorageError(error, { jti });
199
- }
200
- }
201
- /**
202
- * Batched revocation check - one network round trip instead of N.
203
- *
204
- * Useful for validating a whole family of rotated tokens, or a batch
205
- * of refresh attempts, at once. Commands are grouped by hash slot
206
- * (one pipeline per slot) to stay Cluster-safe, and the check fails
207
- * closed: if any command errors, {@link RevocationBatchError} is thrown
208
- * rather than silently treating the jti as "not revoked".
209
- *
210
- * @param jtis - The token/session ids to check.
211
- * @returns A `Set` containing exactly the revoked jtis.
212
- * @throws {RevocationBatchError} when a pipeline command fails -
213
- * the caller must treat the outcome as unknown, not as "valid".
214
- *
215
- * @example
216
- * ```ts
217
- * const revoked = await revocations.isRevokedMany(['a1', 'b2', 'c3']);
218
- * if (revoked.has('b2')) {
219
- * // b2 must not be accepted
220
- * }
221
- * ```
222
- */
223
- async isRevokedMany(jtis) {
224
- if (jtis.length === 0)
225
- return new Set();
226
- const groups = new Map();
227
- for (const jti of jtis) {
228
- const key = this.key(jti);
229
- const slot = this.client.calculateSlot(key);
230
- const group = groups.get(slot);
231
- if (group) {
232
- group.push(jti);
233
- }
234
- else {
235
- groups.set(slot, [jti]);
236
- }
237
- }
238
- const revoked = new Set();
239
- for (const groupJtis of groups.values()) {
240
- const pipeline = this.client.pipeline();
241
- for (const jti of groupJtis) {
242
- pipeline.exists(this.key(jti));
243
- }
244
- const results = await pipeline.exec();
245
- for (let i = 0; i < groupJtis.length; i++) {
246
- const result = results?.[i];
247
- const error = Array.isArray(result) ? result[0] : undefined;
248
- const value = Array.isArray(result) ? result[1] : undefined;
249
- if (error) {
250
- // Fail closed: if we can't confirm a jti's status, don't silently
251
- // treat it as "not revoked".
252
- throw new RevocationBatchError([{ jti: groupJtis[i], error }]);
253
- }
254
- if (value === 1) {
255
- revoked.add(groupJtis[i]);
256
- }
257
- }
258
- }
259
- return revoked;
260
- }
261
- key(jti) {
262
- return `${this.keyPrefix}${jti}`;
263
- }
264
- }
265
- /* -------------------------------------------------------------------------- */
266
- /* Helpers */
267
- /* -------------------------------------------------------------------------- */
268
- function nowSeconds() {
269
- return Math.floor(Date.now() / 1000);
270
- }
271
- /**
272
- * Validates and computes the Redis TTL for a revocation record.
273
- *
274
- * Throws a typed {@link RevocationError} early with a redacted message
275
- * instead of letting a malformed `expiresAt` (undefined/NaN/past) turn
276
- * into `Math.max(1, NaN) === NaN`, which would otherwise reach Redis as
277
- * an invalid `EX` argument and fail with an opaque "value is not an
278
- * integer" error deep inside the client.
279
- *
280
- * @param record - The revocation record to validate.
281
- * @param now - Reference timestamp (Unix seconds); overridable for tests.
282
- * @returns The TTL in seconds, at least 1.
283
- * @throws {RevocationError} on invalid records.
284
- */
285
- function computeTtl(record, now = nowSeconds()) {
286
- const safe = redactIdentifier(record.jti);
287
- if (!record.jti || typeof record.jti !== 'string') {
288
- throw new RevocationError({ reason: 'missing_jti' });
289
- }
290
- if (!Number.isFinite(record.expiresAt)) {
291
- throw new RevocationError({
292
- reason: 'invalid_expires_at',
293
- jti: safe,
294
- detail: 'expiresAt must be a finite number',
295
- });
296
- }
297
- if (record.expiresAt <= now) {
298
- throw new RevocationError({
299
- reason: 'expires_at_in_past',
300
- jti: safe,
301
- detail: 'expiresAt must be in the future',
302
- });
303
- }
304
- return Math.max(1, record.expiresAt - now);
305
- }
306
- /**
307
- * Wraps an underlying storage failure in a typed, redacted error so the
308
- * caller can fail closed without losing the root cause.
309
- */
310
- function wrapStorageError(error, context) {
311
- if (error instanceof RevocationError)
312
- return error;
313
- return new RevocationError({
314
- reason: 'storage_failure',
315
- jti: redactIdentifier(context.jti),
316
- cause: error instanceof Error ? error.message : String(error),
317
- });
318
- }
@@ -1,21 +0,0 @@
1
- -- cleanup-index.lua (version 1)
2
- -- Lazily removes stale members (whose session records no longer exist,
3
- -- e.g. expired and naturally reclaimed by Redis TTL) from the user index.
4
- -- Bounded: the caller chunks the user's jtis into batches.
5
- --
6
- -- KEYS[1] = user session index key
7
- -- KEYS[2..] = session record keys (same slot as KEYS[1])
8
- --
9
- -- ARGV[1..] = matching jtis (index i pairs with KEYS[i + 1])
10
- --
11
- -- Returns: the jtis removed from the index.
12
- local removed = {}
13
-
14
- for i = 2, #KEYS do
15
- if redis.call('EXISTS', KEYS[i]) == 0 then
16
- redis.call('ZREM', KEYS[1], ARGV[i - 1])
17
- table.insert(removed, ARGV[i - 1])
18
- end
19
- end
20
-
21
- return removed
@@ -1,60 +0,0 @@
1
- -- conditional-update-encrypted.lua (version 1) - encrypted (v2) envelopes
2
- -- Optimistic-concurrency patch update for encrypted sessions.
3
- --
4
- -- The app decrypts the record, applies the whitelisted patch, re-encrypts
5
- -- and passes the new envelope. The script CAS's the plaintext version
6
- -- mirror and enforces status/expiry; the absolute expiry mirror is
7
- -- preserved from the stored record (identity/security fields can never be
8
- -- changed through update).
9
- --
10
- -- KEYS[1] = session record key
11
- --
12
- -- ARGV[1] = expected version ('' = no check)
13
- -- ARGV[2] = new serialized envelope (app-built, re-encrypted)
14
- -- ARGV[3] = new version (expected + 1)
15
- -- ARGV[4] = new TTL (clamped >= 1)
16
- --
17
- -- Returns:
18
- -- {1, newVersion} applied
19
- -- 0 not found
20
- -- -1 consumed or revoked
21
- -- -2 expired (record removed)
22
- -- -3 version conflict
23
- -- 6 envelope is plain (use the plain 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 ~= 2 then
33
- return 6
34
- end
35
-
36
- if env.st ~= 'active' then
37
- return -1
38
- end
39
-
40
- local now = tonumber(redis.call('TIME')[1])
41
-
42
- if tonumber(env.exp) <= now then
43
- redis.call('DEL', KEYS[1])
44
- return -2
45
- end
46
-
47
- if ARGV[1] ~= '' and tostring(env.ver) ~= ARGV[1] then
48
- return -3
49
- end
50
-
51
- local newEnv = cjson.decode(ARGV[2])
52
- newEnv.st = 'active'
53
- newEnv.ver = tonumber(ARGV[3])
54
- newEnv.exp = env.exp
55
- newEnv.rn = env.rn
56
- newEnv.rj = env.rj
57
-
58
- redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', tonumber(ARGV[4]))
59
-
60
- return { 1, ARGV[3] }
@@ -1,63 +0,0 @@
1
- -- conditional-update.lua (version 1) - plain (v1) envelopes
2
- -- Optimistic-concurrency patch update. The script is the authority for:
3
- -- - existence / status / expiry checks
4
- -- - the version compare-and-swap
5
- -- - the mutable-field whitelist (identity and security-critical fields
6
- -- are never touched, regardless of what the caller sends)
7
- --
8
- -- KEYS[1] = session record key
9
- --
10
- -- ARGV[1] = expected version ('' = no check)
11
- -- ARGV[2] = patch JSON: only { deviceId?, ipAddress?, userAgent?, metadata? }
12
- -- may be present; other keys are ignored (never applied)
13
- --
14
- -- Returns:
15
- -- {1, newVersion} applied
16
- -- 0 not found
17
- -- -1 consumed or revoked
18
- -- -2 expired (record removed)
19
- -- -3 version conflict
20
- -- 5 envelope is encrypted (use the encrypted path)
21
- local raw = redis.call('GET', KEYS[1])
22
-
23
- if not raw then
24
- return 0
25
- end
26
-
27
- local env = cjson.decode(raw)
28
-
29
- if env.v ~= 1 then
30
- return 5
31
- end
32
-
33
- local s = env.s
34
-
35
- if s.status ~= 'active' then
36
- return -1
37
- end
38
-
39
- local now = tonumber(redis.call('TIME')[1])
40
-
41
- if tonumber(s.absoluteExpiresAt) <= now then
42
- redis.call('DEL', KEYS[1])
43
- return -2
44
- end
45
-
46
- if ARGV[1] ~= '' and tostring(s.version) ~= ARGV[1] then
47
- return -3
48
- end
49
-
50
- local patch = cjson.decode(ARGV[2])
51
-
52
- if patch.deviceId ~= nil then s.deviceId = patch.deviceId end
53
- if patch.ipAddress ~= nil then s.ipAddress = patch.ipAddress end
54
- if patch.userAgent ~= nil then s.userAgent = patch.userAgent end
55
- if patch.metadata ~= nil then s.metadata = patch.metadata end
56
-
57
- s.version = tonumber(s.version) + 1
58
-
59
- local ttl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
60
-
61
- redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
62
-
63
- return { 1, tostring(s.version) }