@stjoseph/prumo 0.2.0 → 0.4.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 (252) hide show
  1. package/.prumo-templates/api/auth.md +51 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -4
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/email/codes.md +62 -0
  10. package/.prumo-templates/email/mailer.md +47 -0
  11. package/.prumo-templates/mcp/server.md +55 -14
  12. package/.prumo-templates/mobile/routing.md +1 -1
  13. package/.prumo-templates/mobile/storage.md +7 -6
  14. package/.prumo-templates/monorepo/layout.md +4 -4
  15. package/.prumo-templates/multi-tenancy/client.md +6 -6
  16. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  17. package/.prumo-templates/social/sign-in.md +77 -0
  18. package/README.md +8 -6
  19. package/dist/asker.js +13 -0
  20. package/dist/clean.js +2 -2
  21. package/dist/cli.js +16 -2
  22. package/dist/commands.js +7 -0
  23. package/dist/compose.js +3 -11
  24. package/dist/context.js +4 -0
  25. package/dist/features.js +230 -0
  26. package/dist/generate.js +20 -7
  27. package/dist/questions.js +64 -1
  28. package/package.json +1 -1
  29. package/templates/api/.env.example +21 -5
  30. package/templates/api/README.md +4 -5
  31. package/templates/api/docker-compose.yml +0 -1
  32. package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
  33. package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
  34. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  35. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  36. package/templates/api/package.json +7 -8
  37. package/templates/api/scripts/database.mjs +3 -11
  38. package/templates/api/scripts/ports.mjs +1 -1
  39. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  40. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  41. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  42. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  43. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  44. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  45. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  46. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  47. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  48. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  49. package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
  50. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  51. package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
  52. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  53. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  54. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  55. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  56. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  57. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  58. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  59. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  60. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  61. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  62. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  63. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  64. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  65. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  66. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  67. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  68. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  69. package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
  70. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  71. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  72. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  73. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  74. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  75. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  76. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  77. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
  78. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  79. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  80. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  81. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
  82. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  83. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
  84. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
  85. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  86. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  87. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  88. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
  89. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
  90. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  91. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
  92. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  93. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  94. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  95. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  96. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  97. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  98. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
  99. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  100. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
  101. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  102. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  103. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  104. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
  105. package/templates/api/src/infra/config/env.ts +17 -3
  106. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
  107. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  108. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  109. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  110. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
  111. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  112. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
  113. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
  114. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  115. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  116. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  117. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
  118. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  119. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  120. package/templates/api/src/infra/di/auth.di.ts +21 -0
  121. package/templates/api/src/infra/di/email.di.ts +23 -0
  122. package/templates/api/src/infra/di/index.ts +8 -10
  123. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  124. package/templates/api/src/infra/di/social.di.ts +17 -0
  125. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  126. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  127. package/templates/api/src/infra/http/app.ts +15 -1
  128. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  129. package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
  130. package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
  131. package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
  132. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  133. package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
  134. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  135. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  136. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  137. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  138. package/templates/api/src/infra/http/server.ts +1 -4
  139. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  140. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  141. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  142. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  143. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  144. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  145. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  146. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  147. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  148. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  149. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  150. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
  151. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  152. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
  153. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  154. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  155. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  156. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  157. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  158. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  159. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
  160. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
  161. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  162. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
  163. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  164. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  165. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  166. package/templates/api/test/support/factories/user.factory.ts +9 -11
  167. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  168. package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
  169. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  170. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  171. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  172. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  173. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  174. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  175. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  176. package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
  177. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  178. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  179. package/templates/api/test/support/setup.ts +6 -28
  180. package/templates/api/test/support/test-app.ts +65 -0
  181. package/templates/mobile/README.md +1 -1
  182. package/templates/mobile/jest.config.js +0 -17
  183. package/templates/mobile/package.json +0 -4
  184. package/templates/mobile/scripts/ports.mjs +1 -1
  185. package/templates/mobile/src/api-contract/api-error.ts +4 -11
  186. package/templates/mobile/src/api-contract/auth.ts +221 -0
  187. package/templates/mobile/src/api-contract/index.ts +14 -0
  188. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  189. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  190. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  191. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  192. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  193. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  194. package/templates/mobile/src/app/_layout.tsx +5 -5
  195. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  196. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  197. package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
  198. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  199. package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
  200. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  201. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  202. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  203. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  204. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  205. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  206. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  207. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  208. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  209. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  210. package/templates/site/scripts/ports.mjs +1 -1
  211. package/templates/web/package.json +0 -3
  212. package/templates/web/scripts/ports.mjs +1 -1
  213. package/templates/web/src/api-contract/api-error.ts +4 -11
  214. package/templates/web/src/api-contract/auth.ts +221 -0
  215. package/templates/web/src/api-contract/index.ts +14 -0
  216. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  217. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  218. package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
  219. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  220. package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
  221. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  222. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  223. package/templates/web/src/features/oauth/consent-form.tsx +72 -35
  224. package/templates/web/src/main.tsx +6 -4
  225. package/templates/web/src/routeTree.gen.ts +73 -2
  226. package/templates/web/src/router.tsx +1 -2
  227. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  228. package/templates/web/src/routes/_authenticated.tsx +1 -1
  229. package/templates/web/src/routes/forgot-password.tsx +17 -0
  230. package/templates/web/src/routes/reset-password.tsx +20 -0
  231. package/templates/web/src/routes/sign-in.tsx +10 -3
  232. package/templates/web/src/routes/sign-up.tsx +4 -0
  233. package/templates/web/src/routes/verify-email.tsx +20 -0
  234. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  235. package/templates/web/test/app.spec.tsx +47 -22
  236. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  237. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  238. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  239. package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
  240. package/templates/web/test/support/render-app.tsx +4 -4
  241. package/templates/workspace/scripts/ports.mjs +1 -1
  242. package/dist/mcp.js +0 -100
  243. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  244. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  245. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  246. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  247. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  248. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  249. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  250. package/templates/mobile/src/features/auth/session.ts +0 -17
  251. package/templates/web/src/features/auth/auth-client.ts +0 -17
  252. package/templates/web/src/features/auth/session.ts +0 -17
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import cookie from '@fastify/cookie'
2
3
  import cors from '@fastify/cors'
