@stjoseph/prumo 0.1.1 → 0.3.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 (173) hide show
  1. package/.prumo-templates/api/auth.md +50 -48
  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 -5
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/mcp/server.md +39 -12
  10. package/.prumo-templates/mobile/routing.md +1 -1
  11. package/.prumo-templates/mobile/storage.md +7 -6
  12. package/.prumo-templates/monorepo/layout.md +4 -4
  13. package/.prumo-templates/multi-tenancy/client.md +6 -6
  14. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  15. package/README.md +19 -14
  16. package/dist/clean.js +13 -3
  17. package/dist/cli.js +38 -9
  18. package/dist/commands.js +7 -4
  19. package/dist/compose.js +4 -12
  20. package/dist/doctor.js +31 -17
  21. package/dist/generate.js +56 -9
  22. package/dist/mcp.js +15 -8
  23. package/dist/names.js +42 -0
  24. package/dist/questions.js +32 -16
  25. package/package.json +2 -2
  26. package/templates/api/.env.example +3 -5
  27. package/templates/api/README.md +5 -6
  28. package/templates/api/docker-compose.yml +0 -1
  29. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  30. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  31. package/templates/api/package.json +7 -8
  32. package/templates/api/scripts/database.mjs +3 -11
  33. package/templates/api/scripts/ports.mjs +1 -1
  34. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  35. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  36. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  37. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  38. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  39. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  40. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  41. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  42. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  43. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  44. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  45. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  46. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  47. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  48. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  49. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  50. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  53. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  54. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  55. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  56. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  60. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  61. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  62. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  63. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  64. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  65. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  66. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  67. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  68. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  69. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  70. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  71. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  72. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  73. package/templates/api/src/infra/config/env.ts +3 -7
  74. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  75. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  76. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  77. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  78. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  79. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  80. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  81. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  82. package/templates/api/src/infra/di/auth.di.ts +21 -0
  83. package/templates/api/src/infra/di/index.ts +4 -10
  84. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  85. package/templates/api/src/infra/http/app.ts +11 -1
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  89. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  90. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  91. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  92. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  93. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  94. package/templates/api/src/infra/http/server.ts +1 -4
  95. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  96. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  97. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  98. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  99. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  100. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  101. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  102. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  103. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  104. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  105. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  106. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  107. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  108. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  109. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  110. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  111. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  112. package/templates/api/test/support/factories/user.factory.ts +9 -11
  113. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  114. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  115. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  116. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  117. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  118. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  119. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  120. package/templates/api/test/support/setup.ts +6 -28
  121. package/templates/mobile/README.md +1 -1
  122. package/templates/mobile/jest.config.js +0 -17
  123. package/templates/mobile/package.json +1 -4
  124. package/templates/mobile/scripts/ports.mjs +1 -1
  125. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  126. package/templates/mobile/src/api-contract/auth.ts +161 -0
  127. package/templates/mobile/src/api-contract/index.ts +10 -0
  128. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  129. package/templates/mobile/src/app/_layout.tsx +5 -5
  130. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  131. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  132. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  133. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  134. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  135. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  136. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  137. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  138. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  139. package/templates/site/README.md +1 -1
  140. package/templates/site/package.json +1 -1
  141. package/templates/site/scripts/ports.mjs +1 -1
  142. package/templates/web/README.md +1 -1
  143. package/templates/web/package.json +1 -3
  144. package/templates/web/scripts/ports.mjs +1 -1
  145. package/templates/web/src/api-contract/api-error.ts +0 -11
  146. package/templates/web/src/api-contract/auth.ts +161 -0
  147. package/templates/web/src/api-contract/index.ts +10 -0
  148. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  149. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  150. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  151. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  152. package/templates/web/src/main.tsx +6 -4
  153. package/templates/web/src/router.tsx +1 -2
  154. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  155. package/templates/web/src/routes/_authenticated.tsx +1 -1
  156. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  157. package/templates/web/test/app.spec.tsx +47 -22
  158. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  159. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  160. package/templates/web/test/support/render-app.tsx +4 -4
  161. package/templates/workspace/README.md +1 -1
  162. package/templates/workspace/package.json +1 -1
  163. package/templates/workspace/scripts/ports.mjs +1 -1
  164. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  165. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  166. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  167. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  168. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  169. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  170. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  171. package/templates/mobile/src/features/auth/session.ts +0 -17
  172. package/templates/web/src/features/auth/auth-client.ts +0 -17
  173. package/templates/web/src/features/auth/session.ts +0 -17
