@shipfox/api-auth 2.0.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 (319) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +116 -0
  5. package/LICENSE +21 -0
  6. package/README.md +336 -0
  7. package/dist/config.d.ts +22 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +95 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/auth.d.ts +108 -0
  12. package/dist/core/auth.d.ts.map +1 -0
  13. package/dist/core/auth.js +476 -0
  14. package/dist/core/auth.js.map +1 -0
  15. package/dist/core/entities/email-verification.d.ts +9 -0
  16. package/dist/core/entities/email-verification.d.ts.map +1 -0
  17. package/dist/core/entities/email-verification.js +3 -0
  18. package/dist/core/entities/email-verification.js.map +1 -0
  19. package/dist/core/entities/password-reset.d.ts +9 -0
  20. package/dist/core/entities/password-reset.d.ts.map +1 -0
  21. package/dist/core/entities/password-reset.js +3 -0
  22. package/dist/core/entities/password-reset.js.map +1 -0
  23. package/dist/core/entities/refresh-token.d.ts +12 -0
  24. package/dist/core/entities/refresh-token.d.ts.map +1 -0
  25. package/dist/core/entities/refresh-token.js +3 -0
  26. package/dist/core/entities/refresh-token.js.map +1 -0
  27. package/dist/core/entities/user.d.ts +12 -0
  28. package/dist/core/entities/user.d.ts.map +1 -0
  29. package/dist/core/entities/user.js +3 -0
  30. package/dist/core/entities/user.js.map +1 -0
  31. package/dist/core/errors.d.ts +54 -0
  32. package/dist/core/errors.d.ts.map +1 -0
  33. package/dist/core/errors.js +106 -0
  34. package/dist/core/errors.js.map +1 -0
  35. package/dist/core/job-lease-token.d.ts +12 -0
  36. package/dist/core/job-lease-token.d.ts.map +1 -0
  37. package/dist/core/job-lease-token.js +58 -0
  38. package/dist/core/job-lease-token.js.map +1 -0
  39. package/dist/core/jwt.d.ts +37 -0
  40. package/dist/core/jwt.d.ts.map +1 -0
  41. package/dist/core/jwt.js +46 -0
  42. package/dist/core/jwt.js.map +1 -0
  43. package/dist/core/password.d.ts +10 -0
  44. package/dist/core/password.d.ts.map +1 -0
  45. package/dist/core/password.js +13 -0
  46. package/dist/core/password.js.map +1 -0
  47. package/dist/core/rate-limit.d.ts +34 -0
  48. package/dist/core/rate-limit.d.ts.map +1 -0
  49. package/dist/core/rate-limit.js +76 -0
  50. package/dist/core/rate-limit.js.map +1 -0
  51. package/dist/core/runner-session-token.d.ts +6 -0
  52. package/dist/core/runner-session-token.d.ts.map +1 -0
  53. package/dist/core/runner-session-token.js +37 -0
  54. package/dist/core/runner-session-token.js.map +1 -0
  55. package/dist/db/db.d.ts +1723 -0
  56. package/dist/db/db.d.ts.map +1 -0
  57. package/dist/db/db.js +28 -0
  58. package/dist/db/db.js.map +1 -0
  59. package/dist/db/email-verifications.d.ts +39 -0
  60. package/dist/db/email-verifications.d.ts.map +1 -0
  61. package/dist/db/email-verifications.js +95 -0
  62. package/dist/db/email-verifications.js.map +1 -0
  63. package/dist/db/migrations.d.ts +2 -0
  64. package/dist/db/migrations.d.ts.map +1 -0
  65. package/dist/db/migrations.js +5 -0
  66. package/dist/db/migrations.js.map +1 -0
  67. package/dist/db/password-resets.d.ts +23 -0
  68. package/dist/db/password-resets.d.ts.map +1 -0
  69. package/dist/db/password-resets.js +37 -0
  70. package/dist/db/password-resets.js.map +1 -0
  71. package/dist/db/rate-limits.d.ts +18 -0
  72. package/dist/db/rate-limits.d.ts.map +1 -0
  73. package/dist/db/rate-limits.js +44 -0
  74. package/dist/db/rate-limits.js.map +1 -0
  75. package/dist/db/refresh-tokens.d.ts +44 -0
  76. package/dist/db/refresh-tokens.d.ts.map +1 -0
  77. package/dist/db/refresh-tokens.js +71 -0
  78. package/dist/db/refresh-tokens.js.map +1 -0
  79. package/dist/db/schema/common.d.ts +2 -0
  80. package/dist/db/schema/common.d.ts.map +1 -0
  81. package/dist/db/schema/common.js +4 -0
  82. package/dist/db/schema/common.js.map +1 -0
  83. package/dist/db/schema/email-verifications.d.ts +114 -0
  84. package/dist/db/schema/email-verifications.d.ts.map +1 -0
  85. package/dist/db/schema/email-verifications.js +35 -0
  86. package/dist/db/schema/email-verifications.js.map +1 -0
  87. package/dist/db/schema/outbox.d.ts +195 -0
  88. package/dist/db/schema/outbox.d.ts.map +1 -0
  89. package/dist/db/schema/outbox.js +5 -0
  90. package/dist/db/schema/outbox.js.map +1 -0
  91. package/dist/db/schema/password-resets.d.ts +114 -0
  92. package/dist/db/schema/password-resets.d.ts.map +1 -0
  93. package/dist/db/schema/password-resets.js +35 -0
  94. package/dist/db/schema/password-resets.js.map +1 -0
  95. package/dist/db/schema/rate-limits.d.ts +145 -0
  96. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  97. package/dist/db/schema/rate-limits.js +25 -0
  98. package/dist/db/schema/rate-limits.js.map +1 -0
  99. package/dist/db/schema/refresh-tokens.d.ts +165 -0
  100. package/dist/db/schema/refresh-tokens.d.ts.map +1 -0
  101. package/dist/db/schema/refresh-tokens.js +47 -0
  102. package/dist/db/schema/refresh-tokens.js.map +1 -0
  103. package/dist/db/schema/users.d.ts +149 -0
  104. package/dist/db/schema/users.d.ts.map +1 -0
  105. package/dist/db/schema/users.js +40 -0
  106. package/dist/db/schema/users.js.map +1 -0
  107. package/dist/db/users.d.ts +28 -0
  108. package/dist/db/users.d.ts.map +1 -0
  109. package/dist/db/users.js +83 -0
  110. package/dist/db/users.js.map +1 -0
  111. package/dist/index.d.ts +14 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +52 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/metrics/index.d.ts +2 -0
  116. package/dist/metrics/index.d.ts.map +1 -0
  117. package/dist/metrics/index.js +3 -0
  118. package/dist/metrics/index.js.map +1 -0
  119. package/dist/metrics/instance.d.ts +16 -0
  120. package/dist/metrics/instance.d.ts.map +1 -0
  121. package/dist/metrics/instance.js +52 -0
  122. package/dist/metrics/instance.js.map +1 -0
  123. package/dist/presentation/auth/bearer-token-auth.d.ts +15 -0
  124. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -0
  125. package/dist/presentation/auth/bearer-token-auth.js +31 -0
  126. package/dist/presentation/auth/bearer-token-auth.js.map +1 -0
  127. package/dist/presentation/auth/jwt-auth.d.ts +10 -0
  128. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -0
  129. package/dist/presentation/auth/jwt-auth.js +31 -0
  130. package/dist/presentation/auth/jwt-auth.js.map +1 -0
  131. package/dist/presentation/auth/lease-token-auth.d.ts +15 -0
  132. package/dist/presentation/auth/lease-token-auth.d.ts.map +1 -0
  133. package/dist/presentation/auth/lease-token-auth.js +27 -0
  134. package/dist/presentation/auth/lease-token-auth.js.map +1 -0
  135. package/dist/presentation/auth/refresh-cookie.d.ts +7 -0
  136. package/dist/presentation/auth/refresh-cookie.d.ts.map +1 -0
  137. package/dist/presentation/auth/refresh-cookie.js +29 -0
  138. package/dist/presentation/auth/refresh-cookie.js.map +1 -0
  139. package/dist/presentation/auth/runner-session-auth.d.ts +3 -0
  140. package/dist/presentation/auth/runner-session-auth.d.ts.map +1 -0
  141. package/dist/presentation/auth/runner-session-auth.js +16 -0
  142. package/dist/presentation/auth/runner-session-auth.js.map +1 -0
  143. package/dist/presentation/dto/user.d.ts +8 -0
  144. package/dist/presentation/dto/user.d.ts.map +1 -0
  145. package/dist/presentation/dto/user.js +19 -0
  146. package/dist/presentation/dto/user.js.map +1 -0
  147. package/dist/presentation/e2eRoutes/create-session.d.ts +2 -0
  148. package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -0
  149. package/dist/presentation/e2eRoutes/create-session.js +49 -0
  150. package/dist/presentation/e2eRoutes/create-session.js.map +1 -0
  151. package/dist/presentation/e2eRoutes/create-user.d.ts +2 -0
  152. package/dist/presentation/e2eRoutes/create-user.d.ts.map +1 -0
  153. package/dist/presentation/e2eRoutes/create-user.js +42 -0
  154. package/dist/presentation/e2eRoutes/create-user.js.map +1 -0
  155. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  156. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  157. package/dist/presentation/e2eRoutes/index.js +15 -0
  158. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  159. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -0
  160. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -0
  161. package/dist/presentation/routes/email-verification/verify-email-confirm.js +35 -0
  162. package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -0
  163. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts +2 -0
  164. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -0
  165. package/dist/presentation/routes/email-verification/verify-email-resend.js +27 -0
  166. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -0
  167. package/dist/presentation/routes/index.d.ts +3 -0
  168. package/dist/presentation/routes/index.d.ts.map +1 -0
  169. package/dist/presentation/routes/index.js +31 -0
  170. package/dist/presentation/routes/index.js.map +1 -0
  171. package/dist/presentation/routes/password/change-password.d.ts +2 -0
  172. package/dist/presentation/routes/password/change-password.d.ts.map +1 -0
  173. package/dist/presentation/routes/password/change-password.js +50 -0
  174. package/dist/presentation/routes/password/change-password.js.map +1 -0
  175. package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -0
  176. package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -0
  177. package/dist/presentation/routes/password/password-reset-confirm.js +36 -0
  178. package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -0
  179. package/dist/presentation/routes/password/password-reset-request.d.ts +2 -0
  180. package/dist/presentation/routes/password/password-reset-request.d.ts.map +1 -0
  181. package/dist/presentation/routes/password/password-reset-request.js +26 -0
  182. package/dist/presentation/routes/password/password-reset-request.js.map +1 -0
  183. package/dist/presentation/routes/rate-limit.d.ts +10 -0
  184. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  185. package/dist/presentation/routes/rate-limit.js +101 -0
  186. package/dist/presentation/routes/rate-limit.js.map +1 -0
  187. package/dist/presentation/routes/registration/signup.d.ts +2 -0
  188. package/dist/presentation/routes/registration/signup.d.ts.map +1 -0
  189. package/dist/presentation/routes/registration/signup.js +82 -0
  190. package/dist/presentation/routes/registration/signup.js.map +1 -0
  191. package/dist/presentation/routes/session/login.d.ts +2 -0
  192. package/dist/presentation/routes/session/login.d.ts.map +1 -0
  193. package/dist/presentation/routes/session/login.js +48 -0
  194. package/dist/presentation/routes/session/login.js.map +1 -0
  195. package/dist/presentation/routes/session/logout.d.ts +2 -0
  196. package/dist/presentation/routes/session/logout.d.ts.map +1 -0
  197. package/dist/presentation/routes/session/logout.js +25 -0
  198. package/dist/presentation/routes/session/logout.js.map +1 -0
  199. package/dist/presentation/routes/session/me.d.ts +2 -0
  200. package/dist/presentation/routes/session/me.d.ts.map +1 -0
  201. package/dist/presentation/routes/session/me.js +42 -0
  202. package/dist/presentation/routes/session/me.js.map +1 -0
  203. package/dist/presentation/routes/session/refresh.d.ts +2 -0
  204. package/dist/presentation/routes/session/refresh.d.ts.map +1 -0
  205. package/dist/presentation/routes/session/refresh.js +49 -0
  206. package/dist/presentation/routes/session/refresh.js.map +1 -0
  207. package/dist/presentation/subscribers/index.d.ts +3 -0
  208. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  209. package/dist/presentation/subscribers/index.js +4 -0
  210. package/dist/presentation/subscribers/index.js.map +1 -0
  211. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +3 -0
  212. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +1 -0
  213. package/dist/presentation/subscribers/on-email-verification-send-requested.js +13 -0
  214. package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +1 -0
  215. package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts +3 -0
  216. package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts.map +1 -0
  217. package/dist/presentation/subscribers/on-password-reset-send-requested.js +14 -0
  218. package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -0
  219. package/dist/tsconfig.test.tsbuildinfo +1 -0
  220. package/drizzle/0000_initial.sql +81 -0
  221. package/drizzle/meta/0000_snapshot.json +643 -0
  222. package/drizzle/meta/_journal.json +13 -0
  223. package/drizzle.config.ts +7 -0
  224. package/package.json +88 -0
  225. package/src/config.ts +88 -0
  226. package/src/core/auth.test.ts +675 -0
  227. package/src/core/auth.ts +560 -0
  228. package/src/core/entities/email-verification.ts +8 -0
  229. package/src/core/entities/password-reset.ts +8 -0
  230. package/src/core/entities/refresh-token.ts +11 -0
  231. package/src/core/entities/user.ts +12 -0
  232. package/src/core/errors.ts +123 -0
  233. package/src/core/job-lease-token.test.ts +201 -0
  234. package/src/core/job-lease-token.ts +83 -0
  235. package/src/core/jwt.test.ts +180 -0
  236. package/src/core/jwt.ts +66 -0
  237. package/src/core/password.test.ts +28 -0
  238. package/src/core/password.ts +21 -0
  239. package/src/core/rate-limit.test.ts +462 -0
  240. package/src/core/rate-limit.ts +130 -0
  241. package/src/core/runner-session-token.test.ts +126 -0
  242. package/src/core/runner-session-token.ts +49 -0
  243. package/src/db/db.ts +28 -0
  244. package/src/db/email-verifications.test.ts +61 -0
  245. package/src/db/email-verifications.ts +160 -0
  246. package/src/db/migrations.ts +4 -0
  247. package/src/db/password-resets.test.ts +61 -0
  248. package/src/db/password-resets.ts +69 -0
  249. package/src/db/rate-limits.ts +69 -0
  250. package/src/db/refresh-tokens.test.ts +164 -0
  251. package/src/db/refresh-tokens.ts +149 -0
  252. package/src/db/schema/common.ts +3 -0
  253. package/src/db/schema/email-verifications.ts +37 -0
  254. package/src/db/schema/outbox.ts +4 -0
  255. package/src/db/schema/password-resets.ts +37 -0
  256. package/src/db/schema/rate-limits.ts +27 -0
  257. package/src/db/schema/refresh-tokens.ts +43 -0
  258. package/src/db/schema/users.ts +37 -0
  259. package/src/db/users.test.ts +75 -0
  260. package/src/db/users.ts +121 -0
  261. package/src/index.test.ts +39 -0
  262. package/src/index.ts +69 -0
  263. package/src/metrics/index.ts +13 -0
  264. package/src/metrics/instance.test.ts +69 -0
  265. package/src/metrics/instance.ts +77 -0
  266. package/src/presentation/auth/bearer-token-auth.test.ts +99 -0
  267. package/src/presentation/auth/bearer-token-auth.ts +51 -0
  268. package/src/presentation/auth/jwt-auth.test.ts +136 -0
  269. package/src/presentation/auth/jwt-auth.ts +39 -0
  270. package/src/presentation/auth/lease-token-auth.test.ts +56 -0
  271. package/src/presentation/auth/lease-token-auth.ts +25 -0
  272. package/src/presentation/auth/refresh-cookie.test.ts +39 -0
  273. package/src/presentation/auth/refresh-cookie.ts +34 -0
  274. package/src/presentation/auth/runner-session-auth.test.ts +54 -0
  275. package/src/presentation/auth/runner-session-auth.ts +13 -0
  276. package/src/presentation/dto/user.ts +21 -0
  277. package/src/presentation/e2eRoutes/create-session.ts +39 -0
  278. package/src/presentation/e2eRoutes/create-user.ts +38 -0
  279. package/src/presentation/e2eRoutes/index.test.ts +102 -0
  280. package/src/presentation/e2eRoutes/index.ts +10 -0
  281. package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +66 -0
  282. package/src/presentation/routes/email-verification/verify-email-confirm.ts +37 -0
  283. package/src/presentation/routes/email-verification/verify-email-resend.test.ts +100 -0
  284. package/src/presentation/routes/email-verification/verify-email-resend.ts +26 -0
  285. package/src/presentation/routes/index.ts +29 -0
  286. package/src/presentation/routes/password/change-password.test.ts +106 -0
  287. package/src/presentation/routes/password/change-password.ts +45 -0
  288. package/src/presentation/routes/password/password-reset-confirm.test.ts +63 -0
  289. package/src/presentation/routes/password/password-reset-confirm.ts +35 -0
  290. package/src/presentation/routes/password/password-reset-request.test.ts +56 -0
  291. package/src/presentation/routes/password/password-reset-request.ts +25 -0
  292. package/src/presentation/routes/rate-limit.test.ts +341 -0
  293. package/src/presentation/routes/rate-limit.ts +119 -0
  294. package/src/presentation/routes/registration/signup.test.ts +284 -0
  295. package/src/presentation/routes/registration/signup.ts +81 -0
  296. package/src/presentation/routes/session/login.test.ts +77 -0
  297. package/src/presentation/routes/session/login.ts +50 -0
  298. package/src/presentation/routes/session/logout.test.ts +55 -0
  299. package/src/presentation/routes/session/logout.ts +23 -0
  300. package/src/presentation/routes/session/me.test.ts +56 -0
  301. package/src/presentation/routes/session/me.ts +35 -0
  302. package/src/presentation/routes/session/refresh.test.ts +89 -0
  303. package/src/presentation/routes/session/refresh.ts +54 -0
  304. package/src/presentation/subscribers/email-send-requested.test.ts +77 -0
  305. package/src/presentation/subscribers/index.ts +2 -0
  306. package/src/presentation/subscribers/on-email-verification-send-requested.ts +10 -0
  307. package/src/presentation/subscribers/on-password-reset-send-requested.ts +13 -0
  308. package/test/env.ts +10 -0
  309. package/test/factories/index.ts +1 -0
  310. package/test/factories/user.ts +36 -0
  311. package/test/globalSetup.ts +18 -0
  312. package/test/index.ts +1 -0
  313. package/test/routes.ts +275 -0
  314. package/test/setup.ts +17 -0
  315. package/tsconfig.build.json +9 -0
  316. package/tsconfig.build.tsbuildinfo +1 -0
  317. package/tsconfig.json +4 -0
  318. package/tsconfig.test.json +8 -0
  319. package/vitest.config.ts +11 -0
