@stjoseph/prumo 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/.prumo-templates/api/auth.md +51 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -4
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/email/codes.md +62 -0
  10. package/.prumo-templates/email/mailer.md +47 -0
  11. package/.prumo-templates/mcp/server.md +55 -14
  12. package/.prumo-templates/mobile/routing.md +1 -1
  13. package/.prumo-templates/mobile/storage.md +7 -6
  14. package/.prumo-templates/monorepo/layout.md +4 -4
  15. package/.prumo-templates/multi-tenancy/client.md +6 -6
  16. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  17. package/.prumo-templates/social/sign-in.md +77 -0
  18. package/README.md +8 -6
  19. package/dist/asker.js +13 -0
  20. package/dist/clean.js +2 -2
  21. package/dist/cli.js +16 -2
  22. package/dist/commands.js +7 -0
  23. package/dist/compose.js +3 -11
  24. package/dist/context.js +4 -0
  25. package/dist/features.js +230 -0
  26. package/dist/generate.js +20 -7
  27. package/dist/questions.js +64 -1
  28. package/package.json +1 -1
  29. package/templates/api/.env.example +21 -5
  30. package/templates/api/README.md +4 -5
  31. package/templates/api/docker-compose.yml +0 -1
  32. package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
  33. package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
  34. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  35. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  36. package/templates/api/package.json +7 -8
  37. package/templates/api/scripts/database.mjs +3 -11
  38. package/templates/api/scripts/ports.mjs +1 -1
  39. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  40. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  41. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  42. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  43. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  44. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  45. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  46. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  47. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  48. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  49. package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
  50. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  51. package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
  52. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  53. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  54. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  55. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  56. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  57. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  58. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  59. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  60. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  61. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  62. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  63. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  64. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  65. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  66. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  67. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  68. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  69. package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
  70. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  71. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  72. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  73. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  74. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  75. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  76. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  77. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
  78. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  79. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  80. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  81. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
  82. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  83. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
  84. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
  85. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  86. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  87. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  88. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
  89. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
  90. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  91. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
  92. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  93. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  94. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  95. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  96. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  97. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  98. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
  99. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  100. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
  101. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  102. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  103. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  104. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
  105. package/templates/api/src/infra/config/env.ts +17 -3
  106. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
  107. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  108. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  109. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  110. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
  111. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  112. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
  113. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
  114. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  115. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  116. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  117. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
  118. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  119. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  120. package/templates/api/src/infra/di/auth.di.ts +21 -0
  121. package/templates/api/src/infra/di/email.di.ts +23 -0
  122. package/templates/api/src/infra/di/index.ts +8 -10
  123. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  124. package/templates/api/src/infra/di/social.di.ts +17 -0
  125. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  126. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  127. package/templates/api/src/infra/http/app.ts +15 -1
  128. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  129. package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
  130. package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
  131. package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
  132. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  133. package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
  134. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  135. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  136. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  137. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  138. package/templates/api/src/infra/http/server.ts +1 -4
  139. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  140. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  141. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  142. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  143. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  144. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  145. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  146. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  147. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  148. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  149. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  150. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
  151. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  152. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
  153. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  154. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  155. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  156. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  157. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  158. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  159. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
  160. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
  161. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  162. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
  163. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  164. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  165. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  166. package/templates/api/test/support/factories/user.factory.ts +9 -11
  167. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  168. package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
  169. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  170. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  171. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  172. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  173. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  174. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  175. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  176. package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
  177. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  178. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  179. package/templates/api/test/support/setup.ts +6 -28
  180. package/templates/api/test/support/test-app.ts +65 -0
  181. package/templates/mobile/README.md +1 -1
  182. package/templates/mobile/jest.config.js +0 -17
  183. package/templates/mobile/package.json +0 -4
  184. package/templates/mobile/scripts/ports.mjs +1 -1
  185. package/templates/mobile/src/api-contract/api-error.ts +4 -11
  186. package/templates/mobile/src/api-contract/auth.ts +221 -0
  187. package/templates/mobile/src/api-contract/index.ts +14 -0
  188. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  189. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  190. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  191. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  192. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  193. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  194. package/templates/mobile/src/app/_layout.tsx +5 -5
  195. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  196. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  197. package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
  198. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  199. package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
  200. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  201. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  202. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  203. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  204. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  205. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  206. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  207. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  208. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  209. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  210. package/templates/site/scripts/ports.mjs +1 -1
  211. package/templates/web/package.json +0 -3
  212. package/templates/web/scripts/ports.mjs +1 -1
  213. package/templates/web/src/api-contract/api-error.ts +4 -11
  214. package/templates/web/src/api-contract/auth.ts +221 -0
  215. package/templates/web/src/api-contract/index.ts +14 -0
  216. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  217. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  218. package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
  219. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  220. package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
  221. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  222. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  223. package/templates/web/src/features/oauth/consent-form.tsx +72 -35
  224. package/templates/web/src/main.tsx +6 -4
  225. package/templates/web/src/routeTree.gen.ts +73 -2
  226. package/templates/web/src/router.tsx +1 -2
  227. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  228. package/templates/web/src/routes/_authenticated.tsx +1 -1
  229. package/templates/web/src/routes/forgot-password.tsx +17 -0
  230. package/templates/web/src/routes/reset-password.tsx +20 -0
  231. package/templates/web/src/routes/sign-in.tsx +10 -3
  232. package/templates/web/src/routes/sign-up.tsx +4 -0
  233. package/templates/web/src/routes/verify-email.tsx +20 -0
  234. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  235. package/templates/web/test/app.spec.tsx +47 -22
  236. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  237. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  238. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  239. package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
  240. package/templates/web/test/support/render-app.tsx +4 -4
  241. package/templates/workspace/scripts/ports.mjs +1 -1
  242. package/dist/mcp.js +0 -100
  243. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  244. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  245. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  246. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  247. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  248. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  249. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  250. package/templates/mobile/src/features/auth/session.ts +0 -17
  251. package/templates/web/src/features/auth/auth-client.ts +0 -17
  252. package/templates/web/src/features/auth/session.ts +0 -17
