@stjoseph/prumo 0.3.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.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +6 -4
- package/dist/asker.js +13 -0
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -3
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +17 -3
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- 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 {
|
|
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
|
|
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('
|
|
36
|
-
const response = await
|
|
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
|
|
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
|
|
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
|
|
115
|
-
|
|
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(
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
12
|
-
|
|
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
|
|
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
|
|
51
|
-
|
|
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)
|