@stjoseph/prumo 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +50 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/README.md +2 -2
- package/dist/clean.js +2 -2
- package/dist/compose.js +1 -9
- package/dist/generate.js +3 -4
- package/dist/mcp.js +15 -8
- package/package.json +1 -1
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
- package/templates/api/package.json +6 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -1,36 +1,159 @@
|
|
|
1
|
-
import type { FastifyInstance } from 'fastify'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
3
|
+
import { refreshSchema } from '@/domain/auth/dto/refresh.dto'
|
|
4
|
+
import { sessionResponseSchema } from '@/domain/auth/dto/session-response.dto'
|
|
5
|
+
import { signInSchema } from '@/domain/auth/dto/sign-in.dto'
|
|
6
|
+
import { signUpSchema } from '@/domain/auth/dto/sign-up.dto'
|
|
7
|
+
import { type TokensResponseDto, tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
|
|
8
|
+
import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
|
|
9
|
+
import { FindUserUseCase } from '@/domain/auth/use-cases/find-user.use-case'
|
|
10
|
+
import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
|
|
11
|
+
import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
|
|
12
|
+
import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
|
|
13
|
+
import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
|
|
14
|
+
import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
|
|
15
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
16
|
+
import { ACCESS_COOKIE, currentSession, currentUser } from '@/infra/http/hooks/auth.hook'
|
|
17
|
+
|
|
18
|
+
const REFRESH_COOKIE = 'refresh_token'
|
|
19
|
+
|
|
20
|
+
// The refresh cookie travels only to the one route that reads it, never with an ordinary request.
|
|
21
|
+
const REFRESH_PATH = '/api/auth/refresh'
|
|
22
|
+
|
|
23
|
+
// Repeated guessing is slowed down per address; a refresh happens every quarter hour per tab, so it gets more room.
|
|
24
|
+
const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
|
|
25
|
+
const REFRESHES = { rateLimit: { max: 60, timeWindow: '1 minute' } }
|
|
26
|
+
|
|
27
|
+
// A native client asks for its tokens in the body. The web never does: a token JavaScript can read is a token an
|
|
28
|
+
// injected script can take.
|
|
29
|
+
function wantsBearer(request: FastifyRequest): boolean {
|
|
30
|
+
return request.headers['x-auth-transport'] === 'bearer'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asBody(tokens: SessionTokens): TokensResponseDto {
|
|
34
|
+
return {
|
|
35
|
+
accessToken: tokens.accessToken.token,
|
|
36
|
+
refreshToken: tokens.refreshToken,
|
|
37
|
+
expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
4
40
|
|
|
5
41
|
export async function authController(fastify: FastifyInstance): Promise<void> {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
42
|
+
const app = fastify.withTypeProvider<ZodTypeProvider>()
|
|
43
|
+
const env = app.container.resolve<Env>(ENV)
|
|
44
|
+
const secure = env.API_URL.startsWith('https://')
|
|
45
|
+
const cookie = { httpOnly: true, sameSite: 'lax', secure } as const
|
|
46
|
+
|
|
47
|
+
function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
|
|
48
|
+
reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
|
|
49
|
+
...cookie,
|
|
50
|
+
path: '/',
|
|
51
|
+
expires: tokens.accessToken.expiresAt,
|
|
52
|
+
})
|
|
53
|
+
reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
|
|
54
|
+
...cookie,
|
|
55
|
+
path: REFRESH_PATH,
|
|
56
|
+
expires: tokens.refreshExpiresAt,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clearCookies(reply: FastifyReply): void {
|
|
61
|
+
reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
|
|
62
|
+
reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The web gets cookies and an empty body; a native client gets the tokens and no cookie.
|
|
66
|
+
function deliver(
|
|
67
|
+
request: FastifyRequest,
|
|
68
|
+
reply: FastifyReply,
|
|
69
|
+
tokens: SessionTokens,
|
|
70
|
+
status: number,
|
|
71
|
+
) {
|
|
72
|
+
if (wantsBearer(request)) {
|
|
73
|
+
return reply.code(status).send(asBody(tokens))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setCookies(reply, tokens)
|
|
77
|
+
return reply.code(status === 201 ? 201 : 204).send()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
app.post(
|
|
81
|
+
'/sign-up',
|
|
82
|
+
{
|
|
83
|
+
config: { public: true, ...ATTEMPTS },
|
|
84
|
+
schema: { tags: ['auth'], body: signUpSchema, response: { 201: tokensResponseSchema } },
|
|
85
|
+
},
|
|
86
|
+
async (request, reply) =>
|
|
87
|
+
deliver(
|
|
88
|
+
request,
|
|
89
|
+
reply,
|
|
90
|
+
await app.container.resolve(SignUpUseCase).execute(request.body),
|
|
91
|
+
201,
|
|
92
|
+
),
|
|
15
93
|
)
|
|
16
|
-
// prumo:end-mcp
|
|
17
|
-
|
|
18
|
-
fastify.route({
|
|
19
|
-
...routes,
|
|
20
|
-
method: ['GET', 'POST'],
|
|
21
|
-
url: '/api/auth/*',
|
|
22
|
-
handler: async (request, reply) =>
|
|
23
|
-
sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
|
|
24
|
-
})
|
|
25
94
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
95
|
+
app.post(
|
|
96
|
+
'/sign-in',
|
|
97
|
+
{
|
|
98
|
+
config: { public: true, ...ATTEMPTS },
|
|
99
|
+
schema: { tags: ['auth'], body: signInSchema, response: { 200: tokensResponseSchema } },
|
|
100
|
+
},
|
|
101
|
+
async (request, reply) =>
|
|
102
|
+
deliver(
|
|
103
|
+
request,
|
|
104
|
+
reply,
|
|
105
|
+
await app.container.resolve(SignInUseCase).execute(request.body),
|
|
106
|
+
200,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
app.post(
|
|
111
|
+
'/refresh',
|
|
112
|
+
{
|
|
113
|
+
config: { public: true, ...REFRESHES },
|
|
114
|
+
schema: { tags: ['auth'], body: refreshSchema, response: { 200: tokensResponseSchema } },
|
|
115
|
+
},
|
|
116
|
+
async (request, reply) => {
|
|
117
|
+
const token = request.body?.refreshToken ?? request.cookies[REFRESH_COOKIE]
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (token === undefined) {
|
|
121
|
+
throw new InvalidSessionError()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return deliver(
|
|
125
|
+
request,
|
|
126
|
+
reply,
|
|
127
|
+
await app.container.resolve(RefreshSessionUseCase).execute(token),
|
|
128
|
+
200,
|
|
129
|
+
)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// A refresh that cannot succeed will not succeed later either: drop what the browser holds.
|
|
132
|
+
if (error instanceof InvalidSessionError && !wantsBearer(request)) {
|
|
133
|
+
clearCookies(reply)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
app.post('/sign-out', { schema: { tags: ['auth'] } }, async (request, reply) => {
|
|
142
|
+
await app.container
|
|
143
|
+
.resolve(SignOutUseCase)
|
|
144
|
+
.execute(currentUser(request).id, currentSession(request))
|
|
145
|
+
clearCookies(reply)
|
|
146
|
+
|
|
147
|
+
return reply.code(204).send()
|
|
34
148
|
})
|
|
35
|
-
|
|
149
|
+
|
|
150
|
+
app.get(
|
|
151
|
+
'/session',
|
|
152
|
+
{ schema: { tags: ['auth'], response: { 200: sessionResponseSchema } } },
|
|
153
|
+
async (request) => {
|
|
154
|
+
const user = await app.container.resolve(FindUserUseCase).execute(currentUser(request).id)
|
|
155
|
+
|
|
156
|
+
return { user: { id: user.id, email: user.email } }
|
|
157
|
+
},
|
|
158
|
+
)
|
|
36
159
|
}
|
|
@@ -1,40 +1,45 @@
|
|
|
1
|
-
import { requireMcpAuth } from '@better-auth/mcp'
|
|
2
1
|
import type { AuthInfo } from '@modelcontextprotocol/server'
|
|
3
2
|
import type { FastifyInstance } from 'fastify'
|
|
4
|
-
import {
|
|
3
|
+
import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tokens.port'
|
|
5
4
|
import { ENV, type Env } from '@/infra/config/env'
|
|
5
|
+
import { HttpError } from '@/infra/http/errors/http-error'
|
|
6
6
|
import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
|
|
7
|
-
import { createMcpServerHandler } from '@/infra/mcp/mcp.server'
|
|
7
|
+
import { createMcpServerHandler, mcpResource, mcpResourceMetadataUrl } from '@/infra/mcp/mcp.server'
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
function authInfoOf(request: Request, claims: AccessTokenClaims): AuthInfo {
|
|
12
|
-
const scope = typeof claims.scope === 'string' ? claims.scope : ''
|
|
13
|
-
const clientId = claims.azp ?? claims.client_id
|
|
14
|
-
|
|
15
|
-
return {
|
|
16
|
-
token: request.headers.get('authorization')?.replace(/^\w+\s+/, '') ?? '',
|
|
17
|
-
clientId: typeof clientId === 'string' ? clientId : '',
|
|
18
|
-
scopes: scope.split(' ').filter(Boolean),
|
|
19
|
-
...(claims.exp === undefined ? {} : { expiresAt: claims.exp }),
|
|
20
|
-
extra: { userId: claims.sub },
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// The route answers a bearer access token, never a session cookie, so the session hook stands aside.
|
|
9
|
+
// The route answers an OAuth access token issued for it, never a session cookie or a first-party token: the
|
|
10
|
+
// audience decides, so the session hook stands aside.
|
|
25
11
|
export async function mcpController(fastify: FastifyInstance): Promise<void> {
|
|
26
|
-
const
|
|
12
|
+
const tokens = fastify.container.resolve<AccessTokens>(ACCESS_TOKENS)
|
|
27
13
|
const env = fastify.container.resolve<Env>(ENV)
|
|
14
|
+
const resource = mcpResource(env)
|
|
28
15
|
const mcp = createMcpServerHandler(fastify.container)
|
|
29
|
-
const handle = requireMcpAuth(
|
|
30
|
-
auth,
|
|
31
|
-
(request, claims) => mcp.fetch(request, { authInfo: authInfoOf(request, claims) }),
|
|
32
|
-
{ resource: mcpResource(env) },
|
|
33
|
-
)
|
|
34
16
|
|
|
35
17
|
fastify.addHook('onClose', () => mcp.close())
|
|
36
18
|
|
|
37
|
-
fastify.post(
|
|
38
|
-
|
|
19
|
+
fastify.post(
|
|
20
|
+
'/',
|
|
21
|
+
{ config: { public: true }, schema: { hide: true } },
|
|
22
|
+
async (request, reply) => {
|
|
23
|
+
const token = /^Bearer (.+)$/i.exec(request.headers.authorization ?? '')?.[1]
|
|
24
|
+
const claims = token === undefined ? null : await tokens.verify(token, resource)
|
|
25
|
+
|
|
26
|
+
if (token === undefined || claims === null || claims.clientId === undefined) {
|
|
27
|
+
// RFC 9728: the 401 names the document that says where to obtain a token.
|
|
28
|
+
reply.header(
|
|
29
|
+
'www-authenticate',
|
|
30
|
+
`Bearer resource_metadata="${mcpResourceMetadataUrl(env)}"${token === undefined ? '' : ', error="invalid_token"'}`,
|
|
31
|
+
)
|
|
32
|
+
throw new HttpError(401, 'Unauthorized')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const authInfo: AuthInfo = {
|
|
36
|
+
token,
|
|
37
|
+
clientId: claims.clientId,
|
|
38
|
+
scopes: [],
|
|
39
|
+
extra: { userId: claims.userId },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return sendFetchResponse(reply, await mcp.fetch(toFetchRequest(request), { authInfo }))
|
|
43
|
+
},
|
|
39
44
|
)
|
|
40
45
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
|
|
5
|
+
import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
|
|
6
|
+
import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
|
|
7
|
+
import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
|
|
8
|
+
import {
|
|
9
|
+
authorizationDecisionSchema,
|
|
10
|
+
authorizationRedirectSchema,
|
|
11
|
+
} from '@/domain/oauth/dto/authorization-decision.dto'
|
|
12
|
+
import { authorizationViewSchema } from '@/domain/oauth/dto/authorization-view.dto'
|
|
13
|
+
import { AuthorizationRequestError } from '@/domain/oauth/errors/authorization-request.error'
|
|
14
|
+
import { InvalidGrantError } from '@/domain/oauth/errors/invalid-grant.error'
|
|
15
|
+
import { DecideAuthorizationUseCase } from '@/domain/oauth/use-cases/decide-authorization.use-case'
|
|
16
|
+
import { ExchangeCodeUseCase } from '@/domain/oauth/use-cases/exchange-code.use-case'
|
|
17
|
+
import { FindAuthorizationUseCase } from '@/domain/oauth/use-cases/find-authorization.use-case'
|
|
18
|
+
import { StartAuthorizationUseCase } from '@/domain/oauth/use-cases/start-authorization.use-case'
|
|
19
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
20
|
+
import { currentUser } from '@/infra/http/hooks/auth.hook'
|
|
21
|
+
import { mcpResource } from '@/infra/mcp/mcp.server'
|
|
22
|
+
|
|
23
|
+
const PUBLIC = { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } }
|
|
24
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
25
|
+
|
|
26
|
+
const authorizeQuerySchema = z.object({
|
|
27
|
+
response_type: z.string().optional(),
|
|
28
|
+
client_id: z.string().max(2048),
|
|
29
|
+
redirect_uri: z.string().max(2048),
|
|
30
|
+
code_challenge: z.string().optional(),
|
|
31
|
+
code_challenge_method: z.string().optional(),
|
|
32
|
+
state: z.string().max(1024).optional(),
|
|
33
|
+
resource: z.string().optional(),
|
|
34
|
+
scope: z.string().optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const idParamsSchema = z.object({ id: z.uuid() })
|
|
38
|
+
|
|
39
|
+
function withParams(uri: string, params: Record<string, string | null | undefined>): string {
|
|
40
|
+
const url = new URL(uri)
|
|
41
|
+
|
|
42
|
+
for (const [key, value] of Object.entries(params)) {
|
|
43
|
+
if (value !== null && value !== undefined) {
|
|
44
|
+
url.searchParams.set(key, value)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return url.toString()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tokenResponse(tokens: SessionTokens) {
|
|
52
|
+
return {
|
|
53
|
+
access_token: tokens.accessToken.token,
|
|
54
|
+
token_type: 'Bearer',
|
|
55
|
+
expires_in: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
|
|
56
|
+
refresh_token: tokens.refreshToken,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// MCP's authorization server, from discovery to the token: RFC 9728, RFC 8414, OAuth 2.1 with PKCE, RFC 8707 and
|
|
61
|
+
// RFC 9207, with clients identified by their metadata document. The user signs in and consents on the web app.
|
|
62
|
+
export async function oauthController(fastify: FastifyInstance): Promise<void> {
|
|
63
|
+
const app = fastify.withTypeProvider<ZodTypeProvider>()
|
|
64
|
+
const env = app.container.resolve<Env>(ENV)
|
|
65
|
+
const issuer = env.API_URL
|
|
66
|
+
const resource = mcpResource(env)
|
|
67
|
+
|
|
68
|
+
// The token endpoint takes a form, as OAuth writes it.
|
|
69
|
+
app.addContentTypeParser(
|
|
70
|
+
'application/x-www-form-urlencoded',
|
|
71
|
+
{ parseAs: 'string' },
|
|
72
|
+
(_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const resourceMetadata = {
|
|
76
|
+
resource,
|
|
77
|
+
authorization_servers: [issuer],
|
|
78
|
+
bearer_methods_supported: ['header'],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// At the path of the resource first, then at the root: the two places an MCP client looks.
|
|
82
|
+
for (const url of [
|
|
83
|
+
'/.well-known/oauth-protected-resource/api/mcp',
|
|
84
|
+
'/.well-known/oauth-protected-resource',
|
|
85
|
+
]) {
|
|
86
|
+
app.get(url, { config: { public: true }, schema: { hide: true } }, async () => resourceMetadata)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
app.get(
|
|
90
|
+
'/.well-known/oauth-authorization-server',
|
|
91
|
+
{ config: { public: true }, schema: { hide: true } },
|
|
92
|
+
async () => ({
|
|
93
|
+
issuer,
|
|
94
|
+
authorization_endpoint: `${issuer}/api/oauth/authorize`,
|
|
95
|
+
token_endpoint: `${issuer}/api/oauth/token`,
|
|
96
|
+
response_types_supported: ['code'],
|
|
97
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
98
|
+
code_challenge_methods_supported: ['S256'],
|
|
99
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
100
|
+
client_id_metadata_document_supported: true,
|
|
101
|
+
authorization_response_iss_parameter_supported: true,
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
app.get(
|
|
106
|
+
'/api/oauth/authorize',
|
|
107
|
+
{ config: PUBLIC, schema: { tags: ['oauth'], querystring: authorizeQuerySchema } },
|
|
108
|
+
async (request, reply) => {
|
|
109
|
+
const query = request.query
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const authorization = await app.container.resolve(StartAuthorizationUseCase).execute(
|
|
113
|
+
{
|
|
114
|
+
responseType: query.response_type,
|
|
115
|
+
clientId: query.client_id,
|
|
116
|
+
redirectUri: query.redirect_uri,
|
|
117
|
+
codeChallenge: query.code_challenge,
|
|
118
|
+
codeChallengeMethod: query.code_challenge_method,
|
|
119
|
+
clientState: query.state ?? null,
|
|
120
|
+
resource: query.resource,
|
|
121
|
+
},
|
|
122
|
+
resource,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Signing in, if needed, and the consent itself happen on the web app.
|
|
126
|
+
return reply.redirect(`${env.WEB_ORIGIN}/consent?request=${authorization.id}`)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof AuthorizationRequestError) {
|
|
129
|
+
return reply.redirect(
|
|
130
|
+
withParams(query.redirect_uri, {
|
|
131
|
+
error: error.code,
|
|
132
|
+
error_description: error.message,
|
|
133
|
+
state: query.state,
|
|
134
|
+
iss: issuer,
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
app.get(
|
|
145
|
+
'/api/oauth/authorizations/:id',
|
|
146
|
+
{
|
|
147
|
+
schema: {
|
|
148
|
+
tags: ['oauth'],
|
|
149
|
+
params: idParamsSchema,
|
|
150
|
+
response: { 200: authorizationViewSchema },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
async (request) => {
|
|
154
|
+
const authorization = await app.container
|
|
155
|
+
.resolve(FindAuthorizationUseCase)
|
|
156
|
+
.execute(request.params.id)
|
|
157
|
+
const redirect = new URL(authorization.redirectUri)
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
clientId: authorization.clientId,
|
|
161
|
+
clientName: authorization.clientName,
|
|
162
|
+
clientHost: new URL(authorization.clientId).host,
|
|
163
|
+
redirectHost: redirect.host,
|
|
164
|
+
redirectsToThisDevice: LOOPBACK.has(redirect.hostname),
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
app.post(
|
|
170
|
+
'/api/oauth/authorizations/:id/decision',
|
|
171
|
+
{
|
|
172
|
+
schema: {
|
|
173
|
+
tags: ['oauth'],
|
|
174
|
+
params: idParamsSchema,
|
|
175
|
+
body: authorizationDecisionSchema,
|
|
176
|
+
response: { 200: authorizationRedirectSchema },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
async (request) => {
|
|
180
|
+
const { authorization, code } = await app.container
|
|
181
|
+
.resolve(DecideAuthorizationUseCase)
|
|
182
|
+
.execute(request.params.id, currentUser(request).id, request.body.accept)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
redirectTo: withParams(authorization.redirectUri, {
|
|
186
|
+
...(code === null ? { error: 'access_denied' } : { code }),
|
|
187
|
+
state: authorization.clientState,
|
|
188
|
+
iss: issuer,
|
|
189
|
+
}),
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// Errors here are OAuth's, not problem+json: an MCP client reads `error` and `error_description`.
|
|
195
|
+
app.post(
|
|
196
|
+
'/api/oauth/token',
|
|
197
|
+
{ config: PUBLIC, schema: { tags: ['oauth'] } },
|
|
198
|
+
async (request, reply: FastifyReply) => {
|
|
199
|
+
const body = (request.body ?? {}) as Record<string, string | undefined>
|
|
200
|
+
const fail = (error: string, description: string) =>
|
|
201
|
+
reply.code(400).send({ error, error_description: description })
|
|
202
|
+
|
|
203
|
+
reply.header('cache-control', 'no-store')
|
|
204
|
+
|
|
205
|
+
if (body.resource !== undefined && body.resource !== resource) {
|
|
206
|
+
return fail('invalid_target', `resource must be ${resource}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
if (body.grant_type === 'authorization_code') {
|
|
211
|
+
const { code, client_id, redirect_uri, code_verifier } = body
|
|
212
|
+
|
|
213
|
+
if (!code || !client_id || !redirect_uri || !code_verifier) {
|
|
214
|
+
return fail(
|
|
215
|
+
'invalid_request',
|
|
216
|
+
'code, client_id, redirect_uri and code_verifier are required',
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return tokenResponse(
|
|
221
|
+
await app.container.resolve(ExchangeCodeUseCase).execute({
|
|
222
|
+
code,
|
|
223
|
+
clientId: client_id,
|
|
224
|
+
redirectUri: redirect_uri,
|
|
225
|
+
codeVerifier: code_verifier,
|
|
226
|
+
resource,
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (body.grant_type === 'refresh_token') {
|
|
232
|
+
if (!body.refresh_token || !body.client_id) {
|
|
233
|
+
return fail('invalid_request', 'refresh_token and client_id are required')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return tokenResponse(
|
|
237
|
+
await app.container
|
|
238
|
+
.resolve(RefreshSessionUseCase)
|
|
239
|
+
.execute(body.refresh_token, body.client_id),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return fail('unsupported_grant_type', 'Only authorization_code and refresh_token')
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (
|
|
246
|
+
error instanceof InvalidGrantError ||
|
|
247
|
+
error instanceof InvalidSessionError ||
|
|
248
|
+
error instanceof SupersededTokenError
|
|
249
|
+
) {
|
|
250
|
+
return fail('invalid_grant', error.message)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw error
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { DependencyContainer } from 'tsyringe'
|
|
2
|
-
import type { AuthSession, AuthUser } from '@/infra/auth/auth.factory'
|
|
3
2
|
|
|
4
3
|
declare module 'fastify' {
|
|
5
4
|
interface FastifyInstance {
|
|
@@ -11,7 +10,7 @@ declare module 'fastify' {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
interface FastifyRequest {
|
|
14
|
-
user?:
|
|
15
|
-
|
|
13
|
+
user?: { id: string }
|
|
14
|
+
sessionId?: string
|
|
16
15
|
}
|
|
17
16
|
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import { fromNodeHeaders } from 'better-auth/node'
|
|
2
1
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
3
2
|
|
|
3
|
+
function headersOf(request: FastifyRequest): Headers {
|
|
4
|
+
const headers = new Headers()
|
|
5
|
+
|
|
6
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
7
|
+
for (const each of [value].flat()) {
|
|
8
|
+
if (each !== undefined) {
|
|
9
|
+
headers.append(name, each)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return headers
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
function bodyOf(body: unknown): string | undefined {
|
|
5
18
|
if (body === undefined) {
|
|
6
19
|
return undefined
|
|
@@ -9,15 +22,14 @@ function bodyOf(body: unknown): string | undefined {
|
|
|
9
22
|
return typeof body === 'string' ? body : JSON.stringify(body)
|
|
10
23
|
}
|
|
11
24
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// string, such as a form, passes unchanged.
|
|
25
|
+
// The MCP handler answers Fetch requests. Fastify has already parsed a JSON body, so the request is rebuilt
|
|
26
|
+
// rather than handed over raw; a body kept as a string, such as a form, passes unchanged.
|
|
15
27
|
export function toFetchRequest(request: FastifyRequest): Request {
|
|
16
28
|
const body = bodyOf(request.body)
|
|
17
29
|
|
|
18
30
|
return new Request(new URL(request.url, `${request.protocol}://${request.host}`), {
|
|
19
31
|
method: request.method,
|
|
20
|
-
headers:
|
|
32
|
+
headers: headersOf(request),
|
|
21
33
|
...(body === undefined ? {} : { body }),
|
|
22
34
|
})
|
|
23
35
|
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { fromNodeHeaders } from 'better-auth/node'
|
|
2
1
|
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
|
3
|
-
import {
|
|
2
|
+
import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tokens.port'
|
|
4
3
|
import { HttpError } from '@/infra/http/errors/http-error'
|
|
5
4
|
|
|
5
|
+
export const ACCESS_COOKIE = 'access_token'
|
|
6
|
+
|
|
7
|
+
// The web sends a cookie the browser keeps; a native app and an MCP client send a bearer token.
|
|
8
|
+
function tokenOf(request: FastifyRequest): string | undefined {
|
|
9
|
+
const bearer = /^Bearer (.+)$/i.exec(request.headers.authorization ?? '')?.[1]
|
|
10
|
+
|
|
11
|
+
return bearer ?? request.cookies[ACCESS_COOKIE]
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
export function registerAuthHook(app: FastifyInstance): void {
|
|
7
|
-
const
|
|
15
|
+
const tokens = app.container.resolve<AccessTokens>(ACCESS_TOKENS)
|
|
8
16
|
|
|
9
17
|
app.addHook('preHandler', async (request) => {
|
|
10
18
|
// An unknown route answers 404, not 401: there is nothing there to protect.
|
|
@@ -12,21 +20,30 @@ export function registerAuthHook(app: FastifyInstance): void {
|
|
|
12
20
|
return
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
const
|
|
23
|
+
const token = tokenOf(request)
|
|
24
|
+
const claims = token === undefined ? null : await tokens.verify(token)
|
|
16
25
|
|
|
17
|
-
request.user =
|
|
18
|
-
request.
|
|
26
|
+
request.user = claims === null ? undefined : { id: claims.userId }
|
|
27
|
+
request.sessionId = claims?.sessionId
|
|
19
28
|
|
|
20
|
-
if (request.routeOptions.config.public !== true &&
|
|
29
|
+
if (request.routeOptions.config.public !== true && claims === null) {
|
|
21
30
|
throw new HttpError(401, 'Unauthorized')
|
|
22
31
|
}
|
|
23
32
|
})
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
export function currentUser(request: FastifyRequest):
|
|
35
|
+
export function currentUser(request: FastifyRequest): { id: string } {
|
|
27
36
|
if (request.user === undefined) {
|
|
28
37
|
throw new HttpError(401, 'Unauthorized')
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
return request.user
|
|
32
41
|
}
|
|
42
|
+
|
|
43
|
+
export function currentSession(request: FastifyRequest): string {
|
|
44
|
+
if (request.sessionId === undefined) {
|
|
45
|
+
throw new HttpError(401, 'Unauthorized')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return request.sessionId
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import { HttpError } from '@/infra/http/errors/http-error'
|
|
3
|
+
|
|
4
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
5
|
+
|
|
6
|
+
// A cookie is sent by the browser whoever wrote the page, so a request that changes something and leans on one must
|
|
7
|
+
// come from the web app. Without a cookie there is nothing for another site to borrow: a native app and an MCP
|
|
8
|
+
// client send a bearer, and pass.
|
|
9
|
+
export function registerOriginCheck(app: FastifyInstance, webOrigin: string): void {
|
|
10
|
+
app.addHook('onRequest', async (request) => {
|
|
11
|
+
const leansOnCookie =
|
|
12
|
+
request.headers.cookie !== undefined && request.headers.authorization === undefined
|
|
13
|
+
|
|
14
|
+
if (SAFE.has(request.method) || !leansOnCookie) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (request.headers.origin !== webOrigin) {
|
|
19
|
+
throw new HttpError(403, 'Cross-origin request refused')
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|