@stjoseph/prumo 0.1.1 → 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 -48
- 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 +19 -14
- package/dist/clean.js +13 -3
- package/dist/cli.js +38 -9
- package/dist/commands.js +7 -4
- package/dist/compose.js +4 -12
- package/dist/doctor.js +31 -17
- package/dist/generate.js +56 -9
- package/dist/mcp.js +15 -8
- package/dist/names.js +42 -0
- package/dist/questions.js +32 -16
- package/package.json +2 -2
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +5 -6
- 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 +7 -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 +1 -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/README.md +1 -1
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/README.md +1 -1
- package/templates/web/package.json +1 -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/README.md +1 -1
- package/templates/workspace/package.json +1 -1
- 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,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
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
|
|
2
2
|
import 'reflect-metadata'
|
|
3
3
|
import { MikroORM } from '@mikro-orm/postgresql'
|
|
4
|
-
import type { Pool } from 'pg'
|
|
5
|
-
import { AUTH, type Auth } from '@/infra/auth/auth.factory'
|
|
6
4
|
import { loadEnv } from '@/infra/config/env'
|
|
7
5
|
import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
|
|
8
6
|
import { createContainer } from '@/infra/di'
|
|
@@ -18,10 +16,9 @@ async function main(): Promise<void> {
|
|
|
18
16
|
const container = createContainer({ env, orm })
|
|
19
17
|
const app = await buildApp(container)
|
|
20
18
|
|
|
21
|
-
// Handling the signals takes away Node's default exit, so
|
|
19
|
+
// Handling the signals takes away Node's default exit, so the pool must close for the process to end.
|
|
22
20
|
app.addHook('onClose', async () => {
|
|
23
21
|
await orm.close()
|
|
24
|
-
await (container.resolve<Auth>(AUTH).options.database as Pool).end()
|
|
25
22
|
})
|
|
26
23
|
|
|
27
24
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { createMcpHandler, type McpHttpHandler, McpServer } from '@modelcontextprotocol/server'
|
|
2
2
|
import type { DependencyContainer } from 'tsyringe'
|
|
3
|
+
import type { Env } from '@/infra/config/env'
|
|
3
4
|
import { registerUserTools } from './tools/users.tool'
|
|
4
5
|
|
|
6
|
+
// The protected resource MCP clients ask a token for; its access tokens carry it as their audience.
|
|
7
|
+
export function mcpResource(env: Pick<Env, 'API_URL'>): string {
|
|
8
|
+
return `${env.API_URL}/api/mcp`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function mcpResourceMetadataUrl(env: Pick<Env, 'API_URL'>): string {
|
|
12
|
+
return `${env.API_URL}/.well-known/oauth-protected-resource/api/mcp`
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
// One server per request: the protocol is stateless, and each instance knows only its caller.
|
|
6
16
|
export function createMcpServerHandler(container: DependencyContainer): McpHttpHandler {
|
|
7
17
|
return createMcpHandler(
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { AuthorizationCodes } from '@/domain/oauth/ports/authorization-codes.port'
|
|
3
|
+
|
|
4
|
+
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64url')
|
|
5
|
+
|
|
6
|
+
export class CryptoAuthorizationCodes implements AuthorizationCodes {
|
|
7
|
+
create(): string {
|
|
8
|
+
return randomBytes(32).toString('base64url')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest(code: string): string {
|
|
12
|
+
return sha256(code)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
matchesChallenge(verifier: string, challenge: string): boolean {
|
|
16
|
+
const expected = Buffer.from(sha256(verifier))
|
|
17
|
+
const given = Buffer.from(challenge)
|
|
18
|
+
|
|
19
|
+
return expected.length === given.length && timingSafeEqual(expected, given)
|
|
20
|
+
}
|
|
21
|
+
}
|