@stjoseph/prumo 0.2.0 → 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 (163) hide show
  1. package/.prumo-templates/api/auth.md +50 -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 -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 +2 -2
  16. package/dist/clean.js +2 -2
  17. package/dist/compose.js +1 -9
  18. package/dist/generate.js +3 -4
  19. package/dist/mcp.js +15 -8
  20. package/package.json +1 -1
  21. package/templates/api/.env.example +3 -5
  22. package/templates/api/README.md +4 -5
  23. package/templates/api/docker-compose.yml +0 -1
  24. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  25. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  26. package/templates/api/package.json +6 -8
  27. package/templates/api/scripts/database.mjs +3 -11
  28. package/templates/api/scripts/ports.mjs +1 -1
  29. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  30. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  31. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  32. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  33. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  34. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  35. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  36. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  37. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  39. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  40. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  41. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  42. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  43. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  44. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  46. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  47. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  48. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  49. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  50. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  51. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  52. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  53. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  54. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  55. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  56. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  57. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  58. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  59. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  60. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  61. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  62. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  63. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  65. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  66. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  67. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  68. package/templates/api/src/infra/config/env.ts +3 -7
  69. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  73. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  77. package/templates/api/src/infra/di/auth.di.ts +21 -0
  78. package/templates/api/src/infra/di/index.ts +4 -10
  79. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  80. package/templates/api/src/infra/http/app.ts +11 -1
  81. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  82. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  83. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  84. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  85. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  86. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  87. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  88. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  89. package/templates/api/src/infra/http/server.ts +1 -4
  90. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  91. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  92. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  93. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  94. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  95. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  96. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  98. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  99. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  100. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  101. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  102. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  103. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  104. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  105. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  106. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  107. package/templates/api/test/support/factories/user.factory.ts +9 -11
  108. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  109. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  110. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  111. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  112. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  114. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  115. package/templates/api/test/support/setup.ts +6 -28
  116. package/templates/mobile/README.md +1 -1
  117. package/templates/mobile/jest.config.js +0 -17
  118. package/templates/mobile/package.json +0 -4
  119. package/templates/mobile/scripts/ports.mjs +1 -1
  120. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  121. package/templates/mobile/src/api-contract/auth.ts +161 -0
  122. package/templates/mobile/src/api-contract/index.ts +10 -0
  123. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  124. package/templates/mobile/src/app/_layout.tsx +5 -5
  125. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  126. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  127. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  128. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  129. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  130. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  131. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  132. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  133. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  134. package/templates/site/scripts/ports.mjs +1 -1
  135. package/templates/web/package.json +0 -3
  136. package/templates/web/scripts/ports.mjs +1 -1
  137. package/templates/web/src/api-contract/api-error.ts +0 -11
  138. package/templates/web/src/api-contract/auth.ts +161 -0
  139. package/templates/web/src/api-contract/index.ts +10 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  141. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  142. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  143. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  144. package/templates/web/src/main.tsx +6 -4
  145. package/templates/web/src/router.tsx +1 -2
  146. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  147. package/templates/web/src/routes/_authenticated.tsx +1 -1
  148. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  149. package/templates/web/test/app.spec.tsx +47 -22
  150. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  151. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  152. package/templates/web/test/support/render-app.tsx +4 -4
  153. package/templates/workspace/scripts/ports.mjs +1 -1
  154. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  155. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  156. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  157. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  158. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  159. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  160. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  161. package/templates/mobile/src/features/auth/session.ts +0 -17
  162. package/templates/web/src/features/auth/auth-client.ts +0 -17
  163. package/templates/web/src/features/auth/session.ts +0 -17
