@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
@@ -0,0 +1,192 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { z } from 'zod'
4
+ import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
5
+ import type { SocialClient } from '@/domain/auth/entities/social-sign-in.entity'
6
+ import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
7
+ import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
8
+ import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
9
+ import { DomainError } from '@/domain/shared/errors/domain-error'
10
+ import { ENV, type Env } from '@/infra/config/env'
11
+ import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
12
+ import { HttpError } from '@/infra/http/errors/http-error'
13
+ import { sessionDelivery } from '@/infra/http/session-delivery'
14
+
15
+ const BROWSER_COOKIE = 'social_sign_in'
16
+ const BROWSER_COOKIE_MAX_AGE_S = 10 * 60
17
+
18
+ const PROVIDERS = [
19
+ 'google', // prumo:google
20
+ 'apple', // prumo:apple
21
+ ] as const
22
+
23
+ const providerParams = z.object({ provider: z.enum(PROVIDERS) })
24
+
25
+ // Only a path on the web app: an absolute URL here would turn sign-in into an open redirect.
26
+ function safePath(path: string | undefined): string {
27
+ return path?.startsWith('/') && !path.startsWith('//') ? path : '/'
28
+ }
29
+
30
+ // Apple sends the name it shares once, as JSON beside the code.
31
+ function appleName(user: string | undefined): string | null {
32
+ try {
33
+ const name = (JSON.parse(user ?? '') as { name?: { firstName?: string; lastName?: string } })
34
+ .name
35
+ const full = [name?.firstName, name?.lastName].filter(Boolean).join(' ')
36
+
37
+ return full === '' ? null : full
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ export async function socialController(fastify: FastifyInstance): Promise<void> {
44
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
45
+ const env = app.container.resolve<Env>(ENV)
46
+ const { deliver, setCookies } = sessionDelivery(env)
47
+ const cookie = {
48
+ httpOnly: true,
49
+ sameSite: 'lax',
50
+ secure: env.API_URL.startsWith('https://'),
51
+ path: '/api/auth/social',
52
+ } as const
53
+
54
+ const back = (client: SocialClient, query: Record<string, string>) =>
55
+ client === 'mobile'
56
+ ? `${env.MOBILE_APP_SCHEME}://social?${new URLSearchParams(query)}`
57
+ : `${env.WEB_ORIGIN}/sign-in?${new URLSearchParams(query)}`
58
+
59
+ // Apple's answer is a form.
60
+ app.addContentTypeParser(
61
+ 'application/x-www-form-urlencoded',
62
+ { parseAs: 'string' },
63
+ (_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
64
+ )
65
+
66
+ app.get(
67
+ '/:provider',
68
+ {
69
+ config: { public: true, ...ATTEMPTS },
70
+ schema: {
71
+ tags: ['auth'],
72
+ params: providerParams,
73
+ querystring: z.object({
74
+ client: z.enum(['web', 'mobile']).default('web'),
75
+ returnTo: z.string().max(512).optional(),
76
+ }),
77
+ },
78
+ },
79
+ async (request, reply) => {
80
+ const { client, returnTo } = request.query
81
+
82
+ if (client === 'mobile' && env.MOBILE_APP_SCHEME === undefined) {
83
+ throw new HttpError(
84
+ 422,
85
+ 'MOBILE_APP_SCHEME is not set, so a mobile app cannot be sent back',
86
+ )
87
+ }
88
+
89
+ const started = await app.container.resolve(StartSocialSignInUseCase).execute({
90
+ provider: request.params.provider,
91
+ client,
92
+ returnTo: safePath(returnTo),
93
+ })
94
+
95
+ // The client rides beside the token, so an error found before the flow is read still goes to the right place.
96
+ reply.setCookie(BROWSER_COOKIE, `${client}~${started.browserToken}`, {
97
+ ...cookie,
98
+ maxAge: BROWSER_COOKIE_MAX_AGE_S,
99
+ })
100
+
101
+ return reply.redirect(started.url)
102
+ },
103
+ )
104
+
105
+ // A cross-site POST carries no SameSite=Lax cookie, and a top-level GET does: Apple's form is turned into a
106
+ // redirect to the same address, so the browser's token comes with it.
107
+ app.post(
108
+ '/:provider/callback',
109
+ { config: { public: true }, schema: { hide: true, params: providerParams } },
110
+ async (request, reply) => {
111
+ const form = new URLSearchParams(request.body as Record<string, string>)
112
+
113
+ return reply
114
+ .code(303)
115
+ .redirect(`${env.API_URL}/api/auth/social/${request.params.provider}/callback?${form}`)
116
+ },
117
+ )
118
+
119
+ app.get(
120
+ '/:provider/callback',
121
+ {
122
+ config: { public: true },
123
+ schema: {
124
+ hide: true,
125
+ params: providerParams,
126
+ querystring: z.object({
127
+ state: z.string().optional(),
128
+ code: z.string().optional(),
129
+ error: z.string().optional(),
130
+ user: z.string().optional(),
131
+ }),
132
+ },
133
+ },
134
+ async (request, reply) => {
135
+ const [client, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~').split('~') as [
136
+ SocialClient,
137
+ string,
138
+ ]
139
+ const { state, code, error } = request.query
140
+
141
+ reply.clearCookie(BROWSER_COOKIE, cookie)
142
+
143
+ if (error !== undefined || state === undefined || code === undefined) {
144
+ return reply.redirect(back(client, { error: 'social_sign_in_failed' }))
145
+ }
146
+
147
+ try {
148
+ const outcome = await app.container.resolve(CompleteSocialSignInUseCase).execute({
149
+ provider: request.params.provider,
150
+ state,
151
+ browserToken: browserToken === '' ? undefined : browserToken,
152
+ callbackUrl: new URL(request.url, env.API_URL),
153
+ name: appleName(request.query.user),
154
+ })
155
+
156
+ if (outcome.client === 'mobile') {
157
+ return reply.redirect(back('mobile', { code: outcome.code }))
158
+ }
159
+
160
+ setCookies(reply, outcome.tokens)
161
+
162
+ return reply.redirect(`${env.WEB_ORIGIN}${outcome.returnTo}`)
163
+ } catch (caught) {
164
+ if (caught instanceof DomainError) {
165
+ return reply.redirect(back(client, { error: caught.code ?? 'social_sign_in_failed' }))
166
+ }
167
+
168
+ throw caught
169
+ }
170
+ },
171
+ )
172
+
173
+ // The mobile app trades the code it was sent back with for its tokens, once.
174
+ app.post(
175
+ '/exchange',
176
+ {
177
+ config: { public: true, ...ATTEMPTS },
178
+ schema: {
179
+ tags: ['auth'],
180
+ body: z.strictObject({ code: z.string().min(1).max(512) }),
181
+ response: { 200: tokensResponseSchema },
182
+ },
183
+ },
184
+ async (request, reply) =>
185
+ deliver(
186
+ request,
187
+ reply,
188
+ await app.container.resolve(ExchangeSocialCodeUseCase).execute(request.body.code),
189
+ 200,
190
+ ),
191
+ )
192
+ }
@@ -11,6 +11,7 @@ type ProblemDocument = {
11
11
  detail: string
12
12
  instance: string
13
13
  requestId: string
14
+ code?: string
14
15
  errors?: FieldErrors
15
16
  }
16
17
 
@@ -31,6 +32,7 @@ const TITLES: Record<number, string> = {
31
32
  413: 'Payload Too Large',
32
33
  415: 'Unsupported Media Type',
33
34
  422: 'Unprocessable Entity',
35
+ 429: 'Too Many Requests',
34
36
  500: 'Internal Server Error',
35
37
  503: 'Service Unavailable',
36
38
  }
@@ -103,9 +105,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
103
105
  request.log.error(error)
104
106
  }
105
107
 
108
+ const code = error instanceof DomainError ? error.code : undefined
109
+
106
110
  return send(request, reply, {
107
111
  status,
108
112
  detail: detailOf(error, status, errors),
113
+ ...(code === undefined ? {} : { code }),
109
114
  ...(errors === undefined ? {} : { errors }),
110
115
  })
111
116
  })
