@stjoseph/prumo 0.3.0 → 0.4.1

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 (155) hide show
  1. package/.prumo-templates/api/auth.md +3 -2
  2. package/.prumo-templates/core/testing.md +4 -3
  3. package/.prumo-templates/email/codes.md +62 -0
  4. package/.prumo-templates/email/mailer.md +47 -0
  5. package/.prumo-templates/mcp/server.md +19 -5
  6. package/.prumo-templates/monorepo/tasks.md +1 -1
  7. package/.prumo-templates/social/sign-in.md +77 -0
  8. package/README.md +9 -7
  9. package/dist/asker.js +13 -0
  10. package/dist/cli.js +25 -2
  11. package/dist/commands.js +7 -0
  12. package/dist/compose.js +6 -3
  13. package/dist/context.js +4 -0
  14. package/dist/features.js +234 -0
  15. package/dist/generate.js +17 -3
  16. package/dist/next-steps.js +24 -0
  17. package/dist/questions.js +64 -1
  18. package/package.json +1 -1
  19. package/templates/api/.env.example +18 -0
  20. package/templates/api/README.md +19 -0
  21. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  22. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  23. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  24. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  25. package/templates/api/package.json +1 -0
  26. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  27. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  28. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  29. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  30. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  31. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  32. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  33. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  34. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  36. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  37. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  39. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  40. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  41. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  42. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  43. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  46. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  47. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  48. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  49. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  50. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  52. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  53. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  54. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  55. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  56. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  57. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  58. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  59. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  60. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  61. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  62. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  63. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  65. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  66. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  67. package/templates/api/src/infra/config/env.ts +18 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  73. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  74. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  77. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  78. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  79. package/templates/api/src/infra/di/email.di.ts +23 -0
  80. package/templates/api/src/infra/di/index.ts +4 -0
  81. package/templates/api/src/infra/di/social.di.ts +17 -0
  82. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  83. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  84. package/templates/api/src/infra/http/app.ts +4 -0
  85. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  89. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  90. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  91. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  92. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  93. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  94. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  95. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  96. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  97. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  98. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  99. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  100. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  101. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  102. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  103. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  104. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  105. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  106. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  107. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  108. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  109. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  110. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  111. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  112. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  114. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  115. package/templates/api/test/support/test-app.ts +65 -0
  116. package/templates/mobile/README.md +16 -3
  117. package/templates/mobile/package.json +3 -2
  118. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  119. package/templates/mobile/src/api-contract/auth.ts +63 -3
  120. package/templates/mobile/src/api-contract/index.ts +4 -0
  121. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  122. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  125. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  126. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  127. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  128. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  129. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  130. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  131. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  132. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  133. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  134. package/templates/web/README.md +5 -0
  135. package/templates/web/src/api-contract/api-error.ts +4 -0
  136. package/templates/web/src/api-contract/auth.ts +63 -3
  137. package/templates/web/src/api-contract/index.ts +4 -0
  138. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  139. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  141. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  142. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  143. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  144. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  145. package/templates/web/src/routeTree.gen.ts +73 -2
  146. package/templates/web/src/routes/forgot-password.tsx +17 -0
  147. package/templates/web/src/routes/reset-password.tsx +20 -0
  148. package/templates/web/src/routes/sign-in.tsx +10 -3
  149. package/templates/web/src/routes/sign-up.tsx +4 -0
  150. package/templates/web/src/routes/verify-email.tsx +20 -0
  151. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  152. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  153. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  154. package/templates/workspace/README.md +10 -0
  155. package/dist/mcp.js +0 -107