4
+ import rateLimit from '@fastify/rate-limit'
3
5
  import swagger from '@fastify/swagger'
4
6
  import swaggerUi from '@fastify/swagger-ui'
5
7
  import { EntityManager } from '@mikro-orm/postgresql'
@@ -12,11 +14,15 @@ import {
12
14
  import type { DependencyContainer } from 'tsyringe'
13
15
  import { ENV, type Env } from '@/infra/config/env'
14
16
  import { authController } from '@/infra/http/controllers/auth.controller'
17
+ import { authEmailController } from '@/infra/http/controllers/auth-email.controller' // prumo:email
15
18
  import { healthController } from '@/infra/http/controllers/health.controller'
16
19
  import { mcpController } from '@/infra/http/controllers/mcp.controller' // prumo:mcp
20
+ import { oauthController } from '@/infra/http/controllers/oauth.controller' // prumo:mcp
21
+ import { socialController } from '@/infra/http/controllers/social.controller' // prumo:social
17
22
  import { usersController } from '@/infra/http/controllers/users.controller'
18
23
  import { registerErrorHandler } from '@/infra/http/errors/error-handler'
19
24
  import { registerAuthHook } from '@/infra/http/hooks/auth.hook'
25
+ import { registerOriginCheck } from '@/infra/http/hooks/origin.hook'
20
26
  import { registerRequestContext } from '@/infra/http/hooks/request-context.hook'
21
27
 
22
28
  const DOCS = '/api/docs'
@@ -82,16 +88,24 @@ export async function buildApp(container: DependencyContainer): Promise<FastifyI
82
88
  exposedHeaders: ['X-Request-Id'],
83
89
  })
84
90
 
91
+ await app.register(cookie)
92
+ // Off by default: a route opts in through its config, so only the routes that take a password are limited.
93
+ await app.register(rateLimit, { global: false })
94
+ registerOriginCheck(app, env.WEB_ORIGIN)
95
+
85
96
  if (env.NODE_ENV !== 'production') {
86
97
  await registerDocs(app)
87
98
  }