@@ -1,5 +1,4 @@
1
1
  import type { DependencyContainer } from 'tsyringe'
2
- import type { AuthSession, AuthUser } from '@/infra/auth/auth.factory'
3
2
 
4
3
  declare module 'fastify' {
5
4
  interface FastifyInstance {
@@ -11,7 +10,7 @@ declare module 'fastify' {
11
10
  }
12
11
 
13
12
  interface FastifyRequest {
14
- user?: AuthUser
15
- session?: NonNullable<AuthSession>['session']
13
+ user?: { id: string }
14
+ sessionId?: string
16
15
  }
17
16
  }
@@ -1,6 +1,19 @@
1
- import { fromNodeHeaders } from 'better-auth/node'
2
1
  import type { FastifyReply, FastifyRequest } from 'fastify'
3
2
 
3
+ function headersOf(request: FastifyRequest): Headers {
4
+ const headers = new Headers()
5
+
6
+ for (const [name, value] of Object.entries(request.headers)) {
7
+ for (const each of [value].flat()) {
8
+ if (each !== undefined) {
9
+ headers.append(name, each)
10
+ }
11
+ }
12
+ }
13
+
14
+ return headers
15
+ }
16
+
4
17
  function bodyOf(body: unknown): string | undefined {
5
18
  if (body === undefined) {
6
19
  return undefined
@@ -9,15 +22,14 @@ function bodyOf(body: unknown): string | undefined {
9
22
  return typeof body === 'string' ? body : JSON.stringify(body)
10
23
  }
11
24
 
12
- // Better Auth and the MCP handler answer Fetch requests. Fastify has already parsed a JSON body, so the
13
- // request is rebuilt rather than handed over raw, as Better Auth's Fastify guide does; a body kept as a
14
- // string, such as a form, passes unchanged.
25
+ // The MCP handler answers Fetch requests. Fastify has already parsed a JSON body, so the request is rebuilt
26
+ // rather than handed over raw; a body kept as a string, such as a form, passes unchanged.
15
27
  export function toFetchRequest(request: FastifyRequest): Request {
16
28
  const body = bodyOf(request.body)
17
29
 
18
30
  return new Request(new URL(request.url, `${request.protocol}://${request.host}`), {
19
31
  method: request.method,
20
- headers: fromNodeHeaders(request.headers),
32
+ headers: headersOf(request),
21
33
  ...(body === undefined ? {} : { body }),
22
34
  })
23
35
  }
@@ -1,10 +1,18 @@
1
- import { fromNodeHeaders } from 'better-auth/node'
2
1
  import type { FastifyInstance, FastifyRequest } from 'fastify'
3
- import { AUTH, type Auth, type AuthUser } from '@/infra/auth/auth.factory'
2
+ import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tokens.port'
4
3
  import { HttpError } from '@/infra/http/errors/http-error'
5
4
 
5
+ export const ACCESS_COOKIE = 'access_token'
6
+
7
+ // The web sends a cookie the browser keeps; a native app and an MCP client send a bearer token.
8
+ function tokenOf(request: FastifyRequest): string | undefined {
9
+ const bearer = /^Bearer (.+)$/i.exec(request.headers.authorization ?? '')?.[1]
10
+
11
+ return bearer ?? request.cookies[ACCESS_COOKIE]
12
+ }
13
+
6
14
  export function registerAuthHook(app: FastifyInstance): void {
7
- const auth = app.container.resolve<Auth>(AUTH)
15
+ const tokens = app.container.resolve<AccessTokens>(ACCESS_TOKENS)
8
16
 
9
17
  app.addHook('preHandler', async (request) => {
10
18
  // An unknown route answers 404, not 401: there is nothing there to protect.
@@ -12,21 +20,30 @@ export function registerAuthHook(app: FastifyInstance): void {
12
20
  return
13
21
  }
14
22
 
15
- const session = await auth.api.getSession({ headers: fromNodeHeaders(request.headers) })
23
+ const token = tokenOf(request)
24
+ const claims = token === undefined ? null : await tokens.verify(token)
16
25
 
17
- request.user = session?.user
18
- request.session = session?.session
26
+ request.user = claims === null ? undefined : { id: claims.userId }
27
+ request.sessionId = claims?.sessionId
19
28
 
20
- if (request.routeOptions.config.public !== true && session === null) {
29
+ if (request.routeOptions.config.public !== true && claims === null) {
21
30
  throw new HttpError(401, 'Unauthorized')
22
31
  }
23
32
  })
24
33
  }
25
34
 
26
- export function currentUser(request: FastifyRequest): AuthUser {
35
+ export function currentUser(request: FastifyRequest): { id: string } {
27
36
  if (request.user === undefined) {
28
37
  throw new HttpError(401, 'Unauthorized')
29
38
  }
30
39
 
31
40
  return request.user
32
41
  }
42
+
43
+ export function currentSession(request: FastifyRequest): string {
44
+ if (request.sessionId === undefined) {
45
+ throw new HttpError(401, 'Unauthorized')
46
+ }
47
+
48
+ return request.sessionId
49
+ }
@@ -0,0 +1,22 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import { HttpError } from '@/infra/http/errors/http-error'
3
+
4
+ const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
5
+
6
+ // A cookie is sent by the browser whoever wrote the page, so a request that changes something and leans on one must
7
+ // come from the web app. Without a cookie there is nothing for another site to borrow: a native app and an MCP
8
+ // client send a bearer, and pass.
9
+ export function registerOriginCheck(app: FastifyInstance, webOrigin: string): void {
10
+ app.addHook('onRequest', async (request) => {
11
+ const leansOnCookie =
12
+ request.headers.cookie !== undefined && request.headers.authorization === undefined
13
+
14
+ if (SAFE.has(request.method) || !leansOnCookie) {
15
+ return
16
+ }
17
+
18
+ if (request.headers.origin !== webOrigin) {
19
+ throw new HttpError(403, 'Cross-origin request refused')
20
+ }
21
+ })
22
+ }
@@ -1,8 +1,6 @@
1
1
  // tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
2
2
  import 'reflect-metadata'
3
3
  import { MikroORM } from '@mikro-orm/postgresql'
4
- import type { Pool } from 'pg'
5
- import { AUTH, type Auth } from '@/infra/auth/auth.factory'
6
4
  import { loadEnv } from '@/infra/config/env'
7
5
  import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
8
6
  import { createContainer } from '@/infra/di'
@@ -18,10 +16,9 @@ async function main(): Promise<void> {
18
16
  const container = createContainer({ env, orm })
19
17
  const app = await buildApp(container)
20
18
 
21
- // Handling the signals takes away Node's default exit, so every open pool must close for the process to end.
19
+ // Handling the signals takes away Node's default exit, so the pool must close for the process to end.
22
20
  app.addHook('onClose', async () => {
23
21
  await orm.close()
24
- await (container.resolve<Auth>(AUTH).options.database as Pool).end()
25
22
  })
26
23
 
27
24
  for (const signal of ['SIGINT', 'SIGTERM'] as const) {
@@ -0,0 +1,69 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify'
2
+ import type { TokensResponseDto } from '@/domain/auth/dto/tokens-response.dto'
3
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
4
+ import type { Env } from '@/infra/config/env'
5
+ import { ACCESS_COOKIE } from '@/infra/http/hooks/auth.hook'
6
+
7
+ export const REFRESH_COOKIE = 'refresh_token'
8
+
9
+ // The refresh cookie travels only to the one route that reads it, never with an ordinary request.
10
+ const REFRESH_PATH = '/api/auth/refresh'
11
+
12
+ // A native client asks for its tokens in the body. The web never does: a token JavaScript can read is a token an
13
+ // injected script can take.
14
+ export function wantsBearer(request: FastifyRequest): boolean {
15
+ return request.headers['x-auth-transport'] === 'bearer'
16
+ }
17
+
18
+ function asBody(tokens: SessionTokens): TokensResponseDto {
19
+ return {
20
+ accessToken: tokens.accessToken.token,
21
+ refreshToken: tokens.refreshToken,
22
+ expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
23
+ }
24
+ }
25
+
26
+ // How a started session reaches the client, wherever it was started: sign-in, a confirmed email, a provider.
27
+ export function sessionDelivery(env: Pick<Env, 'API_URL'>) {
28
+ const cookie = {
29
+ httpOnly: true,
30
+ sameSite: 'lax',
31
+ secure: env.API_URL.startsWith('https://'),
32
+ } as const
33
+
34
+ function clearCookies(reply: FastifyReply): void {
35
+ reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
36
+ reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
37
+ }
38
+
39
+ function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
40
+ reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
41
+ ...cookie,
42
+ path: '/',
43
+ expires: tokens.accessToken.expiresAt,
44
+ })
45
+ reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
46
+ ...cookie,
47
+ path: REFRESH_PATH,
48
+ expires: tokens.refreshExpiresAt,
49
+ })
50
+ }
51
+
52
+ // The web gets cookies and an empty body; a native client gets the tokens and no cookie.
53
+ function deliver(
54
+ request: FastifyRequest,
55
+ reply: FastifyReply,
56
+ tokens: SessionTokens,
57
+ status: 200 | 201,
58
+ ) {
59
+ if (wantsBearer(request)) {
60
+ return reply.code(status).send(asBody(tokens))
61
+ }
62
+
63
+ setCookies(reply, tokens)
64
+
65
+ return reply.code(status === 201 ? 201 : 204).send()
66
+ }
67
+
68
+ return { deliver, setCookies, clearCookies }
69
+ }
@@ -0,0 +1,27 @@
1
+ import { USER_TOOL_SCOPES } from './tools/users.tool'
2
+
3
+ // Every scope this server offers, as the consent page words it. A module adds its read and write here, with its
4
+ // tools' scopes below.
5
+ export const SCOPES: Record<string, string> = {
6
+ 'profile:read': 'Read your profile',
7
+ 'profile:write': 'Change your display name, language and timezone',
8
+ }
9
+
10
+ export const TOOL_SCOPES: Record<string, string> = { ...USER_TOOL_SCOPES }
11
+
12
+ // The scopes a batch of JSON-RPC messages needs and the token lacks, all of them at once: MCP asks for one
13
+ // challenge per operation, not one per retry.
14
+ export function missingScopes(messages: unknown, granted: readonly string[]): string[] {
15
+ const needed = [messages]
16
+ .flat()
17
+ .flatMap((message) => {
18
+ const call = message as { method?: unknown; params?: { name?: unknown } } | null
19
+
20
+ return call?.method === 'tools/call' && typeof call.params?.name === 'string'
21
+ ? [TOOL_SCOPES[call.params.name]]
22
+ : []
23
+ })
24
+ .filter((scope): scope is string => scope !== undefined)
25
+
26
+ return [...new Set(needed)].filter((scope) => !granted.includes(scope))
27
+ }
@@ -1,7 +1,17 @@
1
1
  import { createMcpHandler, type McpHttpHandler, McpServer } from '@modelcontextprotocol/server'
2
2
  import type { DependencyContainer } from 'tsyringe'
3
+ import type { Env } from '@/infra/config/env'
3
4
  import { registerUserTools } from './tools/users.tool'
4
5
 
6
+ // The protected resource MCP clients ask a token for; its access tokens carry it as their audience.
7
+ export function mcpResource(env: Pick<Env, 'API_URL'>): string {
8
+ return `${env.API_URL}/api/mcp`
9
+ }
10
+
11
+ export function mcpResourceMetadataUrl(env: Pick<Env, 'API_URL'>): string {
12
+ return `${env.API_URL}/.well-known/oauth-protected-resource/api/mcp`
13
+ }
14
+
5
15
  // One server per request: the protocol is stateless, and each instance knows only its caller.
6
16
  export function createMcpServerHandler(container: DependencyContainer): McpHttpHandler {
7
17
  return createMcpHandler(
@@ -16,6 +16,12 @@ function asResult(profile: Profile) {
16
16
  }
17
17
  }
18
18
 
19
+ // The scope each tool needs: reading a module's data is `<module>:read`, changing it `<module>:write`.
20
+ export const USER_TOOL_SCOPES = {
21
+ get_profile: 'profile:read',
22
+ update_profile: 'profile:write',
23
+ } as const
24
+
19
25
  export function registerUserTools(
20
26
  server: McpServer,
21
27
  container: DependencyContainer,
@@ -0,0 +1,21 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import type { AuthorizationCodes } from '@/domain/oauth/ports/authorization-codes.port'
3
+
4
+ const sha256 = (value: string) => createHash('sha256').update(value).digest('base64url')
5
+
6
+ export class CryptoAuthorizationCodes implements AuthorizationCodes {
7
+ create(): string {
8
+ return randomBytes(32).toString('base64url')
9
+ }
10
+
11
+ digest(code: string): string {
12
+ return sha256(code)
13
+ }
14
+
15
+ matchesChallenge(verifier: string, challenge: string): boolean {
16
+ const expected = Buffer.from(sha256(verifier))
17
+ const given = Buffer.from(challenge)
18
+
19
+ return expected.length === given.length && timingSafeEqual(expected, given)
20
+ }
21
+ }