@@ -0,0 +1,201 @@
1
+ import { testEnv, testOrm } from '@test/support/setup'
2
+ import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { createContainer } from '@/infra/di'
5
+ import { buildApp } from '@/infra/http/app'
6
+
7
+ const ORIGIN = 'http://localhost:5173'
8
+ const BEARER = { 'x-auth-transport': 'bearer' }
9
+
10
+ let app: FastifyInstance
11
+
12
+ const account = () => ({
13
+ name: 'Ana',
14
+ email: `${crypto.randomUUID()}@example.com`,
15
+ password: 'correct-horse-battery',
16
+ })
17
+
18
+ function cookiesOf(response: LightMyRequestResponse): Record<string, string> {
19
+ return Object.fromEntries(response.cookies.map((cookie) => [cookie.name, cookie.value]))
20
+ }
21
+
22
+ function setCookie(response: LightMyRequestResponse, name: string) {
23
+ return response.cookies.find((cookie) => cookie.name === name)
24
+ }
25
+
26
+ beforeEach(async () => {
27
+ app = await buildApp(createContainer({ env: testEnv(), orm: testOrm() }))
28
+ })
29
+
30
+ afterEach(async () => {
31
+ await app.close()
32
+ })
33
+
34
+ describe('auth controller, on the web', () => {
35
+ it('signs up into httpOnly cookies, the refresh one sent only to its route', async () => {
36
+ const response = await app.inject({
37
+ method: 'POST',
38
+ url: '/api/auth/sign-up',
39
+ headers: { origin: ORIGIN },
40
+ payload: account(),
41
+ })
42
+
43
+ expect(response.statusCode).toBe(201)
44
+ expect(response.body).toBe('')
45
+ expect(setCookie(response, 'access_token')).toMatchObject({
46
+ httpOnly: true,
47
+ sameSite: 'Lax',
48
+ path: '/',
49
+ })
50
+ expect(setCookie(response, 'refresh_token')).toMatchObject({
51
+ httpOnly: true,
52
+ path: '/api/auth/refresh',
53
+ })
54
+ })
55
+
56
+ it('reads the session from the cookie, refreshes it, and signs out', async () => {
57
+ const signUp = await app.inject({
58
+ method: 'POST',
59
+ url: '/api/auth/sign-up',
60
+ headers: { origin: ORIGIN },
61
+ payload: account(),
62
+ })
63
+ const { access_token, refresh_token } = cookiesOf(signUp)
64
+
65
+ const session = await app.inject({
66
+ url: '/api/auth/session',
67
+ headers: { cookie: `access_token=${access_token}` },
68
+ })
69
+ expect(session.json()).toMatchObject({ user: { id: expect.any(String) } })
70
+
71
+ const refreshed = await app.inject({
72
+ method: 'POST',
73
+ url: '/api/auth/refresh',
74
+ headers: { cookie: `refresh_token=${refresh_token}`, origin: ORIGIN },
75
+ })
76
+ expect(refreshed.statusCode).toBe(204)
77
+ expect(cookiesOf(refreshed).refresh_token).not.toBe(refresh_token)
78
+
79
+ const signOut = await app.inject({
80
+ method: 'POST',
81
+ url: '/api/auth/sign-out',
82
+ headers: { cookie: `access_token=${cookiesOf(refreshed).access_token}`, origin: ORIGIN },
83
+ })
84
+ expect(signOut.statusCode).toBe(204)
85
+
86
+ const afterSignOut = await app.inject({
87
+ method: 'POST',
88
+ url: '/api/auth/refresh',
89
+ headers: { cookie: `refresh_token=${cookiesOf(refreshed).refresh_token}`, origin: ORIGIN },
90
+ })
91
+ expect(afterSignOut.statusCode).toBe(401)
92
+ })
93
+
94
+ it('refuses a request that leans on a cookie from another origin, or from none', async () => {
95
+ const signUp = await app.inject({
96
+ method: 'POST',
97
+ url: '/api/auth/sign-up',
98
+ headers: { origin: ORIGIN },
99
+ payload: account(),
100
+ })
101
+ const cookie = `access_token=${cookiesOf(signUp).access_token}`
102
+
103
+ for (const headers of [{ cookie, origin: 'https://evil.example' }, { cookie }]) {
104
+ const response = await app.inject({ method: 'POST', url: '/api/auth/sign-out', headers })
105
+
106
+ expect(response.statusCode).toBe(403)
107
+ expect(response.headers['content-type']).toContain('application/problem+json')
108
+ }
109
+ })
110
+ })
111
+
112
+ describe('auth controller, for a native client', () => {
113
+ it('hands the tokens over in the body, and accepts them as a bearer', async () => {
114
+ const signUp = await app.inject({
115
+ method: 'POST',
116
+ url: '/api/auth/sign-up',
117
+ headers: BEARER,
118
+ payload: account(),
119
+ })
120
+ const tokens = signUp.json()
121
+
122
+ expect(signUp.statusCode).toBe(201)
123
+ expect(signUp.cookies).toEqual([])
124
+ expect(tokens).toEqual({
125
+ accessToken: expect.any(String),
126
+ refreshToken: expect.any(String),
127
+ expiresIn: 900,
128
+ })
129
+
130
+ const session = await app.inject({
131
+ url: '/api/auth/session',
132
+ headers: { authorization: `Bearer ${tokens.accessToken}` },
133
+ })
134
+ expect(session.statusCode).toBe(200)
135
+
136
+ const refreshed = await app.inject({
137
+ method: 'POST',
138
+ url: '/api/auth/refresh',
139
+ headers: BEARER,
140
+ payload: { refreshToken: tokens.refreshToken },
141
+ })
142
+ expect(refreshed.statusCode).toBe(200)
143
+ expect(refreshed.json().refreshToken).not.toBe(tokens.refreshToken)
144
+ })
145
+ })
146
+
147
+ describe('auth controller, refusing', () => {
148
+ it('answers a wrong password with 401 and a taken email with 409, as problem+json', async () => {
149
+ const ana = account()
150
+ await app.inject({ method: 'POST', url: '/api/auth/sign-up', headers: BEARER, payload: ana })
151
+
152
+ const wrong = await app.inject({
153
+ method: 'POST',
154
+ url: '/api/auth/sign-in',
155
+ headers: BEARER,
156
+ payload: { email: ana.email, password: 'wrong-password' },
157
+ })
158
+ const taken = await app.inject({
159
+ method: 'POST',
160
+ url: '/api/auth/sign-up',
161
+ headers: BEARER,
162
+ payload: ana,
163
+ })
164
+
165
+ expect(wrong.statusCode).toBe(401)
166
+ expect(wrong.json()).toMatchObject({ detail: 'Invalid email or password' })
167
+ expect(taken.statusCode).toBe(409)
168
+ expect(taken.headers['content-type']).toContain('application/problem+json')
169
+ })
170
+
171
+ it('names the fields a sign-up gets wrong', async () => {
172
+ const response = await app.inject({
173
+ method: 'POST',
174
+ url: '/api/auth/sign-up',
175
+ headers: BEARER,
176
+ payload: { name: '', email: 'not-an-email', password: 'short' },
177
+ })
178
+
179
+ expect(response.statusCode).toBe(400)
180
+ expect(Object.keys(response.json().errors).sort()).toEqual(['email', 'name', 'password'])
181
+ })
182
+
183
+ it('slows down repeated sign-in attempts from one address', async () => {
184
+ const attempt = () =>
185
+ app.inject({
186
+ method: 'POST',
187
+ url: '/api/auth/sign-in',
188
+ headers: BEARER,
189
+ payload: { email: 'nobody@example.com', password: 'whatever' },
190
+ })
191
+
192
+ for (let i = 0; i < 10; i += 1) {
193
+ expect((await attempt()).statusCode).toBe(401)
194
+ }
195
+
196
+ const limited = await attempt()
197
+
198
+ expect(limited.statusCode).toBe(429)
199
+ expect(limited.headers['content-type']).toContain('application/problem+json')
200
+ })
201
+ })
@@ -4,11 +4,14 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli
4
4
  import { testEnv, testOrm } from '@test/support/setup'