88
99
 
89
100
  registerAuthHook(app)
90
101
 
91
- await app.register(authController)
102
+ await app.register(authController, { prefix: '/api/auth' })
103
+ await app.register(authEmailController, { prefix: '/api/auth' }) // prumo:email
104
+ await app.register(socialController, { prefix: '/api/auth/social' }) // prumo:social
92
105
  await app.register(healthController, { prefix: '/api/health' })
93
106
  await app.register(usersController, { prefix: '/api/v1/users' })
94
107
  // prumo:mcp
108
+ await app.register(oauthController)
95
109
  await app.register(mcpController, { prefix: '/api/mcp' })
96
110
  // prumo:end-mcp
97
111
 
@@ -0,0 +1,73 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { emailRequestSchema } from '@/domain/auth/dto/email-request.dto'
4
+ import { resetPasswordSchema } from '@/domain/auth/dto/reset-password.dto'
5
+ import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
6
+ import { verifyEmailSchema } from '@/domain/auth/dto/verify-email.dto'
7
+ import { RequestEmailVerificationUseCase } from '@/domain/auth/use-cases/request-email-verification.use-case'
8
+ import { RequestPasswordResetUseCase } from '@/domain/auth/use-cases/request-password-reset.use-case'
9
+ import { ResetPasswordUseCase } from '@/domain/auth/use-cases/reset-password.use-case'
10
+ import { VerifyEmailUseCase } from '@/domain/auth/use-cases/verify-email.use-case'
11
+ import { ENV, type Env } from '@/infra/config/env'
12
+ import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
13
+ import { sessionDelivery } from '@/infra/http/session-delivery'
14
+
15
+ // Every route answers alike whether or not the email has an account: none of them can be used to find out.
16
+ export async function authEmailController(fastify: FastifyInstance): Promise<void> {
17
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
18
+ const { deliver } = sessionDelivery(app.container.resolve<Env>(ENV))
19
+
20
+ app.post(
21
+ '/email/verification',
22
+ { config: { public: true, ...ATTEMPTS }, schema: { tags: ['auth'], body: emailRequestSchema } },
23
+ async (request, reply) => {
24
+ await app.container.resolve(RequestEmailVerificationUseCase).execute(request.body.email)
25
+
26
+ return reply.code(204).send()
27
+ },
28
+ )
29
+
30
+ app.post(
31
+ '/email/verify',
32
+ {
33
+ config: { public: true, ...ATTEMPTS },
34
+ schema: { tags: ['auth'], body: verifyEmailSchema, response: { 200: tokensResponseSchema } },
35
+ },
36
+ async (request, reply) =>
37
+ deliver(
38
+ request,
39
+ reply,
40
+ await app.container.resolve(VerifyEmailUseCase).execute(request.body),
41
+ 200,
42
+ ),
43
+ )
44
+
45
+ app.post(
46
+ '/password/forgot',
47
+ { config: { public: true, ...ATTEMPTS }, schema: { tags: ['auth'], body: emailRequestSchema } },
48
+ async (request, reply) => {
49
+ await app.container.resolve(RequestPasswordResetUseCase).execute(request.body.email)
50
+
51
+ return reply.code(204).send()
52
+ },
53
+ )
54
+
55
+ app.post(
56
+ '/password/reset',
57
+ {
58
+ config: { public: true, ...ATTEMPTS },
59
+ schema: {
60
+ tags: ['auth'],
61
+ body: resetPasswordSchema,
62
+ response: { 200: tokensResponseSchema },
63
+ },
64
+ },
65
+ async (request, reply) =>
66
+ deliver(
67
+ request,
68
+ reply,
69
+ await app.container.resolve(ResetPasswordUseCase).execute(request.body),
70
+ 200,
71
+ ),
72
+ )
73
+ }
@@ -1,36 +1,115 @@
1
1
  import type { FastifyInstance } from 'fastify'