@@ -0,0 +1,92 @@
1
+ import type { FakeMailer } from '@test/support/fakes/fake-mailer'
2
+ import { ORIGIN, PASSWORD, testApp } from '@test/support/test-app'
3
+ import type { FastifyInstance } from 'fastify'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+
6
+ const BEARER = { 'x-auth-transport': 'bearer' }
7
+
8
+ let app: FastifyInstance
9
+ let mailer: FakeMailer
10
+
11
+ const post = (
12
+ url: string,
13
+ payload: Record<string, string>,
14
+ headers: Record<string, string> = BEARER,
15
+ ) => app.inject({ method: 'POST', url, headers, payload })
16
+
17
+ beforeEach(async () => {
18
+ ;({ app, mailer } = await testApp())
19
+ })
20
+
21
+ afterEach(async () => {
22
+ await app.close()
23
+ })
24
+
25
+ describe('email verification over HTTP', () => {
26
+ it('opens no session at sign-up, refuses sign-in, and signs in once the code is entered', async () => {
27
+ const email = 'ana@example.com'
28
+ const signUp = await post('/api/auth/sign-up', { name: 'Ana', email, password: PASSWORD })
29
+ const refused = await post('/api/auth/sign-in', { email, password: PASSWORD })
30
+
31
+ expect(signUp.statusCode).toBe(201)
32
+ expect(signUp.json()).toEqual({ verificationRequired: true })
33
+ expect(refused.statusCode).toBe(403)
34
+ expect(refused.json()).toMatchObject({ code: 'email_not_verified' })
35
+
36
+ await post('/api/auth/email/verification', { email })
37
+ const verified = await post('/api/auth/email/verify', { email, code: mailer.lastCode(email) })
38
+
39
+ expect(verified.statusCode).toBe(200)
40
+ expect(verified.json().accessToken).toEqual(expect.any(String))
41
+ })
42
+
43
+ it('delivers the web its cookies once the code is entered', async () => {
44
+ const email = 'ana@example.com'
45
+ await post('/api/auth/sign-up', { name: 'Ana', email, password: PASSWORD }, { origin: ORIGIN })
46
+
47
+ const verified = await post(
48
+ '/api/auth/email/verify',
49
+ { email, code: mailer.lastCode(email) },
50
+ { origin: ORIGIN },
51
+ )
52
+
53
+ expect(verified.statusCode).toBe(204)
54
+ expect(verified.cookies.map((cookie) => cookie.name).sort()).toEqual([
55
+ 'access_token',
56
+ 'refresh_token',
57
+ ])
58
+ })
59
+
60
+ it('answers a wrong code with invalid_code, and an unknown email with 204 and no mail', async () => {
61
+ await post('/api/auth/sign-up', { name: 'Ana', email: 'ana@example.com', password: PASSWORD })
62
+
63
+ const wrong = await post('/api/auth/email/verify', { email: 'ana@example.com', code: '000000' })
64
+ const unknown = await post('/api/auth/email/verification', { email: 'nobody@example.com' })
65
+
66
+ expect(wrong.statusCode).toBe(422)
67
+ expect(wrong.json()).toMatchObject({ code: 'invalid_code' })
68
+ expect(unknown.statusCode).toBe(204)
69
+ expect(mailer.sent.map((mail) => mail.to)).not.toContain('nobody@example.com')
70
+ })
71
+ })
72
+
73
+ describe('password reset over HTTP', () => {
74
+ it('resets the password with the mailed code and signs in with the new one', async () => {
75
+ const email = 'ana@example.com'
76
+ await post('/api/auth/sign-up', { name: 'Ana', email, password: PASSWORD })
77
+
78
+ const forgot = await post('/api/auth/password/forgot', { email })
79
+ const reset = await post('/api/auth/password/reset', {
80
+ email,
81
+ code: mailer.lastCode(email),
82
+ password: 'another-good-password',
83
+ })
84
+ const old = await post('/api/auth/sign-in', { email, password: PASSWORD })
85
+ const fresh = await post('/api/auth/sign-in', { email, password: 'another-good-password' })
86
+
87
+ expect(forgot.statusCode).toBe(204)
88
+ expect(reset.statusCode).toBe(200)
89
+ expect(old.statusCode).toBe(401)
90
+ expect(fresh.statusCode).toBe(200)
91
+ })
92
+ })
@@ -1,19 +1,11 @@
1
- import { testEnv, testOrm } from '@test/support/setup'
1
+ import { ORIGIN, PASSWORD, testApp } from '@test/support/test-app'
2
2
  import type { FastifyInstance, LightMyRequestResponse } 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