5
5
  import type { FastifyInstance } from 'fastify'
6
6
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
- import { mcpResource } from '@/infra/auth/auth.factory'
7
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
8
+ import { CLIENT_METADATA, type ClientMetadata } from '@/domain/oauth/ports/client-metadata.port'
8
9
  import { createContainer } from '@/infra/di'
9
10
  import { buildApp } from '@/infra/http/app'
11
+ import { mcpResource } from '@/infra/mcp/mcp.server'
10
12
 
11
13
  const ORIGIN = 'http://localhost:5173'
14
+ const CLIENT_ID = 'https://client.example.com/oauth/client.json'
12
15
  // A web client needs an https redirect off loopback; the test never follows it.
13
16
  const REDIRECT = 'https://client.example.com/callback'
14
17
 
@@ -24,11 +27,25 @@ function freePort(): Promise<number> {
24
27
  })
25
28
  }
26
29
 
27
- // requireMcpAuth fetches the JWKS from BETTER_AUTH_URL, so the application listens for real.
30
+ // Stands in for the HTTPS fetch of the client's metadata document, which the adapter's own test covers.
31
+ const clients = {
32
+ async read(clientId: string): Promise<ClientMetadata> {
33
+ if (clientId !== CLIENT_ID) {
34
+ throw new InvalidClientError('Unknown client')
35
+ }
36
+
37
+ return { clientId, clientName: 'Example Client', redirectUris: [REDIRECT] }
38
+ },
39
+ }
40
+
41
+ // The SDK's client reaches the MCP route over real HTTP, so the application listens.
28
42
  beforeEach(async () => {
29
43
  const port = await freePort()
30
- env = { ...testEnv(), BETTER_AUTH_URL: `http://127.0.0.1:${port}` }
31
- app = await buildApp(createContainer({ env, orm: testOrm() }))
44
+ env = { ...testEnv(), API_URL: `http://127.0.0.1:${port}` }
45
+ const container = createContainer({ env, orm: testOrm() })
46
+
47
+ container.register(CLIENT_METADATA, { useValue: clients })
48
+ app = await buildApp(container)
32
49
  await app.listen({ port, host: '127.0.0.1' })
33
50
  })
