@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,69 @@
1
+ const metricMocks = vi.hoisted(() => {
2
+ const counters = new Map<string, {add: ReturnType<typeof vi.fn>}>();
3
+ const createCounter = vi.fn((name: string) => {
4
+ const counter = {add: vi.fn()};
5
+ counters.set(name, counter);
6
+ return counter;
7
+ });
8
+
9
+ return {counters, createCounter};
10
+ });
11
+
12
+ vi.mock('@shipfox/node-opentelemetry', () => ({
13
+ instanceMetrics: {
14
+ getMeter: () => ({
15
+ createCounter: metricMocks.createCounter,
16
+ }),
17
+ },
18
+ }));
19
+
20
+ const metrics = await import('./instance.js');
21
+
22
+ function counterAdd(name: string): ReturnType<typeof vi.fn> {
23
+ const counter = metricMocks.counters.get(name);
24
+ if (!counter) throw new Error(`Missing counter: ${name}`);
25
+ return counter.add;
26
+ }
27
+
28
+ describe('auth metrics', () => {
29
+ beforeEach(() => {
30
+ for (const counter of metricMocks.counters.values()) {
31
+ counter.add.mockReset();
32
+ }
33
+ });
34
+
35
+ it('records auth rate-limit checks with low-cardinality labels', () => {
36
+ metrics.recordAuthRateLimitCheck({
37
+ action: 'login',
38
+ scope: 'ip',
39
+ outcome: 'blocked',
40
+ });
41
+
42
+ expect(counterAdd('auth_rate_limit_checks')).toHaveBeenCalledWith(1, {
43
+ action: 'login',
44
+ scope: 'ip',
45
+ outcome: 'blocked',
46
+ });
47
+ });
48
+
49
+ it('records auth rate-limit prune failures', () => {
50
+ metrics.recordAuthRateLimitPruneFailure();
51
+
52
+ expect(counterAdd('auth_rate_limit_prune_failures')).toHaveBeenCalledWith(1);
53
+ });
54
+
55
+ it('does not let metric failures affect callers', () => {
56
+ counterAdd('auth_rate_limit_checks').mockImplementationOnce(() => {
57
+ throw new Error('metrics unavailable');
58
+ });
59
+
60
+ const act = () =>
61
+ metrics.recordAuthRateLimitCheck({
62
+ action: 'email-send',
63
+ scope: 'email',
64
+ outcome: 'unavailable',
65
+ });
66
+
67
+ expect(act).not.toThrow();
68
+ });
69
+ });
@@ -0,0 +1,77 @@
1
+ import {instanceMetrics} from '@shipfox/node-opentelemetry';
2
+
3
+ export type AuthTokenType = 'session' | 'job_lease' | 'runner_session';
4
+ export type AuthTokenVerificationOutcome = 'ok' | 'rejected';
5
+ export type AuthTokenRefreshOutcome = 'rotated' | 'grace' | 'rejected';
6
+ export type AuthRateLimitAction = 'login' | 'email-send';
7
+ export type AuthRateLimitScope = 'ip' | 'email';
8
+ export type AuthRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
9
+
10
+ const meter = instanceMetrics.getMeter('auth');
11
+
12
+ const tokenIssuedCount = meter.createCounter<{token_type: AuthTokenType}>('auth_token_issued', {
13
+ description: 'Tokens issued by token type',
14
+ });
15
+
16
+ const tokenVerifiedCount = meter.createCounter<{
17
+ token_type: AuthTokenType;
18
+ outcome: AuthTokenVerificationOutcome;
19
+ }>('auth_token_verified', {description: 'Token verification attempts by token type and outcome'});
20
+
21
+ const tokenRefreshedCount = meter.createCounter<{outcome: AuthTokenRefreshOutcome}>(
22
+ 'auth_token_refreshed',
23
+ {description: 'Refresh-token exchanges by outcome'},
24
+ );
25
+
26
+ const rateLimitCheckCount = meter.createCounter<{
27
+ action: AuthRateLimitAction;
28
+ scope: AuthRateLimitScope;
29
+ outcome: AuthRateLimitOutcome;
30
+ }>('auth_rate_limit_checks', {
31
+ description: 'Authentication rate limit checks by action, scope, and outcome',
32
+ });
33
+
34
+ const rateLimitPruneFailureCount = meter.createCounter('auth_rate_limit_prune_failures', {
35
+ description: 'Authentication rate limit prune failures',
36
+ });
37
+
38
+ function recordMetric(record: () => void): void {
39
+ try {
40
+ record();
41
+ } catch {
42
+ // Metrics must not affect authentication outcomes.
43
+ }
44
+ }
45
+
46
+ export function recordTokenIssued(tokenType: AuthTokenType): void {
47
+ recordMetric(() => tokenIssuedCount.add(1, {token_type: tokenType}));
48
+ }
49
+
50
+ export function recordTokenVerified(
51
+ tokenType: AuthTokenType,
52
+ outcome: AuthTokenVerificationOutcome,
53
+ ): void {
54
+ recordMetric(() => tokenVerifiedCount.add(1, {token_type: tokenType, outcome}));
55
+ }
56
+
57
+ export function recordTokenRefreshed(outcome: AuthTokenRefreshOutcome): void {
58
+ recordMetric(() => tokenRefreshedCount.add(1, {outcome}));
59
+ }
60
+
61
+ export function recordAuthRateLimitCheck(params: {
62
+ action: AuthRateLimitAction;
63
+ scope: AuthRateLimitScope;
64
+ outcome: AuthRateLimitOutcome;
65
+ }): void {
66
+ recordMetric(() =>
67
+ rateLimitCheckCount.add(1, {
68
+ action: params.action,
69
+ scope: params.scope,
70
+ outcome: params.outcome,
71
+ }),
72
+ );
73
+ }
74
+
75
+ export function recordAuthRateLimitPruneFailure(): void {
76
+ recordMetric(() => rateLimitPruneFailureCount.add(1));
77
+ }
@@ -0,0 +1,99 @@
1
+ import {errorHandler} from '@shipfox/node-fastify';
2
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
3
+ import Fastify from 'fastify';
4
+ import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
5
+ import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
6
+
7
+ const CONTEXT_KEY = 'testContext';
8
+
9
+ interface TestClaims {
10
+ subject: string;
11
+ }
12
+
13
+ describe('bearer-token-auth', () => {
14
+ let app: FastifyInstance;
15
+ let verifyToken: ReturnType<typeof vi.fn<(token: string) => Promise<TestClaims | null>>>;
16
+
17
+ beforeEach(async () => {
18
+ verifyToken = vi.fn<(token: string) => Promise<TestClaims | null>>();
19
+ app = Fastify();
20
+ app.setValidatorCompiler(validatorCompiler);
21
+ app.setSerializerCompiler(serializerCompiler);
22
+ app.setErrorHandler(errorHandler);
23
+
24
+ const authMethod = createBearerTokenAuthMethod({
25
+ name: 'test-bearer',
26
+ verifyToken,
27
+ invalidTokenError: {message: 'Invalid test bearer token', code: 'invalid-test-token'},
28
+ setContext: (request, claims) => {
29
+ (request as FastifyRequest & Record<typeof CONTEXT_KEY, typeof claims>)[CONTEXT_KEY] =
30
+ claims;
31
+ },
32
+ });
33
+
34
+ app.addHook('onRequest', async (request, reply) => {
35
+ await authMethod.authenticate(request, reply);
36
+ });
37
+
38
+ app.get('/protected', (request) => {
39
+ return {
40
+ context: (request as FastifyRequest & Record<typeof CONTEXT_KEY, unknown>)[CONTEXT_KEY],
41
+ };
42
+ });
43
+ await app.ready();
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await app.close();
48
+ });
49
+
50
+ test('valid bearer token sets context on the request', async () => {
51
+ verifyToken.mockResolvedValue({subject: 'runner-1'});
52
+
53
+ const res = await app.inject({
54
+ method: 'GET',
55
+ url: '/protected',
56
+ headers: {authorization: 'Bearer valid-token'},
57
+ });
58
+
59
+ expect(res.statusCode).toBe(200);
60
+ expect(verifyToken).toHaveBeenCalledWith('valid-token');
61
+ expect(res.json().context).toEqual({subject: 'runner-1'});
62
+ });
63
+
64
+ test('missing bearer token returns the shared unauthorized response', async () => {
65
+ const res = await app.inject({method: 'GET', url: '/protected'});
66
+
67
+ expect(res.statusCode).toBe(401);
68
+ expect(res.json()).toEqual({code: 'unauthorized'});
69
+ expect(verifyToken).not.toHaveBeenCalled();
70
+ });
71
+
72
+ test('invalid bearer token returns the configured unauthorized response', async () => {
73
+ verifyToken.mockResolvedValue(null);
74
+
75
+ const res = await app.inject({
76
+ method: 'GET',
77
+ url: '/protected',
78
+ headers: {authorization: 'Bearer invalid-token'},
79
+ });
80
+
81
+ expect(res.statusCode).toBe(401);
82
+ expect(res.json()).toEqual({code: 'invalid-test-token'});
83
+ expect(verifyToken).toHaveBeenCalledWith('invalid-token');
84
+ });
85
+
86
+ test('thrown verifier errors return the configured unauthorized response', async () => {
87
+ verifyToken.mockRejectedValue(new Error('bad signature'));
88
+
89
+ const res = await app.inject({
90
+ method: 'GET',
91
+ url: '/protected',
92
+ headers: {authorization: 'Bearer invalid-token'},
93
+ });
94
+
95
+ expect(res.statusCode).toBe(401);
96
+ expect(res.json()).toEqual({code: 'invalid-test-token'});
97
+ expect(verifyToken).toHaveBeenCalledWith('invalid-token');
98
+ });
99
+ });
@@ -0,0 +1,51 @@
1
+ import {type AuthMethod, ClientError, extractBearerToken} from '@shipfox/node-fastify';
2
+ import type {FastifyRequest} from 'fastify';
3
+
4
+ interface UnauthorizedErrorParams {
5
+ message: string;
6
+ code: string;
7
+ }
8
+
9
+ interface BearerTokenAuthMethodOptions<TClaims> {
10
+ name: string;
11
+ verifyToken: (token: string) => Promise<TClaims | null>;
12
+ invalidTokenError: UnauthorizedErrorParams;
13
+ setContext: (request: FastifyRequest, claims: TClaims) => void;
14
+ }
15
+
16
+ const missingBearerError: UnauthorizedErrorParams = {
17
+ message: 'Missing or invalid Authorization header',
18
+ code: 'unauthorized',
19
+ };
20
+
21
+ export function createBearerTokenAuthMethod<TClaims>(
22
+ options: BearerTokenAuthMethodOptions<TClaims>,
23
+ ): AuthMethod {
24
+ return {
25
+ name: options.name,
26
+ authenticate: async (request) => {
27
+ const token = extractBearerToken(request.headers.authorization);
28
+ if (!token) throwUnauthorized(missingBearerError);
29
+
30
+ const claims = await verifyBearerToken(token, options.verifyToken);
31
+ if (!claims) throwUnauthorized(options.invalidTokenError);
32
+
33
+ options.setContext(request, claims);
34
+ },
35
+ };
36
+ }
37
+
38
+ async function verifyBearerToken<TClaims>(
39
+ token: string,
40
+ verifyToken: (token: string) => Promise<TClaims | null>,
41
+ ): Promise<TClaims | null> {
42
+ try {
43
+ return await verifyToken(token);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function throwUnauthorized(error: UnauthorizedErrorParams): never {
50
+ throw new ClientError(error.message, error.code, {status: 401});
51
+ }
@@ -0,0 +1,136 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import Fastify from 'fastify';
3
+ import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
4
+ import {signUserToken} from '#core/jwt.js';
5
+ import {createUser, findUserById} from '#db/users.js';
6
+ import {createJwtAuthMethod, getClientContext} from './jwt-auth.js';
7
+
8
+ const SECRET = 'test-secret';
9
+
10
+ function emailFor(suffix: string): string {
11
+ return `${suffix}-${crypto.randomUUID()}@example.com`;
12
+ }
13
+
14
+ describe('jwt-auth', () => {
15
+ let app: FastifyInstance;
16
+
17
+ beforeAll(async () => {
18
+ app = Fastify();
19
+ app.setValidatorCompiler(validatorCompiler);
20
+ app.setSerializerCompiler(serializerCompiler);
21
+
22
+ const authMethod = createJwtAuthMethod();
23
+ app.addHook('onRequest', async (request, reply) => {
24
+ await authMethod.authenticate(request, reply);
25
+ });
26
+
27
+ app.get('/protected', (request) => {
28
+ return {client: getClientContext(request)};
29
+ });
30
+ await app.ready();
31
+ });
32
+
33
+ test('decorates request.client on success', async () => {
34
+ const user = await createUser({email: emailFor('jwt-ok'), hashedPassword: 'h'});
35
+ const token = await signUserToken({
36
+ userId: user.id,
37
+ email: user.email,
38
+ name: 'JWT User',
39
+ memberships: [],
40
+ secret: SECRET,
41
+ expiresIn: '7d',
42
+ });
43
+
44
+ const res = await app.inject({
45
+ method: 'GET',
46
+ url: '/protected',
47
+ headers: {authorization: `Bearer ${token}`},
48
+ });
49
+
50
+ expect(res.statusCode).toBe(200);
51
+ expect(res.json().client.userId).toBe(user.id);
52
+ expect(res.json().client.email).toBe(user.email);
53
+ expect(res.json().client.name).toBe('JWT User');
54
+ });
55
+
56
+ test('401 on missing Authorization header', async () => {
57
+ const res = await app.inject({method: 'GET', url: '/protected'});
58
+
59
+ expect(res.statusCode).toBe(401);
60
+ });
61
+
62
+ test('401 on wrong scheme', async () => {
63
+ const res = await app.inject({
64
+ method: 'GET',
65
+ url: '/protected',
66
+ headers: {authorization: 'Basic xxx'},
67
+ });
68
+
69
+ expect(res.statusCode).toBe(401);
70
+ });
71
+
72
+ test('401 on expired token', async () => {
73
+ const user = await createUser({email: emailFor('jwt-exp'), hashedPassword: 'h'});
74
+ const token = await signUserToken({
75
+ userId: user.id,
76
+ email: user.email,
77
+ memberships: [],
78
+ secret: SECRET,
79
+ expiresIn: '-1s',
80
+ });
81
+
82
+ const res = await app.inject({
83
+ method: 'GET',
84
+ url: '/protected',
85
+ headers: {authorization: `Bearer ${token}`},
86
+ });
87
+
88
+ expect(res.statusCode).toBe(401);
89
+ });
90
+
91
+ test('401 on tampered signature', async () => {
92
+ const user = await createUser({email: emailFor('jwt-tamper'), hashedPassword: 'h'});
93
+ const token = await signUserToken({
94
+ userId: user.id,
95
+ email: user.email,
96
+ memberships: [],
97
+ secret: SECRET,
98
+ expiresIn: '7d',
99
+ });
100
+ const tampered = `${token.slice(0, -4)}xxxx`;
101
+
102
+ const res = await app.inject({
103
+ method: 'GET',
104
+ url: '/protected',
105
+ headers: {authorization: `Bearer ${tampered}`},
106
+ });
107
+
108
+ expect(res.statusCode).toBe(401);
109
+ });
110
+
111
+ test('does not read user state during JWT validation', async () => {
112
+ const userId = crypto.randomUUID();
113
+ const email = emailFor('jwt-stateless');
114
+ const token = await signUserToken({
115
+ userId,
116
+ email,
117
+ memberships: [],
118
+ secret: SECRET,
119
+ expiresIn: '7d',
120
+ });
121
+
122
+ const res = await app.inject({
123
+ method: 'GET',
124
+ url: '/protected',
125
+ headers: {authorization: `Bearer ${token}`},
126
+ });
127
+
128
+ expect(res.statusCode).toBe(200);
129
+ expect(res.json().client.userId).toBe(userId);
130
+ expect(res.json().client.email).toBe(email);
131
+ });
132
+
133
+ test('helpers remain available', () => {
134
+ expect(typeof findUserById).toBe('function');
135
+ });
136
+ });
@@ -0,0 +1,39 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ getUserContext,
5
+ setUserContext,
6
+ type UserContext,
7
+ } from '@shipfox/api-auth-context';
8
+ import type {AuthMethod} from '@shipfox/node-fastify';
9
+ import type {FastifyRequest} from 'fastify';
10
+ import {config} from '#config.js';
11
+ import {verifyUserToken} from '#core/jwt.js';
12
+ import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
13
+
14
+ export type ClientContext = UserContext;
15
+
16
+ export interface CreateJwtAuthMethodOptions {
17
+ secret: string;
18
+ }
19
+
20
+ export function getClientContext(request: FastifyRequest): ClientContext | null {
21
+ return getUserContext(request);
22
+ }
23
+
24
+ export function createJwtAuthMethod(): AuthMethod {
25
+ return createBearerTokenAuthMethod({
26
+ name: AUTH_USER,
27
+ verifyToken: (token) => verifyUserToken({token, secret: config.AUTH_JWT_SECRET}),
28
+ invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
29
+ setContext: (request, claims) => {
30
+ const clientContext: ClientContext = buildUserContext({
31
+ userId: claims.sub,
32
+ email: claims.email,
33
+ name: claims.name ?? null,
34
+ memberships: claims.memberships,
35
+ });
36
+ setUserContext(request, clientContext);
37
+ },
38
+ });
39
+ }
@@ -0,0 +1,56 @@
1
+ import {getLeasedJobContext} from '@shipfox/api-auth-context';
2
+ import {JOB_LEASE_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
3
+ import type {FastifyInstance} from 'fastify';
4
+ import Fastify from 'fastify';
5
+ import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
6
+ import {issueJobLeaseToken} from '#core/job-lease-token.js';
7
+ import {createLeaseTokenAuthMethod} from './lease-token-auth.js';
8
+
9
+ describe('lease-token-auth', () => {
10
+ let app: FastifyInstance;
11
+
12
+ beforeAll(async () => {
13
+ app = Fastify();
14
+ app.setValidatorCompiler(validatorCompiler);
15
+ app.setSerializerCompiler(serializerCompiler);
16
+
17
+ const authMethod = createLeaseTokenAuthMethod();
18
+ app.addHook('onRequest', async (request, reply) => {
19
+ await authMethod.authenticate(request, reply);
20
+ });
21
+
22
+ app.get('/protected', (request) => {
23
+ return {leasedJob: getLeasedJobContext(request)};
24
+ });
25
+ await app.ready();
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await app.close();
30
+ });
31
+
32
+ test('valid lease token sets leased job context on the request', async () => {
33
+ const claims = {
34
+ workflowRunId: crypto.randomUUID(),
35
+ workflowRunAttemptId: crypto.randomUUID(),
36
+ jobId: crypto.randomUUID(),
37
+ jobExecutionId: crypto.randomUUID(),
38
+ projectId: crypto.randomUUID(),
39
+ workspaceId: crypto.randomUUID(),
40
+ runnerSessionId: crypto.randomUUID(),
41
+ };
42
+ const token = await issueJobLeaseToken(claims);
43
+
44
+ const res = await app.inject({
45
+ method: 'GET',
46
+ url: '/protected',
47
+ headers: {authorization: `Bearer ${token}`},
48
+ });
49
+
50
+ expect(res.statusCode).toBe(200);
51
+ expect(res.json().leasedJob).toMatchObject({
52
+ ...claims,
53
+ aud: JOB_LEASE_TOKEN_AUDIENCE,
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,25 @@
1
+ import {AUTH_LEASED_JOB, setLeasedJobContext} from '@shipfox/api-auth-context';
2
+ import type {AuthMethod} from '@shipfox/node-fastify';
3
+ import {verifyJobLeaseToken} from '#core/job-lease-token.js';
4
+ import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
5
+
6
+ /**
7
+ * Trust boundary: the signed token is the sole authority — `claims.jobId` scopes
8
+ * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`
9
+ * are carried for consumers but are NOT verified against the database here.
10
+ *
11
+ * Revocation tradeoff: `runnerSessionId` is carried in the claims but is not checked
12
+ * against runner-session storage, so a lease minted before its registration token is
13
+ * revoked stays usable until it expires (`AUTH_JOB_LEASE_TOKEN_EXPIRES_IN`, 90m).
14
+ * Accepted deliberately for a short-lived, job-scoped capability token; tightening
15
+ * this would mean a per-request DB lookup on the hot status-reporting path. See the
16
+ * "Security model" section in the package README for the full rationale.
17
+ */
18
+ export function createLeaseTokenAuthMethod(): AuthMethod {
19
+ return createBearerTokenAuthMethod({
20
+ name: AUTH_LEASED_JOB,
21
+ verifyToken: verifyJobLeaseToken,
22
+ invalidTokenError: {message: 'Invalid or expired job lease token', code: 'unauthorized'},
23
+ setContext: setLeasedJobContext,
24
+ });
25
+ }
@@ -0,0 +1,39 @@
1
+ import fastify from 'fastify';
2
+ import {authCookiePlugin, getRefreshTokenCookie, setRefreshTokenCookie} from '#index.js';
3
+
4
+ vi.mock('#config.js', () => ({
5
+ config: {
6
+ AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
7
+ AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
8
+ },
9
+ }));
10
+
11
+ describe('auth cookie extension point', () => {
12
+ test('a separately composed callback route can set and read the refresh cookie', async () => {
13
+ const app = fastify();
14
+ app.register(authCookiePlugin);
15
+ app.post('/auth/callbacks/session', (_request, reply) => {
16
+ setRefreshTokenCookie(reply, 'callback-refresh-token');
17
+ reply.code(204).send();
18
+ });
19
+ app.get('/auth/callbacks/session', (request) => ({
20
+ refresh_token: getRefreshTokenCookie(request) ?? null,
21
+ }));
22
+ await app.ready();
23
+
24
+ const callback = await app.inject({method: 'POST', url: '/auth/callbacks/session'});
25
+ const cookie = callback.headers['set-cookie'];
26
+ const session = await app.inject({
27
+ method: 'GET',
28
+ url: '/auth/callbacks/session',
29
+ headers: {cookie: Array.isArray(cookie) ? cookie[0] : cookie},
30
+ });
31
+
32
+ expect(callback.statusCode).toBe(204);
33
+ expect(cookie).toContain('shipfox_refresh_token=callback-refresh-token');
34
+ expect(cookie).toContain('Path=/auth');
35
+ expect(session.json()).toEqual({refresh_token: 'callback-refresh-token'});
36
+
37
+ await app.close();
38
+ });
39
+ });
@@ -0,0 +1,34 @@
1
+ import cookie from '@fastify/cookie';
2
+ import type {FastifyReply, FastifyRequest} from 'fastify';
3
+ import {config} from '#config.js';
4
+
5
+ const REFRESH_COOKIE_PATH = '/auth';
6
+
7
+ export const authCookiePlugin = cookie;
8
+
9
+ function refreshCookieMaxAgeSeconds(): number {
10
+ return config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS * 24 * 60 * 60;
11
+ }
12
+
13
+ export function getRefreshTokenCookie(request: FastifyRequest): string | undefined {
14
+ return request.cookies[config.AUTH_REFRESH_COOKIE_NAME];
15
+ }
16
+
17
+ export function setRefreshTokenCookie(reply: FastifyReply, refreshToken: string): void {
18
+ reply.setCookie(config.AUTH_REFRESH_COOKIE_NAME, refreshToken, {
19
+ httpOnly: true,
20
+ secure: true,
21
+ sameSite: 'lax',
22
+ path: REFRESH_COOKIE_PATH,
23
+ maxAge: refreshCookieMaxAgeSeconds(),
24
+ });
25
+ }
26
+
27
+ export function clearRefreshTokenCookie(reply: FastifyReply): void {
28
+ reply.clearCookie(config.AUTH_REFRESH_COOKIE_NAME, {
29
+ httpOnly: true,
30
+ secure: true,
31
+ sameSite: 'lax',
32
+ path: REFRESH_COOKIE_PATH,
33
+ });
34
+ }
@@ -0,0 +1,54 @@
1
+ import {getRunnerSessionContext} from '@shipfox/api-auth-context';
2
+ import {RUNNER_SESSION_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
3
+ import type {FastifyInstance} from 'fastify';
4
+ import Fastify from 'fastify';
5
+ import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
6
+ import {issueRunnerSessionToken} from '#core/runner-session-token.js';
7
+ import {createRunnerSessionAuthMethod} from './runner-session-auth.js';
8
+
9
+ describe('runner-session-auth', () => {
10
+ let app: FastifyInstance;
11
+
12
+ beforeAll(async () => {
13
+ app = Fastify();
14
+ app.setValidatorCompiler(validatorCompiler);
15
+ app.setSerializerCompiler(serializerCompiler);
16
+
17
+ const authMethod = createRunnerSessionAuthMethod();
18
+ app.addHook('onRequest', async (request, reply) => {
19
+ await authMethod.authenticate(request, reply);
20
+ });
21
+
22
+ app.get('/protected', (request) => {
23
+ return {runnerSession: getRunnerSessionContext(request)};
24
+ });
25
+ await app.ready();
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await app.close();
30
+ });
31
+
32
+ test('valid session token sets runner session context on the request', async () => {
33
+ const claims = {
34
+ runnerSessionId: crypto.randomUUID(),
35
+ workspaceId: crypto.randomUUID(),
36
+ scope: 'workspace' as const,
37
+ labels: ['linux', 'x64'],
38
+ maxClaims: null,
39
+ };
40
+ const token = await issueRunnerSessionToken(claims);
41
+
42
+ const res = await app.inject({
43
+ method: 'GET',
44
+ url: '/protected',
45
+ headers: {authorization: `Bearer ${token}`},
46
+ });
47
+
48
+ expect(res.statusCode).toBe(200);
49
+ expect(res.json().runnerSession).toMatchObject({
50
+ ...claims,
51
+ aud: RUNNER_SESSION_TOKEN_AUDIENCE,
52
+ });
53
+ });
54
+ });