@@ -1,19 +1,23 @@
1
1
  import { createHash, randomBytes } from 'node:crypto'
2
2
  import { createServer } from 'node:net'
3
3
  import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
4
- import { testEnv, testOrm } from '@test/support/setup'
4
+ import { testEnv } from '@test/support/setup'
5
+ import { cookieHeader, testApp } from '@test/support/test-app'
5
6
  import type { FastifyInstance } from 'fastify'
6
7
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
- import { mcpResource } from '@/infra/auth/auth.factory'
8
- import { createContainer } from '@/infra/di'
9
- import { buildApp } from '@/infra/http/app'
8
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
9
+ import { CLIENT_METADATA, type ClientMetadata } from '@/domain/oauth/ports/client-metadata.port'
10
+ import { TOOL_SCOPES } from '@/infra/mcp/mcp.scopes'
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
 
15
18
  let app: FastifyInstance
16
19
  let env: ReturnType<typeof testEnv>
20
+ let register: Awaited<ReturnType<typeof testApp>>['signUp']
17
21
 
18
22
  function freePort(): Promise<number> {
19
23
  return new Promise((resolve) => {
@@ -24,11 +28,25 @@ function freePort(): Promise<number> {
24
28
  })
25
29
  }
26
30
 
27
- // requireMcpAuth fetches the JWKS from BETTER_AUTH_URL, so the application listens for real.
31
+ // Stands in for the HTTPS fetch of the client's metadata document, which the adapter's own test covers.
32
+ const clients = {
33
+ async read(clientId: string): Promise<ClientMetadata> {
34
+ if (clientId !== CLIENT_ID) {
35
+ throw new InvalidClientError('Unknown client')
36
+ }
37
+
38
+ return { clientId, clientName: 'Example Client', redirectUris: [REDIRECT] }
39
+ },
40
+ }
41
+
42
+ // The SDK's client reaches the MCP route over real HTTP, so the application listens.
28
43
  beforeEach(async () => {
29
44
  const port = await freePort()
30
- env = { ...testEnv(), BETTER_AUTH_URL: `http://127.0.0.1:${port}` }
31
- app = await buildApp(createContainer({ env, orm: testOrm() }))
45
+ env = { ...testEnv(), API_URL: `http://127.0.0.1:${port}` }
46
+ ;({ app, signUp: register } = await testApp({
47
+ env,
48
+ configure: (container) => container.register(CLIENT_METADATA, { useValue: clients }),
49
+ }))
32
50
  await app.listen({ port, host: '127.0.0.1' })
33
51
  })
34
52
 
@@ -37,73 +55,77 @@ afterEach(async () => {
37
55
  })
38
56
 
39
57
  async function signUp(): Promise<string> {
40
- const response = await app.inject({
41
- method: 'POST',
42
- url: '/api/auth/sign-up/email',
43
- headers: { origin: ORIGIN },
44
- payload: {
45
- name: 'Ana',
46
- email: `${crypto.randomUUID()}@example.com`,
47
- password: 'correct-horse-battery',
48
- },
49
- })
50
- const cookies = [response.headers['set-cookie']].flat().filter((c) => c !== undefined)
51
-
52
- return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
58
+ return cookieHeader((await register()).response)
53
59
  }
54
60
 
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
61
+ function pkce() {
64
62
  const verifier = randomBytes(32).toString('base64url')
65
- const query = new URLSearchParams({
63
+
64
+ return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
65
+ }
66
+
67
+ function authorizeUrl(
68
+ overrides: Record<string, string | undefined> = {},
69
+ challenge = pkce().challenge,
70
+ ) {
71
+ const params = {
66
72
  response_type: 'code',
67
- client_id: clientId,
73
+ client_id: CLIENT_ID,
68
74
  redirect_uri: REDIRECT,
69
- scope: 'openid',
70
- state: 'state',
71
- code_challenge: createHash('sha256').update(verifier).digest('base64url'),
75
+ state: 'xyz',
76
+ code_challenge: challenge,
72
77
  code_challenge_method: 'S256',
73
78
  resource: mcpResource(env),
79
+ ...overrides,
80
+ }
81
+ const query = new URLSearchParams(
82
+ Object.entries(params).filter((entry): entry is [string, string] => entry[1] !== undefined),
83
+ )
84
+
85
+ return `/api/oauth/authorize?${query}`
86
+ }
87
+
88
+ function token(form: Record<string, string>) {
89
+ return app.inject({
90
+ method: 'POST',
91
+ url: '/api/oauth/token',
92
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
93
+ payload: new URLSearchParams(form).toString(),
74
94
  })
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({
95
+ }
96
+
97
+ // The path an MCP client takes: authorize with PKCE, the user consents on the web, the code becomes tokens.
98
+ async function authorize(cookie: string, accept = true, scope?: string) {
99
+ const { verifier, challenge } = pkce()
100
+ const started = await app.inject({ url: authorizeUrl({ scope }, challenge) })
101
+ const consent = new URL(started.headers.location as string)
102
+ const id = consent.searchParams.get('request') ?? ''
103
+ const view = await app.inject({ url: `/api/oauth/authorizations/${id}`, headers: { cookie } })
104
+ const decision = await app.inject({
81
105
  method: 'POST',
82
- url: '/api/auth/oauth2/consent',
106
+ url: `/api/oauth/authorizations/${id}/decision`,
83
107
  headers: { cookie, origin: ORIGIN },
84
- payload: { accept: true, oauth_query: consentPage.search.slice(1) },
108
+ payload: { accept },
85
109
  })
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(),
110
+
111
+ return { started, consent, view, redirect: new URL(decision.json().redirectTo), verifier }
112
+ }
113
+
114
+ async function tokens(cookie: string, scope?: string) {
115
+ const { redirect, verifier } = await authorize(cookie, true, scope)
116
+ const response = await token({
117
+ grant_type: 'authorization_code',
118
+ code: redirect.searchParams.get('code') ?? '',
119
+ redirect_uri: REDIRECT,
120
+ client_id: CLIENT_ID,
121
+ code_verifier: verifier,
122
+ resource: mcpResource(env),
99
123
  })
100
124
 
101
- return token.json().access_token as string
125
+ return { response, code: redirect.searchParams.get('code') ?? '', verifier }
102
126
  }
103
127
 
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.
128
+ async function connect(accessToken: string): Promise<Client> {
107
129
  const client = new Client(
108
130
  { name: 'test', version: '1.0.0' },
109
131
  { versionNegotiation: { mode: { pin: '2026-07-28' } } },
@@ -111,25 +133,169 @@ async function connect(token: string): Promise<Client> {
111
133
 
112
134
  await client.connect(
113
135
  new StreamableHTTPClientTransport(new URL(mcpResource(env)), {
114
- requestInit: { headers: { authorization: `Bearer ${token}` } },
136
+ requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
115
137
  }),
116
138
  )
117
139
  return client
118
140
  }
119
141
 
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' })
142
+ describe('discovery', () => {
143
+ it('tells an MCP client where to authorize, from the 401 to the server metadata', async () => {
123
144
  const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
145
+ const challenge = refused.headers['www-authenticate'] as string
146
+ const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? ''
147
+ const resource = await app.inject({ url: new URL(metadataUrl).pathname })
148
+ const server = await app.inject({ url: '/.well-known/oauth-authorization-server' })
124
149
 
125
- expect(metadata.json()).toMatchObject({ resource: mcpResource(env) })
126
150
  expect(refused.statusCode).toBe(401)
127
- expect(refused.headers['www-authenticate']).toContain('resource_metadata=')
151
+ expect(resource.json()).toMatchObject({
152
+ resource: mcpResource(env),
153
+ authorization_servers: [env.API_URL],
154
+ })
155
+ expect(server.json()).toMatchObject({
156
+ issuer: env.API_URL,
157
+ code_challenge_methods_supported: ['S256'],
158
+ client_id_metadata_document_supported: true,
159
+ authorization_response_iss_parameter_supported: true,
160
+ })
161
+ })
162
+ })
163
+
164
+ describe('authorization', () => {
165
+ it('sends the browser to consent, shows who asks and where the code goes, and returns it with iss', async () => {
166
+ const { started, consent, view, redirect } = await authorize(await signUp())
167
+
168
+ expect(started.statusCode).toBe(302)
169
+ expect(`${consent.origin}${consent.pathname}`).toBe(`${ORIGIN}/consent`)
170
+ expect(view.json()).toEqual({
171
+ clientId: CLIENT_ID,
172
+ clientName: 'Example Client',
173
+ clientHost: 'client.example.com',
174
+ redirectHost: 'client.example.com',
175
+ redirectsToThisDevice: false,
176
+ // Asked for nothing, so offered everything, each scope worded for a person.
177
+ scopes: [
178
+ { scope: 'profile:read', description: 'Read your profile' },
179
+ { scope: 'profile:write', description: 'Change your display name, language and timezone' },
180
+ ],
181
+ })
182
+ expect(`${redirect.origin}${redirect.pathname}`).toBe(REDIRECT)
183
+ expect(redirect.searchParams.get('state')).toBe('xyz')
184
+ expect(redirect.searchParams.get('iss')).toBe(env.API_URL)
185
+ expect(redirect.searchParams.get('code')).not.toBeNull()
186
+ })
187
+
188
+ it('answers a denial with access_denied', async () => {
189
+ const { redirect } = await authorize(await signUp(), false)
190
+
191
+ expect(redirect.searchParams.get('error')).toBe('access_denied')
192
+ expect(redirect.searchParams.get('code')).toBeNull()
193
+ })
194
+
195
+ it('never redirects for an unknown client or an unregistered redirect URI', async () => {
196
+ for (const overrides of [
197
+ { client_id: 'https://evil.example.com/client.json' },
198
+ { redirect_uri: 'https://evil.example.com/callback' },
199
+ ]) {
200
+ const response = await app.inject({ url: authorizeUrl(overrides) })
201
+
202
+ expect(response.statusCode).toBe(422)
203
+ expect(response.headers.location).toBeUndefined()
204
+ }
205
+ })
206
+
207
+ it('sends a known client its error: no PKCE, plain PKCE, another resource', async () => {
208
+ for (const [overrides, error] of [
209
+ [{ code_challenge_method: undefined }, 'invalid_request'],
210
+ [{ code_challenge_method: 'plain' }, 'invalid_request'],
211
+ [{ resource: 'https://elsewhere.example.com/mcp' }, 'invalid_target'],
212
+ ] as const) {
213
+ const response = await app.inject({ url: authorizeUrl(overrides) })
214
+ const location = new URL(response.headers.location as string)
215
+
216
+ expect(location.searchParams.get('error')).toBe(error)
217
+ expect(location.searchParams.get('state')).toBe('xyz')
218
+ expect(location.searchParams.get('iss')).toBe(env.API_URL)
219
+ }
128
220
  })
221
+ })
222
+
223
+ describe('scopes', () => {
224
+ it('are advertised, and named in the 401 a client starts from', async () => {
225
+ const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
226
+ const resource = await app.inject({ url: '/.well-known/oauth-protected-resource/api/mcp' })
227
+ const server = await app.inject({ url: '/.well-known/oauth-authorization-server' })
228
+
229
+ expect(refused.headers['www-authenticate']).toContain('scope="profile:read profile:write"')
230
+ expect(resource.json().scopes_supported).toEqual(['profile:read', 'profile:write'])
231
+ expect(server.json().scopes_supported).toEqual(['profile:read', 'profile:write'])
232
+ })
233
+
234
+ it('let a read-only token read, and refuse its write with the scope it needs', async () => {
235
+ const { response } = await tokens(await signUp(), 'profile:read')
236
+ const { access_token, scope } = response.json()
237
+ const client = await connect(access_token)
238
+ const read = await client.callTool({ name: 'get_profile', arguments: {} })
239
+ const write = await app.inject({
240
+ method: 'POST',
241
+ url: '/api/mcp',
242
+ headers: {
243
+ authorization: `Bearer ${access_token}`,
244
+ accept: 'application/json, text/event-stream',
245
+ },
246
+ payload: {
247
+ jsonrpc: '2.0',
248
+ id: 1,
249
+ method: 'tools/call',
250
+ params: { name: 'update_profile', arguments: { timezone: 'UTC' } },
251
+ },
252
+ })
253
+
254
+ expect(scope).toBe('profile:read')
255
+ expect(read.structuredContent).toMatchObject({ displayName: 'Ana' })
256
+ expect(write.statusCode).toBe(403)
257
+ expect(write.headers['www-authenticate']).toContain('error="insufficient_scope"')
258
+ expect(write.headers['www-authenticate']).toContain('scope="profile:write"')
259
+
260
+ await client.close()
261
+ })
262
+
263
+ // A tool missing from TOOL_SCOPES would answer any token, so every tool the server lists must be there.
264
+ it('cover every tool the server offers', async () => {
265
+ const client = await connect((await tokens(await signUp())).response.json().access_token)
266
+ const { tools } = await client.listTools()
129
267
 
268
+ expect(tools.length).toBeGreaterThan(0)
269
+ for (const tool of tools) {
270
+ expect(TOOL_SCOPES[tool.name], tool.name).toBeDefined()
271
+ }
272
+
273
+ await client.close()
274
+ })
275
+
276
+ it('keep the scope through a refresh, and refuse one the server does not offer', async () => {
277
+ const { response } = await tokens(await signUp(), 'profile:read')
278
+ const refreshed = await token({
279
+ grant_type: 'refresh_token',
280
+ refresh_token: response.json().refresh_token,
281
+ client_id: CLIENT_ID,
282
+ })
283
+ const unknown = await app.inject({ url: authorizeUrl({ scope: 'billing:write' }) })
284
+
285
+ expect(refreshed.json().scope).toBe('profile:read')
286
+ expect(new URL(unknown.headers.location as string).searchParams.get('error')).toBe(
287
+ 'invalid_scope',
288
+ )
289
+ })
290
+ })
291
+
292
+ describe('token', () => {
130
293
  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()))
294
+ const { response } = await tokens(await signUp())
295
+ const client = await connect(response.json().access_token)
132
296
  const result = await client.callTool({ name: 'get_profile', arguments: {} })
297
+
298
+ expect(response.headers['cache-control']).toBe('no-store')
133
299
  expect(Object.keys(result.structuredContent ?? {}).sort()).toEqual(
134
300
  ['createdAt', 'displayName', 'id', 'locale', 'timezone', 'updatedAt', 'userId'].sort(),
135
301
  )
@@ -139,7 +305,8 @@ describe('mcp controller', () => {
139
305
  })
140
306
 
141
307
  it('changes the profile through a tool', async () => {
142
- const client = await connect(await accessToken(await signUp()))
308
+ const { response } = await tokens(await signUp())
309
+ const client = await connect(response.json().access_token)
143
310
  const result = await client.callTool({
144
311
  name: 'update_profile',
145
312
  arguments: { timezone: 'America/Sao_Paulo' },
@@ -149,4 +316,79 @@ describe('mcp controller', () => {
149
316
 
150
317
  await client.close()
151
318
  })
319
+
320
+ it('refuses a wrong verifier, and a code spent twice ends what the first use obtained', async () => {
321
+ const cookie = await signUp()
322
+ const { redirect } = await authorize(cookie)
323
+ const wrong = await token({
324
+ grant_type: 'authorization_code',
325
+ code: redirect.searchParams.get('code') ?? '',
326
+ redirect_uri: REDIRECT,
327
+ client_id: CLIENT_ID,
328
+ code_verifier: pkce().verifier,
329
+ })
330
+
331
+ expect(wrong.statusCode).toBe(400)
332
+ expect(wrong.json().error).toBe('invalid_grant')
333
+
334
+ const { response, code, verifier } = await tokens(cookie)
335
+ const replay = await token({
336
+ grant_type: 'authorization_code',
337
+ code,
338
+ redirect_uri: REDIRECT,
339
+ client_id: CLIENT_ID,
340
+ code_verifier: verifier,
341
+ })
342
+ const refresh = await token({
343
+ grant_type: 'refresh_token',
344
+ refresh_token: response.json().refresh_token,
345
+ client_id: CLIENT_ID,
346
+ })
347
+
348
+ expect(replay.json().error).toBe('invalid_grant')
349
+ expect(refresh.json().error).toBe('invalid_grant')
350
+ })
351
+
352
+ it('rotates the refresh token, and only for the client it was issued to', async () => {
353
+ const { response } = await tokens(await signUp())
354
+ const refreshToken = response.json().refresh_token
355
+ const otherClient = await token({
356
+ grant_type: 'refresh_token',
357
+ refresh_token: refreshToken,
358
+ client_id: 'https://other.example.com/client.json',
359
+ })
360
+ const rotated = await token({
361
+ grant_type: 'refresh_token',
362
+ refresh_token: refreshToken,
363
+ client_id: CLIENT_ID,
364
+ })
365
+
366
+ expect(otherClient.json().error).toBe('invalid_grant')
367
+ expect(rotated.statusCode).toBe(200)
368
+ expect(rotated.json().refresh_token).not.toBe(refreshToken)
369
+ })
370
+
371
+ it('keeps the MCP token and the application’s tokens apart', async () => {
372
+ const cookie = await signUp()
373
+ const { response } = await tokens(cookie)
374
+ const access = cookie
375
+ .split('; ')
376
+ .find((pair) => pair.startsWith('access_token='))
377
+ ?.slice(13)
378
+
379
+ const mcpTokenOnApi = await app.inject({
380
+ url: '/api/v1/users/me',
381
+ headers: { authorization: `Bearer ${response.json().access_token}` },
382
+ })
383
+ const apiTokenOnMcp = await app.inject({
384
+ method: 'POST',
385
+ url: '/api/mcp',
386
+ headers: { authorization: `Bearer ${access}` },
387
+ payload: {},
388
+ })
389
+
390
+ expect(mcpTokenOnApi.statusCode).toBe(401)
391
+ expect(apiTokenOnMcp.statusCode).toBe(401)
392
+ expect(apiTokenOnMcp.headers['www-authenticate']).toContain('error="invalid_token"')
393
+ })
152
394
  })
@@ -0,0 +1,108 @@
1
+ import { FakeIdentityProviders } from '@test/support/fakes/social-fakes'
2
+ import { testEnv } from '@test/support/setup'
3
+ import { testApp } from '@test/support/test-app'
4
+ import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+ import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
7
+
8
+ const WEB = 'http://localhost:5173'
9
+
10
+ // Whichever provider the project kept: the flow is the same for each.
11
+ const PROVIDER = [
12
+ 'google', // prumo:google
13
+ 'apple', // prumo:apple
14
+ ][0]
15
+
16
+ let app: FastifyInstance
17
+
18
+ function browserCookie(response: LightMyRequestResponse): string {
19
+ const cookie = response.cookies.find((each) => each.name === 'social_sign_in')
20
+ return `social_sign_in=${cookie?.value ?? ''}`
21
+ }
22
+
23
+ function stateOf(response: LightMyRequestResponse): string {
24
+ return new URL(response.headers.location as string).searchParams.get('state') ?? ''
25
+ }
26
+
27
+ beforeEach(async () => {
28
+ ;({ app } = await testApp({
29
+ env: { ...testEnv(), MOBILE_APP_SCHEME: 'acme' },
30
+ configure: (container) =>
31
+ container.register(IDENTITY_PROVIDERS, { useValue: new FakeIdentityProviders() }),
32
+ }))
33
+ })
34
+
35
+ afterEach(async () => {
36
+ await app.close()
37
+ })
38
+
39
+ describe('social sign-in on the web', () => {
40
+ it('sends the browser to the provider and back into the app, signed in', async () => {
41
+ const started = await app.inject({ url: `/api/auth/social/${PROVIDER}?returnTo=/orders` })
42
+ const back = await app.inject({
43
+ url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
44
+ headers: { cookie: browserCookie(started) },
45
+ })
46
+
47
+ expect(started.statusCode).toBe(302)
48
+ expect(started.headers.location).toMatch(/^https:\/\/\w+\.test\/authorize/)
49
+ expect(back.statusCode).toBe(302)
50
+ expect(back.headers.location).toBe(`${WEB}/orders`)
51
+ expect(back.cookies.map((cookie) => cookie.name)).toEqual(
52
+ expect.arrayContaining(['access_token', 'refresh_token']),
53
+ )
54
+ })
55
+
56
+ it('refuses a return without the browser that started it, and a foreign returnTo', async () => {
57
+ const started = await app.inject({
58
+ url: `/api/auth/social/${PROVIDER}?returnTo=//evil.example`,
59
+ })
60
+ const forwarded = await app.inject({
61
+ url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
62
+ })
63
+ const back = await app.inject({
64
+ url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
65
+ headers: { cookie: browserCookie(started) },
66
+ })
67
+
68
+ expect(forwarded.headers.location).toBe(`${WEB}/sign-in?error=social_sign_in_failed`)
69
+ expect(back.headers.location).toBe(`${WEB}/`)
70
+ })
71
+
72
+ it('turns a form post, as Apple sends, into a redirect the browser’s cookie comes with', async () => {
73
+ const posted = await app.inject({
74
+ method: 'POST',
75
+ url: `/api/auth/social/${PROVIDER}/callback`,
76
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
77
+ payload: 'code=c&state=s&user=%7B%7D',
78
+ })
79
+
80
+ expect(posted.statusCode).toBe(303)
81
+ expect(posted.headers.location).toBe(
82
+ `http://localhost:3000/api/auth/social/${PROVIDER}/callback?code=c&state=s&user=%7B%7D`,
83
+ )
84
+ })
85
+ })
86
+
87
+ describe('social sign-in on mobile', () => {
88
+ it('sends the app a code, which it trades once for its tokens', async () => {
89
+ const started = await app.inject({ url: `/api/auth/social/${PROVIDER}?client=mobile` })
90
+ const back = await app.inject({
91
+ url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
92
+ headers: { cookie: browserCookie(started) },
93
+ })
94
+ const location = new URL(back.headers.location as string)
95
+ const code = location.searchParams.get('code') ?? ''
96
+ const exchange = () =>
97
+ app.inject({
98
+ method: 'POST',
99
+ url: '/api/auth/social/exchange',
100
+ headers: { 'x-auth-transport': 'bearer' },
101
+ payload: { code },
102
+ })
103
+
104
+ expect(`${location.protocol}//${location.host}`).toBe('acme://social')
105
+ expect((await exchange()).json().accessToken).toEqual(expect.any(String))
106
+ expect((await exchange()).statusCode).toBe(401)
107
+ })
108
+ })
@@ -1,37 +1,16 @@
1
- import { testEnv, testOrm } from '@test/support/setup'
1
+ import { cookieHeader, ORIGIN, testApp } from '@test/support/test-app'
2
2
  import type { FastifyInstance } from 'fastify'
3
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
4
 
9
5
  let app: FastifyInstance
6
+ let signUp: Awaited<ReturnType<typeof testApp>>['signUp']
10
7
 
11
- async function signUp(): Promise<string[]> {
12
- const response = await app.inject({
13
- method: 'POST',
14
- url: '/api/auth/sign-up/email',
15
- headers: { origin: ORIGIN },
16
- payload: {
17
- name: 'Ana',
18
- email: `${crypto.randomUUID()}@example.com`,
19
- password: 'correct-horse-battery',
20
- },
21
- })
22
-
23
- expect(response.statusCode).toBe(200)
24
-
25
- const cookies = response.headers['set-cookie']
26
- return Array.isArray(cookies) ? cookies : [cookies ?? '']
27
- }
28
-
29
- function cookieHeader(cookies: string[]): string {
30
- return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
8
+ async function signedIn(): Promise<string> {
9
+ return cookieHeader((await signUp()).response)
31
10
  }
32
11
 
33
12
  beforeEach(async () => {
34
- app = await buildApp(createContainer({ env: testEnv(), orm: testOrm() }))
13
+ ;({ app, signUp } = await testApp())
35
14
  })
36
15
 
37
16
  afterEach(async () => {
@@ -47,8 +26,10 @@ describe('users controller', () => {
47
26
  })
48
27
 
49
28
  it('returns the profile created at sign-up, and only its public fields', async () => {
50
- const cookie = cookieHeader(await signUp())
51
- const response = await app.inject({ url: '/api/v1/users/me', headers: { cookie } })
29
+ const response = await app.inject({
30
+ url: '/api/v1/users/me',
31
+ headers: { cookie: await signedIn() },
32
+ })
52
33
 
53
34
  expect(response.statusCode).toBe(200)
54
35
  expect(Object.keys(response.json()).sort()).toEqual(
@@ -58,11 +39,10 @@ describe('users controller', () => {
58
39
  })
59
40
 
60
41
  it('updates the profile', async () => {
61
- const cookie = cookieHeader(await signUp())
62
42
  const response = await app.inject({
63
43
  method: 'PATCH',
64
44
  url: '/api/v1/users/me',
65
- headers: { cookie, origin: ORIGIN },
45
+ headers: { cookie: await signedIn(), origin: ORIGIN },
66
46
  payload: { timezone: 'America/Sao_Paulo' },
67
47
  })
68
48
 
@@ -71,11 +51,10 @@ describe('users controller', () => {
71
51
  })
72
52
 
73
53
  it('names every rejected field, including one the schema does not know', async () => {
74
- const cookie = cookieHeader(await signUp())
75
54
  const response = await app.inject({
76
55
  method: 'PATCH',
77
56
  url: '/api/v1/users/me',
78
- headers: { cookie, origin: ORIGIN },
57
+ headers: { cookie: await signedIn(), origin: ORIGIN },
79
58
  payload: { displayName: '', role: 'admin' },
80
59
  })
81
60
 
@@ -87,14 +66,13 @@ describe('users controller', () => {
87
66
  })
88
67
 
89
68
  it('signs out', async () => {
90
- const cookie = cookieHeader(await signUp())
91
69
  const response = await app.inject({
92
70
  method: 'POST',
93
71
  url: '/api/auth/sign-out',
94
- headers: { cookie, origin: ORIGIN },
72
+ headers: { cookie: await signedIn(), origin: ORIGIN },
95
73
  })
96
74
 
97
- expect(response.statusCode).toBe(200)
75
+ expect(response.statusCode).toBe(204)
98
76
  })
99
77
 
100
78
  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
+ })