34
51
 
@@ -39,7 +56,7 @@ afterEach(async () => {
39
56
  async function signUp(): Promise<string> {
40
57
  const response = await app.inject({
41
58
  method: 'POST',
42
- url: '/api/auth/sign-up/email',
59
+ url: '/api/auth/sign-up',
43
60
  headers: { origin: ORIGIN },
44
61
  payload: {
45
62
  name: 'Ana',
@@ -47,63 +64,78 @@ async function signUp(): Promise<string> {
47
64
  password: 'correct-horse-battery',
48
65
  },
49
66
  })
50
- const cookies = [response.headers['set-cookie']].flat().filter((c) => c !== undefined)
51
67
 
52
- return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
68
+ return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
53
69
  }
54
70
 
55
- // The path an MCP client takes: register, authorize with PKCE, consent, exchange the code.
56
- async function accessToken(cookie: string): Promise<string> {
57
- const client = await app.inject({
58
- method: 'POST',
59
- url: '/api/auth/oauth2/create-client',
60
- headers: { cookie, origin: ORIGIN },
61
- payload: { redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none' },
62
- })
63
- const clientId = client.json().client_id as string
71
+ function pkce() {
64
72
  const verifier = randomBytes(32).toString('base64url')
65
- const query = new URLSearchParams({
73
+
74
+ return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
75
+ }
76
+
77
+ function authorizeUrl(
78
+ overrides: Record<string, string | undefined> = {},
79
+ challenge = pkce().challenge,
80
+ ) {
81
+ const params = {
66
82
  response_type: 'code',
67
- client_id: clientId,
83
+ client_id: CLIENT_ID,
68
84
  redirect_uri: REDIRECT,
69
- scope: 'openid',
70
- state: 'state',
71
- code_challenge: createHash('sha256').update(verifier).digest('base64url'),
85
+ state: 'xyz',
86
+ code_challenge: challenge,
72
87
  code_challenge_method: 'S256',
73
88
  resource: mcpResource(env),
89
+ ...overrides,
90
+ }
91
+ const query = new URLSearchParams(
92
+ Object.entries(params).filter((entry): entry is [string, string] => entry[1] !== undefined),
93
+ )
94
+
95
+ return `/api/oauth/authorize?${query}`
96
+ }
97
+
98
+ function token(form: Record<string, string>) {
99
+ return app.inject({
100
+ method: 'POST',
101
+ url: '/api/oauth/token',
102
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
103
+ payload: new URLSearchParams(form).toString(),
74
104
  })
75
- const authorize = await app.inject({
76
- url: `/api/auth/oauth2/authorize?${query}`,
77
- headers: { cookie },
78
- })
79
- const consentPage = new URL(authorize.headers.location as string)
80
- const consent = await app.inject({
105
+ }
106
+
107
+ // The path an MCP client takes: authorize with PKCE, the user consents on the web, the code becomes tokens.
108
+ async function authorize(cookie: string, accept = true) {
109
+ const { verifier, challenge } = pkce()
110
+ const started = await app.inject({ url: authorizeUrl({}, challenge) })
111
+ const consent = new URL(started.headers.location as string)
112
+ const id = consent.searchParams.get('request') ?? ''
113
+ const view = await app.inject({ url: `/api/oauth/authorizations/${id}`, headers: { cookie } })
114
+ const decision = await app.inject({
81
115
  method: 'POST',
82
- url: '/api/auth/oauth2/consent',
116
+ url: `/api/oauth/authorizations/${id}/decision`,
83
117
  headers: { cookie, origin: ORIGIN },
84
- payload: { accept: true, oauth_query: consentPage.search.slice(1) },
118
+ payload: { accept },
85
119
  })
86
- const redirect = new URL(consent.json().url ?? consent.json().redirect_uri)
87
- const token = await app.inject({
88
- method: 'POST',
89
- url: '/api/auth/oauth2/token',
90
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
91
- payload: new URLSearchParams({
92
- grant_type: 'authorization_code',
93
- code: redirect.searchParams.get('code') ?? '',
94
- redirect_uri: REDIRECT,
95
- client_id: clientId,
96
- code_verifier: verifier,
97
- resource: mcpResource(env),
98
- }).toString(),
120
+
121
+ return { started, consent, view, redirect: new URL(decision.json().redirectTo), verifier }
122
+ }
123
+
124
+ async function tokens(cookie: string) {
125
+ const { redirect, verifier } = await authorize(cookie)
126
+ const response = await token({
127
+ grant_type: 'authorization_code',
128
+ code: redirect.searchParams.get('code') ?? '',
129
+ redirect_uri: REDIRECT,
130
+ client_id: CLIENT_ID,
131
+ code_verifier: verifier,
132
+ resource: mcpResource(env),
99
133
  })
100
134
 
101
- return token.json().access_token as string
135
+ return { response, code: redirect.searchParams.get('code') ?? '', verifier }
102
136
  }
103
137
 
104
- // The SDK's own client, speaking the real protocol over HTTP.
105
- async function connect(token: string): Promise<Client> {
106
- // The server rejects 2025-era traffic, as Better Auth's MCP profile asks.
138
+ async function connect(accessToken: string): Promise<Client> {
107
139
  const client = new Client(
108
140
  { name: 'test', version: '1.0.0' },
109
141
  { versionNegotiation: { mode: { pin: '2026-07-28' } } },
@@ -111,25 +143,95 @@ async function connect(token: string): Promise<Client> {
111
143
 
112
144
  await client.connect(
113
145
  new StreamableHTTPClientTransport(new URL(mcpResource(env)), {
114
- requestInit: { headers: { authorization: `Bearer ${token}` } },
146
+ requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
115
147
  }),
116
148
  )
117
149
  return client
118
150
  }
119
151
 
120
- describe('mcp controller', () => {
121
- it('tells an MCP client where to authorize', async () => {
122
- const metadata = await app.inject({ url: '/.well-known/oauth-protected-resource' })
152
+ describe('discovery', () => {
153
+ it('tells an MCP client where to authorize, from the 401 to the server metadata', async () => {
123
154
  const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
155
+ const challenge = refused.headers['www-authenticate'] as string
156
+ const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? ''
157
+ const resource = await app.inject({ url: new URL(metadataUrl).pathname })
158
+ const server = await app.inject({ url: '/.well-known/oauth-authorization-server' })
124
159
 
125
- expect(metadata.json()).toMatchObject({ resource: mcpResource(env) })
126
160
  expect(refused.statusCode).toBe(401)
127
- expect(refused.headers['www-authenticate']).toContain('resource_metadata=')
161
+ expect(resource.json()).toMatchObject({
162
+ resource: mcpResource(env),
163
+ authorization_servers: [env.API_URL],
164
+ })
165
+ expect(server.json()).toMatchObject({
166
+ issuer: env.API_URL,
167
+ code_challenge_methods_supported: ['S256'],
168
+ client_id_metadata_document_supported: true,
169
+ authorization_response_iss_parameter_supported: true,
170
+ })
128
171
  })
172
+ })
173
+
174
+ describe('authorization', () => {
175
+ it('sends the browser to consent, shows who asks and where the code goes, and returns it with iss', async () => {
176
+ const { started, consent, view, redirect } = await authorize(await signUp())
129
177
 
178
+ expect(started.statusCode).toBe(302)
179
+ expect(`${consent.origin}${consent.pathname}`).toBe(`${ORIGIN}/consent`)
180
+ expect(view.json()).toEqual({
181
+ clientId: CLIENT_ID,
182
+ clientName: 'Example Client',
183
+ clientHost: 'client.example.com',
184
+ redirectHost: 'client.example.com',
185
+ redirectsToThisDevice: false,
186
+ })
187
+ expect(`${redirect.origin}${redirect.pathname}`).toBe(REDIRECT)
188
+ expect(redirect.searchParams.get('state')).toBe('xyz')
189
+ expect(redirect.searchParams.get('iss')).toBe(env.API_URL)
190
+ expect(redirect.searchParams.get('code')).not.toBeNull()
191
+ })
192
+
193
+ it('answers a denial with access_denied', async () => {
194
+ const { redirect } = await authorize(await signUp(), false)
195
+
196
+ expect(redirect.searchParams.get('error')).toBe('access_denied')
197
+ expect(redirect.searchParams.get('code')).toBeNull()
198
+ })
199
+
200
+ it('never redirects for an unknown client or an unregistered redirect URI', async () => {
201
+ for (const overrides of [
202
+ { client_id: 'https://evil.example.com/client.json' },
203
+ { redirect_uri: 'https://evil.example.com/callback' },
204
+ ]) {
205
+ const response = await app.inject({ url: authorizeUrl(overrides) })
206
+
207
+ expect(response.statusCode).toBe(422)
208
+ expect(response.headers.location).toBeUndefined()
209
+ }
210
+ })
211
+
212
+ it('sends a known client its error: no PKCE, plain PKCE, another resource', async () => {
213
+ for (const [overrides, error] of [
214
+ [{ code_challenge_method: undefined }, 'invalid_request'],
215
+ [{ code_challenge_method: 'plain' }, 'invalid_request'],
216
+ [{ resource: 'https://elsewhere.example.com/mcp' }, 'invalid_target'],
217
+ ] as const) {
218
+ const response = await app.inject({ url: authorizeUrl(overrides) })
219
+ const location = new URL(response.headers.location as string)
220
+
221
+ expect(location.searchParams.get('error')).toBe(error)
222
+ expect(location.searchParams.get('state')).toBe('xyz')
223
+ expect(location.searchParams.get('iss')).toBe(env.API_URL)
224
+ }
225
+ })
226
+ })
227
+
228
+ describe('token', () => {
130
229
  it('runs a tool for the user who authorized the client, with public fields only', async () => {
131
- const client = await connect(await accessToken(await signUp()))
230
+ const { response } = await tokens(await signUp())
231
+ const client = await connect(response.json().access_token)
132
232
  const result = await client.callTool({ name: 'get_profile', arguments: {} })
233
+
234
+ expect(response.headers['cache-control']).toBe('no-store')
133
235
  expect(Object.keys(result.structuredContent ?? {}).sort()).toEqual(
134
236
  ['createdAt', 'displayName', 'id', 'locale', 'timezone', 'updatedAt', 'userId'].sort(),
135
237
  )
@@ -139,7 +241,8 @@ describe('mcp controller', () => {
139
241
  })
140
242
 
141
243
  it('changes the profile through a tool', async () => {
142
- const client = await connect(await accessToken(await signUp()))
244
+ const { response } = await tokens(await signUp())
245
+ const client = await connect(response.json().access_token)
143
246
  const result = await client.callTool({
144
247
  name: 'update_profile',
145
248
  arguments: { timezone: 'America/Sao_Paulo' },
@@ -149,4 +252,79 @@ describe('mcp controller', () => {
149
252
 
150
253
  await client.close()
151
254
  })
255
+
256
+ it('refuses a wrong verifier, and a code spent twice ends what the first use obtained', async () => {
257
+ const cookie = await signUp()
258
+ const { redirect } = await authorize(cookie)
259
+ const wrong = await token({
260
+ grant_type: 'authorization_code',
261
+ code: redirect.searchParams.get('code') ?? '',
262
+ redirect_uri: REDIRECT,
263
+ client_id: CLIENT_ID,
264
+ code_verifier: pkce().verifier,
265
+ })
266
+
267
+ expect(wrong.statusCode).toBe(400)
268
+ expect(wrong.json().error).toBe('invalid_grant')
269
+
270
+ const { response, code, verifier } = await tokens(cookie)
271
+ const replay = await token({
272
+ grant_type: 'authorization_code',
273
+ code,
274
+ redirect_uri: REDIRECT,
275
+ client_id: CLIENT_ID,
276
+ code_verifier: verifier,
277
+ })
278
+ const refresh = await token({
279
+ grant_type: 'refresh_token',
280
+ refresh_token: response.json().refresh_token,
281
+ client_id: CLIENT_ID,
282
+ })
283
+
284
+ expect(replay.json().error).toBe('invalid_grant')
285
+ expect(refresh.json().error).toBe('invalid_grant')
286
+ })
287
+
288
+ it('rotates the refresh token, and only for the client it was issued to', async () => {
289
+ const { response } = await tokens(await signUp())
290
+ const refreshToken = response.json().refresh_token
291
+ const otherClient = await token({
292
+ grant_type: 'refresh_token',
293
+ refresh_token: refreshToken,
294
+ client_id: 'https://other.example.com/client.json',
295
+ })
296
+ const rotated = await token({
297
+ grant_type: 'refresh_token',
298
+ refresh_token: refreshToken,
299
+ client_id: CLIENT_ID,
300
+ })
301
+
302
+ expect(otherClient.json().error).toBe('invalid_grant')
303
+ expect(rotated.statusCode).toBe(200)
304
+ expect(rotated.json().refresh_token).not.toBe(refreshToken)
305
+ })
306
+
307
+ it('keeps the MCP token and the application’s tokens apart', async () => {
308
+ const cookie = await signUp()
309
+ const { response } = await tokens(cookie)
310
+ const access = cookie
311
+ .split('; ')
312
+ .find((pair) => pair.startsWith('access_token='))
313
+ ?.slice(13)
314
+
315
+ const mcpTokenOnApi = await app.inject({
316
+ url: '/api/v1/users/me',
317
+ headers: { authorization: `Bearer ${response.json().access_token}` },
318
+ })
319
+ const apiTokenOnMcp = await app.inject({
320
+ method: 'POST',
321
+ url: '/api/mcp',
322
+ headers: { authorization: `Bearer ${access}` },
323
+ payload: {},
324
+ })
325
+
326
+ expect(mcpTokenOnApi.statusCode).toBe(401)
327
+ expect(apiTokenOnMcp.statusCode).toBe(401)
328
+ expect(apiTokenOnMcp.headers['www-authenticate']).toContain('error="invalid_token"')
329
+ })
152
330
  })
@@ -11,7 +11,7 @@ let app: FastifyInstance
11
11
  async function signUp(): Promise<string[]> {
12
12
  const response = await app.inject({
13
13
  method: 'POST',
14
- url: '/api/auth/sign-up/email',
14
+ url: '/api/auth/sign-up',
15
15
  headers: { origin: ORIGIN },
16
16
  payload: {
17
17
  name: 'Ana',
@@ -20,7 +20,7 @@ async function signUp(): Promise<string[]> {
20
20
  },
21
21
  })
22
22
 
23
- expect(response.statusCode).toBe(200)
23
+ expect(response.statusCode).toBe(201)
24
24
 
25
25
  const cookies = response.headers['set-cookie']
26
26
  return Array.isArray(cookies) ? cookies : [cookies ?? '']
@@ -94,7 +94,7 @@ describe('users controller', () => {
94
94
  headers: { cookie, origin: ORIGIN },
95
95
  })
96
96
 
97
- expect(response.statusCode).toBe(200)
97
+ expect(response.statusCode).toBe(204)
98
98
  })
99
99
 
100
100
  it('answers an unknown route with 404, not 401', async () => {
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { CryptoAuthorizationCodes } from '@/infra/oauth/crypto-authorization-codes.adapter'
3
+
4
+ describe('CryptoAuthorizationCodes', () => {
5
+ // The example in RFC 7636, Appendix B.
6
+ it('matches the RFC’s own S256 example, and nothing else', () => {
7
+ const codes = new CryptoAuthorizationCodes()
8
+ const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
9
+
10
+ expect(codes.matchesChallenge(verifier, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM')).toBe(
11
+ true,
12
+ )
13
+ expect(codes.matchesChallenge(verifier, verifier)).toBe(false)
14
+ expect(
15
+ codes.matchesChallenge(`${verifier}x`, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'),
16
+ ).toBe(false)
17
+ })
18
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
3
+ import { HttpsClientMetadata, isPrivateAddress } from '@/infra/oauth/https-client-metadata.adapter'
4
+
5
+ describe('HttpsClientMetadata', () => {
6
+ it('refuses a client_id that is not a canonical https URL with a path', async () => {
7
+ const source = new HttpsClientMetadata()
8
+
9
+ for (const clientId of [
10
+ 'not a url',
11
+ 'http://client.example.com/client.json',
12
+ 'https://client.example.com',
13
+ 'https://client.example.com/',
14
+ 'https://client.example.com/client.json?x=1',
15
+ 'https://client.example.com/client.json#top',
16
+ 'https://user:pass@client.example.com/client.json',
17
+ 'https://client.example.com/a/../client.json',
18
+ 'https://CLIENT.example.com/client.json',
19
+ ]) {
20
+ await expect(source.read(clientId), clientId).rejects.toBeInstanceOf(InvalidClientError)
21
+ }
22
+ })
23
+
24
+ it('refuses to fetch from this machine or its network, whatever the name says', async () => {
25
+ const source = new HttpsClientMetadata()
26
+
27
+ for (const clientId of [
28
+ 'https://localhost/client.json',
29
+ 'https://127.0.0.1/client.json',
30
+ 'https://10.0.0.1/client.json',
31
+ 'https://[::1]/client.json',
32
+ ]) {
33
+ await expect(source.read(clientId), clientId).rejects.toThrow(
34
+ /private address|could not be read/,
35
+ )
36
+ }
37
+ })
38
+
39
+ it('knows the private, loopback, link-local and mapped ranges from public ones', () => {
40
+ for (const address of [
41
+ '127.0.0.1',
42
+ '10.1.2.3',
43
+ '172.16.0.1',
44
+ '192.168.1.1',
45
+ '169.254.169.254',
46
+ '::1',
47
+ 'fe80::1',
48
+ 'fd00::1',
49
+ '::ffff:127.0.0.1',
50
+ ]) {
51
+ expect(isPrivateAddress(address), address).toBe(true)
52
+ }
53
+
54
+ for (const address of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) {
55
+ expect(isPrivateAddress(address), address).toBe(false)
56
+ }
57
+ })
58
+ })