@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,560 @@
1
+ import {EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS, emailSchema} from '@shipfox/api-auth-dto';
2
+ import {
3
+ acceptWorkspaceInvitation,
4
+ InvitationEmailMismatchError,
5
+ listMembershipsByUser,
6
+ peekInvitationByRawToken,
7
+ TokenAlreadyUsedError,
8
+ TokenExpiredError,
9
+ TokenInvalidError as WorkspacesTokenInvalidError,
10
+ } from '@shipfox/api-workspaces';
11
+ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
12
+ import {config} from '#config.js';
13
+ import {
14
+ consumeEmailVerification,
15
+ createEmailVerification,
16
+ createResendEmailVerification,
17
+ } from '#db/email-verifications.js';
18
+ import {consumePasswordReset, createPasswordReset} from '#db/password-resets.js';
19
+ import {
20
+ createRefreshToken,
21
+ findActiveRefreshTokenByHash,
22
+ findRefreshTokenByHash,
23
+ revokeRefreshTokenByHash,
24
+ revokeRefreshTokensForUser,
25
+ rotateRefreshToken,
26
+ } from '#db/refresh-tokens.js';
27
+ import {
28
+ createUser as createDbUser,
29
+ findUserByEmail,
30
+ findUserById,
31
+ markEmailVerified,
32
+ provisionUser as provisionDbUser,
33
+ updateUserPassword,
34
+ } from '#db/users.js';
35
+ import {type AuthTokenRefreshOutcome, recordTokenRefreshed} from '#metrics/index.js';
36
+ import type {RefreshToken} from './entities/refresh-token.js';
37
+ import type {User} from './entities/user.js';
38
+ import {
39
+ AuthDependencyUnavailableError,
40
+ EmailNotVerifiedError,
41
+ EmailTakenError,
42
+ InvalidCredentialsError,
43
+ TokenInvalidError,
44
+ UserNotFoundError,
45
+ } from './errors.js';
46
+ import {signUserToken} from './jwt.js';
47
+ import {hashPassword, verifyPassword} from './password.js';
48
+
49
+ const VERIFICATION_TTL_HOURS = 24;
50
+ const RESET_TTL_HOURS = 1;
51
+
52
+ let dummyHashCache: string | undefined;
53
+ async function getDummyHash(): Promise<string> {
54
+ if (!dummyHashCache) {
55
+ dummyHashCache = await hashPassword({password: 'dummy-for-timing-parity'});
56
+ }
57
+ return dummyHashCache;
58
+ }
59
+
60
+ function hoursFromNow(hours: number): Date {
61
+ return new Date(Date.now() + hours * 60 * 60 * 1000);
62
+ }
63
+
64
+ function daysFromNow(days: number): Date {
65
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
66
+ }
67
+
68
+ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
69
+ recordTokenRefreshed(outcome);
70
+ }
71
+
72
+ async function signAccessToken(user: User): Promise<string> {
73
+ const memberships = await listMembershipsByUser({userId: user.id}).catch((error: unknown) => {
74
+ throw new AuthDependencyUnavailableError('workspaces', error);
75
+ });
76
+ return await signUserToken({
77
+ userId: user.id,
78
+ email: user.email,
79
+ name: user.name,
80
+ memberships: memberships.map((m) => ({workspaceId: m.workspaceId, role: 'admin' as const})),
81
+ secret: config.AUTH_JWT_SECRET,
82
+ expiresIn: config.AUTH_JWT_EXPIRES_IN,
83
+ });
84
+ }
85
+
86
+ function isWithinRotationGrace(refreshToken: RefreshToken): boolean {
87
+ if (!refreshToken.rotatedAt) return false;
88
+ const graceMs = config.AUTH_REFRESH_ROTATION_GRACE_SECONDS * 1000;
89
+ return Date.now() - refreshToken.rotatedAt.getTime() <= graceMs;
90
+ }
91
+
92
+ async function createRefreshSession(user: User): Promise<string> {
93
+ const refreshToken = generateOpaqueToken('refreshToken');
94
+ await createRefreshToken({
95
+ userId: user.id,
96
+ hashedToken: hashOpaqueToken(refreshToken),
97
+ expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
98
+ });
99
+ return refreshToken;
100
+ }
101
+
102
+ function verificationLink(rawToken: string): string {
103
+ return `${config.CLIENT_BASE_URL}/auth/verify-email?token=${rawToken}`;
104
+ }
105
+
106
+ function passwordResetLink(rawToken: string): string {
107
+ return `${config.CLIENT_BASE_URL}/auth/reset?token=${rawToken}`;
108
+ }
109
+
110
+ async function createAndQueueEmailVerification(user: User): Promise<void> {
111
+ const rawToken = generateOpaqueToken('emailVerification');
112
+ await createEmailVerification({
113
+ userId: user.id,
114
+ hashedToken: hashOpaqueToken(rawToken),
115
+ expiresAt: hoursFromNow(VERIFICATION_TTL_HOURS),
116
+ sendEmail: {
117
+ email: user.email,
118
+ verifyLink: verificationLink(rawToken),
119
+ },
120
+ });
121
+ }
122
+
123
+ export interface SignupParams {
124
+ email: string;
125
+ password: string;
126
+ name?: string | undefined;
127
+ }
128
+
129
+ export interface ProvisionUserParams {
130
+ email: string;
131
+ name?: string | null | undefined;
132
+ }
133
+
134
+ /**
135
+ * Creates a verified, password-less user for an external identity provider.
136
+ * Existing users are returned unchanged, including their password and profile.
137
+ */
138
+ export async function provisionUser(params: ProvisionUserParams): Promise<User> {
139
+ return await provisionDbUser({
140
+ email: emailSchema.parse(params.email),
141
+ name: params.name ?? null,
142
+ });
143
+ }
144
+
145
+ export async function signup(params: SignupParams): Promise<User> {
146
+ const existing = await findUserByEmail({email: params.email});
147
+ if (existing) {
148
+ throw new EmailTakenError(params.email);
149
+ }
150
+
151
+ const hashedPassword = await hashPassword({password: params.password});
152
+ const user = await createDbUser({
153
+ email: params.email,
154
+ hashedPassword,
155
+ name: params.name ?? null,
156
+ });
157
+
158
+ await createAndQueueEmailVerification(user);
159
+
160
+ return user;
161
+ }
162
+
163
+ export interface SignupWithInvitationParams extends SignupParams {
164
+ invitationToken: string;
165
+ }
166
+
167
+ export interface SignupWithInvitationResult extends LoginResult {
168
+ membership: {id: string; userId: string; workspaceId: string} | null;
169
+ acceptError?: {code: string; message: string};
170
+ }
171
+
172
+ export async function signupWithInvitation(
173
+ params: SignupWithInvitationParams,
174
+ ): Promise<SignupWithInvitationResult> {
175
+ // Step 1: Pre-validate the invitation BEFORE any user write so an invalid
176
+ // token never produces an orphan user. Re-validation happens again inside
177
+ // acceptWorkspaceInvitation (race-safe) after the user is created.
178
+ const invitation = await peekInvitationByRawToken({token: params.invitationToken});
179
+ if (!invitation) {
180
+ throw new WorkspacesTokenInvalidError('Invitation token is invalid');
181
+ }
182
+ if (invitation.acceptedAt !== null) {
183
+ throw new TokenAlreadyUsedError();
184
+ }
185
+ if (invitation.expiresAt.getTime() <= Date.now()) {
186
+ throw new TokenExpiredError();
187
+ }
188
+ if (invitation.email !== params.email) {
189
+ throw new InvitationEmailMismatchError();
190
+ }
191
+
192
+ // Step 2: Create the user as verified. The invitation email is the proof of
193
+ // ownership, and this avoids a second auth-table write just to mark verified.
194
+ const existing = await findUserByEmail({email: params.email});
195
+ if (existing) {
196
+ throw new EmailTakenError(params.email);
197
+ }
198
+ const hashedPassword = await hashPassword({password: params.password});
199
+ const user = await createDbUser({
200
+ email: params.email,
201
+ hashedPassword,
202
+ name: params.name ?? null,
203
+ emailVerifiedAt: new Date(),
204
+ });
205
+
206
+ // Step 3: Accept the invitation through the workspaces module boundary.
207
+ // This is intentionally not one database transaction with auth; treat it like
208
+ // calling another service. If it fails after user creation, return a verified
209
+ // signed-in user and an explicit retryable accept error.
210
+ let membership: SignupWithInvitationResult['membership'] = null;
211
+ let acceptError: SignupWithInvitationResult['acceptError'];
212
+ try {
213
+ const result = await acceptWorkspaceInvitation({
214
+ token: params.invitationToken,
215
+ userId: user.id,
216
+ email: user.email,
217
+ name: user.name,
218
+ });
219
+ membership = {
220
+ id: result.membership.id,
221
+ userId: result.membership.userId,
222
+ workspaceId: result.membership.workspaceId,
223
+ };
224
+ } catch (error) {
225
+ if (
226
+ error instanceof WorkspacesTokenInvalidError ||
227
+ error instanceof TokenAlreadyUsedError ||
228
+ error instanceof TokenExpiredError ||
229
+ error instanceof InvitationEmailMismatchError
230
+ ) {
231
+ acceptError = {code: error.name, message: error.message};
232
+ } else {
233
+ acceptError = {
234
+ code: 'AcceptFailed',
235
+ message: 'Could not accept the invitation; please retry from the invite link.',
236
+ };
237
+ }
238
+ }
239
+
240
+ // Step 4: Issue session. signAccessToken reads memberships through the
241
+ // workspaces module API, so a successful accept is reflected in the JWT.
242
+ const token = await signAccessToken(user);
243
+ const refreshToken = await createRefreshSession(user);
244
+
245
+ if (acceptError) {
246
+ return {token, refreshToken, user, membership, acceptError};
247
+ }
248
+ return {token, refreshToken, user, membership};
249
+ }
250
+
251
+ export interface CreateUserParams extends SignupParams {
252
+ verified: boolean;
253
+ }
254
+
255
+ export async function createUser(params: CreateUserParams): Promise<User> {
256
+ const existing = await findUserByEmail({email: params.email});
257
+ if (existing) {
258
+ throw new EmailTakenError(params.email);
259
+ }
260
+
261
+ const hashedPassword = await hashPassword({password: params.password});
262
+ const user = await createDbUser({
263
+ email: params.email,
264
+ hashedPassword,
265
+ name: params.name ?? null,
266
+ emailVerifiedAt: params.verified ? new Date() : null,
267
+ });
268
+
269
+ return user;
270
+ }
271
+
272
+ export interface LoginParams {
273
+ email: string;
274
+ password: string;
275
+ }
276
+
277
+ export interface LoginResult {
278
+ token: string;
279
+ refreshToken: string;
280
+ user: User;
281
+ }
282
+
283
+ export async function login(params: LoginParams): Promise<LoginResult> {
284
+ const user = await findUserByEmail({email: params.email});
285
+
286
+ if (!user) {
287
+ await verifyPassword({password: params.password, hash: await getDummyHash()});
288
+ throw new InvalidCredentialsError();
289
+ }
290
+
291
+ const hasPassword = user.hashedPassword !== null;
292
+ const ok = await verifyPassword({
293
+ password: params.password,
294
+ hash: user.hashedPassword ?? (await getDummyHash()),
295
+ });
296
+ if (!hasPassword || !ok || user.status !== 'active') {
297
+ throw new InvalidCredentialsError();
298
+ }
299
+
300
+ if (user.emailVerifiedAt === null) {
301
+ throw new EmailNotVerifiedError();
302
+ }
303
+
304
+ const token = await signAccessToken(user);
305
+ const refreshToken = await createRefreshSession(user);
306
+
307
+ return {token, refreshToken, user};
308
+ }
309
+
310
+ export interface CreateSessionForUserParams {
311
+ userId?: string | undefined;
312
+ email?: string | undefined;
313
+ }
314
+
315
+ export interface CreateSessionForUserResult {
316
+ token: string;
317
+ refreshToken: string;
318
+ user: User;
319
+ }
320
+
321
+ export type CreateSessionForUserError =
322
+ | AuthDependencyUnavailableError
323
+ | EmailNotVerifiedError
324
+ | InvalidCredentialsError
325
+ | UserNotFoundError;
326
+
327
+ export async function createSessionForUser(
328
+ params: CreateSessionForUserParams,
329
+ ): Promise<CreateSessionForUserResult> {
330
+ const user = params.userId
331
+ ? await findUserById({id: params.userId})
332
+ : params.email
333
+ ? await findUserByEmail({email: params.email})
334
+ : undefined;
335
+
336
+ if (!user) {
337
+ throw new UserNotFoundError(params.userId ?? params.email ?? 'unknown');
338
+ }
339
+ if (user.emailVerifiedAt === null) {
340
+ throw new EmailNotVerifiedError();
341
+ }
342
+ if (user.status !== 'active') {
343
+ throw new InvalidCredentialsError();
344
+ }
345
+
346
+ const token = await signAccessToken(user);
347
+ const refreshToken = await createRefreshSession(user);
348
+
349
+ return {token, refreshToken, user};
350
+ }
351
+
352
+ export interface RefreshAccessTokenResult {
353
+ token: string;
354
+ /** Undefined on a grace-window hit: keep the existing cookie instead of rotating it. */
355
+ refreshToken: string | undefined;
356
+ user: User;
357
+ }
358
+
359
+ export async function refreshAccessToken(params: {
360
+ refreshToken: string;
361
+ }): Promise<RefreshAccessTokenResult> {
362
+ const currentHashedToken = hashOpaqueToken(params.refreshToken);
363
+ const current = await findRefreshTokenByHash({hashedToken: currentHashedToken});
364
+ if (!current) {
365
+ recordRefreshOutcome('rejected');
366
+ throw new TokenInvalidError('Refresh token is invalid or expired');
367
+ }
368
+
369
+ const user = await findUserById({id: current.userId});
370
+ if (user?.status !== 'active') {
371
+ await revokeRefreshTokenByHash({hashedToken: currentHashedToken});
372
+ recordRefreshOutcome('rejected');
373
+ throw new TokenInvalidError('Refresh token is invalid or expired');
374
+ }
375
+
376
+ // Within the grace window a rotated token means a concurrent refresh (e.g. a
377
+ // second tab); past it, reuse of a retired token means a compromised session.
378
+ if (current.rotatedAt) {
379
+ if (isWithinRotationGrace(current)) {
380
+ const token = await signAccessToken(user);
381
+ recordRefreshOutcome('grace');
382
+ return {token, refreshToken: undefined, user};
383
+ }
384
+ await revokeRefreshTokensForUser({userId: user.id});
385
+ recordRefreshOutcome('rejected');
386
+ throw new TokenInvalidError('Refresh token reused after rotation');
387
+ }
388
+
389
+ // Losing the CAS requires a state check: another request may have rotated,
390
+ // revoked, or expired the token before this refresh could claim it.
391
+ const nextRefreshToken = generateOpaqueToken('refreshToken');
392
+ const rotated = await rotateRefreshToken({
393
+ id: current.id,
394
+ currentHashedToken,
395
+ nextHashedToken: hashOpaqueToken(nextRefreshToken),
396
+ expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
397
+ });
398
+ if (!rotated) {
399
+ const latest = await findRefreshTokenByHash({hashedToken: currentHashedToken});
400
+ if (!latest || !isWithinRotationGrace(latest)) {
401
+ recordRefreshOutcome('rejected');
402
+ throw new TokenInvalidError('Refresh token is invalid or expired');
403
+ }
404
+ const token = await signAccessToken(user);
405
+ recordRefreshOutcome('grace');
406
+ return {token, refreshToken: undefined, user};
407
+ }
408
+
409
+ const token = await signAccessToken(user);
410
+ recordRefreshOutcome('rotated');
411
+ return {token, refreshToken: nextRefreshToken, user};
412
+ }
413
+
414
+ export interface ConfirmEmailVerificationResult {
415
+ token: string;
416
+ refreshToken: string;
417
+ user: User;
418
+ }
419
+
420
+ export interface ResendEmailVerificationResult {
421
+ nextResendAvailableAt: Date;
422
+ }
423
+
424
+ export async function confirmEmailVerification(params: {
425
+ token: string;
426
+ }): Promise<ConfirmEmailVerificationResult> {
427
+ const consumed = await consumeEmailVerification({hashedToken: hashOpaqueToken(params.token)});
428
+ if (!consumed) {
429
+ throw new TokenInvalidError('Verification token is invalid or expired');
430
+ }
431
+
432
+ const user = await markEmailVerified({userId: consumed.userId});
433
+ if (user?.status !== 'active') {
434
+ throw new TokenInvalidError('Verification token is invalid or expired');
435
+ }
436
+
437
+ const token = await signAccessToken(user);
438
+ const refreshToken = await createRefreshSession(user);
439
+
440
+ return {token, refreshToken, user};
441
+ }
442
+
443
+ export async function resendEmailVerification(params: {
444
+ email: string;
445
+ }): Promise<ResendEmailVerificationResult> {
446
+ const rawToken = generateOpaqueToken('emailVerification');
447
+ const result = await createResendEmailVerification({
448
+ email: params.email,
449
+ hashedToken: hashOpaqueToken(rawToken),
450
+ expiresAt: hoursFromNow(VERIFICATION_TTL_HOURS),
451
+ cooldownSeconds: EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS,
452
+ sendEmail: {
453
+ verifyLink: verificationLink(rawToken),
454
+ },
455
+ });
456
+
457
+ return {nextResendAvailableAt: result.nextResendAvailableAt};
458
+ }
459
+
460
+ export async function requestPasswordReset(params: {email: string}): Promise<void> {
461
+ const user = await findUserByEmail({email: params.email});
462
+ if (user?.status !== 'active' || user.hashedPassword === null) {
463
+ return;
464
+ }
465
+
466
+ const rawToken = generateOpaqueToken('passwordReset');
467
+ await createPasswordReset({
468
+ userId: user.id,
469
+ hashedToken: hashOpaqueToken(rawToken),
470
+ expiresAt: hoursFromNow(RESET_TTL_HOURS),
471
+ sendEmail: {
472
+ email: user.email,
473
+ resetLink: passwordResetLink(rawToken),
474
+ expiresInHours: RESET_TTL_HOURS,
475
+ },
476
+ });
477
+ }
478
+
479
+ export interface ConfirmPasswordResetResult {
480
+ token: string;
481
+ refreshToken: string;
482
+ user: User;
483
+ }
484
+
485
+ export async function confirmPasswordReset(params: {
486
+ token: string;
487
+ newPassword: string;
488
+ }): Promise<ConfirmPasswordResetResult> {
489
+ const consumed = await consumePasswordReset({hashedToken: hashOpaqueToken(params.token)});
490
+ if (!consumed) {
491
+ throw new TokenInvalidError('Reset token is invalid or expired');
492
+ }
493
+
494
+ const existingUser = await findUserById({id: consumed.userId});
495
+ if (existingUser?.status !== 'active' || existingUser.hashedPassword === null) {
496
+ throw new TokenInvalidError('Reset token is invalid or expired');
497
+ }
498
+
499
+ const hashedPassword = await hashPassword({password: params.newPassword});
500
+ const user = await updateUserPassword({userId: consumed.userId, hashedPassword});
501
+ if (user?.status !== 'active') {
502
+ throw new TokenInvalidError('Reset token is invalid or expired');
503
+ }
504
+
505
+ await revokeRefreshTokensForUser({userId: consumed.userId});
506
+
507
+ const token = await signAccessToken(user);
508
+ const refreshToken = await createRefreshSession(user);
509
+
510
+ return {token, refreshToken, user};
511
+ }
512
+
513
+ export async function changePassword(params: {
514
+ userId: string;
515
+ currentPassword: string;
516
+ newPassword: string;
517
+ refreshToken?: string | undefined;
518
+ }): Promise<void> {
519
+ const user = await findUserById({id: params.userId});
520
+ if (!user) {
521
+ throw new UserNotFoundError(params.userId);
522
+ }
523
+
524
+ const hasPassword = user.hashedPassword !== null;
525
+ const ok = await verifyPassword({
526
+ password: params.currentPassword,
527
+ hash: user.hashedPassword ?? (await getDummyHash()),
528
+ });
529
+ if (!hasPassword || !ok) {
530
+ throw new InvalidCredentialsError();
531
+ }
532
+
533
+ const hashedPassword = await hashPassword({password: params.newPassword});
534
+ const currentRefreshSession = params.refreshToken
535
+ ? await findActiveRefreshTokenByHash({hashedToken: hashOpaqueToken(params.refreshToken)})
536
+ : undefined;
537
+ const exceptRefreshTokenId =
538
+ currentRefreshSession?.userId === user.id ? currentRefreshSession.id : undefined;
539
+
540
+ await updateUserPassword({userId: user.id, hashedPassword});
541
+ await revokeRefreshTokensForUser({userId: user.id, exceptRefreshTokenId});
542
+ }
543
+
544
+ export async function logout(params: {refreshToken?: string | undefined}): Promise<void> {
545
+ if (!params.refreshToken) return;
546
+ await revokeRefreshTokenByHash({hashedToken: hashOpaqueToken(params.refreshToken)});
547
+ }
548
+
549
+ export interface GetCurrentUserResult {
550
+ user: User;
551
+ }
552
+
553
+ export async function getCurrentUser(params: {userId: string}): Promise<GetCurrentUserResult> {
554
+ const user = await findUserById({id: params.userId});
555
+ if (!user) {
556
+ throw new UserNotFoundError(params.userId);
557
+ }
558
+
559
+ return {user};
560
+ }
@@ -0,0 +1,8 @@
1
+ export interface EmailVerification {
2
+ id: string;
3
+ userId: string;
4
+ hashedToken: string;
5
+ expiresAt: Date;
6
+ usedAt: Date | null;
7
+ createdAt: Date;
8
+ }
@@ -0,0 +1,8 @@
1
+ export interface PasswordReset {
2
+ id: string;
3
+ userId: string;
4
+ hashedToken: string;
5
+ expiresAt: Date;
6
+ usedAt: Date | null;
7
+ createdAt: Date;
8
+ }
@@ -0,0 +1,11 @@
1
+ export interface RefreshToken {
2
+ id: string;
3
+ userId: string;
4
+ hashedToken: string;
5
+ expiresAt: Date;
6
+ revokedAt: Date | null;
7
+ rotatedAt: Date | null;
8
+ lastUsedAt: Date | null;
9
+ createdAt: Date;
10
+ updatedAt: Date;
11
+ }
@@ -0,0 +1,12 @@
1
+ export type UserStatus = 'active' | 'suspended' | 'deleted';
2
+
3
+ export interface User {
4
+ id: string;
5
+ email: string;
6
+ hashedPassword: string | null;
7
+ name: string | null;
8
+ emailVerifiedAt: Date | null;
9
+ status: UserStatus;
10
+ createdAt: Date;
11
+ updatedAt: Date;
12
+ }