@@ -1,40 +1,45 @@
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 { createMcpServerHandler, mcpResource, mcpResourceMetadataUrl } from '@/infra/mcp/mcp.server'
8
8
 
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.
9
+ // The route answers an OAuth access token issued for it, never a session cookie or a first-party token: the
10
+ // audience decides, so the session hook stands aside.
25
11
  export async function mcpController(fastify: FastifyInstance): Promise<void> {
26
- const auth = fastify.container.resolve<Auth>(AUTH)
12
+ const tokens = fastify.container.resolve<AccessTokens>(ACCESS_TOKENS)
27
13
  const env = fastify.container.resolve<Env>(ENV)
14
+ const resource = mcpResource(env)
28
15
  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
16
 
35
17
  fastify.addHook('onClose', () => mcp.close())
36
18
 
37
- fastify.post('/', { config: { public: true }, schema: { hide: true } }, async (request, reply) =>
38
- sendFetchResponse(reply, await handle(toFetchRequest(request))),
19
+ fastify.post(
20
+ '/',
21
+ { config: { public: true }, schema: { hide: true } },
22
+ async (request, reply) => {
23
+ const token = /^Bearer (.+)$/i.exec(request.headers.authorization ?? '')?.[1]
24
+ const claims = token === undefined ? null : await tokens.verify(token, resource)
25
+
26
+ if (token === undefined || claims === null || claims.clientId === undefined) {
27
+ // RFC 9728: the 401 names the document that says where to obtain a token.
28
+ reply.header(
29
+ 'www-authenticate',
30
+ `Bearer resource_metadata="${mcpResourceMetadataUrl(env)}"${token === undefined ? '' : ', error="invalid_token"'}`,
31
+ )
32
+ throw new HttpError(401, 'Unauthorized')
33
+ }
34
+
35
+ const authInfo: AuthInfo = {
36
+ token,
37
+ clientId: claims.clientId,
38
+ scopes: [],
39
+ extra: { userId: claims.userId },
40
+ }
41
+
42
+ return sendFetchResponse(reply, await mcp.fetch(toFetchRequest(request), { authInfo }))
43
+ },
39
44
  )
40
45
  }
@@ -0,0 +1,257 @@
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 { mcpResource } from '@/infra/mcp/mcp.server'
22
+
23
+ const PUBLIC = { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } }
24
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
25
+
26
+ const authorizeQuerySchema = z.object({
27
+ response_type: z.string().optional(),
28
+ client_id: z.string().max(2048),
29
+ redirect_uri: z.string().max(2048),
30
+ code_challenge: z.string().optional(),
31
+ code_challenge_method: z.string().optional(),
32
+ state: z.string().max(1024).optional(),
33
+ resource: z.string().optional(),
34
+ scope: z.string().optional(),
35
+ })
36
+
37
+ const idParamsSchema = z.object({ id: z.uuid() })
38
+
39
+ function withParams(uri: string, params: Record<string, string | null | undefined>): string {
40
+ const url = new URL(uri)
41
+
42
+ for (const [key, value] of Object.entries(params)) {
43
+ if (value !== null && value !== undefined) {
44
+ url.searchParams.set(key, value)
45
+ }
46
+ }
47
+
48
+ return url.toString()
49
+ }
50
+
51
+ function tokenResponse(tokens: SessionTokens) {
52
+ return {
53
+ access_token: tokens.accessToken.token,
54
+ token_type: 'Bearer',
55
+ expires_in: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
56
+ refresh_token: tokens.refreshToken,
57
+ }
58
+ }
59
+
60
+ // MCP's authorization server, from discovery to the token: RFC 9728, RFC 8414, OAuth 2.1 with PKCE, RFC 8707 and
61
+ // RFC 9207, with clients identified by their metadata document. The user signs in and consents on the web app.
62
+ export async function oauthController(fastify: FastifyInstance): Promise<void> {
63
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
64
+ const env = app.container.resolve<Env>(ENV)
65
+ const issuer = env.API_URL
66
+ const resource = mcpResource(env)
67
+
68
+ // The token endpoint takes a form, as OAuth writes it.
69
+ app.addContentTypeParser(
70
+ 'application/x-www-form-urlencoded',
71
+ { parseAs: 'string' },
72
+ (_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
73
+ )
74
+
75
+ const resourceMetadata = {
76
+ resource,
77
+ authorization_servers: [issuer],
78
+ bearer_methods_supported: ['header'],
79
+ }
80
+
81
+ // At the path of the resource first, then at the root: the two places an MCP client looks.
82
+ for (const url of [
83
+ '/.well-known/oauth-protected-resource/api/mcp',
84
+ '/.well-known/oauth-protected-resource',
85
+ ]) {
86
+ app.get(url, { config: { public: true }, schema: { hide: true } }, async () => resourceMetadata)
87
+ }
88
+
89
+ app.get(
90
+ '/.well-known/oauth-authorization-server',
91
+ { config: { public: true }, schema: { hide: true } },
92
+ async () => ({
93
+ issuer,
94
+ authorization_endpoint: `${issuer}/api/oauth/authorize`,
95
+ token_endpoint: `${issuer}/api/oauth/token`,
96
+ response_types_supported: ['code'],
97
+ grant_types_supported: ['authorization_code', 'refresh_token'],
98
+ code_challenge_methods_supported: ['S256'],
99
+ token_endpoint_auth_methods_supported: ['none'],
100
+ client_id_metadata_document_supported: true,
101
+ authorization_response_iss_parameter_supported: true,
102
+ }),
103
+ )
104
+
105
+ app.get(
106
+ '/api/oauth/authorize',
107
+ { config: PUBLIC, schema: { tags: ['oauth'], querystring: authorizeQuerySchema } },
108
+ async (request, reply) => {
109
+ const query = request.query
110
+
111
+ try {
112
+ const authorization = await app.container.resolve(StartAuthorizationUseCase).execute(
113
+ {
114
+ responseType: query.response_type,
115
+ clientId: query.client_id,
116
+ redirectUri: query.redirect_uri,
117
+ codeChallenge: query.code_challenge,
118
+ codeChallengeMethod: query.code_challenge_method,
119
+ clientState: query.state ?? null,
120
+ resource: query.resource,
121
+ },
122
+ resource,
123
+ )
124
+
125
+ // Signing in, if needed, and the consent itself happen on the web app.
126
+ return reply.redirect(`${env.WEB_ORIGIN}/consent?request=${authorization.id}`)
127
+ } catch (error) {
128
+ if (error instanceof AuthorizationRequestError) {
129
+ return reply.redirect(
130
+ withParams(query.redirect_uri, {
131
+ error: error.code,
132
+ error_description: error.message,
133
+ state: query.state,
134
+ iss: issuer,
135
+ }),
136
+ )
137
+ }
138
+
139
+ throw error
140
+ }
141
+ },
142
+ )
143
+
144
+ app.get(
145
+ '/api/oauth/authorizations/:id',
146
+ {
147
+ schema: {
148
+ tags: ['oauth'],
149
+ params: idParamsSchema,
150
+ response: { 200: authorizationViewSchema },
151
+ },
152
+ },
153
+ async (request) => {
154
+ const authorization = await app.container
155
+ .resolve(FindAuthorizationUseCase)
156
+ .execute(request.params.id)
157
+ const redirect = new URL(authorization.redirectUri)
158
+
159
+ return {
160
+ clientId: authorization.clientId,
161
+ clientName: authorization.clientName,
162
+ clientHost: new URL(authorization.clientId).host,
163
+ redirectHost: redirect.host,
164
+ redirectsToThisDevice: LOOPBACK.has(redirect.hostname),
165
+ }
166
+ },
167
+ )
168
+
169
+ app.post(
170
+ '/api/oauth/authorizations/:id/decision',
171
+ {
172
+ schema: {
173
+ tags: ['oauth'],
174
+ params: idParamsSchema,
175
+ body: authorizationDecisionSchema,
176
+ response: { 200: authorizationRedirectSchema },
177
+ },
178
+ },
179
+ async (request) => {
180
+ const { authorization, code } = await app.container
181
+ .resolve(DecideAuthorizationUseCase)
182
+ .execute(request.params.id, currentUser(request).id, request.body.accept)
183
+
184
+ return {
185
+ redirectTo: withParams(authorization.redirectUri, {
186
+ ...(code === null ? { error: 'access_denied' } : { code }),
187
+ state: authorization.clientState,
188
+ iss: issuer,
189
+ }),
190
+ }
191
+ },
192
+ )
193
+
194
+ // Errors here are OAuth's, not problem+json: an MCP client reads `error` and `error_description`.
195
+ app.post(
196
+ '/api/oauth/token',
197
+ { config: PUBLIC, schema: { tags: ['oauth'] } },
198
+ async (request, reply: FastifyReply) => {
199
+ const body = (request.body ?? {}) as Record<string, string | undefined>
200
+ const fail = (error: string, description: string) =>
201
+ reply.code(400).send({ error, error_description: description })
202
+
203
+ reply.header('cache-control', 'no-store')
204
+
205
+ if (body.resource !== undefined && body.resource !== resource) {
206
+ return fail('invalid_target', `resource must be ${resource}`)
207
+ }
208
+
209
+ try {
210
+ if (body.grant_type === 'authorization_code') {
211
+ const { code, client_id, redirect_uri, code_verifier } = body
212
+
213
+ if (!code || !client_id || !redirect_uri || !code_verifier) {
214
+ return fail(
215
+ 'invalid_request',
216
+ 'code, client_id, redirect_uri and code_verifier are required',
217
+ )
218
+ }
219
+
220
+ return tokenResponse(
221
+ await app.container.resolve(ExchangeCodeUseCase).execute({
222
+ code,
223
+ clientId: client_id,
224
+ redirectUri: redirect_uri,
225
+ codeVerifier: code_verifier,
226
+ resource,
227
+ }),
228
+ )
229
+ }
230
+
231
+ if (body.grant_type === 'refresh_token') {
232
+ if (!body.refresh_token || !body.client_id) {
233
+ return fail('invalid_request', 'refresh_token and client_id are required')
234
+ }
235
+
236
+ return tokenResponse(
237
+ await app.container
238
+ .resolve(RefreshSessionUseCase)
239
+ .execute(body.refresh_token, body.client_id),
240
+ )
241
+ }
242
+
243
+ return fail('unsupported_grant_type', 'Only authorization_code and refresh_token')
244
+ } catch (error) {
245
+ if (
246
+ error instanceof InvalidGrantError ||
247
+ error instanceof InvalidSessionError ||
248
+ error instanceof SupersededTokenError
249
+ ) {
250
+ return fail('invalid_grant', error.message)
251
+ }
252
+
253
+ throw error
254
+ }
255
+ },
256
+ )
257
+ }
@@ -31,6 +31,7 @@ const TITLES: Record<number, string> = {
31
31
  413: 'Payload Too Large',
32
32
  415: 'Unsupported Media Type',
33
33
  422: 'Unprocessable Entity',
34
+ 429: 'Too Many Requests',
34
35
  500: 'Internal Server Error',
35
36
  503: 'Service Unavailable',
36
37
  }
@@ -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) {
@@ -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(
@@ -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
+ }