@@ -0,0 +1,462 @@
1
+ import {and, eq, sql} from 'drizzle-orm';
2
+ import {db} from '#db/db.js';
3
+ import {pruneExpiredAuthRateLimits} from '#db/rate-limits.js';
4
+ import {authRateLimits} from '#db/schema/rate-limits.js';
5
+ import {
6
+ AuthRateLimitUnavailableError,
7
+ checkAuthRateLimit,
8
+ hashAuthRateLimitIdentifier,
9
+ } from './rate-limit.js';
10
+
11
+ const HMAC_HEX_PATTERN = /^[a-f0-9]{64}$/;
12
+
13
+ function identifier(prefix: string): string {
14
+ return `${prefix}-${crypto.randomUUID()}@example.com`;
15
+ }
16
+
17
+ async function countRows(params: {
18
+ action: string;
19
+ scope: string;
20
+ identifierHmac: string;
21
+ }): Promise<number> {
22
+ const rows = await db()
23
+ .select({count: sql<number>`count(*)::int`})
24
+ .from(authRateLimits)
25
+ .where(
26
+ and(
27
+ eq(authRateLimits.action, params.action),
28
+ eq(authRateLimits.scope, params.scope),
29
+ eq(authRateLimits.identifierHmac, params.identifierHmac),
30
+ ),
31
+ );
32
+
33
+ return rows[0]?.count ?? 0;
34
+ }
35
+
36
+ describe('checkAuthRateLimit', () => {
37
+ it('allows attempts under the limit and rejects the first over-limit attempt', async () => {
38
+ const email = identifier('limit');
39
+ const now = new Date('2026-06-23T00:00:10Z');
40
+
41
+ await checkAuthRateLimit({
42
+ action: 'login',
43
+ scope: 'email',
44
+ identifier: email,
45
+ limit: 2,
46
+ windowSeconds: 60,
47
+ now,
48
+ });
49
+ await checkAuthRateLimit({
50
+ action: 'login',
51
+ scope: 'email',
52
+ identifier: email,
53
+ limit: 2,
54
+ windowSeconds: 60,
55
+ now,
56
+ });
57
+ const act = checkAuthRateLimit({
58
+ action: 'login',
59
+ scope: 'email',
60
+ identifier: email,
61
+ limit: 2,
62
+ windowSeconds: 60,
63
+ now,
64
+ });
65
+
66
+ await expect(act).rejects.toMatchObject({
67
+ name: 'AuthRateLimitExceededError',
68
+ retryAfterSeconds: 50,
69
+ });
70
+ });
71
+
72
+ it('resets counters in the next fixed window', async () => {
73
+ const email = identifier('window-reset');
74
+ const firstWindow = new Date('2026-06-23T00:00:10Z');
75
+ const secondWindow = new Date('2026-06-23T00:01:01Z');
76
+
77
+ await checkAuthRateLimit({
78
+ action: 'login',
79
+ scope: 'email',
80
+ identifier: email,
81
+ limit: 1,
82
+ windowSeconds: 60,
83
+ now: firstWindow,
84
+ });
85
+ const result = checkAuthRateLimit({
86
+ action: 'login',
87
+ scope: 'email',
88
+ identifier: email,
89
+ limit: 1,
90
+ windowSeconds: 60,
91
+ now: secondWindow,
92
+ });
93
+
94
+ await expect(result).resolves.toBeUndefined();
95
+ });
96
+
97
+ it('keeps actions and scopes separated', async () => {
98
+ const value = identifier('separated');
99
+ const now = new Date('2026-06-23T00:02:10Z');
100
+
101
+ await checkAuthRateLimit({
102
+ action: 'login',
103
+ scope: 'ip',
104
+ identifier: value,
105
+ limit: 1,
106
+ windowSeconds: 60,
107
+ now,
108
+ });
109
+ const emailScope = checkAuthRateLimit({
110
+ action: 'login',
111
+ scope: 'email',
112
+ identifier: value,
113
+ limit: 1,
114
+ windowSeconds: 60,
115
+ now,
116
+ });
117
+ const otherAction = checkAuthRateLimit({
118
+ action: 'email-send',
119
+ scope: 'ip',
120
+ identifier: value,
121
+ limit: 1,
122
+ windowSeconds: 60,
123
+ now,
124
+ });
125
+
126
+ await expect(emailScope).resolves.toBeUndefined();
127
+ await expect(otherAction).resolves.toBeUndefined();
128
+ });
129
+
130
+ it('uses atomic concurrent upserts', async () => {
131
+ const email = identifier('concurrent');
132
+ const now = new Date('2026-06-23T00:03:10Z');
133
+ const identifierHmac = hashAuthRateLimitIdentifier({
134
+ action: 'login',
135
+ scope: 'email',
136
+ identifier: email,
137
+ });
138
+
139
+ await Promise.all(
140
+ Array.from({length: 20}, () =>
141
+ checkAuthRateLimit({
142
+ action: 'login',
143
+ scope: 'email',
144
+ identifier: email,
145
+ limit: 100,
146
+ windowSeconds: 60,
147
+ now,
148
+ timeoutMs: 5_000,
149
+ }),
150
+ ),
151
+ );
152
+ const rows = await db()
153
+ .select({count: authRateLimits.count})
154
+ .from(authRateLimits)
155
+ .where(eq(authRateLimits.identifierHmac, identifierHmac));
156
+
157
+ expect(rows).toHaveLength(1);
158
+ expect(rows[0]?.count).toBe(20);
159
+ });
160
+
161
+ it('fails closed when the limiter query times out', async () => {
162
+ const email = identifier('timeout');
163
+ const now = new Date('2026-06-23T00:03:30Z');
164
+ const identifierHmac = hashAuthRateLimitIdentifier({
165
+ action: 'login',
166
+ scope: 'email',
167
+ identifier: email,
168
+ });
169
+ await db()
170
+ .insert(authRateLimits)
171
+ .values({
172
+ action: 'login',
173
+ scope: 'email',
174
+ identifierHmac,
175
+ windowStart: new Date('2026-06-23T00:03:00Z'),
176
+ count: 1,
177
+ expiresAt: new Date('2026-06-23T00:04:00Z'),
178
+ });
179
+
180
+ await db().transaction(async (tx) => {
181
+ await tx.execute(sql`
182
+ SELECT 1
183
+ FROM auth_rate_limits
184
+ WHERE identifier_hmac = ${identifierHmac}
185
+ FOR UPDATE
186
+ `);
187
+ const act = checkAuthRateLimit({
188
+ action: 'login',
189
+ scope: 'email',
190
+ identifier: email,
191
+ limit: 1,
192
+ windowSeconds: 60,
193
+ now,
194
+ timeoutMs: 10,
195
+ });
196
+
197
+ await expect(act).rejects.toBeInstanceOf(AuthRateLimitUnavailableError);
198
+ });
199
+ });
200
+
201
+ it('prunes expired counters', async () => {
202
+ const expiredIdentifierHmac = hashAuthRateLimitIdentifier({
203
+ action: 'login',
204
+ scope: 'email',
205
+ identifier: identifier('expired'),
206
+ });
207
+ const activeIdentifierHmac = hashAuthRateLimitIdentifier({
208
+ action: 'login',
209
+ scope: 'email',
210
+ identifier: identifier('active'),
211
+ });
212
+ const now = new Date('2026-06-23T00:04:00Z');
213
+ await db()
214
+ .insert(authRateLimits)
215
+ .values([
216
+ {
217
+ action: 'login',
218
+ scope: 'email',
219
+ identifierHmac: expiredIdentifierHmac,
220
+ windowStart: new Date('2026-06-22T23:00:00Z'),
221
+ count: 1,
222
+ expiresAt: new Date('2026-06-22T23:15:00Z'),
223
+ },
224
+ {
225
+ action: 'login',
226
+ scope: 'email',
227
+ identifierHmac: activeIdentifierHmac,
228
+ windowStart: new Date('2026-06-23T00:00:00Z'),
229
+ count: 1,
230
+ expiresAt: new Date('2026-06-23T00:15:00Z'),
231
+ },
232
+ ]);
233
+
234
+ const result = await pruneExpiredAuthRateLimits({now, minIntervalMs: 0});
235
+ const expiredRows = await countRows({
236
+ action: 'login',
237
+ scope: 'email',
238
+ identifierHmac: expiredIdentifierHmac,
239
+ });
240
+ const activeRows = await countRows({
241
+ action: 'login',
242
+ scope: 'email',
243
+ identifierHmac: activeIdentifierHmac,
244
+ });
245
+
246
+ expect(result).toBeGreaterThanOrEqual(1);
247
+ expect(expiredRows).toBe(0);
248
+ expect(activeRows).toBe(1);
249
+ });
250
+
251
+ it('does not wait for opportunistic pruning after an allowed check', async () => {
252
+ const now = new Date('2026-06-23T00:04:30Z');
253
+ const expiresAt = new Date('2026-06-23T00:05:00Z');
254
+ const consumeAuthRateLimit = vi.fn().mockResolvedValue({count: 1, expiresAt});
255
+ let finishPrune: ((value: number) => void) | undefined;
256
+ const pendingPrune = new Promise<number>((resolve) => {
257
+ finishPrune = resolve;
258
+ });
259
+ const pruneExpiredAuthRateLimits = vi.fn(() => pendingPrune);
260
+ vi.resetModules();
261
+ vi.doMock('#config.js', () => ({
262
+ config: {
263
+ AUTH_JWT_SECRET: 'jwt-secret',
264
+ RATE_LIMIT_IDENTIFIER_SECRET: undefined,
265
+ },
266
+ }));
267
+ vi.doMock('#db/rate-limits.js', () => ({
268
+ consumeAuthRateLimit,
269
+ pruneExpiredAuthRateLimits,
270
+ }));
271
+ vi.doMock('#metrics/index.js', () => ({
272
+ recordAuthRateLimitCheck: vi.fn(),
273
+ recordAuthRateLimitPruneFailure: vi.fn(),
274
+ }));
275
+
276
+ try {
277
+ const rateLimitModule = await import('./rate-limit.js');
278
+ const result = await Promise.race([
279
+ rateLimitModule
280
+ .checkAuthRateLimit({
281
+ action: 'login',
282
+ scope: 'email',
283
+ identifier: 'person@example.com',
284
+ limit: 2,
285
+ windowSeconds: 60,
286
+ now,
287
+ })
288
+ .then(() => 'resolved' as const),
289
+ new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 20)),
290
+ ]);
291
+
292
+ expect(result).toBe('resolved');
293
+ expect(pruneExpiredAuthRateLimits).toHaveBeenCalledWith({now});
294
+ } finally {
295
+ finishPrune?.(0);
296
+ vi.doUnmock('#config.js');
297
+ vi.doUnmock('#db/rate-limits.js');
298
+ vi.doUnmock('#metrics/index.js');
299
+ vi.resetModules();
300
+ }
301
+ });
302
+
303
+ it('throttles prune attempts inside the configured interval', async () => {
304
+ const firstIdentifierHmac = hashAuthRateLimitIdentifier({
305
+ action: 'login',
306
+ scope: 'email',
307
+ identifier: identifier('first-prune'),
308
+ });
309
+ const secondIdentifierHmac = hashAuthRateLimitIdentifier({
310
+ action: 'login',
311
+ scope: 'email',
312
+ identifier: identifier('second-prune'),
313
+ });
314
+ await db()
315
+ .insert(authRateLimits)
316
+ .values({
317
+ action: 'login',
318
+ scope: 'email',
319
+ identifierHmac: firstIdentifierHmac,
320
+ windowStart: new Date('2026-06-23T00:00:00Z'),
321
+ count: 1,
322
+ expiresAt: new Date('2026-06-23T00:01:00Z'),
323
+ });
324
+ const firstResult = await pruneExpiredAuthRateLimits({
325
+ now: new Date('2026-06-23T00:05:00Z'),
326
+ minIntervalMs: 60_000,
327
+ });
328
+ await db()
329
+ .insert(authRateLimits)
330
+ .values({
331
+ action: 'login',
332
+ scope: 'email',
333
+ identifierHmac: secondIdentifierHmac,
334
+ windowStart: new Date('2026-06-23T00:01:00Z'),
335
+ count: 1,
336
+ expiresAt: new Date('2026-06-23T00:02:00Z'),
337
+ });
338
+
339
+ const secondResult = await pruneExpiredAuthRateLimits({
340
+ now: new Date('2026-06-23T00:05:30Z'),
341
+ minIntervalMs: 60_000,
342
+ });
343
+ const secondRows = await countRows({
344
+ action: 'login',
345
+ scope: 'email',
346
+ identifierHmac: secondIdentifierHmac,
347
+ });
348
+
349
+ expect(firstResult).toBeGreaterThanOrEqual(1);
350
+ expect(secondResult).toBeUndefined();
351
+ expect(secondRows).toBe(1);
352
+ });
353
+
354
+ it('stores only HMAC identifiers, not raw emails or IPs', async () => {
355
+ const email = identifier('privacy');
356
+ const ip = '203.0.113.42';
357
+ const emailHmac = hashAuthRateLimitIdentifier({
358
+ action: 'login',
359
+ scope: 'email',
360
+ identifier: email,
361
+ });
362
+ const ipHmac = hashAuthRateLimitIdentifier({
363
+ action: 'login',
364
+ scope: 'ip',
365
+ identifier: ip,
366
+ });
367
+
368
+ await checkAuthRateLimit({
369
+ action: 'login',
370
+ scope: 'email',
371
+ identifier: email,
372
+ limit: 1,
373
+ windowSeconds: 60,
374
+ });
375
+ await checkAuthRateLimit({
376
+ action: 'login',
377
+ scope: 'ip',
378
+ identifier: ip,
379
+ limit: 1,
380
+ windowSeconds: 60,
381
+ });
382
+ const stored = await db()
383
+ .select({
384
+ identifierHmac: authRateLimits.identifierHmac,
385
+ })
386
+ .from(authRateLimits)
387
+ .where(eq(authRateLimits.action, 'login'));
388
+
389
+ expect(stored.map((row) => row.identifierHmac)).toContain(emailHmac);
390
+ expect(stored.map((row) => row.identifierHmac)).toContain(ipHmac);
391
+ expect(await countRows({action: 'login', scope: 'email', identifierHmac: emailHmac})).toBe(1);
392
+ expect(JSON.stringify(stored)).not.toContain(email);
393
+ expect(JSON.stringify(stored)).not.toContain(ip);
394
+ expect(emailHmac).toMatch(HMAC_HEX_PATTERN);
395
+ expect(ipHmac).toMatch(HMAC_HEX_PATTERN);
396
+ });
397
+
398
+ it('reports blocked attempts with non-PII context', async () => {
399
+ const email = identifier('blocked-context');
400
+
401
+ await checkAuthRateLimit({
402
+ action: 'login',
403
+ scope: 'email',
404
+ identifier: email,
405
+ limit: 1,
406
+ windowSeconds: 60,
407
+ });
408
+ const act = checkAuthRateLimit({
409
+ action: 'login',
410
+ scope: 'email',
411
+ identifier: email,
412
+ limit: 1,
413
+ windowSeconds: 60,
414
+ });
415
+
416
+ await expect(act).rejects.toMatchObject({
417
+ name: 'AuthRateLimitExceededError',
418
+ action: 'login',
419
+ scope: 'email',
420
+ identifierHmacPrefix: expect.not.stringContaining(email),
421
+ });
422
+ });
423
+
424
+ it('uses the configured identifier secret when present', async () => {
425
+ vi.resetModules();
426
+ vi.doMock('#config.js', () => ({
427
+ config: {
428
+ AUTH_JWT_SECRET: 'jwt-secret',
429
+ RATE_LIMIT_IDENTIFIER_SECRET: 'configured-secret',
430
+ },
431
+ }));
432
+
433
+ try {
434
+ const configuredSecretModule = await import('./rate-limit.js');
435
+ const configuredSecretHash = configuredSecretModule.hashAuthRateLimitIdentifier({
436
+ action: 'login',
437
+ scope: 'email',
438
+ identifier: 'person@example.com',
439
+ });
440
+ vi.doMock('#config.js', () => ({
441
+ config: {
442
+ AUTH_JWT_SECRET: 'jwt-secret',
443
+ RATE_LIMIT_IDENTIFIER_SECRET: undefined,
444
+ },
445
+ }));
446
+ vi.resetModules();
447
+ const derivedSecretModule = await import('./rate-limit.js');
448
+ const derivedSecretHash = derivedSecretModule.hashAuthRateLimitIdentifier({
449
+ action: 'login',
450
+ scope: 'email',
451
+ identifier: 'person@example.com',
452
+ });
453
+
454
+ expect(configuredSecretHash).toMatch(HMAC_HEX_PATTERN);
455
+ expect(derivedSecretHash).toMatch(HMAC_HEX_PATTERN);
456
+ expect(configuredSecretHash).not.toBe(derivedSecretHash);
457
+ } finally {
458
+ vi.doUnmock('#config.js');
459
+ vi.resetModules();
460
+ }
461
+ });
462
+ });
@@ -0,0 +1,130 @@
1
+ import {createHmac} from 'node:crypto';
2
+ import {
3
+ checkRateLimit,
4
+ hashRateLimitIdentifier,
5
+ RateLimitExceededError,
6
+ type RateLimitPolicy,
7
+ RateLimitUnavailableError,
8
+ } from '@shipfox/node-rate-limit';
9
+ import {config} from '#config.js';
10
+ import {consumeAuthRateLimit, pruneExpiredAuthRateLimits} from '#db/rate-limits.js';
11
+ import {
12
+ type AuthRateLimitAction,
13
+ type AuthRateLimitScope,
14
+ recordAuthRateLimitCheck,
15
+ recordAuthRateLimitPruneFailure,
16
+ } from '#metrics/index.js';
17
+
18
+ export type {
19
+ AuthRateLimitAction,
20
+ AuthRateLimitOutcome,
21
+ AuthRateLimitScope,
22
+ } from '#metrics/index.js';
23
+
24
+ export type AuthRateLimitPolicy = RateLimitPolicy;
25
+
26
+ export interface CheckAuthRateLimitParams extends AuthRateLimitPolicy {
27
+ action: AuthRateLimitAction;
28
+ scope: AuthRateLimitScope;
29
+ identifier: string;
30
+ now?: Date | undefined;
31
+ timeoutMs?: number | undefined;
32
+ }
33
+
34
+ export class AuthRateLimitExceededError extends RateLimitExceededError<
35
+ AuthRateLimitAction,
36
+ AuthRateLimitScope
37
+ > {
38
+ constructor(params: {
39
+ action: AuthRateLimitAction;
40
+ scope: AuthRateLimitScope;
41
+ retryAfterSeconds: number;
42
+ identifierHmacPrefix: string;
43
+ }) {
44
+ super(params);
45
+ this.name = 'AuthRateLimitExceededError';
46
+ this.message = 'Authentication rate limit exceeded';
47
+ }
48
+ }
49
+
50
+ export class AuthRateLimitUnavailableError extends RateLimitUnavailableError<
51
+ AuthRateLimitAction,
52
+ AuthRateLimitScope
53
+ > {
54
+ constructor(params: {
55
+ action: AuthRateLimitAction;
56
+ scope: AuthRateLimitScope;
57
+ identifierHmacPrefix: string;
58
+ cause: unknown;
59
+ }) {
60
+ super(params);
61
+ this.name = 'AuthRateLimitUnavailableError';
62
+ this.message = 'Authentication rate limiter unavailable';
63
+ }
64
+ }
65
+
66
+ const DEFAULT_TIMEOUT_MS = 250;
67
+ const IDENTIFIER_SECRET_DERIVATION_DOMAIN = 'shipfox.auth.rate-limit.identifier-secret.v1';
68
+ const IDENTIFIER_HASH_DOMAIN = 'shipfox.auth.rate-limit.identifier.v1';
69
+
70
+ function effectiveIdentifierSecret(): Buffer | string {
71
+ if (config.RATE_LIMIT_IDENTIFIER_SECRET) {
72
+ return config.RATE_LIMIT_IDENTIFIER_SECRET;
73
+ }
74
+
75
+ return createHmac('sha256', config.AUTH_JWT_SECRET)
76
+ .update(IDENTIFIER_SECRET_DERIVATION_DOMAIN)
77
+ .digest();
78
+ }
79
+
80
+ export function hashAuthRateLimitIdentifier(params: {
81
+ action: AuthRateLimitAction;
82
+ scope: AuthRateLimitScope;
83
+ identifier: string;
84
+ }): string {
85
+ return hashRateLimitIdentifier({
86
+ action: params.action,
87
+ scope: params.scope,
88
+ identifier: params.identifier,
89
+ secret: effectiveIdentifierSecret(),
90
+ domain: IDENTIFIER_HASH_DOMAIN,
91
+ });
92
+ }
93
+
94
+ export async function checkAuthRateLimit(params: CheckAuthRateLimitParams): Promise<void> {
95
+ try {
96
+ await checkRateLimit({
97
+ action: params.action,
98
+ scope: params.scope,
99
+ identifier: params.identifier,
100
+ limit: params.limit,
101
+ windowSeconds: params.windowSeconds,
102
+ identifierSecret: effectiveIdentifierSecret(),
103
+ identifierHashDomain: IDENTIFIER_HASH_DOMAIN,
104
+ consume: consumeAuthRateLimit,
105
+ prune: pruneExpiredAuthRateLimits,
106
+ onCheck: recordAuthRateLimitCheck,
107
+ onPruneFailure: recordAuthRateLimitPruneFailure,
108
+ now: params.now,
109
+ timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
110
+ });
111
+ } catch (error) {
112
+ if (error instanceof RateLimitExceededError) {
113
+ throw new AuthRateLimitExceededError({
114
+ action: error.action,
115
+ scope: error.scope,
116
+ retryAfterSeconds: error.retryAfterSeconds,
117
+ identifierHmacPrefix: error.identifierHmacPrefix,
118
+ });
119
+ }
120
+ if (error instanceof RateLimitUnavailableError) {
121
+ throw new AuthRateLimitUnavailableError({
122
+ action: error.action,
123
+ scope: error.scope,
124
+ identifierHmacPrefix: error.identifierHmacPrefix,
125
+ cause: error.cause,
126
+ });
127
+ }
128
+ throw error;
129
+ }
130
+ }
@@ -0,0 +1,126 @@
1
+ import {JOB_LEASE_TOKEN_AUDIENCE, RUNNER_SESSION_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
2
+ import {signHs256} from '@shipfox/node-jwt';
3
+ import {generateKeyPair, SignJWT} from 'jose';
4
+ import {issueJobLeaseToken, verifyJobLeaseToken} from './job-lease-token.js';
5
+ import {issueRunnerSessionToken, verifyRunnerSessionToken} from './runner-session-token.js';
6
+
7
+ const SECRET = process.env.AUTH_RUNNER_SESSION_TOKEN_SECRET ?? 'test-runner-session-secret';
8
+
9
+ function claims() {
10
+ return {
11
+ runnerSessionId: crypto.randomUUID(),
12
+ workspaceId: crypto.randomUUID(),
13
+ scope: 'workspace' as const,
14
+ labels: ['linux', 'x64'],
15
+ maxClaims: null,
16
+ };
17
+ }
18
+
19
+ describe('runner-session-token', () => {
20
+ test('issues a token that verifies round-trip', async () => {
21
+ const input = claims();
22
+
23
+ const token = await issueRunnerSessionToken(input);
24
+ const verified = await verifyRunnerSessionToken(token);
25
+
26
+ expect(verified).not.toBeNull();
27
+ expect(verified?.runnerSessionId).toBe(input.runnerSessionId);
28
+ expect(verified?.workspaceId).toBe(input.workspaceId);
29
+ expect(verified?.scope).toBe(input.scope);
30
+ expect(verified?.labels).toEqual(input.labels);
31
+ expect(verified?.maxClaims).toBeNull();
32
+ expect(verified?.aud).toBe(RUNNER_SESSION_TOKEN_AUDIENCE);
33
+ expect(verified?.iat).toBeTypeOf('number');
34
+ expect(verified?.exp).toBeGreaterThan(verified?.iat ?? 0);
35
+ });
36
+
37
+ test('rejects a job lease token', async () => {
38
+ const leaseToken = await issueJobLeaseToken({
39
+ workflowRunId: crypto.randomUUID(),
40
+ workflowRunAttemptId: crypto.randomUUID(),
41
+ jobId: crypto.randomUUID(),
42
+ jobExecutionId: crypto.randomUUID(),
43
+ projectId: crypto.randomUUID(),
44
+ workspaceId: crypto.randomUUID(),
45
+ runnerSessionId: crypto.randomUUID(),
46
+ });
47
+
48
+ const verified = await verifyRunnerSessionToken(leaseToken);
49
+
50
+ expect(verified).toBeNull();
51
+ });
52
+
53
+ test('job lease verifier rejects a runner session token', async () => {
54
+ const sessionToken = await issueRunnerSessionToken(claims());
55
+
56
+ const verified = await verifyJobLeaseToken(sessionToken);
57
+
58
+ expect(verified).toBeNull();
59
+ });
60
+
61
+ test('returns null for a token signed with a different secret', async () => {
62
+ const token = await signHs256({
63
+ payload: claims(),
64
+ secret: 'a-different-secret',
65
+ expiresIn: '1h',
66
+ audience: RUNNER_SESSION_TOKEN_AUDIENCE,
67
+ });
68
+
69
+ const verified = await verifyRunnerSessionToken(token);
70
+
71
+ expect(verified).toBeNull();
72
+ });
73
+
74
+ test('returns null for an expired token', async () => {
75
+ const token = await signHs256({
76
+ payload: claims(),
77
+ secret: SECRET,
78
+ expiresIn: '-1s',
79
+ audience: RUNNER_SESSION_TOKEN_AUDIENCE,
80
+ });
81
+
82
+ const verified = await verifyRunnerSessionToken(token);
83
+
84
+ expect(verified).toBeNull();
85
+ });
86
+
87
+ test('returns null for an RS256-signed token', async () => {
88
+ const {privateKey} = await generateKeyPair('RS256');
89
+ const token = await new SignJWT(claims())
90
+ .setProtectedHeader({alg: 'RS256'})
91
+ .setIssuedAt()
92
+ .setExpirationTime('1h')
93
+ .setAudience(RUNNER_SESSION_TOKEN_AUDIENCE)
94
+ .sign(privateKey);
95
+
96
+ const verified = await verifyRunnerSessionToken(token);
97
+
98
+ expect(verified).toBeNull();
99
+ });
100
+
101
+ test('returns null for a token with the wrong audience', async () => {
102
+ const token = await signHs256({
103
+ payload: claims(),
104
+ secret: SECRET,
105
+ expiresIn: '1h',
106
+ audience: JOB_LEASE_TOKEN_AUDIENCE,
107
+ });
108
+
109
+ const verified = await verifyRunnerSessionToken(token);
110
+
111
+ expect(verified).toBeNull();
112
+ });
113
+
114
+ test('returns null for a token whose claims fail the schema', async () => {
115
+ const token = await signHs256({
116
+ payload: {...claims(), labels: []},
117
+ secret: SECRET,
118
+ expiresIn: '1h',
119
+ audience: RUNNER_SESSION_TOKEN_AUDIENCE,
120
+ });
121
+
122
+ const verified = await verifyRunnerSessionToken(token);
123
+
124
+ expect(verified).toBeNull();
125
+ });
126
+ });