2
- import { AUTH, type Auth } from '@/infra/auth/auth.factory'
3
- import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { z } from 'zod'
4
+ import { refreshSchema } from '@/domain/auth/dto/refresh.dto'
5
+ import { sessionResponseSchema } from '@/domain/auth/dto/session-response.dto'
6
+ import { signInSchema } from '@/domain/auth/dto/sign-in.dto'
7
+ import { signUpSchema } from '@/domain/auth/dto/sign-up.dto'
8
+ import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
9
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
10
+ import { FindUserUseCase } from '@/domain/auth/use-cases/find-user.use-case'
11
+ import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
12
+ import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
13
+ import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
14
+ import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
15
+ import { ENV, type Env } from '@/infra/config/env'
16
+ import { currentSession, currentUser } from '@/infra/http/hooks/auth.hook'
17
+ import { REFRESH_COOKIE, sessionDelivery, wantsBearer } from '@/infra/http/session-delivery'
18
+
19
+ // Repeated guessing is slowed down per address; a refresh happens every quarter hour per tab, so it gets more room.
20
+ export const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
21
+ const REFRESHES = { rateLimit: { max: 60, timeWindow: '1 minute' } }
22
+
23
+ const verificationRequiredSchema = z.object({ verificationRequired: z.literal(true) })
4
24
 
