@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,63 @@
1
+ import {AUTH_PASSWORD_RESET_SEND_REQUESTED} from '@shipfox/api-auth-dto';
2
+ import type {FastifyInstance} from 'fastify';
3
+ import {
4
+ createAuthTestApp,
5
+ extractToken,
6
+ getSetCookie,
7
+ latestEmailLinkTo,
8
+ login,
9
+ resetCapturedMail,
10
+ signupVerifyLogin,
11
+ } from '#test/routes.js';
12
+
13
+ describe('POST /auth/password-reset/confirm', () => {
14
+ let app: FastifyInstance;
15
+
16
+ beforeAll(async () => {
17
+ app = await createAuthTestApp();
18
+ });
19
+
20
+ beforeEach(() => {
21
+ resetCapturedMail();
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await app.close();
26
+ });
27
+
28
+ test('updates credentials with a reset token', async () => {
29
+ const account = await signupVerifyLogin(app, 'reset-confirm');
30
+ await app.inject({
31
+ method: 'POST',
32
+ url: '/auth/password-reset',
33
+ payload: {email: account.email},
34
+ });
35
+ const resetToken = extractToken(
36
+ await latestEmailLinkTo(account.email, AUTH_PASSWORD_RESET_SEND_REQUESTED),
37
+ );
38
+
39
+ const confirm = await app.inject({
40
+ method: 'POST',
41
+ url: '/auth/password-reset/confirm',
42
+ payload: {token: resetToken, new_password: 'reset password is long'},
43
+ });
44
+ const loginRes = await login(app, {email: account.email, password: 'reset password is long'});
45
+
46
+ expect(confirm.statusCode).toBe(200);
47
+ expect(confirm.json().token).toBeDefined();
48
+ expect(confirm.json().user.email).toBe(account.email);
49
+ expect(getSetCookie(confirm)).toContain('shipfox_refresh_token=');
50
+ expect(loginRes.statusCode).toBe(200);
51
+ });
52
+
53
+ test('transforms invalid token into 410', async () => {
54
+ const res = await app.inject({
55
+ method: 'POST',
56
+ url: '/auth/password-reset/confirm',
57
+ payload: {token: 'bad-token', new_password: 'reset password is long'},
58
+ });
59
+
60
+ expect(res.statusCode).toBe(410);
61
+ expect(res.json().code).toBe('token-invalid');
62
+ });
63
+ });
@@ -0,0 +1,35 @@
1
+ import {
2
+ passwordResetConfirmBodySchema,
3
+ passwordResetConfirmResponseSchema,
4
+ } from '@shipfox/api-auth-dto';
5
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
6
+ import {confirmPasswordReset} from '#core/auth.js';
7
+ import {TokenInvalidError} from '#core/errors.js';
8
+ import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
9
+ import {toAuthSessionDto} from '#presentation/dto/user.js';
10
+
11
+ export const passwordResetConfirmRoute = defineRoute({
12
+ method: 'POST',
13
+ path: '/password-reset/confirm',
14
+ description: 'Set a new password using the link sent by email.',
15
+ schema: {
16
+ body: passwordResetConfirmBodySchema,
17
+ response: {
18
+ 200: passwordResetConfirmResponseSchema,
19
+ },
20
+ },
21
+ errorHandler: (error) => {
22
+ if (error instanceof TokenInvalidError) {
23
+ throw new ClientError('Reset token is invalid or expired', 'token-invalid', {status: 410});
24
+ }
25
+ throw error;
26
+ },
27
+ handler: async (request, reply) => {
28
+ const {token, new_password} = request.body;
29
+
30
+ const result = await confirmPasswordReset({token, newPassword: new_password});
31
+ setRefreshTokenCookie(reply, result.refreshToken);
32
+
33
+ return toAuthSessionDto(result);
34
+ },
35
+ });
@@ -0,0 +1,56 @@
1
+ import {AUTH_PASSWORD_RESET_SEND_REQUESTED} from '@shipfox/api-auth-dto';
2
+ import type {FastifyInstance} from 'fastify';
3
+ import {
4
+ capturedMail,
5
+ createAuthTestApp,
6
+ latestEmailLinkTo,
7
+ outboxEventsTo,
8
+ resetCapturedMail,
9
+ signupVerifyLogin,
10
+ uniqueEmail,
11
+ } from '#test/routes.js';
12
+
13
+ describe('POST /auth/password-reset', () => {
14
+ let app: FastifyInstance;
15
+
16
+ beforeAll(async () => {
17
+ app = await createAuthTestApp();
18
+ });
19
+
20
+ beforeEach(() => {
21
+ resetCapturedMail();
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await app.close();
26
+ });
27
+
28
+ test('returns 204 and sends reset mail for an active account', async () => {
29
+ const account = await signupVerifyLogin(app, 'reset-request');
30
+
31
+ const res = await app.inject({
32
+ method: 'POST',
33
+ url: '/auth/password-reset',
34
+ payload: {email: account.email.toUpperCase()},
35
+ });
36
+
37
+ expect(res.statusCode).toBe(204);
38
+ expect(await latestEmailLinkTo(account.email, AUTH_PASSWORD_RESET_SEND_REQUESTED)).toContain(
39
+ '/auth/reset?token=',
40
+ );
41
+ expect(capturedMail()).toHaveLength(0);
42
+ });
43
+
44
+ test('returns 204 without revealing missing accounts', async () => {
45
+ const email = uniqueEmail('missing-reset');
46
+
47
+ const res = await app.inject({
48
+ method: 'POST',
49
+ url: '/auth/password-reset',
50
+ payload: {email},
51
+ });
52
+
53
+ expect(res.statusCode).toBe(204);
54
+ expect(await outboxEventsTo(email, AUTH_PASSWORD_RESET_SEND_REQUESTED)).toHaveLength(0);
55
+ });
56
+ });
@@ -0,0 +1,25 @@
1
+ import {passwordResetRequestBodySchema} from '@shipfox/api-auth-dto';
2
+ import {defineRoute} from '@shipfox/node-fastify';
3
+ import {z} from 'zod';
4
+ import {requestPasswordReset} from '#core/auth.js';
5
+ import {createAuthRateLimitPreHandler} from '#presentation/routes/rate-limit.js';
6
+
7
+ export const passwordResetRequestRoute = defineRoute({
8
+ method: 'POST',
9
+ path: '/password-reset',
10
+ description: 'Send a password reset email when an account is eligible.',
11
+ schema: {
12
+ body: passwordResetRequestBodySchema,
13
+ response: {
14
+ 204: z.void(),
15
+ },
16
+ },
17
+ preHandler: createAuthRateLimitPreHandler('email-send'),
18
+ handler: async (request, reply) => {
19
+ const {email} = request.body;
20
+
21
+ await requestPasswordReset({email});
22
+
23
+ reply.code(204);
24
+ },
25
+ });
@@ -0,0 +1,341 @@
1
+ import {AUTH_PASSWORD_RESET_SEND_REQUESTED} from '@shipfox/api-auth-dto';
2
+ import type {AppConfig, FastifyInstance} from '@shipfox/node-fastify';
3
+ import {and, eq, sql} from 'drizzle-orm';
4
+ import {
5
+ type AuthRateLimitAction,
6
+ type AuthRateLimitScope,
7
+ hashAuthRateLimitIdentifier,
8
+ } from '#core/rate-limit.js';
9
+ import {db} from '#db/db.js';
10
+ import {authRateLimits} from '#db/schema/rate-limits.js';
11
+ import {
12
+ createAuthTestApp,
13
+ outboxEventsTo,
14
+ resetCapturedMail,
15
+ signupVerifyLogin,
16
+ uniqueEmail,
17
+ } from '#test/routes.js';
18
+
19
+ let ipCounter = 1;
20
+
21
+ function uniqueIp(): string {
22
+ ipCounter += 1;
23
+ return `203.0.113.${ipCounter}`;
24
+ }
25
+
26
+ async function seedExhaustedBucket(params: {
27
+ action: AuthRateLimitAction;
28
+ scope: AuthRateLimitScope;
29
+ identifier: string;
30
+ limit: number;
31
+ windowSeconds: number;
32
+ }): Promise<void> {
33
+ const now = new Date();
34
+ const windowStart = windowStartFor(now, params.windowSeconds);
35
+ const identifierHmac = hashAuthRateLimitIdentifier({
36
+ action: params.action,
37
+ scope: params.scope,
38
+ identifier: params.identifier,
39
+ });
40
+ await db()
41
+ .insert(authRateLimits)
42
+ .values({
43
+ action: params.action,
44
+ scope: params.scope,
45
+ identifierHmac,
46
+ windowStart,
47
+ count: params.limit,
48
+ expiresAt: new Date(windowStart.getTime() + params.windowSeconds * 1000),
49
+ });
50
+ }
51
+
52
+ async function countBucket(params: {
53
+ action: AuthRateLimitAction;
54
+ scope: AuthRateLimitScope;
55
+ identifier: string;
56
+ }): Promise<number> {
57
+ const identifierHmac = hashAuthRateLimitIdentifier({
58
+ action: params.action,
59
+ scope: params.scope,
60
+ identifier: params.identifier,
61
+ });
62
+ const rows = await db()
63
+ .select({count: sql<number>`count(*)::int`})
64
+ .from(authRateLimits)
65
+ .where(
66
+ and(
67
+ eq(authRateLimits.action, params.action),
68
+ eq(authRateLimits.scope, params.scope),
69
+ eq(authRateLimits.identifierHmac, identifierHmac),
70
+ ),
71
+ );
72
+
73
+ return rows[0]?.count ?? 0;
74
+ }
75
+
76
+ async function bucketAttemptCount(params: {
77
+ action: AuthRateLimitAction;
78
+ scope: AuthRateLimitScope;
79
+ identifier: string;
80
+ }): Promise<number> {
81
+ const identifierHmac = hashAuthRateLimitIdentifier({
82
+ action: params.action,
83
+ scope: params.scope,
84
+ identifier: params.identifier,
85
+ });
86
+ const rows = await db()
87
+ .select({count: authRateLimits.count})
88
+ .from(authRateLimits)
89
+ .where(
90
+ and(
91
+ eq(authRateLimits.action, params.action),
92
+ eq(authRateLimits.scope, params.scope),
93
+ eq(authRateLimits.identifierHmac, identifierHmac),
94
+ ),
95
+ );
96
+
97
+ return rows[0]?.count ?? 0;
98
+ }
99
+
100
+ function windowStartFor(now: Date, windowSeconds: number): Date {
101
+ const windowMs = windowSeconds * 1000;
102
+ return new Date(Math.floor(now.getTime() / windowMs) * windowMs);
103
+ }
104
+
105
+ type LoggerInstance = NonNullable<NonNullable<AppConfig['fastifyOptions']>['loggerInstance']>;
106
+
107
+ function createCapturingLogger(logs: unknown[]): LoggerInstance {
108
+ const logger = {
109
+ child: () => logger,
110
+ level: 'info',
111
+ silent: (...args: unknown[]) => {
112
+ logs.push(['silent', ...args]);
113
+ },
114
+ fatal: (...args: unknown[]) => {
115
+ logs.push(['fatal', ...args]);
116
+ },
117
+ error: (...args: unknown[]) => {
118
+ logs.push(['error', ...args]);
119
+ },
120
+ warn: (...args: unknown[]) => {
121
+ logs.push(['warn', ...args]);
122
+ },
123
+ info: (...args: unknown[]) => {
124
+ logs.push(['info', ...args]);
125
+ },
126
+ debug: (...args: unknown[]) => {
127
+ logs.push(['debug', ...args]);
128
+ },
129
+ trace: (...args: unknown[]) => {
130
+ logs.push(['trace', ...args]);
131
+ },
132
+ };
133
+ return logger as unknown as LoggerInstance;
134
+ }
135
+
136
+ describe('auth rate-limit routes', () => {
137
+ let app: FastifyInstance;
138
+
139
+ beforeEach(async () => {
140
+ resetCapturedMail();
141
+ app = await createAuthTestApp({fastifyOptions: {trustProxy: true}});
142
+ });
143
+
144
+ afterEach(async () => {
145
+ await app.close();
146
+ });
147
+
148
+ it('blocks exhausted login IP buckets before login work runs', async () => {
149
+ const ip = uniqueIp();
150
+ await seedExhaustedBucket({
151
+ action: 'login',
152
+ scope: 'ip',
153
+ identifier: ip,
154
+ limit: 60,
155
+ windowSeconds: 5 * 60,
156
+ });
157
+
158
+ const res = await app.inject({
159
+ method: 'POST',
160
+ url: '/auth/login',
161
+ headers: {'x-forwarded-for': ip},
162
+ payload: {email: uniqueEmail('login-ip-block'), password: 'wrong password'},
163
+ });
164
+
165
+ expect(res.statusCode).toBe(429);
166
+ expect(res.json()).toEqual({
167
+ code: 'rate-limited',
168
+ details: {retry_after_seconds: expect.any(Number)},
169
+ });
170
+ expect(res.headers['retry-after']).toEqual(expect.any(String));
171
+ });
172
+
173
+ it('blocks exhausted login email buckets after the IP bucket passes', async () => {
174
+ const ip = uniqueIp();
175
+ const email = uniqueEmail('login-email-block');
176
+ await seedExhaustedBucket({
177
+ action: 'login',
178
+ scope: 'email',
179
+ identifier: email,
180
+ limit: 10,
181
+ windowSeconds: 15 * 60,
182
+ });
183
+
184
+ const res = await app.inject({
185
+ method: 'POST',
186
+ url: '/auth/login',
187
+ headers: {'x-forwarded-for': ip},
188
+ payload: {email, password: 'wrong password'},
189
+ });
190
+
191
+ expect(res.statusCode).toBe(429);
192
+ expect(res.json().code).toBe('rate-limited');
193
+ expect(await countBucket({action: 'login', scope: 'ip', identifier: ip})).toBe(1);
194
+ });
195
+
196
+ it('shares the email-send bucket across password reset and verification resend', async () => {
197
+ const account = await signupVerifyLogin(app, 'email-send-shared');
198
+ resetCapturedMail();
199
+
200
+ for (let index = 0; index < 3; index += 1) {
201
+ const res = await app.inject({
202
+ method: 'POST',
203
+ url: '/auth/password-reset',
204
+ headers: {'x-forwarded-for': uniqueIp()},
205
+ payload: {email: account.email},
206
+ });
207
+ expect(res.statusCode).toBe(204);
208
+ }
209
+ const blockedReset = await app.inject({
210
+ method: 'POST',
211
+ url: '/auth/password-reset',
212
+ headers: {'x-forwarded-for': uniqueIp()},
213
+ payload: {email: account.email},
214
+ });
215
+ const blockedResend = await app.inject({
216
+ method: 'POST',
217
+ url: '/auth/verify-email/resend',
218
+ headers: {'x-forwarded-for': uniqueIp()},
219
+ payload: {email: account.email},
220
+ });
221
+
222
+ expect(blockedReset.statusCode).toBe(429);
223
+ expect(blockedResend.statusCode).toBe(429);
224
+ expect(await outboxEventsTo(account.email, AUTH_PASSWORD_RESET_SEND_REQUESTED)).toHaveLength(3);
225
+ });
226
+
227
+ it('blocks exhausted email-send IP buckets before consuming email buckets', async () => {
228
+ const ip = uniqueIp();
229
+ const blockedEmail = uniqueEmail('email-send-ip-blocked');
230
+ await seedExhaustedBucket({
231
+ action: 'email-send',
232
+ scope: 'ip',
233
+ identifier: ip,
234
+ limit: 30,
235
+ windowSeconds: 60 * 60,
236
+ });
237
+
238
+ const blocked = await app.inject({
239
+ method: 'POST',
240
+ url: '/auth/password-reset',
241
+ headers: {'x-forwarded-for': ip},
242
+ payload: {email: blockedEmail},
243
+ });
244
+
245
+ expect(blocked.statusCode).toBe(429);
246
+ expect(blocked.json().code).toBe('rate-limited');
247
+ expect(await bucketAttemptCount({action: 'email-send', scope: 'ip', identifier: ip})).toBe(31);
248
+ expect(
249
+ await countBucket({action: 'email-send', scope: 'email', identifier: blockedEmail}),
250
+ ).toBe(0);
251
+ });
252
+
253
+ it('does not consume buckets for invalid bodies', async () => {
254
+ const ip = uniqueIp();
255
+
256
+ const res = await app.inject({
257
+ method: 'POST',
258
+ url: '/auth/login',
259
+ headers: {'x-forwarded-for': ip},
260
+ payload: {email: 'not-an-email', password: 'wrong password'},
261
+ });
262
+
263
+ expect(res.statusCode).toBe(400);
264
+ expect(await countBucket({action: 'login', scope: 'ip', identifier: ip})).toBe(0);
265
+ });
266
+
267
+ it('fails closed when limiter storage is unavailable', async () => {
268
+ const ip = uniqueIp();
269
+ const identifierHmac = hashAuthRateLimitIdentifier({
270
+ action: 'login',
271
+ scope: 'ip',
272
+ identifier: ip,
273
+ });
274
+ const windowStart = windowStartFor(new Date(), 5 * 60);
275
+ await db()
276
+ .insert(authRateLimits)
277
+ .values({
278
+ action: 'login',
279
+ scope: 'ip',
280
+ identifierHmac,
281
+ windowStart,
282
+ count: 1,
283
+ expiresAt: new Date(windowStart.getTime() + 5 * 60 * 1000),
284
+ });
285
+
286
+ await db().transaction(async (tx) => {
287
+ await tx.execute(sql`
288
+ SELECT 1
289
+ FROM auth_rate_limits
290
+ WHERE identifier_hmac = ${identifierHmac}
291
+ FOR UPDATE
292
+ `);
293
+ const res = await app.inject({
294
+ method: 'POST',
295
+ url: '/auth/login',
296
+ headers: {'x-forwarded-for': ip},
297
+ payload: {email: uniqueEmail('limiter-unavailable'), password: 'wrong password'},
298
+ });
299
+
300
+ expect(res.statusCode).toBe(503);
301
+ expect(res.json()).toEqual({code: 'auth-rate-limit-unavailable'});
302
+ });
303
+ });
304
+
305
+ it('does not log raw email addresses or IP addresses for blocked requests', async () => {
306
+ await app.close();
307
+ const logs: unknown[] = [];
308
+ app = await createAuthTestApp({
309
+ fastifyOptions: {loggerInstance: createCapturingLogger(logs), trustProxy: true},
310
+ });
311
+ const ip = uniqueIp();
312
+ const email = uniqueEmail('log-privacy');
313
+ await seedExhaustedBucket({
314
+ action: 'login',
315
+ scope: 'email',
316
+ identifier: email,
317
+ limit: 10,
318
+ windowSeconds: 15 * 60,
319
+ });
320
+
321
+ const res = await app.inject({
322
+ method: 'POST',
323
+ url: '/auth/login',
324
+ headers: {'x-forwarded-for': ip},
325
+ payload: {email, password: 'wrong password'},
326
+ });
327
+ const authLogs = logs.filter((entry) => {
328
+ const serializedEntry = JSON.stringify(entry);
329
+ return (
330
+ serializedEntry.includes('Auth rate limit blocked request') ||
331
+ serializedEntry.includes('Rate limit exceeded')
332
+ );
333
+ });
334
+ const serializedLogs = JSON.stringify(authLogs);
335
+
336
+ expect(res.statusCode).toBe(429);
337
+ expect(serializedLogs).toContain('identifierHmacPrefix');
338
+ expect(serializedLogs).not.toContain(email);
339
+ expect(serializedLogs).not.toContain(ip);
340
+ });
341
+ });
@@ -0,0 +1,119 @@
1
+ import {ClientError, type FastifyReply, type FastifyRequest} from '@shipfox/node-fastify';
2
+ import {
3
+ type AuthRateLimitAction,
4
+ AuthRateLimitExceededError,
5
+ type AuthRateLimitPolicy,
6
+ type AuthRateLimitScope,
7
+ AuthRateLimitUnavailableError,
8
+ checkAuthRateLimit,
9
+ } from '#core/rate-limit.js';
10
+
11
+ const policies: Record<AuthRateLimitAction, Record<AuthRateLimitScope, AuthRateLimitPolicy>> = {
12
+ login: {
13
+ ip: {limit: 60, windowSeconds: 5 * 60},
14
+ email: {limit: 10, windowSeconds: 15 * 60},
15
+ },
16
+ 'email-send': {
17
+ ip: {limit: 30, windowSeconds: 60 * 60},
18
+ email: {limit: 3, windowSeconds: 60 * 60},
19
+ },
20
+ };
21
+
22
+ interface EmailBody {
23
+ email: string;
24
+ }
25
+
26
+ function routeName(request: FastifyRequest): string {
27
+ return request.routeOptions.url ?? request.url.split('?')[0] ?? 'unknown';
28
+ }
29
+
30
+ async function enforceRateLimit(params: {
31
+ request: FastifyRequest;
32
+ reply: FastifyReply;
33
+ action: AuthRateLimitAction;
34
+ scope: AuthRateLimitScope;
35
+ identifier: string;
36
+ }): Promise<void> {
37
+ try {
38
+ await checkAuthRateLimit({
39
+ action: params.action,
40
+ scope: params.scope,
41
+ identifier: params.identifier,
42
+ ...policies[params.action][params.scope],
43
+ });
44
+ } catch (error) {
45
+ if (error instanceof AuthRateLimitExceededError) {
46
+ params.request.log.warn(
47
+ {
48
+ action: error.action,
49
+ scope: error.scope,
50
+ route: routeName(params.request),
51
+ retryAfterSeconds: error.retryAfterSeconds,
52
+ identifierHmacPrefix: error.identifierHmacPrefix,
53
+ },
54
+ 'Auth rate limit blocked request',
55
+ );
56
+ params.reply.header('Retry-After', String(error.retryAfterSeconds));
57
+ throw new ClientError('Rate limit exceeded', 'rate-limited', {
58
+ status: 429,
59
+ details: {retry_after_seconds: error.retryAfterSeconds},
60
+ data: {
61
+ action: error.action,
62
+ scope: error.scope,
63
+ route: routeName(params.request),
64
+ identifierHmacPrefix: error.identifierHmacPrefix,
65
+ },
66
+ cause: error,
67
+ });
68
+ }
69
+
70
+ if (error instanceof AuthRateLimitUnavailableError) {
71
+ params.request.log.error(
72
+ {
73
+ action: error.action,
74
+ scope: error.scope,
75
+ route: routeName(params.request),
76
+ identifierHmacPrefix: error.identifierHmacPrefix,
77
+ err: error,
78
+ },
79
+ 'Auth rate limiter unavailable',
80
+ );
81
+ throw new ClientError(
82
+ 'Authentication rate limiter unavailable',
83
+ 'auth-rate-limit-unavailable',
84
+ {
85
+ status: 503,
86
+ data: {
87
+ action: error.action,
88
+ scope: error.scope,
89
+ route: routeName(params.request),
90
+ identifierHmacPrefix: error.identifierHmacPrefix,
91
+ },
92
+ cause: error,
93
+ },
94
+ );
95
+ }
96
+
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ export function createAuthRateLimitPreHandler(action: AuthRateLimitAction) {
102
+ return async (request: FastifyRequest<{Body: EmailBody}>, reply: FastifyReply): Promise<void> => {
103
+ await enforceRateLimit({
104
+ request,
105
+ reply,
106
+ action,
107
+ scope: 'ip',
108
+ identifier: request.ip,
109
+ });
110
+
111
+ await enforceRateLimit({
112
+ request,
113
+ reply,
114
+ action,
115
+ scope: 'email',
116
+ identifier: request.body.email,
117
+ });
118
+ };
119
+ }