4
 
7
- const ORIGIN = 'http://localhost:5173'
8
5
  const BEARER = { 'x-auth-transport': 'bearer' }
9
6
 
10
7
  let app: FastifyInstance
11
-
12
- const account = () => ({
13
- name: 'Ana',
14
- email: `${crypto.randomUUID()}@example.com`,
15
- password: 'correct-horse-battery',
16
- })
8
+ let signUp: Awaited<ReturnType<typeof testApp>>['signUp']
17
9
 
18
10
  function cookiesOf(response: LightMyRequestResponse): Record<string, string> {
19
11
  return Object.fromEntries(response.cookies.map((cookie) => [cookie.name, cookie.value]))
@@ -24,7 +16,7 @@ function setCookie(response: LightMyRequestResponse, name: string) {
24
16
  }
25
17
 
26
18
  beforeEach(async () => {
27
- app = await buildApp(createContainer({ env: testEnv(), orm: testOrm() }))
19
+ ;({ app, signUp } = await testApp())
28
20
  })
29
21
 
30
22
  afterEach(async () => {
@@ -32,15 +24,9 @@ afterEach(async () => {
32
24
  })
33
25
 
34
26
  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
- })
27
+ it('starts a session in httpOnly cookies, the refresh one sent only to its route', async () => {
28
+ const { response } = await signUp()
42
29
 
43
- expect(response.statusCode).toBe(201)
44
30
  expect(response.body).toBe('')
45
31
  expect(setCookie(response, 'access_token')).toMatchObject({
46
32
  httpOnly: true,
@@ -54,13 +40,7 @@ describe('auth controller, on the web', () => {
54
40
  })
55
41
 
56
42
  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)
43
+ const { access_token, refresh_token } = cookiesOf((await signUp()).response)
64
44
 
65
45
  const session = await app.inject({
66
46
  url: '/api/auth/session',
@@ -92,13 +72,7 @@ describe('auth controller, on the web', () => {
92
72
  })
93
73
 
94
74
  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}`
75
+ const cookie = `access_token=${cookiesOf((await signUp()).response).access_token}`
102
76
 
103
77
  for (const headers of [{ cookie, origin: 'https://evil.example' }, { cookie }]) {
104
78
  const response = await app.inject({ method: 'POST', url: '/api/auth/sign-out', headers })
@@ -111,16 +85,10 @@ describe('auth controller, on the web', () => {
111
85
 
112
86
  describe('auth controller, for a native client', () => {
113
87
  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()
88
+ const { response } = await signUp({ bearer: true })
89
+ const tokens = response.json()
121
90
 
122
- expect(signUp.statusCode).toBe(201)
123
- expect(signUp.cookies).toEqual([])
91
+ expect(response.cookies).toEqual([])
124
92
  expect(tokens).toEqual({
125
93
  accessToken: expect.any(String),
126
94
  refreshToken: expect.any(String),
@@ -146,20 +114,19 @@ describe('auth controller, for a native client', () => {
146
114
 
147
115
  describe('auth controller, refusing', () => {
148
116
  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 })
117
+ const { email } = await signUp({ bearer: true })
151
118
 
152
119
  const wrong = await app.inject({
153
120
  method: 'POST',
154
121
  url: '/api/auth/sign-in',
155
122
  headers: BEARER,
156
- payload: { email: ana.email, password: 'wrong-password' },
123
+ payload: { email, password: 'wrong-password' },
157
124
  })
158
125
  const taken = await app.inject({
159
126
  method: 'POST',
160
127
  url: '/api/auth/sign-up',
161
128
  headers: BEARER,
162
- payload: ana,
129
+ payload: { name: 'Ana', email, password: PASSWORD },
163
130
  })
164
131
 
165
132
  expect(wrong.statusCode).toBe(401)
@@ -1,13 +1,13 @@
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
8
  import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
8
9
  import { CLIENT_METADATA, type ClientMetadata } from '@/domain/oauth/ports/client-metadata.port'
9
- import { createContainer } from '@/infra/di'
10
- import { buildApp } from '@/infra/http/app'
10
+ import { TOOL_SCOPES } from '@/infra/mcp/mcp.scopes'
11
11
  import { mcpResource } from '@/infra/mcp/mcp.server'
12
12
 
13
13
  const ORIGIN = 'http://localhost:5173'
@@ -17,6 +17,7 @@ const REDIRECT = 'https://client.example.com/callback'
17
17
 
18
18
  let app: FastifyInstance
19
19
  let env: ReturnType<typeof testEnv>
20
+ let register: Awaited<ReturnType<typeof testApp>>['signUp']
20
21
 
21
22
  function freePort(): Promise<number> {
22
23
  return new Promise((resolve) => {
@@ -42,10 +43,10 @@ const clients = {
42
43
  beforeEach(async () => {
43
44
  const port = await freePort()
44
45
  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)
46
+ ;({ app, signUp: register } = await testApp({
47
+ env,
48
+ configure: (container) => container.register(CLIENT_METADATA, { useValue: clients }),
49
+ }))
49
50
  await app.listen({ port, host: '127.0.0.1' })
50
51
  })
51
52
 
@@ -54,18 +55,7 @@ afterEach(async () => {
54
55
  })
55
56
 
56
57
  async function signUp(): Promise<string> {
57
- const response = await app.inject({
58
- method: 'POST',
59
- url: '/api/auth/sign-up',
60
- headers: { origin: ORIGIN },
61
- payload: {
62
- name: 'Ana',
63
- email: `${crypto.randomUUID()}@example.com`,
64
- password: 'correct-horse-battery',
65
- },
66
- })
67
-
68
- return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
58
+ return cookieHeader((await register()).response)
69
59
  }
70
60
 
71
61
  function pkce() {
@@ -105,9 +95,9 @@ function token(form: Record<string, string>) {
105
95
  }
106
96
 
107
97
  // 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) {
98
+ async function authorize(cookie: string, accept = true, scope?: string) {
109
99
  const { verifier, challenge } = pkce()
110
- const started = await app.inject({ url: authorizeUrl({}, challenge) })
100
+ const started = await app.inject({ url: authorizeUrl({ scope }, challenge) })
111
101
  const consent = new URL(started.headers.location as string)
112
102
  const id = consent.searchParams.get('request') ?? ''
113
103
  const view = await app.inject({ url: `/api/oauth/authorizations/${id}`, headers: { cookie } })
@@ -121,8 +111,8 @@ async function authorize(cookie: string, accept = true) {
121
111
  return { started, consent, view, redirect: new URL(decision.json().redirectTo), verifier }
122
112
  }
123
113
 
124
- async function tokens(cookie: string) {
125
- const { redirect, verifier } = await authorize(cookie)
114
+ async function tokens(cookie: string, scope?: string) {
115
+ const { redirect, verifier } = await authorize(cookie, true, scope)
126
116
  const response = await token({
127
117
  grant_type: 'authorization_code',
128
118
  code: redirect.searchParams.get('code') ?? '',
@@ -183,6 +173,11 @@ describe('authorization', () => {
183
173
  clientHost: 'client.example.com',
184
174
  redirectHost: 'client.example.com',
185
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
+ ],
186
181
  })
187
182
  expect(`${redirect.origin}${redirect.pathname}`).toBe(REDIRECT)
188
183
  expect(redirect.searchParams.get('state')).toBe('xyz')
@@ -225,6 +220,75 @@ describe('authorization', () => {
225
220
  })
226
221
  })
227
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()
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
+
228
292
  describe('token', () => {
229
293
  it('runs a tool for the user who authorized the client, with public fields only', async () => {
230
294
  const { response } = await tokens(await signUp())
@@ -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',
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(201)
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,11 +66,10 @@ 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
75
  expect(response.statusCode).toBe(204)