5
25
  export async function authController(fastify: FastifyInstance): Promise<void> {
6
- const auth = fastify.container.resolve<Auth>(AUTH)
7
- const routes = { config: { public: true }, schema: { hide: true } } as const
8
-
9
- // prumo:mcp
10
- // OAuth's token endpoint takes a form. It is kept as text and handed to Better Auth as sent.
11
- fastify.addContentTypeParser(
12
- 'application/x-www-form-urlencoded',
13
- { parseAs: 'string' },
14
- (_request, body, done) => done(null, body),
26
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
27
+ const { deliver, clearCookies } = sessionDelivery(app.container.resolve<Env>(ENV))
28
+
29
+ app.post(
30
+ '/sign-up',
31
+ {
32
+ config: { public: true, ...ATTEMPTS },
33
+ schema: {
34
+ tags: ['auth'],
35
+ body: signUpSchema,
36
+ response: { 201: z.union([tokensResponseSchema, verificationRequiredSchema]) },
37
+ },
38
+ },
39
+ async (request, reply) => {
40
+ const tokens = await app.container.resolve(SignUpUseCase).execute(request.body)
41
+
42
+ // No session until the address is confirmed: the client asks for the code next.
43
+ if (tokens === null) {
44
+ return reply.code(201).send({ verificationRequired: true })
45
+ }
46
+
47
+ return deliver(request, reply, tokens, 201)
48
+ },
15
49
  )
16
- // prumo:end-mcp
17
-
18
- fastify.route({
19
- ...routes,
20
- method: ['GET', 'POST'],
21
- url: '/api/auth/*',
22
- handler: async (request, reply) =>
23
- sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
24
- })
25
50
 
26
- // prumo:mcp
27
- // OAuth discovery lives at the root, outside Better Auth's base path, and still reaches its handler.
28
- fastify.route({
29
- ...routes,
30
- method: 'GET',
31
- url: '/.well-known/*',
32
- handler: async (request, reply) =>
33
- sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
51
+ app.post(
52
+ '/sign-in',
53
+ {
54
+ config: { public: true, ...ATTEMPTS },
55
+ schema: { tags: ['auth'], body: signInSchema, response: { 200: tokensResponseSchema } },
56
+ },
57
+ async (request, reply) =>
58
+ deliver(
59
+ request,
60
+ reply,
61
+ await app.container.resolve(SignInUseCase).execute(request.body),
62
+ 200,
63
+ ),
64
+ )
65
+
66
+ app.post(
67
+ '/refresh',
68
+ {
69
+ config: { public: true, ...REFRESHES },
70
+ schema: { tags: ['auth'], body: refreshSchema, response: { 200: tokensResponseSchema } },
71
+ },
72
+ async (request, reply) => {
73
+ const token = request.body?.refreshToken ?? request.cookies[REFRESH_COOKIE]
74
+
75
+ try {
76
+ if (token === undefined) {
77
+ throw new InvalidSessionError()
78
+ }
79
+
80
+ return deliver(
81
+ request,
82
+ reply,
83
+ await app.container.resolve(RefreshSessionUseCase).execute(token),
84
+ 200,
85
+ )
86
+ } catch (error) {
87
+ // A refresh that cannot succeed will not succeed later either: drop what the browser holds.
88
+ if (error instanceof InvalidSessionError && !wantsBearer(request)) {
89
+ clearCookies(reply)
90
+ }
91
+
92
+ throw error
93
+ }
94
+ },
95
+ )
96
+
97
+ app.post('/sign-out', { schema: { tags: ['auth'] } }, async (request, reply) => {
98
+ await app.container
99
+ .resolve(SignOutUseCase)
100
+ .execute(currentUser(request).id, currentSession(request))
101
+ clearCookies(reply)
102
+
103
+ return reply.code(204).send()
34
104
  })
35
- // prumo:end-mcp
105
+
106
+ app.get(
107
+ '/session',
108
+ { schema: { tags: ['auth'], response: { 200: sessionResponseSchema } } },
109
+ async (request) => {
110
+ const user = await app.container.resolve(FindUserUseCase).execute(currentUser(request).id)
111
+
112
+ return { user: { id: user.id, email: user.email } }
113
+ },
114
+ )
36
115
  }
@@ -1,40 +1,59 @@
1
- import { requireMcpAuth } from '@better-auth/mcp'
2
1
  import type { AuthInfo } from '@modelcontextprotocol/server'
3
2
  import type { FastifyInstance } from 'fastify'
4
- import { AUTH, type Auth, mcpResource } from '@/infra/auth/auth.factory'
3
+ import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tokens.port'
5
4
  import { ENV, type Env } from '@/infra/config/env'
5
+ import { HttpError } from '@/infra/http/errors/http-error'
6
6
  import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
7
- import { createMcpServerHandler } from '@/infra/mcp/mcp.server'
7
+ import { missingScopes, SCOPES } from '@/infra/mcp/mcp.scopes'
8
+ import { createMcpServerHandler, mcpResource, mcpResourceMetadataUrl } from '@/infra/mcp/mcp.server'
8
9
 
9
- type AccessTokenClaims = Parameters<Parameters<typeof requireMcpAuth>[1]>[1]
10
-
11
- function authInfoOf(request: Request, claims: AccessTokenClaims): AuthInfo {
12
- const scope = typeof claims.scope === 'string' ? claims.scope : ''
13
- const clientId = claims.azp ?? claims.client_id
14
-
15
- return {
16
- token: request.headers.get('authorization')?.replace(/^\w+\s+/, '') ?? '',
17
- clientId: typeof clientId === 'string' ? clientId : '',
18
- scopes: scope.split(' ').filter(Boolean),
19
- ...(claims.exp === undefined ? {} : { expiresAt: claims.exp }),
20
- extra: { userId: claims.sub },
21
- }
22
- }
23
-
24
- // The route answers a bearer access token, never a session cookie, so the session hook stands aside.
10
+ // The route answers an OAuth access token issued for it, never a session cookie or a first-party token: the
11
+ // audience decides, so the session hook stands aside.
25
12
  export async function mcpController(fastify: FastifyInstance): Promise<void> {
26
- const auth = fastify.container.resolve<Auth>(AUTH)
13
+ const tokens = fastify.container.resolve<AccessTokens>(ACCESS_TOKENS)
27
14
  const env = fastify.container.resolve<Env>(ENV)
15
+ const resource = mcpResource(env)
28
16
  const mcp = createMcpServerHandler(fastify.container)
29
- const handle = requireMcpAuth(
30
- auth,
31
- (request, claims) => mcp.fetch(request, { authInfo: authInfoOf(request, claims) }),
32
- { resource: mcpResource(env) },
33
- )
34
17
 
35
18
  fastify.addHook('onClose', () => mcp.close())
36
19
 
37
- fastify.post('/', { config: { public: true }, schema: { hide: true } }, async (request, reply) =>
38
- sendFetchResponse(reply, await handle(toFetchRequest(request))),
20
+ fastify.post(
21
+ '/',
22
+ { config: { public: true }, schema: { hide: true } },
23
+ async (request, reply) => {
24
+ const token = /^Bearer (.+)$/i.exec(request.headers.authorization ?? '')?.[1]
25
+ const claims = token === undefined ? null : await tokens.verify(token, resource)
26
+
27
+ if (token === undefined || claims === null || claims.clientId === undefined) {
28
+ // RFC 9728: the 401 names the document that says where to obtain a token, and the scopes worth asking for.
29
+ reply.header(
30
+ 'www-authenticate',
31
+ `Bearer resource_metadata="${mcpResourceMetadataUrl(env)}", scope="${Object.keys(SCOPES).join(' ')}"${token === undefined ? '' : ', error="invalid_token"'}`,
32
+ )
33
+ throw new HttpError(401, 'Unauthorized')
34
+ }
35
+
36
+ const scopes = (claims.scope ?? '').split(' ').filter(Boolean)
37
+ const missing = missingScopes(request.body, scopes)
38
+
39
+ // Checked before the server runs the call: a tool the user did not grant is refused with the scopes it needs,
40
+ // so the client can ask the user for them and come back.
41
+ if (missing.length > 0) {
42
+ reply.header(
43
+ 'www-authenticate',
44
+ `Bearer error="insufficient_scope", scope="${missing.join(' ')}", resource_metadata="${mcpResourceMetadataUrl(env)}"`,
45
+ )
46
+ throw new HttpError(403, `This token lacks ${missing.join(' ')}`)
47
+ }
48
+
49
+ const authInfo: AuthInfo = {
50
+ token,
51
+ clientId: claims.clientId,
52
+ scopes,
53
+ extra: { userId: claims.userId },
54
+ }
55
+
56
+ return sendFetchResponse(reply, await mcp.fetch(toFetchRequest(request), { authInfo }))
57
+ },
39
58
  )
40
59
  }
@@ -0,0 +1,267 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { z } from 'zod'
4
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
5
+ import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
6
+ import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
7
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
8
+ import {
9
+ authorizationDecisionSchema,
10
+ authorizationRedirectSchema,
11
+ } from '@/domain/oauth/dto/authorization-decision.dto'
12
+ import { authorizationViewSchema } from '@/domain/oauth/dto/authorization-view.dto'
13
+ import { AuthorizationRequestError } from '@/domain/oauth/errors/authorization-request.error'
14
+ import { InvalidGrantError } from '@/domain/oauth/errors/invalid-grant.error'
15
+ import { DecideAuthorizationUseCase } from '@/domain/oauth/use-cases/decide-authorization.use-case'
16
+ import { ExchangeCodeUseCase } from '@/domain/oauth/use-cases/exchange-code.use-case'
17
+ import { FindAuthorizationUseCase } from '@/domain/oauth/use-cases/find-authorization.use-case'
18
+ import { StartAuthorizationUseCase } from '@/domain/oauth/use-cases/start-authorization.use-case'
19
+ import { ENV, type Env } from '@/infra/config/env'
20
+ import { currentUser } from '@/infra/http/hooks/auth.hook'
21
+ import { SCOPES } from '@/infra/mcp/mcp.scopes'
22
+ import { mcpResource } from '@/infra/mcp/mcp.server'
23
+
24
+ const PUBLIC = { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } }
25
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
26
+
27
+ const authorizeQuerySchema = z.object({
28
+ response_type: z.string().optional(),
29
+ client_id: z.string().max(2048),
30
+ redirect_uri: z.string().max(2048),
31
+ code_challenge: z.string().optional(),
32
+ code_challenge_method: z.string().optional(),
33
+ state: z.string().max(1024).optional(),
34
+ resource: z.string().optional(),
35
+ scope: z.string().optional(),
36
+ })
37
+
38
+ const idParamsSchema = z.object({ id: z.uuid() })
39
+
40
+ function withParams(uri: string, params: Record<string, string | null | undefined>): string {
41
+ const url = new URL(uri)
42
+
43
+ for (const [key, value] of Object.entries(params)) {
44
+ if (value !== null && value !== undefined) {
45
+ url.searchParams.set(key, value)
46
+ }
47
+ }
48
+
49
+ return url.toString()
50
+ }
51
+
52
+ function tokenResponse(tokens: SessionTokens) {
53
+ return {
54
+ access_token: tokens.accessToken.token,
55
+ token_type: 'Bearer',
56
+ expires_in: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
57
+ refresh_token: tokens.refreshToken,
58
+ scope: tokens.scope ?? '',
59
+ }
60
+ }
61
+
62
+ // MCP's authorization server, from discovery to the token: RFC 9728, RFC 8414, OAuth 2.1 with PKCE, RFC 8707 and
63
+ // RFC 9207, with clients identified by their metadata document. The user signs in and consents on the web app.
64
+ export async function oauthController(fastify: FastifyInstance): Promise<void> {
65
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
66
+ const env = app.container.resolve<Env>(ENV)
67
+ const issuer = env.API_URL
68
+ const resource = mcpResource(env)
69
+
70
+ // The token endpoint takes a form, as OAuth writes it.
71
+ app.addContentTypeParser(
72
+ 'application/x-www-form-urlencoded',
73
+ { parseAs: 'string' },
74
+ (_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
75
+ )
76
+
77
+ const scopesSupported = Object.keys(SCOPES)
78
+ const resourceMetadata = {
79
+ resource,
80
+ authorization_servers: [issuer],
81
+ bearer_methods_supported: ['header'],
82
+ scopes_supported: scopesSupported,
83
+ }
84
+
85
+ // At the path of the resource first, then at the root: the two places an MCP client looks.
86
+ for (const url of [
87
+ '/.well-known/oauth-protected-resource/api/mcp',
88
+ '/.well-known/oauth-protected-resource',
89
+ ]) {
90
+ app.get(url, { config: { public: true }, schema: { hide: true } }, async () => resourceMetadata)
91
+ }
92
+
93
+ app.get(
94
+ '/.well-known/oauth-authorization-server',
95
+ { config: { public: true }, schema: { hide: true } },
96
+ async () => ({
97
+ issuer,
98
+ authorization_endpoint: `${issuer}/api/oauth/authorize`,
99
+ token_endpoint: `${issuer}/api/oauth/token`,
100
+ response_types_supported: ['code'],
101
+ grant_types_supported: ['authorization_code', 'refresh_token'],
102
+ code_challenge_methods_supported: ['S256'],
103
+ token_endpoint_auth_methods_supported: ['none'],
104
+ client_id_metadata_document_supported: true,
105
+ scopes_supported: scopesSupported,
106
+ authorization_response_iss_parameter_supported: true,
107
+ }),
108
+ )
109
+
110
+ app.get(
111
+ '/api/oauth/authorize',
112
+ { config: PUBLIC, schema: { tags: ['oauth'], querystring: authorizeQuerySchema } },
113
+ async (request, reply) => {
114
+ const query = request.query
115
+
116
+ try {
117
+ const authorization = await app.container.resolve(StartAuthorizationUseCase).execute(
118
+ {
119
+ responseType: query.response_type,
120
+ clientId: query.client_id,
121
+ redirectUri: query.redirect_uri,
122
+ codeChallenge: query.code_challenge,
123
+ codeChallengeMethod: query.code_challenge_method,
124
+ clientState: query.state ?? null,
125
+ resource: query.resource,
126
+ scope: query.scope,
127
+ },
128
+ resource,
129
+ scopesSupported,
130
+ )
131
+
132
+ // Signing in, if needed, and the consent itself happen on the web app.
133
+ return reply.redirect(`${env.WEB_ORIGIN}/consent?request=${authorization.id}`)
134
+ } catch (error) {
135
+ if (error instanceof AuthorizationRequestError) {
136
+ return reply.redirect(
137
+ withParams(query.redirect_uri, {
138
+ error: error.code,
139
+ error_description: error.message,
140
+ state: query.state,
141
+ iss: issuer,
142
+ }),
143
+ )
144
+ }
145
+
146
+ throw error
147
+ }
148
+ },
149
+ )
150
+
151
+ app.get(
152
+ '/api/oauth/authorizations/:id',
153
+ {
154
+ schema: {
155
+ tags: ['oauth'],
156
+ params: idParamsSchema,
157
+ response: { 200: authorizationViewSchema },
158
+ },
159
+ },
160
+ async (request) => {
161
+ const authorization = await app.container
162
+ .resolve(FindAuthorizationUseCase)
163
+ .execute(request.params.id)
164
+ const redirect = new URL(authorization.redirectUri)
165
+
166
+ return {
167
+ clientId: authorization.clientId,
168
+ clientName: authorization.clientName,
169
+ clientHost: new URL(authorization.clientId).host,
170
+ redirectHost: redirect.host,
171
+ redirectsToThisDevice: LOOPBACK.has(redirect.hostname),
172
+ scopes: authorization.scope
173
+ .split(' ')
174
+ .map((scope) => ({ scope, description: SCOPES[scope] ?? scope })),
175
+ }
176
+ },
177
+ )
178
+
179
+ app.post(
180
+ '/api/oauth/authorizations/:id/decision',
181
+ {
182
+ schema: {
183
+ tags: ['oauth'],
184
+ params: idParamsSchema,
185
+ body: authorizationDecisionSchema,
186
+ response: { 200: authorizationRedirectSchema },
187
+ },
188
+ },
189
+ async (request) => {
190
+ const { authorization, code } = await app.container
191
+ .resolve(DecideAuthorizationUseCase)
192
+ .execute(request.params.id, currentUser(request).id, request.body.accept)
193
+
194
+ return {
195
+ redirectTo: withParams(authorization.redirectUri, {
196
+ ...(code === null ? { error: 'access_denied' } : { code }),
197
+ state: authorization.clientState,
198
+ iss: issuer,
199
+ }),
200
+ }
201
+ },
202
+ )
203
+
204
+ // Errors here are OAuth's, not problem+json: an MCP client reads `error` and `error_description`.
205
+ app.post(
206
+ '/api/oauth/token',
207
+ { config: PUBLIC, schema: { tags: ['oauth'] } },
208
+ async (request, reply: FastifyReply) => {
209
+ const body = (request.body ?? {}) as Record<string, string | undefined>
210
+ const fail = (error: string, description: string) =>
211
+ reply.code(400).send({ error, error_description: description })
212
+
213
+ reply.header('cache-control', 'no-store')
214
+
215
+ if (body.resource !== undefined && body.resource !== resource) {
216
+ return fail('invalid_target', `resource must be ${resource}`)
217
+ }
218
+
219
+ try {
220
+ if (body.grant_type === 'authorization_code') {
221
+ const { code, client_id, redirect_uri, code_verifier } = body
222
+
223
+ if (!code || !client_id || !redirect_uri || !code_verifier) {
224
+ return fail(
225
+ 'invalid_request',
226
+ 'code, client_id, redirect_uri and code_verifier are required',
227
+ )
228
+ }
229
+
230
+ return tokenResponse(
231
+ await app.container.resolve(ExchangeCodeUseCase).execute({
232
+ code,
233
+ clientId: client_id,
234
+ redirectUri: redirect_uri,
235
+ codeVerifier: code_verifier,
236
+ resource,
237
+ }),
238
+ )
239
+ }
240
+
241
+ if (body.grant_type === 'refresh_token') {
242
+ if (!body.refresh_token || !body.client_id) {
243
+ return fail('invalid_request', 'refresh_token and client_id are required')
244
+ }
245
+
246
+ return tokenResponse(
247
+ await app.container
248
+ .resolve(RefreshSessionUseCase)
249
+ .execute(body.refresh_token, body.client_id),
250
+ )
251
+ }
252
+
253
+ return fail('unsupported_grant_type', 'Only authorization_code and refresh_token')
254
+ } catch (error) {
255
+ if (
256
+ error instanceof InvalidGrantError ||
257
+ error instanceof InvalidSessionError ||
258
+ error instanceof SupersededTokenError
259
+ ) {
260
+ return fail('invalid_grant', error.message)
261
+ }
262
+
263
+ throw error
264
+ }
265
+ },
266
+ )
267
+ }