From f17918ea1746df840ef766ba43e019629bf7708e Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 28 Oct 2026 05:54:56 +0800 Subject: [PATCH 1/8] feat(backend): add OAuth-authenticated management MCP --- .gitignore | 1 + backend/cmd/server/wire_gen.go | 7 +- backend/go.mod | 6 + backend/go.sum | 30 +- backend/internal/config/config.go | 51 ++ backend/internal/config/mcp_config_test.go | 64 ++ backend/internal/handler/handler.go | 1 + backend/internal/handler/mcp_oauth_handler.go | 368 +++++++++++ .../handler/mcp_oauth_handler_test.go | 451 +++++++++++++ backend/internal/handler/wire.go | 3 + backend/internal/managementmcp/auth.go | 127 ++++ backend/internal/managementmcp/catalog.go | 280 ++++++++ .../internal/managementmcp/catalog_test.go | 168 +++++ backend/internal/managementmcp/server.go | 236 +++++++ backend/internal/managementmcp/server_test.go | 325 ++++++++++ backend/internal/repository/mcp_oauth.go | 269 ++++++++ .../repository/mcp_oauth_integration_test.go | 203 ++++++ backend/internal/repository/mcp_oauth_test.go | 177 +++++ backend/internal/repository/wire.go | 1 + .../internal/server/middleware/audit_log.go | 1 + backend/internal/server/middleware/cors.go | 36 +- .../internal/server/middleware/cors_test.go | 97 +++ .../server/middleware/panel_rate_limit.go | 6 + backend/internal/server/middleware/step_up.go | 5 + .../server/middleware/step_up_test.go | 10 + backend/internal/server/router.go | 2 + backend/internal/server/routes/mcp.go | 45 ++ .../server/routes/mcp_oauth_sdk_test.go | 120 ++++ .../internal/server/routes/mcp_oauth_test.go | 491 ++++++++++++++ .../server/routes/mcp_rate_limit_test.go | 117 ++++ backend/internal/server/routes/mcp_test.go | 190 ++++++ backend/internal/server/routes/user.go | 7 + backend/internal/service/mcp_oauth_service.go | 609 ++++++++++++++++++ .../service/mcp_oauth_service_test.go | 552 ++++++++++++++++ backend/internal/service/wire.go | 1 + backend/internal/web/embed_on.go | 3 + backend/internal/web/embed_test.go | 6 + backend/migrations/249_mcp_oauth.sql | 50 ++ deploy/.env.example | 5 + deploy/config.example.yaml | 13 + deploy/docker-compose.dev.yml | 1 + deploy/docker-compose.local.yml | 1 + deploy/docker-compose.standalone.yml | 1 + deploy/docker-compose.yml | 1 + docs/mcp.md | 171 +++++ 45 files changed, 5288 insertions(+), 21 deletions(-) create mode 100644 backend/internal/config/mcp_config_test.go create mode 100644 backend/internal/handler/mcp_oauth_handler.go create mode 100644 backend/internal/handler/mcp_oauth_handler_test.go create mode 100644 backend/internal/managementmcp/auth.go create mode 100644 backend/internal/managementmcp/catalog.go create mode 100644 backend/internal/managementmcp/catalog_test.go create mode 100644 backend/internal/managementmcp/server.go create mode 100644 backend/internal/managementmcp/server_test.go create mode 100644 backend/internal/repository/mcp_oauth.go create mode 100644 backend/internal/repository/mcp_oauth_integration_test.go create mode 100644 backend/internal/repository/mcp_oauth_test.go create mode 100644 backend/internal/server/routes/mcp.go create mode 100644 backend/internal/server/routes/mcp_oauth_sdk_test.go create mode 100644 backend/internal/server/routes/mcp_oauth_test.go create mode 100644 backend/internal/server/routes/mcp_rate_limit_test.go create mode 100644 backend/internal/server/routes/mcp_test.go create mode 100644 backend/internal/service/mcp_oauth_service.go create mode 100644 backend/internal/service/mcp_oauth_service_test.go create mode 100644 backend/migrations/249_mcp_oauth.sql create mode 100644 docs/mcp.md diff --git a/.gitignore b/.gitignore index 93d301f15714..3ee8dfee88b3 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,7 @@ deploy/docker-compose.override.yml vite.config.js docs/* !docs/admin-api-keys.md +!docs/mcp.md !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 49e42b4c7d59..a59093ed0461 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -49,10 +49,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { redeemCodeRepository := repository.NewRedeemCodeRepository(client) redisClient := repository.ProvideRedis(configConfig) refreshTokenCache := repository.NewRefreshTokenCache(redisClient) + scopedAdminKeyRepository := repository.NewScopedAdminKeyRepository(db) settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db) - scopedAdminKeyRepository := repository.NewScopedAdminKeyRepository(db) settingService := service.ProvideSettingService(scopedAdminKeyRepository, settingRepository, groupRepository, proxyRepository, configConfig) emailCache := repository.NewEmailCache(redisClient) emailService := service.NewEmailService(settingRepository, emailCache) @@ -98,6 +98,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService) userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository) + mcpoAuthRepository := repository.NewMCPOAuthRepository(db) + mcpoAuthService := service.NewMCPOAuthService(mcpoAuthRepository, userRepository) + mcpoAuthHandler := handler.NewMCPOAuthHandler(mcpoAuthService, totpService, userService, settingService, configConfig) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) @@ -328,7 +331,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService) + handlers := handler.ProvideHandlers(authHandler, userHandler, mcpoAuthHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) diff --git a/backend/go.mod b/backend/go.mod index 50865afb0c7b..5cc1749a4d88 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -30,6 +30,7 @@ require ( github.com/imroc/req/v3 v3.59.0 github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 + github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.5.0 github.com/redis/go-redis/v9 v9.22.0 @@ -128,6 +129,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect @@ -167,6 +169,8 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/smartwalle/ncrypto v1.0.4 // indirect github.com/smartwalle/ngx v1.1.0 // indirect @@ -187,6 +191,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect @@ -201,6 +206,7 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.12.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 78270b757eed..cd58e9944fe8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -251,6 +251,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -349,6 +351,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -405,6 +409,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= @@ -498,6 +506,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -562,8 +572,6 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -580,8 +588,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -605,11 +611,11 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -620,8 +626,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -655,8 +659,6 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -671,8 +673,6 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -685,8 +685,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -703,10 +701,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1f488c89afff..55b335c304dc 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,9 +7,11 @@ import ( "fmt" "log/slog" "math" + "net/netip" "net/textproto" "net/url" "os" + "strconv" "strings" "sync/atomic" "time" @@ -69,6 +71,7 @@ type Config struct { Server ServerConfig `mapstructure:"server"` Log LogConfig `mapstructure:"log"` CORS CORSConfig `mapstructure:"cors"` + MCP MCPConfig `mapstructure:"mcp"` Security SecurityConfig `mapstructure:"security"` Billing BillingConfig `mapstructure:"billing"` Turnstile TurnstileConfig `mapstructure:"turnstile"` @@ -116,6 +119,50 @@ type SimpleModeConfig struct { AutoCreateDefaultGroups bool `mapstructure:"auto_create_default_groups" yaml:"auto_create_default_groups"` } +// MCPConfig fixes the public resource URL used by OAuth discovery and tokens. +type MCPConfig struct { + PublicURL string `mapstructure:"public_url"` +} + +// Validate normalizes the configured resource URL without trusting request headers. +// An empty URL leaves selection of the trusted frontend URL to the MCP handler. +func (c *MCPConfig) Validate() error { + raw := strings.TrimSpace(c.PublicURL) + if raw == "" { + c.PublicURL = "" + return nil + } + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("mcp.public_url invalid: %w", err) + } + if !u.IsAbs() || u.Hostname() == "" || !httpguts.ValidHostHeader(u.Host) || strings.ContainsAny(u.Host, "*%") { + return fmt.Errorf("mcp.public_url must be an absolute URL with a valid host") + } + if u.User != nil || u.RawQuery != "" || u.ForceQuery || strings.Contains(raw, "#") { + return fmt.Errorf("mcp.public_url must not include userinfo, query, or fragment") + } + if u.Path != "/api/v1/mcp" || u.EscapedPath() != "/api/v1/mcp" { + return fmt.Errorf("mcp.public_url must have the exact path /api/v1/mcp without a trailing slash") + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("mcp.public_url must have a valid port") + } + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + host := u.Hostname() + addr, _ := netip.ParseAddr(host) + localDevelopment := host == "localhost" || addr.IsLoopback() + if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) { + return fmt.Errorf("mcp.public_url must use HTTPS (HTTP is allowed only for localhost or loopback IPs)") + } + c.PublicURL = u.String() + return nil +} + // PluginConfig 控制管理员手动上传的本地进程插件。 // 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。 type PluginConfig struct { @@ -2082,6 +2129,7 @@ func setDefaults() { // CORS viper.SetDefault("cors.allowed_origins", []string{}) viper.SetDefault("cors.allow_credentials", true) + viper.SetDefault("mcp.public_url", "") // WebAuthn / Passkeys are opt-in because every deployment must explicitly // declare its relying-party domain and trusted browser origins. @@ -2856,6 +2904,9 @@ func (c *Config) Validate() error { return fmt.Errorf("gemini.oauth.client_id and gemini.oauth.client_secret must be both set or both empty") } + if err := c.MCP.Validate(); err != nil { + return err + } if strings.TrimSpace(c.Server.FrontendURL) != "" { if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil { return fmt.Errorf("server.frontend_url invalid: %w", err) diff --git a/backend/internal/config/mcp_config_test.go b/backend/internal/config/mcp_config_test.go new file mode 100644 index 000000000000..93a002be1430 --- /dev/null +++ b/backend/internal/config/mcp_config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMCPConfigValidatePublicURL(t *testing.T) { + for _, tc := range []struct { + name, value, want string + }{ + {"unset", "", ""}, + {"blank", " ", ""}, + {"https", "https://api.example.com/api/v1/mcp", "https://api.example.com/api/v1/mcp"}, + {"normalize", " HTTPS://API.Example.COM:8443/api/v1/mcp ", "https://api.example.com:8443/api/v1/mcp"}, + {"localhost", "http://localhost:8080/api/v1/mcp", "http://localhost:8080/api/v1/mcp"}, + {"loopback IPv4", "http://127.0.0.2:8080/api/v1/mcp", "http://127.0.0.2:8080/api/v1/mcp"}, + {"loopback IPv6", "http://[::1]:8080/api/v1/mcp", "http://[::1]:8080/api/v1/mcp"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := MCPConfig{PublicURL: tc.value} + require.NoError(t, cfg.Validate()) + require.Equal(t, tc.want, cfg.PublicURL) + }) + } + for _, value := range []string{ + "/api/v1/mcp", "//example.com/api/v1/mcp", "https:///api/v1/mcp", + "https://:443/api/v1/mcp", "https://*.example.com/api/v1/mcp", + "http://example.com/api/v1/mcp", "http://localhost.example.com/api/v1/mcp", + "http://10.0.0.1/api/v1/mcp", "http://[::2]/api/v1/mcp", + "ftp://localhost/api/v1/mcp", "https://example.com/api/v1/mcp/", + "https://example.com/mcp", "https://example.com/api/v1/%6dcp", + "https://example.com/api/v1/mcp?", "https://example.com/api/v1/mcp?x=1", + "https://example.com/api/v1/mcp#", "https://example.com/api/v1/mcp#fragment", + "https://user:password@example.com/api/v1/mcp", "https://user@example.com/api/v1/mcp", + "https://example.com:0/api/v1/mcp", "https://example.com:65536/api/v1/mcp", + "https://example.com:invalid/api/v1/mcp", "http://[::1%25eth0]/api/v1/mcp", + } { + t.Run(value, func(t *testing.T) { + cfg := MCPConfig{PublicURL: value} + require.ErrorContains(t, cfg.Validate(), "mcp.public_url") + }) + } +} + +func TestLoadMCPPublicURLFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("MCP_PUBLIC_URL", "HTTPS://MCP.Example.COM/api/v1/mcp") + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "https://mcp.example.com/api/v1/mcp", cfg.MCP.PublicURL) + + cfg.MCP.PublicURL = "http://example.com/api/v1/mcp" + require.ErrorContains(t, cfg.Validate(), "mcp.public_url must use HTTPS") +} + +func TestLoadMCPPublicURLDefault(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("MCP_PUBLIC_URL", "") + cfg, err := Load() + require.NoError(t, err) + require.Empty(t, cfg.MCP.PublicURL) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index f7126904db0d..6e11effe02be 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -49,6 +49,7 @@ type AdminHandlers struct { type Handlers struct { Auth *AuthHandler User *UserHandler + MCPOAuth *MCPOAuthHandler APIKey *APIKeyHandler Usage *UsageHandler Redeem *RedeemHandler diff --git a/backend/internal/handler/mcp_oauth_handler.go b/backend/internal/handler/mcp_oauth_handler.go new file mode 100644 index 000000000000..a0b95665463c --- /dev/null +++ b/backend/internal/handler/mcp_oauth_handler.go @@ -0,0 +1,368 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +type MCPOAuthHandler struct { + oauth *service.MCPOAuthService + totp *service.TotpService + users *service.UserService + settings *service.SettingService + cfg *config.Config +} + +func NewMCPOAuthHandler(oauth *service.MCPOAuthService, totp *service.TotpService, users *service.UserService, settings *service.SettingService, cfg *config.Config) *MCPOAuthHandler { + return &MCPOAuthHandler{oauth: oauth, totp: totp, users: users, settings: settings, cfg: cfg} +} + +func (h *MCPOAuthHandler) Service() *service.MCPOAuthService { return h.oauth } + +// ResourceURL is anchored to operator configuration, never the request Host or +// forwarded headers. A separate API origin can override the frontend fallback. +func (h *MCPOAuthHandler) ResourceURL(ctx context.Context) (string, error) { + var raw string + if h.cfg != nil { + raw = h.cfg.MCP.PublicURL + } + if strings.TrimSpace(raw) == "" { + frontend := h.frontendURL(ctx) + u, err := url.Parse(frontend) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(frontend, "#") { + return "", errors.New("configure MCP_PUBLIC_URL or the platform frontend URL to enable MCP OAuth") + } + raw = u.Scheme + "://" + u.Host + "/api/v1/mcp" + } + configured := config.MCPConfig{PublicURL: raw} + if err := configured.Validate(); err != nil || configured.PublicURL == "" { + return "", errors.New("MCP public URL is not configured correctly") + } + return configured.PublicURL, nil +} + +func (h *MCPOAuthHandler) frontendURL(ctx context.Context) string { + if h.settings != nil { + return strings.TrimRight(strings.TrimSpace(h.settings.GetFrontendURL(ctx)), "/") + } + if h.cfg != nil { + return strings.TrimRight(strings.TrimSpace(h.cfg.Server.FrontendURL), "/") + } + return "" +} + +func (h *MCPOAuthHandler) ResourceMetadata(c *gin.Context) { + resource, ok := h.resource(c, true) + if !ok { + return + } + u, _ := url.Parse(resource) + issuer := u.Scheme + "://" + u.Host + c.JSON(http.StatusOK, oauthex.ProtectedResourceMetadata{ + Resource: resource, AuthorizationServers: []string{issuer}, + ScopesSupported: []string{service.MCPOAuthScope}, BearerMethodsSupported: []string{"header"}, + ResourceName: "Sub2API management", + }) +} + +func (h *MCPOAuthHandler) AuthorizationMetadata(c *gin.Context) { + resource, ok := h.resource(c, true) + if !ok { + return + } + u, _ := url.Parse(resource) + issuer := u.Scheme + "://" + u.Host + frontend := h.frontendURL(c.Request.Context()) + if frontend == "" { + frontend = issuer + } + // The SPA restores the browser login and obtains explicit user consent. + authorization, err := mcpAuthorizationURL(frontend) + if err != nil { + mcpOAuthErrorResponse(c, http.StatusServiceUnavailable, "server_error", "MCP authorization page is not configured correctly") + return + } + c.JSON(http.StatusOK, gin.H{ + "issuer": issuer, "authorization_endpoint": authorization, + "token_endpoint": issuer + "/api/v1/mcp/oauth/token", + "registration_endpoint": issuer + "/api/v1/mcp/oauth/register", + "revocation_endpoint": issuer + "/api/v1/mcp/oauth/revoke", + "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "code_challenge_methods_supported": []string{"S256"}, "token_endpoint_auth_methods_supported": []string{"none"}, + "revocation_endpoint_auth_methods_supported": []string{"none"}, "scopes_supported": []string{service.MCPOAuthScope}, + }) +} + +func mcpAuthorizationURL(frontend string) (string, error) { + u, err := url.Parse(frontend) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(frontend, "#") { + return "", errors.New("invalid frontend URL") + } + origin := config.MCPConfig{PublicURL: u.Scheme + "://" + u.Host + "/api/v1/mcp"} + if err := origin.Validate(); err != nil { + return "", err + } + u.Path = strings.TrimRight(u.Path, "/") + "/oauth/mcp/authorize" + u.RawPath = "" + return u.String(), nil +} + +func (h *MCPOAuthHandler) Register(c *gin.Context) { + mcpOAuthNoStore(c) + if _, ok := h.resource(c, true); !ok { + return + } + var input service.MCPOAuthRegisterInput + if !mcpOAuthReadJSON(c, &input, false) { + return + } + client, err := h.oauth.Register(c.Request.Context(), input) + if mcpOAuthHandleError(c, err, true) { + return + } + c.JSON(http.StatusCreated, client) +} + +func (h *MCPOAuthHandler) Token(c *gin.Context) { + mcpOAuthNoStore(c) + resource, ok := h.resource(c, true) + if !ok { + return + } + form, ok := mcpOAuthReadForm(c) + if !ok { + return + } + if c.GetHeader("Authorization") != "" || form.Get("client_secret") != "" { + mcpOAuthErrorResponse(c, http.StatusBadRequest, "invalid_client", "Only public clients using token_endpoint_auth_method=none are supported") + return + } + input := service.MCPOAuthTokenInput{ + GrantType: form.Get("grant_type"), ClientID: form.Get("client_id"), Code: form.Get("code"), + RedirectURI: form.Get("redirect_uri"), CodeVerifier: form.Get("code_verifier"), + RefreshToken: form.Get("refresh_token"), Resource: form.Get("resource"), Scope: form.Get("scope"), + } + result, err := h.oauth.Token(c.Request.Context(), input, resource) + if mcpOAuthHandleError(c, err, true) { + return + } + c.JSON(http.StatusOK, result) +} + +func (h *MCPOAuthHandler) RevokeToken(c *gin.Context) { + mcpOAuthNoStore(c) + form, ok := mcpOAuthReadForm(c) + if !ok { + return + } + if c.GetHeader("Authorization") != "" || form.Get("client_secret") != "" { + mcpOAuthErrorResponse(c, http.StatusBadRequest, "invalid_client", "Only public clients are supported") + return + } + if mcpOAuthHandleError(c, h.oauth.RevokeToken(c.Request.Context(), form.Get("client_id"), form.Get("token")), true) { + return + } + c.Status(http.StatusOK) +} + +func (h *MCPOAuthHandler) Preview(c *gin.Context) { + mcpOAuthNoStore(c) + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + resource, ok := h.resource(c, false) + if !ok { + return + } + query := c.Request.URL.Query() + for _, values := range query { + if len(values) != 1 { + response.BadRequest(c, "Repeated OAuth parameters are not supported") + return + } + } + input := service.MCPOAuthAuthorizeInput{ + ResponseType: query.Get("response_type"), ClientID: query.Get("client_id"), RedirectURI: query.Get("redirect_uri"), + Scope: query.Get("scope"), State: query.Get("state"), CodeChallenge: query.Get("code_challenge"), + CodeChallengeMethod: query.Get("code_challenge_method"), Resource: query.Get("resource"), + } + consent, err := h.oauth.Preview(c.Request.Context(), subject.UserID, input, resource) + if mcpOAuthHandleError(c, err, false) { + return + } + response.Success(c, consent) +} + +func (h *MCPOAuthHandler) Decide(c *gin.Context) { + mcpOAuthNoStore(c) + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + resource, ok := h.resource(c, false) + if !ok { + return + } + var input service.MCPOAuthConsentInput + if !mcpOAuthReadJSON(c, &input, true) { + return + } + if input.Approved && input.Scope == service.MCPScopeAdmin { + role, _ := middleware.GetUserRoleFromContext(c) + if role != service.RoleAdmin { + response.Forbidden(c, "Only administrators can authorize admin MCP access") + return + } + if !middleware.EnforceStepUp(c, h.totp, h.users, h.settings) { + return + } + } + redirect, err := h.oauth.Decide(c.Request.Context(), subject.UserID, input, resource) + if mcpOAuthHandleError(c, err, false) { + return + } + middleware.SetAuditAction(c, "user.mcp.authorize") + response.Success(c, gin.H{"redirect_url": redirect}) +} + +func (h *MCPOAuthHandler) ListGrants(c *gin.Context) { + mcpOAuthNoStore(c) + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + grants, err := h.oauth.ListGrants(c.Request.Context(), subject.UserID) + if response.ErrorFrom(c, err) { + return + } + response.Success(c, grants) +} + +func (h *MCPOAuthHandler) ConnectionConfig(c *gin.Context) { + mcpOAuthNoStore(c) + if _, ok := middleware.GetAuthSubjectFromContext(c); !ok { + response.Unauthorized(c, "User not authenticated") + return + } + resource, ok := h.resource(c, false) + if !ok { + return + } + response.Success(c, gin.H{"resource": resource}) +} + +func (h *MCPOAuthHandler) RevokeGrant(c *gin.Context) { + mcpOAuthNoStore(c) + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + if response.ErrorFrom(c, h.oauth.RevokeGrant(c.Request.Context(), subject.UserID, c.Param("id"))) { + return + } + middleware.SetAuditAction(c, "user.mcp.revoke") + response.Success(c, gin.H{"revoked": true}) +} + +func (h *MCPOAuthHandler) resource(c *gin.Context, protocol bool) (string, bool) { + resource, err := h.ResourceURL(c.Request.Context()) + if err != nil { + if protocol { + mcpOAuthErrorResponse(c, http.StatusServiceUnavailable, "server_error", err.Error()) + } else { + response.Error(c, http.StatusServiceUnavailable, err.Error()) + } + return "", false + } + return resource, true +} + +func mcpOAuthNoStore(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") +} + +func mcpOAuthErrorResponse(c *gin.Context, status int, code, description string) { + mcpOAuthNoStore(c) + c.JSON(status, service.MCPOAuthError{Code: code, Description: description}) +} + +func mcpOAuthHandleError(c *gin.Context, err error, protocol bool) bool { + if err == nil { + return false + } + var oauthErr *service.MCPOAuthError + if errors.As(err, &oauthErr) { + if protocol { + mcpOAuthErrorResponse(c, http.StatusBadRequest, oauthErr.Code, oauthErr.Description) + } else { + response.BadRequest(c, oauthErr.Description) + } + } else if protocol { + mcpOAuthErrorResponse(c, http.StatusInternalServerError, "server_error", "OAuth request could not be completed") + } else { + response.ErrorFrom(c, err) + } + return true +} + +func mcpOAuthReadJSON(c *gin.Context, out any, browser bool) bool { + mediaType, _, _ := mime.ParseMediaType(c.GetHeader("Content-Type")) + if mediaType == "application/json" { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + decoder := json.NewDecoder(c.Request.Body) + if browser { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(out); err == nil { + var extra any + if errors.Is(decoder.Decode(&extra), io.EOF) { + return true + } + } + } + if browser { + response.BadRequest(c, "Invalid OAuth authorization request") + } else { + mcpOAuthErrorResponse(c, http.StatusBadRequest, "invalid_request", "A bounded JSON request body is required") + } + return false +} + +func mcpOAuthReadForm(c *gin.Context) (url.Values, bool) { + mediaType, _, _ := mime.ParseMediaType(c.GetHeader("Content-Type")) + if mediaType != "application/x-www-form-urlencoded" { + mcpOAuthErrorResponse(c, http.StatusBadRequest, "invalid_request", "Use application/x-www-form-urlencoded") + return nil, false + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + if err := c.Request.ParseForm(); err != nil { + mcpOAuthErrorResponse(c, http.StatusBadRequest, "invalid_request", "Cannot read OAuth form") + return nil, false + } + for _, values := range c.Request.PostForm { + if len(values) != 1 { + mcpOAuthErrorResponse(c, http.StatusBadRequest, "invalid_request", "Repeated OAuth parameters are not supported") + return nil, false + } + } + return c.Request.PostForm, true +} diff --git a/backend/internal/handler/mcp_oauth_handler_test.go b/backend/internal/handler/mcp_oauth_handler_test.go new file mode 100644 index 000000000000..c6d6012154e1 --- /dev/null +++ b/backend/internal/handler/mcp_oauth_handler_test.go @@ -0,0 +1,451 @@ +//go:build unit + +package handler + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const handlerMCPResource = "https://api.example.com/api/v1/mcp" + +type handlerMCPRepo struct { + service.MCPOAuthRepository + client *service.MCPOAuthClient + reads, writes int + grant *service.MCPOAuthGrant +} + +func (r *handlerMCPRepo) GetClient(_ context.Context, id string) (*service.MCPOAuthClient, error) { + r.reads++ + if r.client == nil || r.client.ClientID != id { + return nil, service.ErrMCPOAuthNotFound + } + return r.client, nil +} +func (r *handlerMCPRepo) CreateAuthorization(_ context.Context, grant *service.MCPOAuthGrant, _ *service.MCPOAuthCode, _ int) error { + r.writes++ + r.grant = grant + return nil +} + +type handlerMCPUsers struct { + service.UserRepository + user *service.User +} + +func (r *handlerMCPUsers) GetByID(context.Context, int64) (*service.User, error) { return r.user, nil } +func (r *handlerMCPUsers) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) { + return nil, nil +} + +type handlerMCPSettings struct { + service.SettingRepository + values map[string]string +} + +func (r *handlerMCPSettings) GetValue(_ context.Context, key string) (string, error) { + return r.values[key], nil +} + +type handlerMCPCache struct { + service.TotpCache + granted bool + err error + calls int + userID int64 + session string +} + +func (r *handlerMCPCache) HasStepUpGrant(_ context.Context, id int64, session string) (bool, error) { + r.calls++ + r.userID, r.session = id, session + return r.granted, r.err +} +func newHandlerMCPFixture(t *testing.T) (*MCPOAuthHandler, *handlerMCPRepo, *handlerMCPUsers, *handlerMCPCache) { + t.Helper() + cfg := &config.Config{MCP: config.MCPConfig{PublicURL: handlerMCPResource}} + cfg.Server.FrontendURL = "https://platform.example.com" + repo := &handlerMCPRepo{client: &service.MCPOAuthClient{ClientID: "client-1", ClientName: "Example assistant", RedirectURIs: []string{"https://client.example.com/callback?workspace=one"}, GrantTypes: []string{"authorization_code", "refresh_token"}}} + users := &handlerMCPUsers{user: &service.User{ID: 7, Role: service.RoleAdmin, Status: service.StatusActive, TotpEnabled: true}} + settings := service.NewSettingService(&handlerMCPSettings{values: map[string]string{service.SettingKeyStepUpEnabled: "true"}}, cfg) + cache := &handlerMCPCache{} + totp := service.NewTotpService(users, nil, cache, settings, nil, nil) + return NewMCPOAuthHandler(service.NewMCPOAuthService(repo, users), totp, service.NewUserService(users, nil, nil, nil), settings, cfg), repo, users, cache +} +func handlerMCPContext(t *testing.T, method, target, body, contentType string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(method, target, strings.NewReader(body)) + if contentType != "" { + c.Request.Header.Set("Content-Type", contentType) + } + return c, rec +} +func handlerMCPSubject(c *gin.Context, role string) { + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 7}) + c.Set(string(middleware.ContextKeyUserRole), role) + c.Set(middleware.ContextKeySessionID, "browser-session-7") +} +func handlerMCPAuthorization() service.MCPOAuthAuthorizeInput { + digest := sha256.Sum256([]byte(strings.Repeat("v", 43))) + return service.MCPOAuthAuthorizeInput{ResponseType: "code", ClientID: "client-1", RedirectURI: "https://client.example.com/callback?workspace=one", Scope: "mcp", State: "a+b&c=d", CodeChallenge: base64.RawURLEncoding.EncodeToString(digest[:]), CodeChallengeMethod: "S256", Resource: handlerMCPResource} +} +func handlerMCPBody(t *testing.T, input any) string { + t.Helper() + raw, err := json.Marshal(input) + require.NoError(t, err) + return string(raw) +} +func handlerMCPJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body +} + +func TestMCPOAuthHandlerResourceURLTrustedConfiguration(t *testing.T) { + for _, tt := range []struct{ name, public, frontend, stored, want string }{ + {"explicit API origin", handlerMCPResource, "https://platform.example.com", "", handlerMCPResource}, + {"normalize explicit", " https://API.EXAMPLE.COM/api/v1/mcp ", "", "", handlerMCPResource}, + {"frontend fallback uses origin", "", "https://platform.example.com/console/", "", "https://platform.example.com/api/v1/mcp"}, + {"stored frontend wins", "", "https://configured.example.com", "https://stored.example.com/ui", "https://stored.example.com/api/v1/mcp"}, + {"explicit resource wins", handlerMCPResource, "", "https://stored.example.com", handlerMCPResource}, + {"loopback development", "", "http://127.0.0.1:8080", "", "http://127.0.0.1:8080/api/v1/mcp"}, + {"missing configuration", "", "", "", ""}, + {"insecure public URL", "http://api.example.com/api/v1/mcp", "", "", ""}, + {"wrong resource path", "https://api.example.com/mcp", "", "", ""}, + {"resource userinfo", "https://user:password@api.example.com/api/v1/mcp", "", "", ""}, + {"resource query", handlerMCPResource + "?q=1", "", "", ""}, + {"resource fragment", handlerMCPResource + "#x", "", "", ""}, + {"relative frontend", "", "/console", "", ""}, + {"frontend credentials", "", "https://user:password@platform.example.com", "", ""}, + {"frontend query", "", "https://platform.example.com?q=1", "", ""}, + {"frontend fragment", "", "https://platform.example.com#fragment", "", ""}, + {"frontend empty query", "", "https://platform.example.com?", "", ""}, + {"frontend empty fragment", "", "https://platform.example.com#", "", ""}, + {"insecure frontend", "", "http://platform.example.com", "", ""}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{MCP: config.MCPConfig{PublicURL: tt.public}} + cfg.Server.FrontendURL = tt.frontend + var settings *service.SettingService + if tt.stored != "" { + settings = service.NewSettingService(&handlerMCPSettings{values: map[string]string{service.SettingKeyFrontendURL: tt.stored}}, cfg) + } + h := NewMCPOAuthHandler(nil, nil, nil, settings, cfg) + got, err := h.ResourceURL(context.Background()) + if tt.want == "" { + require.Error(t, err) + require.Empty(t, got) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} + +func TestMCPOAuthHandlerMetadataRawAndHostIndependent(t *testing.T) { + h, _, _, _ := newHandlerMCPFixture(t) + for _, auth := range []bool{false, true} { + c, rec := handlerMCPContext(t, http.MethodGet, "https://attacker.example/.well-known/oauth-authorization-server", "", "") + c.Request.Header.Set("X-Forwarded-Host", "forwarded-attacker.example") + c.Request.Header.Set("X-Forwarded-Proto", "http") + if auth { + h.AuthorizationMetadata(c) + } else { + h.ResourceMetadata(c) + } + require.Equal(t, http.StatusOK, rec.Code) + body := handlerMCPJSON(t, rec) + require.NotContains(t, body, "data") + require.NotContains(t, body, "code") + require.NotContains(t, rec.Body.String(), "attacker") + require.Equal(t, []any{"mcp"}, body["scopes_supported"]) + if auth { + require.Equal(t, "https://api.example.com", body["issuer"]) + require.Equal(t, "https://platform.example.com/oauth/mcp/authorize", body["authorization_endpoint"]) + require.Equal(t, "https://api.example.com/api/v1/mcp/oauth/token", body["token_endpoint"]) + require.Equal(t, "https://api.example.com/api/v1/mcp/oauth/register", body["registration_endpoint"]) + require.Equal(t, "https://api.example.com/api/v1/mcp/oauth/revoke", body["revocation_endpoint"]) + require.Equal(t, []any{"S256"}, body["code_challenge_methods_supported"]) + require.Equal(t, []any{"none"}, body["token_endpoint_auth_methods_supported"]) + } else { + require.Equal(t, handlerMCPResource, body["resource"]) + require.Equal(t, []any{"https://api.example.com"}, body["authorization_servers"]) + require.Equal(t, []any{"header"}, body["bearer_methods_supported"]) + } + } + h = NewMCPOAuthHandler(nil, nil, nil, nil, &config.Config{}) + for _, serve := range []gin.HandlerFunc{h.ResourceMetadata, h.AuthorizationMetadata} { + c, rec := handlerMCPContext(t, http.MethodGet, "https://attacker.example/metadata", "", "") + serve(c) + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Equal(t, "server_error", handlerMCPJSON(t, rec)["error"]) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + } +} + +func TestMCPOAuthHandlerConnectionConfigUsesCanonicalResource(t *testing.T) { + h, repo, _, _ := newHandlerMCPFixture(t) + c, rec := handlerMCPContext(t, http.MethodGet, "https://attacker.example/user/mcp/config", "", "") + c.Request.Header.Set("X-Forwarded-Host", "forwarded-attacker.example") + handlerMCPSubject(c, service.RoleUser) + h.ConnectionConfig(c) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + require.Equal(t, map[string]any{"resource": handlerMCPResource}, handlerMCPJSON(t, rec)["data"]) + require.NotContains(t, rec.Body.String(), "attacker") + require.Zero(t, repo.reads) + require.Zero(t, repo.writes) + + h = NewMCPOAuthHandler(nil, nil, nil, nil, &config.Config{}) + c, rec = handlerMCPContext(t, http.MethodGet, "/user/mcp/config", "", "") + handlerMCPSubject(c, service.RoleUser) + h.ConnectionConfig(c) + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + require.Contains(t, handlerMCPJSON(t, rec), "code") +} + +func TestMCPOAuthHandlerAuthorizationMetadataValidatesFrontendURL(t *testing.T) { + for _, tt := range []struct{ frontend, want string }{ + {"https://platform.example.com/console/", "https://platform.example.com/console/oauth/mcp/authorize"}, + {"http://127.0.0.1:3000/", "http://127.0.0.1:3000/oauth/mcp/authorize"}, + {"", "https://api.example.com/oauth/mcp/authorize"}, + {"http://platform.example.com", ""}, {"https://user:password@platform.example.com", ""}, + {"https://platform.example.com?next=attacker", ""}, {"https://platform.example.com#fragment", ""}, + {"https://platform.example.com?", ""}, {"https://platform.example.com#", ""}, + } { + t.Run(tt.frontend, func(t *testing.T) { + cfg := &config.Config{MCP: config.MCPConfig{PublicURL: handlerMCPResource}} + cfg.Server.FrontendURL = tt.frontend + h := NewMCPOAuthHandler(nil, nil, nil, nil, cfg) + c, rec := handlerMCPContext(t, http.MethodGet, "https://attacker.example/metadata", "", "") + h.AuthorizationMetadata(c) + if tt.want == "" { + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Equal(t, "server_error", handlerMCPJSON(t, rec)["error"]) + } else { + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, tt.want, handlerMCPJSON(t, rec)["authorization_endpoint"]) + } + }) + } +} + +func TestMCPOAuthHandlerJSONValidation(t *testing.T) { + for _, tt := range []struct { + name, contentType, body string + browser, valid bool + }{ + {"valid browser", "application/json; charset=utf-8", `{"approved":false,"authorization":{"client_id":"client-1"}}`, true, true}, + {"unknown browser field", "application/json", `{"approved":false,"unexpected":true}`, true, false}, + {"unknown nested field", "application/json", `{"authorization":{"unexpected":true}}`, true, false}, + {"wrong type", "application/json", `{"approved":"true"}`, true, false}, + {"two documents", "application/json", `{} {}`, true, false}, + {"trailing bytes", "application/json", `{} extra`, true, false}, + {"truncated", "application/json", `{"approved":`, true, false}, + {"wrong content type", "text/plain", `{}`, true, false}, + {"missing content type", "", `{}`, true, false}, + {"oversized browser", "application/json", `{"authorization":{"state":"` + strings.Repeat("x", 17<<10) + `"}}`, true, false}, + {"registration extension metadata", "application/json", `{"client_name":"example","client_uri":"https://client.example.com"}`, false, true}, + {"oversized registration", "application/json", `{"client_name":"` + strings.Repeat("x", 17<<10) + `"}`, false, false}, + {"malformed registration", "application/json", `{`, false, false}, + } { + t.Run(tt.name, func(t *testing.T) { + c, rec := handlerMCPContext(t, http.MethodPost, "/oauth", tt.body, tt.contentType) + var input any = &service.MCPOAuthRegisterInput{} + if tt.browser { + input = &service.MCPOAuthConsentInput{} + } + require.Equal(t, tt.valid, mcpOAuthReadJSON(c, input, tt.browser)) + if !tt.valid { + require.Equal(t, http.StatusBadRequest, rec.Code) + body := handlerMCPJSON(t, rec) + if tt.browser { + require.Contains(t, body, "code") + } else { + require.Equal(t, "invalid_request", body["error"]) + require.NotContains(t, body, "data") + } + } + }) + } +} + +func TestMCPOAuthHandlerFormValidation(t *testing.T) { + for _, tt := range []struct{ name, contentType, body string }{ + {"JSON", "application/json", `{}`}, {"missing content type", "", "client_id=client-1"}, + {"repeated client", "application/x-www-form-urlencoded", "client_id=one&client_id=two"}, + {"repeated code", "application/x-www-form-urlencoded", "code=one&code=two"}, + {"invalid escape", "application/x-www-form-urlencoded", "client_id=%zz"}, + {"oversized", "application/x-www-form-urlencoded", "code=" + strings.Repeat("x", 17<<10)}, + } { + t.Run(tt.name, func(t *testing.T) { + c, rec := handlerMCPContext(t, http.MethodPost, "/oauth/token", tt.body, tt.contentType) + _, ok := mcpOAuthReadForm(c) + require.False(t, ok) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, "invalid_request", handlerMCPJSON(t, rec)["error"]) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + }) + } + c, _ := handlerMCPContext(t, http.MethodPost, "/oauth/token?client_id=attacker&code=query-code", "client_id=client-1&code=body-code", "application/x-www-form-urlencoded; charset=utf-8") + values, ok := mcpOAuthReadForm(c) + require.True(t, ok) + require.Equal(t, url.Values{"client_id": {"client-1"}, "code": {"body-code"}}, values) +} + +func TestMCPOAuthHandlerTokenEndpointsRejectConfidentialAuthentication(t *testing.T) { + for _, endpoint := range []string{"token", "revoke"} { + for _, auth := range []string{"authorization", "client_secret"} { + t.Run(endpoint+"/"+auth, func(t *testing.T) { + h, repo, _, _ := newHandlerMCPFixture(t) + values := url.Values{"client_id": {"client-1"}} + if auth == "client_secret" { + values.Set("client_secret", "not-supported") + } + c, rec := handlerMCPContext(t, http.MethodPost, "/oauth/"+endpoint, values.Encode(), "application/x-www-form-urlencoded") + if auth == "authorization" { + c.Request.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + } + if endpoint == "token" { + h.Token(c) + } else { + h.RevokeToken(c) + } + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, "invalid_client", handlerMCPJSON(t, rec)["error"]) + require.Zero(t, repo.reads) + require.Zero(t, repo.writes) + }) + } + } +} + +func TestMCPOAuthHandlerPreviewFrontendContract(t *testing.T) { + h, repo, _, _ := newHandlerMCPFixture(t) + input := handlerMCPAuthorization() + query := url.Values{"response_type": {input.ResponseType}, "client_id": {input.ClientID}, "redirect_uri": {input.RedirectURI}, "scope": {input.Scope}, "state": {input.State}, "code_challenge": {input.CodeChallenge}, "code_challenge_method": {input.CodeChallengeMethod}, "resource": {input.Resource}, "timezone": {"Asia/Shanghai"}} + c, rec := handlerMCPContext(t, http.MethodGet, "/user/mcp/authorize?"+query.Encode(), "", "") + handlerMCPSubject(c, service.RoleUser) + h.Preview(c) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + body := handlerMCPJSON(t, rec) + require.Equal(t, float64(0), body["code"]) + require.Equal(t, map[string]any{"client_id": "client-1", "client_name": "Example assistant", "redirect_uri": input.RedirectURI, "resource": handlerMCPResource, "scope": "mcp"}, body["data"]) + require.Zero(t, repo.writes) + query.Add("redirect_uri", "https://attacker.example/callback") + c, rec = handlerMCPContext(t, http.MethodGet, "/user/mcp/authorize?"+query.Encode(), "", "") + handlerMCPSubject(c, service.RoleUser) + h.Preview(c) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, 1, repo.reads, "reject duplicate parameters before service access") +} + +func TestMCPOAuthHandlerAdminDecisionGatesBeforePersistence(t *testing.T) { + for _, tt := range []struct { + name, role, authMethod, marker string + totp, granted bool + cacheErr error + status int + }{ + {name: "non-admin", role: service.RoleUser, totp: true, granted: true, status: 403, marker: "Only administrators"}, + {name: "missing role", totp: true, granted: true, status: 403, marker: "Only administrators"}, + {name: "TOTP disabled", role: service.RoleAdmin, status: 403, marker: "STEP_UP_TOTP_NOT_ENABLED"}, + {name: "no recent grant", role: service.RoleAdmin, totp: true, status: 403, marker: "STEP_UP_REQUIRED"}, + {name: "step-up unavailable", role: service.RoleAdmin, totp: true, cacheErr: errors.New("cache unavailable"), status: 503, marker: "STEP_UP_UNAVAILABLE"}, + {name: "admin API key", role: service.RoleAdmin, authMethod: service.AuditAuthMethodAdminAPIKey, totp: true, granted: true, status: 403, marker: "STEP_UP_ADMIN_API_KEY_FORBIDDEN"}, + {name: "MCP credential", role: service.RoleAdmin, authMethod: "mcp_oauth", totp: true, granted: true, status: 403, marker: "STEP_UP_MCP_OAUTH_FORBIDDEN"}, + } { + t.Run(tt.name, func(t *testing.T) { + h, repo, users, cache := newHandlerMCPFixture(t) + users.user.TotpEnabled = tt.totp + cache.granted, cache.err = tt.granted, tt.cacheErr + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: true, Scope: service.MCPScopeAdmin} + c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") + handlerMCPSubject(c, tt.role) + c.Set("auth_method", tt.authMethod) + h.Decide(c) + require.Equal(t, tt.status, rec.Code) + require.Contains(t, rec.Body.String(), tt.marker) + require.Zero(t, repo.reads) + require.Zero(t, repo.writes) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + }) + } +} + +func TestMCPOAuthHandlerAdminApprovalUsesBrowserSession(t *testing.T) { + h, repo, _, cache := newHandlerMCPFixture(t) + cache.granted = true + readOnly := false + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: true, Scope: service.MCPScopeAdmin, ReadOnly: &readOnly} + c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") + handlerMCPSubject(c, service.RoleAdmin) + h.Decide(c) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, int64(7), cache.userID) + require.Equal(t, "browser-session-7", cache.session) + require.Equal(t, 1, repo.writes) + require.Equal(t, service.MCPScopeAdmin, repo.grant.Scope) + require.False(t, repo.grant.ReadOnly) + result := handlerMCPJSON(t, rec)["data"].(map[string]any) + destination, err := url.Parse(result["redirect_url"].(string)) + require.NoError(t, err) + require.Equal(t, "client.example.com", destination.Host) + require.Equal(t, "one", destination.Query().Get("workspace")) + require.Equal(t, input.Authorization.State, destination.Query().Get("state")) + require.NotEmpty(t, destination.Query().Get("code")) + require.Empty(t, rec.Header().Get("Location"), "return a validated URL without an automatic HTTP redirect") +} + +func TestMCPOAuthHandlerDenialSkipsStepUpAndPersistence(t *testing.T) { + h, repo, _, cache := newHandlerMCPFixture(t) + cache.err = errors.New("must not be called") + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: false, Scope: service.MCPScopeAdmin} + c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") + handlerMCPSubject(c, service.RoleUser) + h.Decide(c) + require.Equal(t, http.StatusOK, rec.Code) + require.Zero(t, cache.calls) + require.Zero(t, repo.writes) + result := handlerMCPJSON(t, rec)["data"].(map[string]any) + destination, err := url.Parse(result["redirect_url"].(string)) + require.NoError(t, err) + require.Equal(t, "client.example.com", destination.Host) + require.Equal(t, "access_denied", destination.Query().Get("error")) + require.Equal(t, input.Authorization.State, destination.Query().Get("state")) + require.Empty(t, destination.Query().Get("code")) +} + +func TestMCPOAuthHandlerBrowserEndpointsRequireSession(t *testing.T) { + h, repo, _, _ := newHandlerMCPFixture(t) + for _, serve := range []gin.HandlerFunc{h.ConnectionConfig, h.Preview, h.Decide, h.ListGrants, h.RevokeGrant} { + c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp", `{}`, "application/json") + serve(c) + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + } + require.Zero(t, repo.reads) + require.Zero(t, repo.writes) +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index bdd47f4265f2..86b21a01f9a8 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -183,6 +183,7 @@ func ProvideAdminSettingHandler(settingService *service.SettingService, emailSer func ProvideHandlers( authHandler *AuthHandler, userHandler *UserHandler, + mcpOAuthHandler *MCPOAuthHandler, apiKeyHandler *APIKeyHandler, usageHandler *UsageHandler, redeemHandler *RedeemHandler, @@ -209,6 +210,7 @@ func ProvideHandlers( return &Handlers{ Auth: authHandler, User: userHandler, + MCPOAuth: mcpOAuthHandler, APIKey: apiKeyHandler, Usage: usageHandler, Redeem: redeemHandler, @@ -236,6 +238,7 @@ var ProviderSet = wire.NewSet( // Top-level handlers NewAuthHandler, NewUserHandler, + NewMCPOAuthHandler, NewAPIKeyHandler, NewUsageHandler, NewRedeemHandler, diff --git a/backend/internal/managementmcp/auth.go b/backend/internal/managementmcp/auth.go new file mode 100644 index 000000000000..dbe7240a1ea0 --- /dev/null +++ b/backend/internal/managementmcp/auth.go @@ -0,0 +1,127 @@ +package managementmcp + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const AuthMethod = "mcp_oauth" + +type TokenAuthenticator interface { + Authenticate(context.Context, string) (*service.MCPPrincipal, error) +} + +type requestKey struct{} +type dispatchKey struct{} + +type authenticatedRequest struct { + principal *service.MCPPrincipal + request *http.Request +} + +// Authenticate accepts only resource-bound OAuth access tokens. Browser sessions and +// inference keys never become management credentials through this endpoint. +func Authenticate(tokens TokenAuthenticator, allowedOrigins []string, resourceURL func(context.Context) (string, error)) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + resource, err := resourceURL(c.Request.Context()) + if err != nil { + middleware.AbortWithError(c, http.StatusServiceUnavailable, "MCP_OAUTH_NOT_CONFIGURED", "Configure the public MCP URL to enable OAuth") + return + } + u, err := url.Parse(resource) + if err != nil || u.Host == "" { + middleware.AbortWithError(c, http.StatusServiceUnavailable, "MCP_OAUTH_NOT_CONFIGURED", "MCP public URL is invalid") + return + } + challenge := fmt.Sprintf(`Bearer resource_metadata="%s://%s/.well-known/oauth-protected-resource/api/v1/mcp", scope="mcp"`, u.Scheme, u.Host) + if !originAllowed(c.Request, allowedOrigins) { + middleware.AbortWithError(c, http.StatusForbidden, "MCP_ORIGIN_DENIED", "Origin is not allowed for MCP") + return + } + parts := strings.Fields(c.GetHeader("Authorization")) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.Header("WWW-Authenticate", challenge) + middleware.AbortWithError(c, http.StatusUnauthorized, "INVALID_MCP_TOKEN", "An OAuth access token is required") + return + } + principal, err := tokens.Authenticate(c.Request.Context(), parts[1]) + if err != nil || principal == nil || principal.Resource != resource { + if err != nil && !errors.Is(err, service.ErrMCPOAuthInvalidToken) { + middleware.AbortWithError(c, http.StatusServiceUnavailable, "MCP_AUTH_UNAVAILABLE", "MCP authentication is temporarily unavailable") + return + } + c.Header("WWW-Authenticate", challenge+`, error="invalid_token"`) + middleware.AbortWithError(c, http.StatusUnauthorized, "INVALID_MCP_TOKEN", "MCP OAuth access token is invalid or expired") + return + } + identity := &authenticatedRequest{principal: principal, request: c.Request} + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), requestKey{}, identity)) + setSubject(c, principal) + c.Next() + } +} + +// DispatchAuth is used exclusively by the private in-process management router. +// No HTTP header can supply this context. Public REST auth remains JWT-only. +func DispatchAuth(admin bool) gin.HandlerFunc { + return func(c *gin.Context) { + identity, _ := c.Request.Context().Value(requestKey{}).(*authenticatedRequest) + spec, ok := c.Request.Context().Value(dispatchKey{}).(ToolSpec) + if identity == nil || !ok || spec.Method != c.Request.Method || spec.Path != c.FullPath() || + !toolAllowed(identity.principal, spec) || (admin && identity.principal.Scope != service.MCPScopeAdmin) { + middleware.AbortWithError(c, http.StatusForbidden, "MCP_FORBIDDEN", "MCP operation is not allowed") + return + } + setSubject(c, identity.principal) + middleware.SetAuditExtra(c, map[string]any{"mcp_grant_id": identity.principal.GrantID, "mcp_tool": spec.Name}) + c.Next() + } +} + +func setSubject(c *gin.Context, principal *service.MCPPrincipal) { + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: principal.UserID, Concurrency: principal.Concurrency}) + c.Set(string(middleware.ContextKeyUserRole), principal.Role) + c.Set(middleware.ContextKeyAuthEmail, principal.Email) + c.Set("auth_method", AuthMethod) + // Never share a legacy user's step-up grant with a machine credential. + c.Set(middleware.ContextKeySessionID, "mcp:"+principal.GrantID) +} + +func toolAllowed(p *service.MCPPrincipal, spec ToolSpec) bool { + return p != nil && p.UserID > 0 && + (p.Scope == service.MCPScopeUser || p.Scope == service.MCPScopeAdmin) && + (!spec.Admin || (p.Scope == service.MCPScopeAdmin && p.Role == service.RoleAdmin)) && + (!p.ReadOnly || spec.ReadOnly) +} + +func originAllowed(r *http.Request, origins []string) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // Native MCP clients do not send an Origin. + } + u, err := url.Parse(origin) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || + u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return false + } + // Same-host browser access also works behind an HTTPS terminating proxy. + if strings.EqualFold(u.Host, r.Host) { + return true + } + for _, allowed := range origins { + // Wildcard CORS does not disable MCP's Origin validation. + if allowed != "*" && origin == strings.TrimSpace(allowed) { + return true + } + } + return false +} diff --git a/backend/internal/managementmcp/catalog.go b/backend/internal/managementmcp/catalog.go new file mode 100644 index 000000000000..bd623e59e362 --- /dev/null +++ b/backend/internal/managementmcp/catalog.go @@ -0,0 +1,280 @@ +// Package managementmcp exposes a bounded set of platform management operations. +package managementmcp + +import "strings" + +// ToolSpec binds one MCP tool to an existing authenticated management route. +// InputSchema permits only named path parameters, query parameters and body fields; +// clients never supply a URL, HTTP method or credentials in tool arguments. +type ToolSpec struct { + Name string + Description string + Method string + Path string + Admin bool + ReadOnly bool + Destructive bool + RevealCreatedKey bool + InputSchema map[string]any +} + +// ToolCatalog returns fresh schemas so filtering one connection's tools cannot +// alter another connection's permissions or input validation. +func ToolCatalog() []ToolSpec { + keyList := catalogPage(map[string]any{ + "search": catalogText("Search API key names", 100), + "status": catalogEnum("Key status", "active", "inactive", "quota_exhausted", "expired"), + "group_id": catalogID("Group ID"), + }) + keyCreate := catalogObject(map[string]any{ + "name": catalogNonemptyText("Name for the new API key", 100), + "group_id": catalogID("Accessible group ID; use user_list_groups to find one"), + "quota": catalogNumber("Lifetime spending limit in USD; 0 means unlimited", 0), + "expires_in_days": catalogInteger("Lifetime in days", 1, 36500), + "ip_whitelist": catalogStrings("Allowed IP addresses or CIDR ranges; empty means no allowlist", 100, 128), + "ip_blacklist": catalogStrings("Blocked IP addresses or CIDR ranges", 100, 128), + "rate_limit_5h": catalogNumber("Rolling 5-hour spending limit in USD; 0 means unlimited", 0), + "rate_limit_1d": catalogNumber("Rolling 1-day spending limit in USD; 0 means unlimited", 0), + "rate_limit_7d": catalogNumber("Rolling 7-day spending limit in USD; 0 means unlimited", 0), + }, "name") + keyUpdate := catalogObject(map[string]any{ + "name": catalogNonemptyText("New API key name", 100), + "group_id": catalogID("Accessible destination group ID"), + "status": catalogEnum("Enable or disable this key", "active", "inactive"), + "quota": catalogNumber("Lifetime spending limit in USD; 0 means unlimited", 0), + "expires_at": catalogText("Expiry in RFC3339 format; empty string removes expiry", 64), + "ip_whitelist": catalogStrings("Replace allowed IP addresses/CIDRs; empty clears the list", 100, 128), + "ip_blacklist": catalogStrings("Replace blocked IP addresses/CIDRs; empty clears the list", 100, 128), + "rate_limit_5h": catalogNumber("Rolling 5-hour spending limit in USD; 0 means unlimited", 0), + "rate_limit_1d": catalogNumber("Rolling 1-day spending limit in USD; 0 means unlimited", 0), + "rate_limit_7d": catalogNumber("Rolling 7-day spending limit in USD; 0 means unlimited", 0), + }) + keyUpdate["minProperties"] = 1 + + userUpdate := catalogObject(map[string]any{ + "username": catalogText("Display name", 100), + "notes": catalogText("Administrator notes", 2000), + "concurrency": catalogInteger("Maximum concurrent requests", 1, 100000), + "rpm_limit": catalogInteger("Requests per minute; 0 means unlimited", 0, 1000000), + "status": catalogEnum("User status; disabling blocks access", "active", "disabled"), + "allowed_groups": catalogIDs("Replace the user's allowed group IDs"), + "restrict_public_groups": catalogBool("Restrict public groups to allowed_groups"), + }) + userUpdate["minProperties"] = 1 + + groupCreateFields := catalogGroupFields() + groupCreateFields["platform"] = catalogNonemptyText("Platform identifier, for example anthropic, openai, gemini or composite", 64) + groupCreate := catalogObject(groupCreateFields, "name", "platform", "rate_multiplier") + groupUpdateFields := catalogGroupFields() + groupUpdateFields["status"] = catalogEnum("Group availability", "active", "inactive") + groupUpdate := catalogObject(groupUpdateFields) + groupUpdate["minProperties"] = 1 + + accountUpdate := catalogObject(map[string]any{ + "name": catalogNonemptyText("Upstream account name", 100), + "notes": catalogText("Administrator notes", 2000), + "concurrency": catalogInteger("Maximum concurrent requests", 1, 100000), + "priority": catalogInteger("Scheduling priority; lower values are preferred", 0, 100000), + "rate_multiplier": catalogNumber("Account cost multiplier; 0 means free", 0), + "status": catalogEnum("Account availability", "active", "inactive", "error"), + "expires_at": catalogInteger("Unix expiry timestamp in seconds; 0 removes expiry", 0, 253402300799), + "auto_pause_on_expired": catalogBool("Stop scheduling after expiry"), + }) + accountUpdate["minProperties"] = 1 + + tools := []ToolSpec{ + catalogTool("user_get_profile", "Get your profile, balance and account limits. Always uses the authenticated user.", "GET", "/user/profile", nil, nil), + catalogTool("user_get_platform_quotas", "Get your spending quota and usage windows for each platform.", "GET", "/user/platform-quotas", nil, nil), + catalogTool("user_list_groups", "List the groups available to your API keys, including platform and pricing information.", "GET", "/groups/available", nil, nil), + catalogTool("user_list_channels", "List channels and supported models visible to you. Returns an empty list when channel discovery is disabled.", "GET", "/channels/available", nil, nil), + catalogTool("user_list_api_keys", "List your API key metadata and limits with pagination. Existing key secrets are omitted.", "GET", "/keys", keyList, nil), + catalogTool("user_get_api_key", "Get one of your API keys by ID, including status and limits. The key secret is omitted.", "GET", "/keys/:id", nil, nil), + catalogTool("user_create_api_key", "Create an API key owned by you. Returns the newly created secret once in this tool result; store it securely.", "POST", "/keys", nil, keyCreate), + catalogTool("user_update_api_key", "Update one of your API keys. Only supplied fields change; disabling a key immediately blocks its use. Returns metadata without its secret.", "PUT", "/keys/:id", nil, keyUpdate), + catalogTool("user_delete_api_key", "Permanently delete one of your API keys. Applications using it will lose access. Returns the deletion result.", "DELETE", "/keys/:id", nil, nil), + catalogTool("user_list_usage", "List your request usage, tokens and costs with pagination and optional date, model and key filters.", "GET", "/usage", catalogPage(catalogUsageFields(false)), nil), + catalogTool("user_get_usage_stats", "Get aggregate request counts, token usage and costs for your account; defaults to the last seven days. Dates are inclusive YYYY-MM-DD.", "GET", "/usage/stats", catalogObject(catalogUsageFields(false)), nil), + catalogTool("user_get_dashboard", "Get your dashboard totals, request counts and spending summary.", "GET", "/usage/dashboard/stats", nil, nil), + catalogTool("user_list_subscriptions", "List your subscriptions, associated groups, validity and current usage.", "GET", "/subscriptions", nil, nil), + catalogTool("user_get_subscription_summary", "Get a summary of your active subscription limits and remaining allowance.", "GET", "/subscriptions/summary", nil, nil), + + catalogTool("admin_get_dashboard", "Get platform-wide user/account counts, usage, spending and service uptime.", "GET", "/admin/dashboard/stats", nil, nil), + catalogTool("admin_list_users", "List platform users and account limits with pagination. May include contact information and administrator notes.", "GET", "/admin/users", catalogPage(map[string]any{ + "search": catalogText("Search email or display name", 100), + "status": catalogEnum("User status", "active", "disabled"), + "role": catalogEnum("User role", "admin", "user"), + "group_name": catalogText("Search allowed group name", 100), + }), nil), + catalogTool("admin_get_user", "Get a platform user's profile, balance and limits by ID. Existing API key secrets are omitted.", "GET", "/admin/users/:id", nil, nil), + catalogTool("admin_update_user", "Update a user's display name, notes, limits, status or group access. Does not change passwords, roles or identity bindings. Returns the updated user.", "PUT", "/admin/users/:id", nil, userUpdate), + catalogTool("admin_update_user_balance", "Set, add to or subtract from a user's balance in USD. This changes real billing credit; verify the user, amount and operation before calling. Returns the updated result.", "POST", "/admin/users/:id/balance", nil, catalogObject(map[string]any{ + "balance": map[string]any{"type": "number", "exclusiveMinimum": 0, "description": "Positive amount in USD"}, + "operation": catalogEnum("How to apply the amount", "set", "add", "subtract"), + "notes": catalogText("Reason for the balance adjustment", 2000), + }, "balance", "operation", "notes")), + catalogTool("admin_list_groups", "List platform groups, pricing multipliers and status with pagination.", "GET", "/admin/groups", catalogPage(map[string]any{ + "search": catalogText("Search group name", 100), "platform": catalogText("Platform identifier", 64), + "status": catalogEnum("Group status", "active", "inactive"), "is_exclusive": catalogBool("Filter exclusive groups"), + }), nil), + catalogTool("admin_get_group", "Get one group's configuration and model allowlist by ID.", "GET", "/admin/groups/:id", nil, nil), + catalogTool("admin_create_group", "Create a platform group with explicit platform and price multiplier. Returns the group; attach upstream accounts in the web console before serving traffic.", "POST", "/admin/groups", nil, groupCreate), + catalogTool("admin_update_group", "Update group name, pricing, spending limits, availability or model allowlist. Changes affect every user of this group. Only supplied fields change.", "PUT", "/admin/groups/:id", nil, groupUpdate), + catalogTool("admin_delete_group", "Delete a platform group. This can disrupt API keys and subscriptions associated with it. Returns the deletion result or a dependency error.", "DELETE", "/admin/groups/:id", nil, nil), + catalogTool("admin_list_accounts", "List upstream account metadata, status and scheduling settings with pagination. Credentials are omitted.", "GET", "/admin/accounts", catalogPage(map[string]any{ + "search": catalogText("Search account name", 100), "platform": catalogText("Platform identifier", 64), + "status": catalogEnum("Account status", "active", "inactive", "error", "rate_limited", "temp_unschedulable", "unschedulable"), + "type": catalogEnum("Upstream authentication type", "oauth", "setup-token", "apikey", "upstream", "bedrock", "service_account"), + "group": catalogID("Group ID"), "lite": catalogBool("Request a lightweight account list"), + }), nil), + catalogTool("admin_get_account", "Get one upstream account's metadata and runtime status. Credentials are omitted.", "GET", "/admin/accounts/:id", nil, nil), + catalogTool("admin_update_account", "Update upstream account name, notes, concurrency, priority, cost multiplier, status or expiry. Does not accept credentials or network settings.", "PUT", "/admin/accounts/:id", nil, accountUpdate), + catalogTool("admin_set_account_schedulable", "Enable or disable scheduling for an upstream account. Disabling prevents selection for new requests. Returns updated account metadata.", "POST", "/admin/accounts/:id/schedulable", nil, catalogObject(map[string]any{ + "schedulable": catalogBool("Whether the scheduler may select this account"), + }, "schedulable")), + catalogTool("admin_clear_account_error", "Clear an upstream account's error state so it can recover. Investigate and fix the cause first. Returns the recovery result.", "POST", "/admin/accounts/:id/clear-error", nil, nil), + catalogTool("admin_list_usage", "List platform-wide request usage, tokens and costs with pagination. Filter by user, account, group, model or inclusive date range.", "GET", "/admin/usage", catalogPage(catalogUsageFields(true)), nil), + catalogTool("admin_get_usage_stats", "Get platform-wide aggregate usage and costs; defaults to today. Both start_date and end_date should be supplied for a custom range.", "GET", "/admin/usage/stats", catalogObject(catalogUsageFields(true)), nil), + catalogTool("admin_list_subscriptions", "List platform subscriptions and their user/group associations, expiry and quota usage with pagination.", "GET", "/admin/subscriptions", catalogPage(map[string]any{ + "user_id": catalogID("User ID"), "group_id": catalogID("Group ID"), + "status": catalogEnum("Subscription status", "active", "expired", "revoked"), "platform": catalogText("Platform identifier", 64), + }), nil), + catalogTool("admin_get_subscription", "Get one platform subscription and its user/group association by ID.", "GET", "/admin/subscriptions/:id", nil, nil), + catalogTool("admin_assign_subscription", "Assign a subscription group to a user for the specified number of days. An existing subscription may be extended; do not retry blindly. Returns the resulting subscription.", "POST", "/admin/subscriptions/assign", nil, catalogObject(map[string]any{ + "user_id": catalogID("Recipient user ID"), "group_id": catalogID("Subscription group ID"), + "validity_days": catalogInteger("Validity in days", 1, 36500), "notes": catalogText("Assignment notes", 2000), + }, "user_id", "group_id", "validity_days")), + catalogTool("admin_extend_subscription", "Extend a subscription by the specified positive number of days. Changes billing entitlement; returns the updated subscription.", "POST", "/admin/subscriptions/:id/extend", nil, catalogObject(map[string]any{ + "days": catalogInteger("Additional validity in days", 1, 36500), + }, "days")), + catalogTool("admin_revoke_subscription", "Revoke a subscription immediately. The user loses the associated entitlement. Returns the revocation result.", "POST", "/admin/subscriptions/:id/revoke", nil, nil), + catalogTool("admin_get_ops_overview", "Get platform operational health, traffic, latency and error overview for troubleshooting.", "GET", "/admin/ops/dashboard/overview", nil, nil), + } + for i := range tools { + if tools[i].Name == "user_create_api_key" { + tools[i].RevealCreatedKey = true + } + // Creation does not remove or overwrite existing state. Subscription + // assignment is deliberately excluded because it may extend an existing one. + if tools[i].Name == "user_create_api_key" || tools[i].Name == "admin_create_group" { + tools[i].Destructive = false + } + } + return tools +} + +func catalogTool(name, description, method, path string, query, body map[string]any) ToolSpec { + properties := map[string]any{} + required := []string{} + if strings.Contains(path, ":id") { + properties["id"] = catalogID("Resource ID") + required = append(required, "id") + } + if query != nil { + properties["query"] = query + } + if body != nil { + properties["body"] = body + required = append(required, "body") + } + // These existing routes require an idempotency key. Keep retries within + // their established coordinator rather than inventing MCP-side settlement. + switch name { + case "user_create_api_key", "admin_update_user_balance", "admin_extend_subscription": + properties["idempotency_key"] = map[string]any{ + "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[!-~]+$", + "description": "Unique operation ID (for example a UUID). Reuse the same value and arguments when retrying this operation; use a new value for a new intended operation.", + } + required = append(required, "idempotency_key") + } + return ToolSpec{ + Name: name, Description: description, Method: method, Path: "/api/v1" + path, + Admin: strings.HasPrefix(path, "/admin/"), ReadOnly: method == "GET", + Destructive: method != "GET", InputSchema: catalogObject(properties, required...), + } +} + +func catalogObject(properties map[string]any, required ...string) map[string]any { + schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false} + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func catalogText(description string, maxLength int) map[string]any { + return map[string]any{"type": "string", "description": description, "maxLength": maxLength} +} + +func catalogNonemptyText(description string, maxLength int) map[string]any { + schema := catalogText(description, maxLength) + schema["minLength"] = 1 + return schema +} + +func catalogEnum(description string, values ...string) map[string]any { + return map[string]any{"type": "string", "description": description, "enum": values} +} + +func catalogInteger(description string, min, max int64) map[string]any { + return map[string]any{"type": "integer", "description": description, "minimum": min, "maximum": max} +} + +func catalogID(description string) map[string]any { + return catalogInteger(description, 1, 9007199254740991) +} + +func catalogNumber(description string, min float64) map[string]any { + return map[string]any{"type": "number", "description": description, "minimum": min} +} + +func catalogBool(description string) map[string]any { + return map[string]any{"type": "boolean", "description": description} +} + +func catalogStrings(description string, maxItems, maxLength int) map[string]any { + return map[string]any{"type": "array", "description": description, "items": catalogNonemptyText("Value", maxLength), "maxItems": maxItems} +} + +func catalogIDs(description string) map[string]any { + return map[string]any{"type": "array", "description": description, "items": catalogID("Resource ID"), "maxItems": 100, "uniqueItems": true} +} + +func catalogPage(fields map[string]any) map[string]any { + fields["page"] = catalogInteger("Page number, starting at 1", 1, 1000000) + fields["page_size"] = catalogInteger("Results per page; at most 100", 1, 100) + return catalogObject(fields) +} + +func catalogUsageFields(admin bool) map[string]any { + fields := map[string]any{ + "api_key_id": catalogID("Filter by API key ID"), "group_id": catalogID("Filter by group ID"), + "model": catalogText("Filter by requested model", 200), + "start_date": map[string]any{"type": "string", "description": "Inclusive start date (YYYY-MM-DD)", "pattern": `^\d{4}-\d{2}-\d{2}$`}, + "end_date": map[string]any{"type": "string", "description": "Inclusive end date (YYYY-MM-DD)", "pattern": `^\d{4}-\d{2}-\d{2}$`}, + "timezone": catalogText("IANA time zone, for example Asia/Shanghai or UTC", 100), + "period": catalogEnum("Stats date range when explicit dates are omitted", "today", "week", "month"), + "stream": catalogBool("Filter streaming or non-streaming requests"), + } + if admin { + fields["user_id"] = catalogID("Filter by user ID") + fields["account_id"] = catalogID("Filter by upstream account ID") + } + return fields +} + +func catalogGroupFields() map[string]any { + return map[string]any{ + "name": catalogNonemptyText("Group name", 100), "description": catalogText("Group description", 2000), + "rate_multiplier": map[string]any{"type": "number", "exclusiveMinimum": 0, "description": "Positive user billing multiplier; 1 is standard pricing"}, + "is_exclusive": catalogBool("Limit access to users explicitly assigned this group"), + "subscription_type": catalogEnum("Group billing mode", "standard", "subscription"), + "daily_limit_usd": catalogNumber("Daily subscription spending limit in USD; -1 means unlimited", -1), + "weekly_limit_usd": catalogNumber("Weekly subscription spending limit in USD; -1 means unlimited", -1), + "monthly_limit_usd": catalogNumber("Monthly subscription spending limit in USD; -1 means unlimited", -1), + "rpm_limit": catalogInteger("Requests per minute; 0 means unlimited", 0, 1000000), + "model_allowlist": catalogObject(map[string]any{ + "enabled": catalogBool("Enable the model allowlist; an enabled list must contain at least one model"), + "models": catalogStrings("Allowed upstream model IDs or patterns containing *", 1000, 200), + }, "enabled", "models"), + } +} diff --git a/backend/internal/managementmcp/catalog_test.go b/backend/internal/managementmcp/catalog_test.go new file mode 100644 index 000000000000..010166e90129 --- /dev/null +++ b/backend/internal/managementmcp/catalog_test.go @@ -0,0 +1,168 @@ +package managementmcp + +import ( + "encoding/json" + "regexp" + "slices" + "strings" + "testing" +) + +func TestToolCatalogRouteAndSchemaBoundaries(t *testing.T) { + names := map[string]bool{} + for _, spec := range ToolCatalog() { + t.Run(spec.Name, func(t *testing.T) { + if names[spec.Name] || !regexp.MustCompile(`^(user|admin)_[a-z0-9_]+$`).MatchString(spec.Name) { + t.Fatalf("duplicate or invalid tool name: %q", spec.Name) + } + names[spec.Name] = true + if spec.Description == "" || !slices.Contains([]string{"GET", "POST", "PUT", "DELETE"}, spec.Method) { + t.Fatal("tool must have a description and supported method") + } + if !strings.HasPrefix(spec.Path, "/api/v1/") || strings.ContainsAny(spec.Path, "?*#") || strings.Contains(spec.Path, "..") { + t.Fatalf("route must be a fixed local API path: %q", spec.Path) + } + if spec.Admin != strings.HasPrefix(spec.Path, "/api/v1/admin/") || spec.Admin != strings.HasPrefix(spec.Name, "admin_") { + t.Fatal("tool role does not match its route") + } + if spec.ReadOnly != (spec.Method == "GET") || (spec.ReadOnly && spec.Destructive) { + t.Fatal("tool mutability metadata does not match its route") + } + checkCatalogSchema(t, spec.InputSchema) + properties, ok := spec.InputSchema["properties"].(map[string]any) + if !ok { + t.Fatal("input schema properties must be an object") + } + required, _ := spec.InputSchema["required"].([]string) + for name := range properties { + if !slices.Contains([]string{"id", "query", "body", "idempotency_key"}, name) { + t.Fatalf("unexpected top-level field %q", name) + } + } + pathParameters := regexp.MustCompile(`:([a-z_]+)`).FindAllStringSubmatch(spec.Path, -1) + for _, match := range pathParameters { + if match[1] != "id" || !slices.Contains(required, match[1]) { + t.Fatalf("path parameter %q must be a required ID", match[1]) + } + } + if _, exists := properties["id"]; exists != (len(pathParameters) > 0) { + t.Fatal("ID input and route placeholder disagree") + } + if body, exists := properties["body"]; exists { + bodySchema, ok := body.(map[string]any) + if !ok || spec.ReadOnly || !slices.Contains(required, "body") || bodySchema["type"] != "object" { + t.Fatal("write bodies must be required objects") + } + } + if query, exists := properties["query"].(map[string]any); exists { + queryFields, ok := query["properties"].(map[string]any) + if !ok { + t.Fatal("query schema properties must be an object") + } + if size, exists := queryFields["page_size"].(map[string]any); exists && size["maximum"] != int64(100) { + t.Fatal("list page size must be bounded to 100") + } + if !spec.Admin { + for _, name := range []string{"user_id", "account_id"} { + if _, exists := queryFields[name]; exists { + t.Fatalf("user tool accepts cross-user filter %q", name) + } + } + } + } + if _, err := json.Marshal(spec.InputSchema); err != nil { + t.Fatalf("schema is not JSON: %v", err) + } + }) + } +} + +func checkCatalogSchema(t *testing.T, schema map[string]any) { + t.Helper() + switch schema["type"] { + case "object": + if schema["additionalProperties"] != false { + t.Fatal("all objects must reject unknown fields") + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("object lacks explicit properties") + } + for name, field := range properties { + child, ok := field.(map[string]any) + if !ok { + t.Fatalf("field %q lacks a typed schema", name) + } + checkCatalogSchema(t, child) + } + if required, ok := schema["required"].([]string); ok { + for _, name := range required { + if _, exists := properties[name]; !exists { + t.Fatalf("required field %q is not declared", name) + } + } + } + case "array": + child, ok := schema["items"].(map[string]any) + if !ok || schema["maxItems"] == nil { + t.Fatal("arrays must have typed items and size limits") + } + checkCatalogSchema(t, child) + case "integer", "number", "string", "boolean": + default: + t.Fatalf("unsupported or missing schema type: %v", schema["type"]) + } +} + +func TestToolCatalogDoesNotExposeCredentialOrIdentityManagement(t *testing.T) { + for _, spec := range ToolCatalog() { + if spec.RevealCreatedKey != (spec.Name == "user_create_api_key") { + t.Fatalf("unexpected secret disclosure policy for %s", spec.Name) + } + for _, forbidden := range []string{"admin-api-key", "/auth", "/password", "/totp", "/passkeys", "/payment", "/backup", "/data"} { + if strings.Contains(spec.Path, forbidden) { + t.Fatalf("%s exposes sensitive route %s", spec.Name, spec.Path) + } + } + properties := catalogTestObject(t, spec.InputSchema["properties"]) + if body, ok := properties["body"].(map[string]any); ok { + fields := catalogTestObject(t, body["properties"]) + for _, forbidden := range []string{"credentials", "extra", "password", "role", "email", "custom_key", "proxy_id", "url"} { + if _, exists := fields[forbidden]; exists { + t.Fatalf("%s accepts sensitive field %s", spec.Name, forbidden) + } + } + } + } +} + +func TestToolCatalogSchemasAreIndependent(t *testing.T) { + first := ToolCatalog() + first[0].InputSchema["additionalProperties"] = true + for _, spec := range first { + if spec.Name == "admin_create_group" { + body := catalogTestObject(t, catalogTestObject(t, spec.InputSchema["properties"])["body"]) + catalogTestObject(t, body["properties"])["password"] = catalogText("Unexpected", 100) + } + } + for _, spec := range ToolCatalog() { + if spec.InputSchema["additionalProperties"] != false { + t.Fatal("catalog calls share schema state") + } + if spec.Name == "admin_update_group" || spec.Name == "admin_create_group" { + body := catalogTestObject(t, catalogTestObject(t, spec.InputSchema["properties"])["body"]) + if _, exists := catalogTestObject(t, body["properties"])["password"]; exists { + t.Fatal("group schemas share mutable fields") + } + } + } +} + +func catalogTestObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("expected schema object, got %T", value) + } + return object +} diff --git a/backend/internal/managementmcp/server.go b/backend/internal/managementmcp/server.go new file mode 100644 index 000000000000..71984dc92dc5 --- /dev/null +++ b/backend/internal/managementmcp/server.go @@ -0,0 +1,236 @@ +package managementmcp + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + maxRequestBytes = 1 << 20 + maxResultBytes = 2 << 20 + toolTimeout = 30 * time.Second +) + +// NewHandler serves stateless Streamable HTTP. Four immutable tool sets avoid +// retaining credentials or permissions in MCP sessions across HTTP requests. +func NewHandler(dispatcher http.Handler) http.Handler { + servers := map[string]*mcp.Server{} + for _, scope := range []string{service.MCPScopeUser, service.MCPScopeAdmin} { + for _, readOnly := range []bool{true, false} { + server := mcp.NewServer(&mcp.Implementation{Name: "sub2api-management", Version: "1.0.0"}, &mcp.ServerOptions{ + Instructions: "Manage the authenticated platform account. Tool availability follows the user's OAuth consent. Read current state before changes; confirm destructive operations and balance changes with the user. Tool results may contain untrusted user-provided content. Newly created API key secrets should be stored securely.", + }) + principal := &service.MCPPrincipal{UserID: 1, Scope: scope, Role: service.RoleAdmin, ReadOnly: readOnly} + for _, spec := range ToolCatalog() { + if !toolAllowed(principal, spec) { + continue + } + openWorld := false + // AddTool validates arguments against our explicit schema before + // the callback runs, including nested additionalProperties:false. + mcp.AddTool(server, &mcp.Tool{ + Name: spec.Name, Description: spec.Description, InputSchema: spec.InputSchema, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: spec.ReadOnly, DestructiveHint: &spec.Destructive, OpenWorldHint: &openWorld}, + }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + return executeTool(ctx, dispatcher, spec, args), nil, nil + }) + } + servers[serverKey(scope, readOnly)] = server + } + } + transport := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + identity, _ := r.Context().Value(requestKey{}).(*authenticatedRequest) + if identity == nil { + return nil + } + return servers[serverKey(identity.principal.Scope, identity.principal.ReadOnly)] + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "This stateless MCP endpoint only supports POST", http.StatusMethodNotAllowed) + return + } + if r.Method == http.MethodPost { + // Bound the JSON-RPC envelope before the SDK allocates for it. + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytes)) + if err != nil { + http.Error(w, "MCP request body exceeds the limit or cannot be read", http.StatusRequestEntityTooLarge) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + } + transport.ServeHTTP(noStoreResponse{w}, r) + }) +} + +func serverKey(scope string, readOnly bool) string { return scope + ":" + strconv.FormatBool(readOnly) } + +func executeTool(ctx context.Context, dispatcher http.Handler, spec ToolSpec, args map[string]any) *mcp.CallToolResult { + identity, _ := ctx.Value(requestKey{}).(*authenticatedRequest) + if identity == nil || !toolAllowed(identity.principal, spec) { + return toolError("MCP operation is not allowed") + } + // SDK transports may detach the handler context. Explicitly retain HTTP + // disconnect cancellation and bound database work for each invocation. + ctx, cancel := context.WithTimeout(ctx, toolTimeout) + defer cancel() + stop := context.AfterFunc(identity.request.Context(), cancel) + defer stop() + if identity.request.Context().Err() != nil { + return toolError("MCP request was cancelled") + } + path := spec.Path + if strings.Contains(path, ":id") { + id, ok := args["id"].(float64) + if !ok || id < 1 || id > 9007199254740991 || id != float64(int64(id)) { + return toolError("id must be a positive safe integer") + } + path = strings.ReplaceAll(path, ":id", strconv.FormatInt(int64(id), 10)) + } + query := url.Values{} + if values, ok := args["query"].(map[string]any); ok { + for key, value := range values { + if number, ok := value.(float64); ok { + // REST filters use ParseInt; %v switches large IDs to exponent notation. + query.Set(key, strconv.FormatFloat(number, 'f', -1, 64)) + } else { + query.Set(key, fmt.Sprint(value)) + } + } + } + var body []byte + if value, exists := args["body"]; exists { + var err error + body, err = json.Marshal(value) + if err != nil { + return toolError("Cannot encode tool arguments") + } + } + ctx = context.WithValue(ctx, dispatchKey{}, spec) + req := identity.request.Clone(ctx) + req.Method = spec.Method + req.URL = &url.URL{Path: path, RawQuery: query.Encode()} + req.RequestURI = req.URL.RequestURI() + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + // No cookies, browser JWTs, forwarded headers or other credentials enter + // the private router. The outer security IP snapshot remains in context. + req.Header = http.Header{"Content-Type": {"application/json"}, "User-Agent": {identity.request.UserAgent()}} + if key, ok := args["idempotency_key"].(string); ok { + // Scope retries to this credential so another integration cannot replay + // a previous client's cached creation result by guessing its operation ID. + hash := sha256.Sum256([]byte(identity.principal.GrantID + ":" + key)) + req.Header.Set("Idempotency-Key", fmt.Sprintf("mcp-%x", hash)) + } + response := &boundedResponse{header: make(http.Header)} + dispatcher.ServeHTTP(response, req) + if response.overflow { + return toolError("Result exceeds 2 MiB; narrow the query or reduce page_size") + } + if ctx.Err() != nil { + return toolError("MCP operation was cancelled or timed out; check current state before retrying a write") + } + var result any + decoder := json.NewDecoder(bytes.NewReader(response.body.Bytes())) + decoder.UseNumber() + if decoder.Decode(&result) != nil { + return toolError(fmt.Sprintf("Management API returned HTTP %d without a JSON result", response.status)) + } + redactResult(result, spec.RevealCreatedKey && response.status >= 200 && response.status < 300, "") + out := map[string]any{"http_status": response.status, "result": result} + encoded, err := json.Marshal(out) + if err != nil { + return toolError("Cannot encode management result") + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(encoded)}}, StructuredContent: out, + IsError: response.status < 200 || response.status >= 300, + } +} + +func toolError(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: message}}} +} + +// The SDK sets no-cache for transport frames; management results may contain a +// newly created key, so force no-store at the actual response boundary. +type noStoreResponse struct{ http.ResponseWriter } + +func (w noStoreResponse) WriteHeader(status int) { + w.Header().Set("Cache-Control", "no-store") + w.ResponseWriter.WriteHeader(status) +} +func (w noStoreResponse) Write(p []byte) (int, error) { + w.Header().Set("Cache-Control", "no-store") + return w.ResponseWriter.Write(p) +} +func (w noStoreResponse) Flush() { + w.Header().Set("Cache-Control", "no-store") + _ = http.NewResponseController(w.ResponseWriter).Flush() +} +func (w noStoreResponse) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func redactResult(value any, revealCreatedKey bool, path string) { + switch v := value.(type) { + case map[string]any: + for key, child := range v { + childPath := path + "/" + key + switch strings.ToLower(key) { + case "key": + if !revealCreatedKey || childPath != "/data/key" { + delete(v, key) + continue + } + case "credentials", "password", "password_hash", "api_key", "access_token", "refresh_token", "id_token", "session_token", "session_key", "client_secret", "secret", "totp_secret", "totp_secret_encrypted": + delete(v, key) + continue + } + redactResult(child, revealCreatedKey, childPath) + } + case []any: + for _, child := range v { + redactResult(child, false, path+"/[]") + } + } +} + +// boundedResponse captures only a bounded JSON response from the in-process +// router, never a network hop. Writes beyond the cap are discarded. +type boundedResponse struct { + header http.Header + status int + body bytes.Buffer + overflow bool +} + +func (w *boundedResponse) Header() http.Header { return w.header } +func (w *boundedResponse) WriteHeader(status int) { + if w.status == 0 { + w.status = status + } +} +func (w *boundedResponse) Write(p []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + if w.body.Len()+len(p) > maxResultBytes { + w.overflow = true + } else if !w.overflow { + _, _ = w.body.Write(p) + } + return len(p), nil +} diff --git a/backend/internal/managementmcp/server_test.go b/backend/internal/managementmcp/server_test.go new file mode 100644 index 000000000000..bfe1723df0fa --- /dev/null +++ b/backend/internal/managementmcp/server_test.go @@ -0,0 +1,325 @@ +package managementmcp + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type testTokenAuth struct { + mu sync.Mutex + principals map[string]*service.MCPPrincipal +} + +func (a *testTokenAuth) Authenticate(_ context.Context, token string) (*service.MCPPrincipal, error) { + a.mu.Lock() + defer a.mu.Unlock() + if p := a.principals[token]; p != nil { + copy := *p + if copy.Resource == "" { + copy.Resource = "https://platform.example/api/v1/mcp" + } + return ©, nil + } + return nil, service.ErrMCPOAuthInvalidToken +} + +func newTestEndpoint(dispatcher http.Handler) (*gin.Engine, *testTokenAuth) { + gin.SetMode(gin.TestMode) + auth := &testTokenAuth{principals: map[string]*service.MCPPrincipal{ + "reader": {UserID: 1, GrantID: "one", Role: service.RoleUser, Scope: service.MCPScopeUser, ReadOnly: true}, + "writer": {UserID: 1, GrantID: "two", Role: service.RoleUser, Scope: service.MCPScopeUser}, + "other": {UserID: 2, GrantID: "three", Role: service.RoleUser, Scope: service.MCPScopeUser}, + "admin": {UserID: 3, GrantID: "four", Role: service.RoleAdmin, Scope: service.MCPScopeAdmin}, + "admin-reader": {UserID: 3, GrantID: "five", Role: service.RoleAdmin, Scope: service.MCPScopeAdmin, ReadOnly: true}, + }} + r := gin.New() + r.Any("/api/v1/mcp", Authenticate(auth, []string{"https://console.example"}, func(context.Context) (string, error) { + return "https://platform.example/api/v1/mcp", nil + }), gin.WrapH(NewHandler(dispatcher))) + return r, auth +} + +func rpc(t *testing.T, handler http.Handler, token, method string, params any) (int, map[string]any) { + t.Helper() + body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + require.NoError(t, err) + req := httptest.NewRequest("POST", "http://platform.example/api/v1/mcp", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", "2025-06-18") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + var result map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result), w.Body.String()) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + return w.Code, result +} + +func rpcObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + require.True(t, ok, "expected JSON object, got %T", value) + return object +} + +func toolNames(t *testing.T, result map[string]any) []string { + t.Helper() + var names []string + tools, ok := rpcObject(t, result["result"])["tools"].([]any) + require.True(t, ok) + for _, tool := range tools { + name, ok := rpcObject(t, tool)["name"].(string) + require.True(t, ok) + names = append(names, name) + } + return names +} + +func TestMCPInitializeAndPermissionFilteredDiscovery(t *testing.T) { + r, _ := newTestEndpoint(http.NotFoundHandler()) + status, initialized := rpc(t, r, "reader", "initialize", map[string]any{ + "protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "test", "version": "1"}, + }) + require.Equal(t, 200, status) + require.Equal(t, "sub2api-management", rpcObject(t, rpcObject(t, initialized["result"])["serverInfo"])["name"]) + for _, tc := range []struct { + token string + hasAdmin, hasWrite bool + }{ + {"reader", false, false}, {"writer", false, true}, {"admin", true, true}, {"admin-reader", true, false}, + } { + t.Run(tc.token, func(t *testing.T) { + status, listed := rpc(t, r, tc.token, "tools/list", map[string]any{}) + require.Equal(t, 200, status) + names := toolNames(t, listed) + // Verify the permission boundary, not an exact catalogue size. + require.Contains(t, names, "user_get_profile") + if tc.hasAdmin { + require.Contains(t, names, "admin_get_dashboard") + } else { + require.NotContains(t, names, "admin_get_dashboard") + } + if tc.hasWrite { + require.Contains(t, names, "user_create_api_key") + } else { + require.NotContains(t, names, "user_create_api_key") + } + }) + } +} + +func TestMCPRejectsUnauthorizedToolsAndUnknownArgumentsBeforeDispatch(t *testing.T) { + calls := 0 + r, _ := newTestEndpoint(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })) + for _, tc := range []struct { + token, name string + args any + }{ + {"reader", "user_create_api_key", map[string]any{"body": map[string]any{"name": "test"}}}, + {"writer", "admin_list_users", map[string]any{}}, + {"writer", "user_get_api_key", map[string]any{"id": "../admin/users"}}, + {"writer", "user_get_api_key", map[string]any{"id": -1}}, + {"writer", "user_list_api_keys", map[string]any{"query": map[string]any{"user_id": 2}}}, + {"writer", "user_list_api_keys", map[string]any{"query": map[string]any{"page_size": 101}}}, + {"writer", "user_create_api_key", map[string]any{"body": map[string]any{"name": "test", "user_id": 2}}}, + {"admin", "admin_update_user", map[string]any{"id": 2, "body": map[string]any{"role": "admin"}}}, + {"admin", "admin_update_account", map[string]any{"id": 2, "body": map[string]any{"credentials": map[string]any{"api_key": "x"}}}}, + } { + _, result := rpc(t, r, tc.token, "tools/call", map[string]any{"name": tc.name, "arguments": tc.args}) + require.Contains(t, result, "error", tc.name) + } + require.Zero(t, calls) +} + +func TestMCPDispatchPreservesOwnershipAndRedactsExistingSecrets(t *testing.T) { + inner := gin.New() + inner.GET("/api/v1/keys/:id", DispatchAuth(false), func(c *gin.Context) { + subject, ok := middleware.GetAuthSubjectFromContext(c) + require.True(t, ok) + require.Empty(t, c.GetHeader("Authorization")) + require.Equal(t, AuthMethod, c.GetString("auth_method")) + if subject.UserID != 1 { + c.JSON(403, gin.H{"message": "Key belongs to another user"}) + return + } + c.JSON(200, gin.H{"data": gin.H{"id": 7, "name": "key name", "key": "existing-secret", "usage": gin.H{"access_token": "nested-secret"}}}) + }) + inner.POST("/api/v1/keys", DispatchAuth(false), func(c *gin.Context) { + c.JSON(201, gin.H{"data": gin.H{"id": 8, "key": "new-secret", "group": gin.H{"key": "unrelated-secret"}}}) + }) + r, auth := newTestEndpoint(inner) + for _, token := range []string{"writer", "other"} { + _, result := rpc(t, r, token, "tools/call", map[string]any{"name": "user_get_api_key", "arguments": map[string]any{"id": 7}}) + encoded, _ := json.Marshal(result) + require.NotContains(t, string(encoded), "existing-secret") + require.NotContains(t, string(encoded), "nested-secret") + if token == "other" { + require.Equal(t, true, rpcObject(t, result["result"])["isError"]) + } else { + require.Contains(t, string(encoded), "key name") + } + } + _, created := rpc(t, r, "writer", "tools/call", map[string]any{"name": "user_create_api_key", "arguments": map[string]any{"idempotency_key": "create-test-1", "body": map[string]any{"name": "created"}}}) + encoded, _ := json.Marshal(created) + require.Contains(t, string(encoded), "new-secret") + require.NotContains(t, string(encoded), "unrelated-secret") + // Tokens are verified on every HTTP request, including after initialization. + auth.mu.Lock() + delete(auth.principals, "writer") + auth.mu.Unlock() + status, _ := rpc(t, r, "writer", "tools/list", map[string]any{}) + require.Equal(t, 401, status) +} + +func TestMCPDispatchAuthCannotBeForgedOrUsedForAnotherRoute(t *testing.T) { + inner := gin.New() + inner.GET("/api/v1/user/mcp/grants", DispatchAuth(false), func(c *gin.Context) { t.Error("unexpected credential access") }) + req := httptest.NewRequest("GET", "/api/v1/user/mcp/grants", nil) + req.Header.Set("Authorization", "Bearer writer") + w := httptest.NewRecorder() + inner.ServeHTTP(w, req) + require.Equal(t, 403, w.Code) + identity := &authenticatedRequest{principal: &service.MCPPrincipal{UserID: 1, Scope: "user"}, request: req} + ctx := context.WithValue(req.Context(), requestKey{}, identity) + ctx = context.WithValue(ctx, dispatchKey{}, ToolSpec{Path: "/api/v1/user/profile", Method: "GET", ReadOnly: true}) + w = httptest.NewRecorder() + inner.ServeHTTP(w, req.WithContext(ctx)) + require.Equal(t, 403, w.Code) +} + +func TestMCPPreservesLargeIntegerQueryFilters(t *testing.T) { + var observed string + r, _ := newTestEndpoint(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observed = r.URL.Query().Get("group_id") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + _, result := rpc(t, r, "writer", "tools/call", map[string]any{"name": "user_list_api_keys", "arguments": map[string]any{"query": map[string]any{"group_id": 12345678}}}) + require.NotContains(t, result, "error") + require.Equal(t, "12345678", observed) +} + +func TestMCPAccountStatusFiltersMatchManagementAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + var observed []string + inner := gin.New() + inner.GET("/api/v1/admin/accounts", DispatchAuth(true), func(c *gin.Context) { + observed = append(observed, c.Query("status")) + c.JSON(http.StatusOK, gin.H{"data": []any{}}) + }) + r, _ := newTestEndpoint(inner) + for _, status := range []string{"active", "inactive", "error", "rate_limited", "temp_unschedulable", "unschedulable"} { + t.Run(status, func(t *testing.T) { + before := len(observed) + _, result := rpc(t, r, "admin-reader", "tools/call", map[string]any{ + "name": "admin_list_accounts", "arguments": map[string]any{"query": map[string]any{"status": status}}, + }) + require.NotContains(t, result, "error") + require.Len(t, observed, before+1) + require.Equal(t, status, observed[before]) + }) + } + t.Run("reject unsupported overloaded filter", func(t *testing.T) { + before := len(observed) + _, result := rpc(t, r, "admin-reader", "tools/call", map[string]any{ + "name": "admin_list_accounts", "arguments": map[string]any{"query": map[string]any{"status": "overloaded"}}, + }) + require.Contains(t, result, "error") + require.Len(t, observed, before, "unsupported status must be rejected before dispatch") + }) +} + +func TestMCPIdempotencyKeysAreRequiredAndIsolatedPerCredential(t *testing.T) { + var keys []string + r, _ := newTestEndpoint(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + keys = append(keys, r.Header.Get("Idempotency-Key")) + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + args := map[string]any{"body": map[string]any{"name": "created"}} + _, missing := rpc(t, r, "writer", "tools/call", map[string]any{"name": "user_create_api_key", "arguments": args}) + require.Contains(t, missing, "error") + require.Empty(t, keys) + args["idempotency_key"] = "intent-123" + for _, token := range []string{"writer", "writer", "other"} { + _, result := rpc(t, r, token, "tools/call", map[string]any{"name": "user_create_api_key", "arguments": args}) + require.NotContains(t, result, "error") + } + require.Len(t, keys, 3) + require.NotEmpty(t, keys[0]) + require.Equal(t, keys[0], keys[1]) + require.NotEqual(t, keys[0], keys[2]) +} + +func TestMCPOriginAuthenticationAndRequestLimits(t *testing.T) { + r, _ := newTestEndpoint(http.NotFoundHandler()) + for _, tc := range []struct { + origin, token string + size, status int + }{ + {"https://attacker.example", "reader", 0, 403}, {"null", "reader", 0, 403}, + {"https://console.example", "invalid", 0, 401}, {"", "", 0, 401}, + {"https://platform.example", "reader", maxRequestBytes + 1, 413}, + } { + req := httptest.NewRequest("POST", "http://platform.example/api/v1/mcp", strings.NewReader(strings.Repeat("x", tc.size))) + req.Header.Set("Origin", tc.origin) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, tc.status, w.Code, tc.origin) + } + for _, method := range []string{"GET", "DELETE"} { + req := httptest.NewRequest(method, "http://platform.example/api/v1/mcp", nil) + req.Header.Set("Authorization", "Bearer reader") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 405, w.Code) + } +} + +func TestMCPResultLimitAndCancellation(t *testing.T) { + r, _ := newTestEndpoint(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("x", maxResultBytes+1))) + })) + _, result := rpc(t, r, "reader", "tools/call", map[string]any{"name": "user_get_profile", "arguments": map[string]any{}}) + require.Equal(t, true, rpcObject(t, result["result"])["isError"]) + + started, finished := make(chan struct{}), make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + original := httptest.NewRequest("POST", "/api/v1/mcp", nil).WithContext(ctx) + identity := &authenticatedRequest{principal: &service.MCPPrincipal{UserID: 1, Scope: "user"}, request: original} + // Simulate an SDK context detached from the HTTP lifetime. + detached := context.WithValue(context.Background(), requestKey{}, identity) + go func() { + defer close(finished) + executeTool(detached, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + }), ToolSpec{Path: "/api/v1/user/profile", Method: "GET", ReadOnly: true}, nil) + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("dispatcher did not start") + } + cancel() + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("HTTP cancellation did not reach dispatcher") + } +} diff --git a/backend/internal/repository/mcp_oauth.go b/backend/internal/repository/mcp_oauth.go new file mode 100644 index 000000000000..9f840b2e7058 --- /dev/null +++ b/backend/internal/repository/mcp_oauth.go @@ -0,0 +1,269 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" +) + +type mcpOAuthRepository struct{ db *sql.DB } + +func NewMCPOAuthRepository(db *sql.DB) service.MCPOAuthRepository { + return &mcpOAuthRepository{db: db} +} + +func (r *mcpOAuthRepository) CreateClient(ctx context.Context, client *service.MCPOAuthClient) error { + redirects, err := json.Marshal(client.RedirectURIs) + if err != nil { + return err + } + grants, err := json.Marshal(client.GrantTypes) + if err != nil { + return err + } + // Registration also performs bounded retention work. Active authorizations + // protect their client registration; used refresh hashes remain for the + // entire grant lifetime so replay detection is never shortened by cleanup. + now := time.Unix(client.ClientIDIssuedAt, 0).UTC() + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM mcp_oauth_grants WHERE id IN (SELECT id FROM mcp_oauth_grants WHERE expires_at<$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`, now.Add(-7*24*time.Hour)); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`, now.Add(-90*24*time.Hour)) + if err != nil { + return err + } + var staleClientIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return err + } + staleClientIDs = append(staleClientIDs, id) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if len(staleClientIDs) != 0 { + // The second statement gets a fresh READ COMMITTED snapshot after the + // client rows are locked. A concurrent consent that committed just before + // those locks must protect its newly inserted grant from client cleanup. + if _, err := tx.ExecContext(ctx, `DELETE FROM mcp_oauth_clients c WHERE c.client_id=ANY($1) AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id)`, pq.Array(staleClientIDs)); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO mcp_oauth_clients (client_id,client_name,redirect_uris,grant_types,created_at) VALUES ($1,$2,$3,$4,$5)`, client.ClientID, client.ClientName, string(redirects), string(grants), now); err != nil { + return err + } + return tx.Commit() +} + +func (r *mcpOAuthRepository) GetClient(ctx context.Context, id string) (*service.MCPOAuthClient, error) { + client := &service.MCPOAuthClient{TokenEndpointAuthMethod: "none", ResponseTypes: []string{"code"}} + var redirects, grants []byte + var createdAt time.Time + err := r.db.QueryRowContext(ctx, `SELECT client_id,client_name,redirect_uris,grant_types,created_at FROM mcp_oauth_clients WHERE client_id=$1`, id). + Scan(&client.ClientID, &client.ClientName, &redirects, &grants, &createdAt) + if err != nil { + return nil, mcpOAuthSQLError(err) + } + if err := json.Unmarshal(redirects, &client.RedirectURIs); err != nil { + return nil, err + } + if err := json.Unmarshal(grants, &client.GrantTypes); err != nil { + return nil, err + } + client.ClientIDIssuedAt = createdAt.Unix() + return client, nil +} + +func (r *mcpOAuthRepository) CreateAuthorization(ctx context.Context, grant *service.MCPOAuthGrant, code *service.MCPOAuthCode, limit int) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + // Serialize the per-user limit with other authorizations for this owner. + var ownerID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, grant.UserID).Scan(&ownerID); err != nil { + return mcpOAuthSQLError(err) + } + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_oauth_grants WHERE user_id=$1 AND revoked_at IS NULL AND expires_at>$2`, grant.UserID, grant.CreatedAt).Scan(&count); err != nil { + return err + } + if count >= limit { + return service.ErrMCPOAuthGrantLimit + } + _, err = tx.ExecContext(ctx, `INSERT INTO mcp_oauth_grants (id,client_id,user_id,token_version,resource,scope,read_only,created_at,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + grant.ID, grant.ClientID, grant.UserID, grant.TokenVersion, grant.Resource, grant.Scope, grant.ReadOnly, grant.CreatedAt, grant.ExpiresAt) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO mcp_oauth_codes (code_hash,grant_id,client_id,redirect_uri,resource,code_challenge,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + code.Hash, code.GrantID, code.ClientID, code.RedirectURI, code.Resource, code.CodeChallenge, code.ExpiresAt) + if err != nil { + return err + } + return tx.Commit() +} + +func (r *mcpOAuthRepository) GetCode(ctx context.Context, hash string) (*service.MCPOAuthCode, error) { + code := &service.MCPOAuthCode{} + err := r.db.QueryRowContext(ctx, `SELECT code_hash,grant_id,client_id,redirect_uri,resource,code_challenge,expires_at,consumed_at FROM mcp_oauth_codes WHERE code_hash=$1`, hash). + Scan(&code.Hash, &code.GrantID, &code.ClientID, &code.RedirectURI, &code.Resource, &code.CodeChallenge, &code.ExpiresAt, &code.ConsumedAt) + if err != nil { + return nil, mcpOAuthSQLError(err) + } + return code, nil +} + +func (r *mcpOAuthRepository) GetCredential(ctx context.Context, hash string) (*service.MCPOAuthCredential, error) { + credential := &service.MCPOAuthCredential{} + err := r.db.QueryRowContext(ctx, `SELECT key_hash,grant_id,kind,expires_at,used_at FROM mcp_oauth_credentials WHERE key_hash=$1`, hash). + Scan(&credential.Hash, &credential.GrantID, &credential.Kind, &credential.ExpiresAt, &credential.UsedAt) + if err != nil { + return nil, mcpOAuthSQLError(err) + } + return credential, nil +} + +const mcpOAuthGrantColumns = `g.id,g.client_id,c.client_name,g.user_id,g.token_version,g.resource,g.scope,g.read_only,g.created_at,g.expires_at,g.last_used_at,g.revoked_at` + +func scanMCPOAuthGrant(row interface{ Scan(...any) error }) (*service.MCPOAuthGrant, error) { + grant := &service.MCPOAuthGrant{} + err := row.Scan(&grant.ID, &grant.ClientID, &grant.ClientName, &grant.UserID, &grant.TokenVersion, &grant.Resource, &grant.Scope, &grant.ReadOnly, &grant.CreatedAt, &grant.ExpiresAt, &grant.LastUsedAt, &grant.RevokedAt) + if err != nil { + return nil, mcpOAuthSQLError(err) + } + return grant, nil +} + +func (r *mcpOAuthRepository) GetGrant(ctx context.Context, id string) (*service.MCPOAuthGrant, error) { + return scanMCPOAuthGrant(r.db.QueryRowContext(ctx, `SELECT `+mcpOAuthGrantColumns+` FROM mcp_oauth_grants g JOIN mcp_oauth_clients c ON c.client_id=g.client_id WHERE g.id=$1`, id)) +} + +func (r *mcpOAuthRepository) ListGrants(ctx context.Context, userID int64) ([]*service.MCPOAuthGrant, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+mcpOAuthGrantColumns+` FROM mcp_oauth_grants g JOIN mcp_oauth_clients c ON c.client_id=g.client_id WHERE g.user_id=$1 ORDER BY g.created_at DESC`, userID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + grants := []*service.MCPOAuthGrant{} + for rows.Next() { + grant, err := scanMCPOAuthGrant(rows) + if err != nil { + return nil, err + } + grants = append(grants, grant) + } + return grants, rows.Err() +} + +func (r *mcpOAuthRepository) ExchangeCode(ctx context.Context, hash, grantID string, at time.Time, access, refresh *service.MCPOAuthCredential) error { + return r.consumeAndIssue(ctx, hash, grantID, at, access, refresh, false) +} + +func (r *mcpOAuthRepository) RotateRefresh(ctx context.Context, hash, grantID string, at time.Time, access, refresh *service.MCPOAuthCredential) error { + return r.consumeAndIssue(ctx, hash, grantID, at, access, refresh, true) +} + +func (r *mcpOAuthRepository) consumeAndIssue(ctx context.Context, hash, grantID string, at time.Time, access, refresh *service.MCPOAuthCredential, rotating bool) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + // Always lock the grant first: rotation, code exchange, and revocation + // serialize on the same family without code/refresh lock-order inversion. + var revokedAt *time.Time + var grantExpiry time.Time + if err := tx.QueryRowContext(ctx, `SELECT revoked_at,expires_at FROM mcp_oauth_grants WHERE id=$1 FOR UPDATE`, grantID).Scan(&revokedAt, &grantExpiry); err != nil { + return mcpOAuthSQLError(err) + } + if revokedAt != nil || !at.Before(grantExpiry) { + return service.ErrMCPOAuthNotFound + } + var expiresAt time.Time + var usedAt *time.Time + query := `SELECT expires_at,consumed_at FROM mcp_oauth_codes WHERE code_hash=$1 AND grant_id=$2 FOR UPDATE` + if rotating { + query = `SELECT expires_at,used_at FROM mcp_oauth_credentials WHERE key_hash=$1 AND grant_id=$2 AND kind='refresh' FOR UPDATE` + } + if err := tx.QueryRowContext(ctx, query, hash, grantID).Scan(&expiresAt, &usedAt); err != nil { + return mcpOAuthSQLError(err) + } + if usedAt != nil { + // Do not return before committing: rolling back here would leave all + // newer credentials usable after a detected replay. + if _, err := tx.ExecContext(ctx, `UPDATE mcp_oauth_grants SET revoked_at=$2 WHERE id=$1`, grantID, at); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + return service.ErrMCPOAuthNotFound + } + if !at.Before(expiresAt) { + return service.ErrMCPOAuthNotFound + } + query = `UPDATE mcp_oauth_codes SET consumed_at=$2 WHERE code_hash=$1` + if rotating { + query = `UPDATE mcp_oauth_credentials SET used_at=$2 WHERE key_hash=$1` + } + if _, err := tx.ExecContext(ctx, query, hash, at); err != nil { + return err + } + for _, credential := range []*service.MCPOAuthCredential{access, refresh} { + if credential == nil { + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO mcp_oauth_credentials (key_hash,grant_id,kind,expires_at) VALUES ($1,$2,$3,$4)`, credential.Hash, grantID, credential.Kind, credential.ExpiresAt); err != nil { + return err + } + } + return tx.Commit() +} + +func (r *mcpOAuthRepository) TouchGrant(ctx context.Context, id string, at time.Time) error { + return mcpOAuthResult(r.db.ExecContext(ctx, `UPDATE mcp_oauth_grants SET last_used_at=$2 WHERE id=$1 AND revoked_at IS NULL AND expires_at>$2`, id, at)) +} + +func (r *mcpOAuthRepository) RevokeGrant(ctx context.Context, userID int64, id string, at time.Time) error { + return mcpOAuthResult(r.db.ExecContext(ctx, `UPDATE mcp_oauth_grants SET revoked_at=COALESCE(revoked_at,$3) WHERE id=$1 AND user_id=$2`, id, userID, at)) +} + +func mcpOAuthSQLError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return service.ErrMCPOAuthNotFound + } + return err +} + +func mcpOAuthResult(result sql.Result, err error) error { + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return service.ErrMCPOAuthNotFound + } + return nil +} diff --git a/backend/internal/repository/mcp_oauth_integration_test.go b/backend/internal/repository/mcp_oauth_integration_test.go new file mode 100644 index 000000000000..8a6bda5d58aa --- /dev/null +++ b/backend/internal/repository/mcp_oauth_integration_test.go @@ -0,0 +1,203 @@ +//go:build integration + +package repository + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +const mcpIntegrationResource = "https://platform.example/api/v1/mcp" + +type mcpOAuthIntegrationFixture struct { + oauth *service.MCPOAuthService + repo service.MCPOAuthRepository + input service.MCPOAuthTokenInput + userID int64 + grantID string +} + +// Use committed rows and the production repositories: OAuth owns its database +// transactions, so wrapping this fixture in testTx would hide lock behavior. +func newMCPOAuthIntegrationFixture(t *testing.T, ctx context.Context) mcpOAuthIntegrationFixture { + t.Helper() + user := mustCreateUser(t, testEntClient(t), &service.User{}) + repo := NewMCPOAuthRepository(integrationDB) + oauth := service.NewMCPOAuthService(repo, NewUserRepository(integrationEntClient, integrationDB)) + var clientID string + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := integrationDB.ExecContext(cleanupCtx, `DELETE FROM users WHERE id=$1`, user.ID) + require.NoError(t, err) + if clientID != "" { + _, err = integrationDB.ExecContext(cleanupCtx, `DELETE FROM mcp_oauth_clients WHERE client_id=$1`, clientID) + require.NoError(t, err) + } + }) + + const redirectURI = "https://client.example/callback?channel=desktop" + client, err := oauth.Register(ctx, service.MCPOAuthRegisterInput{ + ClientName: "Integration MCP client", RedirectURIs: []string{redirectURI}, + TokenEndpointAuthMethod: "none", + }) + require.NoError(t, err) + clientID = client.ClientID + registered, err := repo.GetClient(ctx, clientID) + require.NoError(t, err) + require.Equal(t, client, registered) + + verifier := strings.Repeat("v", 64) + challenge := sha256.Sum256([]byte(verifier)) + authorization := service.MCPOAuthAuthorizeInput{ + ResponseType: "code", ClientID: clientID, RedirectURI: redirectURI, + Scope: "mcp", State: "integration-state", Resource: mcpIntegrationResource, + CodeChallenge: base64.RawURLEncoding.EncodeToString(challenge[:]), CodeChallengeMethod: "S256", + } + preview, err := oauth.Preview(ctx, user.ID, authorization, mcpIntegrationResource) + require.NoError(t, err) + require.Equal(t, client.ClientName, preview.ClientName) + require.Equal(t, redirectURI, preview.RedirectURI) + redirect, err := oauth.Decide(ctx, user.ID, service.MCPOAuthConsentInput{ + Authorization: authorization, Approved: true, + }, mcpIntegrationResource) + require.NoError(t, err) + callback, err := url.Parse(redirect) + require.NoError(t, err) + require.Equal(t, "desktop", callback.Query().Get("channel")) + require.Equal(t, authorization.State, callback.Query().Get("state")) + require.NotEmpty(t, callback.Query().Get("code")) + grants, err := oauth.ListGrants(ctx, user.ID) + require.NoError(t, err) + require.Len(t, grants, 1) + require.Equal(t, service.MCPScopeUser, grants[0].Scope) + require.True(t, grants[0].ReadOnly) + require.False(t, grants[0].Invalidated) + return mcpOAuthIntegrationFixture{ + oauth: oauth, repo: repo, userID: user.ID, grantID: grants[0].ID, + input: service.MCPOAuthTokenInput{ + GrantType: "authorization_code", ClientID: clientID, RedirectURI: redirectURI, + Code: callback.Query().Get("code"), CodeVerifier: verifier, Resource: mcpIntegrationResource, + }, + } +} + +func mcpIntegrationDigest(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func requireMCPIntegrationInvalidGrant(t *testing.T, err error) { + t.Helper() + var oauthErr *service.MCPOAuthError + require.ErrorAs(t, err, &oauthErr) + require.Equal(t, "invalid_grant", oauthErr.Code) +} + +func TestMCPOAuthIntegrationAuthorizationAndRefreshReplay(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + f := newMCPOAuthIntegrationFixture(t, ctx) + // TestMain applies the real SQL migrations before this end-to-end flow. + issued, err := f.oauth.Token(ctx, f.input, mcpIntegrationResource) + require.NoError(t, err) + require.NotEmpty(t, issued.RefreshToken) + principal, err := f.oauth.Authenticate(ctx, issued.AccessToken) + require.NoError(t, err) + require.Equal(t, f.userID, principal.UserID) + require.Equal(t, f.grantID, principal.GrantID) + code, err := f.repo.GetCode(ctx, mcpIntegrationDigest(f.input.Code)) + require.NoError(t, err) + require.NotNil(t, code.ConsumedAt) + refresh, err := f.repo.GetCredential(ctx, mcpIntegrationDigest(issued.RefreshToken)) + require.NoError(t, err) + require.Nil(t, refresh.UsedAt) + + // SDK clients omit resource on refresh; it must inherit the original binding. + refreshInput := service.MCPOAuthTokenInput{ + GrantType: "refresh_token", ClientID: f.input.ClientID, RefreshToken: issued.RefreshToken, + } + rotated, err := f.oauth.Token(ctx, refreshInput, mcpIntegrationResource) + require.NoError(t, err) + require.NotEqual(t, issued.RefreshToken, rotated.RefreshToken) + principal, err = f.oauth.Authenticate(ctx, rotated.AccessToken) + require.NoError(t, err) + require.Equal(t, mcpIntegrationResource, principal.Resource) + refresh, err = f.repo.GetCredential(ctx, mcpIntegrationDigest(issued.RefreshToken)) + require.NoError(t, err) + require.NotNil(t, refresh.UsedAt, "old refresh digest must remain for replay detection") + + _, err = f.oauth.Token(ctx, refreshInput, mcpIntegrationResource) + requireMCPIntegrationInvalidGrant(t, err) + grant, err := f.repo.GetGrant(ctx, f.grantID) + require.NoError(t, err) + require.NotNil(t, grant.RevokedAt, "replay revocation must commit despite invalid_grant") + for _, raw := range []string{issued.AccessToken, rotated.AccessToken} { + _, err = f.oauth.Authenticate(ctx, raw) + require.ErrorIs(t, err, service.ErrMCPOAuthInvalidToken) + } + refreshInput.RefreshToken = rotated.RefreshToken + _, err = f.oauth.Token(ctx, refreshInput, mcpIntegrationResource) + requireMCPIntegrationInvalidGrant(t, err) +} + +func TestMCPOAuthIntegrationConcurrentCodeExchange(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + f := newMCPOAuthIntegrationFixture(t, ctx) + const attempts = 8 + type exchangeResult struct { + tokens *service.MCPOAuthTokenResponse + err error + } + start := make(chan struct{}) + results := make(chan exchangeResult, attempts) + var workers sync.WaitGroup + for range attempts { + workers.Add(1) + go func() { + defer workers.Done() + <-start + tokens, err := f.oauth.Token(ctx, f.input, mcpIntegrationResource) + results <- exchangeResult{tokens: tokens, err: err} + }() + } + close(start) + workers.Wait() + close(results) + var issued *service.MCPOAuthTokenResponse + successes := 0 + for result := range results { + if result.err != nil { + requireMCPIntegrationInvalidGrant(t, result.err) + continue + } + successes++ + issued = result.tokens + } + require.Equal(t, 1, successes, "one authorization code must issue only one credential pair") + require.NotNil(t, issued) + var credentialCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM mcp_oauth_credentials WHERE grant_id=$1`, f.grantID).Scan(&credentialCount)) + require.Equal(t, 2, credentialCount, "losing transactions must not persist extra access or refresh tokens") + grant, err := f.repo.GetGrant(ctx, f.grantID) + require.NoError(t, err) + require.NotNil(t, grant.RevokedAt, "the concurrent code replay must revoke the winning issuance") + _, err = f.oauth.Authenticate(ctx, issued.AccessToken) + require.ErrorIs(t, err, service.ErrMCPOAuthInvalidToken) + _, err = f.oauth.Token(ctx, service.MCPOAuthTokenInput{ + GrantType: "refresh_token", ClientID: f.input.ClientID, RefreshToken: issued.RefreshToken, + }, mcpIntegrationResource) + requireMCPIntegrationInvalidGrant(t, err) +} diff --git a/backend/internal/repository/mcp_oauth_test.go b/backend/internal/repository/mcp_oauth_test.go new file mode 100644 index 000000000000..40f227952fe0 --- /dev/null +++ b/backend/internal/repository/mcp_oauth_test.go @@ -0,0 +1,177 @@ +//go:build unit + +package repository + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestMCPOAuthRepositoryRegistrationPrunesOnlyOldUnreferencedDataInBatches(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Unix(1800000000, 0).UTC() + client := &service.MCPOAuthClient{ClientID: "client", ClientName: "Client", RedirectURIs: []string{"https://client.example/cb"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ClientIDIssuedAt: now.Unix()} + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM mcp_oauth_grants WHERE id IN \(SELECT id FROM mcp_oauth_grants WHERE expires_at<\$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED\)`).WithArgs(now.Add(-7 * 24 * time.Hour)).WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectQuery(`SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<\$1 AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`).WithArgs(now.Add(-90 * 24 * time.Hour)).WillReturnRows(sqlmock.NewRows([]string{"client_id"}).AddRow("stale-client")) + mock.ExpectExec(`DELETE FROM mcp_oauth_clients c WHERE c.client_id=ANY\(\$1\) AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\)`).WithArgs(`{"stale-client"}`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO mcp_oauth_clients`).WithArgs(client.ClientID, client.ClientName, `["https://client.example/cb"]`, `["authorization_code","refresh_token"]`, now).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + require.NoError(t, repo.CreateClient(context.Background(), client)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMCPOAuthRepositoryConsumptionCommitsReplayRevocation(t *testing.T) { + for _, rotating := range []bool{false, true} { + t.Run(map[bool]string{false: "code", true: "refresh"}[rotating], func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Now().UTC() + used := now.Add(-time.Minute) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT revoked_at,expires_at FROM mcp_oauth_grants WHERE id=\$1 FOR UPDATE`).WithArgs("grant"). + WillReturnRows(sqlmock.NewRows([]string{"revoked_at", "expires_at"}).AddRow(nil, now.Add(time.Hour))) + if rotating { + mock.ExpectQuery(`SELECT expires_at,used_at FROM mcp_oauth_credentials WHERE key_hash=\$1 AND grant_id=\$2 AND kind='refresh' FOR UPDATE`).WithArgs("old-hash", "grant"). + WillReturnRows(sqlmock.NewRows([]string{"expires_at", "used_at"}).AddRow(now.Add(time.Hour), used)) + } else { + mock.ExpectQuery(`SELECT expires_at,consumed_at FROM mcp_oauth_codes WHERE code_hash=\$1 AND grant_id=\$2 FOR UPDATE`).WithArgs("old-hash", "grant"). + WillReturnRows(sqlmock.NewRows([]string{"expires_at", "consumed_at"}).AddRow(now.Add(time.Hour), used)) + } + mock.ExpectExec(`UPDATE mcp_oauth_grants SET revoked_at=\$2 WHERE id=\$1`).WithArgs("grant", now).WillReturnResult(sqlmock.NewResult(0, 1)) + // The security change MUST commit despite returning an error to OAuth. + mock.ExpectCommit() + if rotating { + err = repo.RotateRefresh(context.Background(), "old-hash", "grant", now, nil, nil) + } else { + err = repo.ExchangeCode(context.Background(), "old-hash", "grant", now, nil, nil) + } + require.ErrorIs(t, err, service.ErrMCPOAuthNotFound) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestMCPOAuthRepositoryConsumptionAndIssuanceAreOneTransaction(t *testing.T) { + for _, rotating := range []bool{false, true} { + for _, failInsert := range []bool{false, true} { + t.Run(map[bool]string{false: "code", true: "refresh"}[rotating]+map[bool]string{false: "/commit", true: "/rollback"}[failInsert], func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Now().UTC() + access := &service.MCPOAuthCredential{Hash: "access-digest", GrantID: "grant", Kind: "access", ExpiresAt: now.Add(10 * time.Minute)} + refresh := &service.MCPOAuthCredential{Hash: "refresh-digest", GrantID: "grant", Kind: "refresh", ExpiresAt: now.Add(time.Hour)} + mock.ExpectBegin() + mock.ExpectQuery(`SELECT revoked_at,expires_at FROM mcp_oauth_grants WHERE id=\$1 FOR UPDATE`).WithArgs("grant"). + WillReturnRows(sqlmock.NewRows([]string{"revoked_at", "expires_at"}).AddRow(nil, now.Add(time.Hour))) + if rotating { + mock.ExpectQuery(`SELECT expires_at,used_at FROM mcp_oauth_credentials`).WithArgs("old-hash", "grant").WillReturnRows(sqlmock.NewRows([]string{"expires_at", "used_at"}).AddRow(now.Add(time.Hour), nil)) + mock.ExpectExec(`UPDATE mcp_oauth_credentials SET used_at=\$2 WHERE key_hash=\$1`).WithArgs("old-hash", now).WillReturnResult(sqlmock.NewResult(0, 1)) + } else { + mock.ExpectQuery(`SELECT expires_at,consumed_at FROM mcp_oauth_codes`).WithArgs("old-hash", "grant").WillReturnRows(sqlmock.NewRows([]string{"expires_at", "consumed_at"}).AddRow(now.Add(time.Hour), nil)) + mock.ExpectExec(`UPDATE mcp_oauth_codes SET consumed_at=\$2 WHERE code_hash=\$1`).WithArgs("old-hash", now).WillReturnResult(sqlmock.NewResult(0, 1)) + } + mock.ExpectExec(`INSERT INTO mcp_oauth_credentials`).WithArgs(access.Hash, "grant", "access", access.ExpiresAt).WillReturnResult(sqlmock.NewResult(0, 1)) + insert := mock.ExpectExec(`INSERT INTO mcp_oauth_credentials`).WithArgs(refresh.Hash, "grant", "refresh", refresh.ExpiresAt) + injected := errors.New("database unavailable") + if failInsert { + insert.WillReturnError(injected) + mock.ExpectRollback() + } else { + insert.WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + } + if rotating { + err = repo.RotateRefresh(context.Background(), "old-hash", "grant", now, access, refresh) + } else { + err = repo.ExchangeCode(context.Background(), "old-hash", "grant", now, access, refresh) + } + if failInsert { + require.ErrorIs(t, err, injected) + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + } +} + +func TestMCPOAuthRepositoryRejectsExpiredAndRevokedFamilies(t *testing.T) { + for _, revoked := range []bool{false, true} { + db, mock, err := sqlmock.New() + require.NoError(t, err) + repo := NewMCPOAuthRepository(db) + now := time.Now().UTC() + var revokedAt *time.Time + expiry := now.Add(-time.Second) + if revoked { + revokedAt, expiry = &now, now.Add(time.Hour) + } + mock.ExpectBegin() + mock.ExpectQuery(`SELECT revoked_at,expires_at FROM mcp_oauth_grants WHERE id=\$1 FOR UPDATE`).WithArgs("grant").WillReturnRows(sqlmock.NewRows([]string{"revoked_at", "expires_at"}).AddRow(revokedAt, expiry)) + mock.ExpectRollback() + err = repo.RotateRefresh(context.Background(), "old-hash", "grant", now, nil, nil) + require.ErrorIs(t, err, service.ErrMCPOAuthNotFound) + require.NoError(t, mock.ExpectationsWereMet()) + mock.ExpectClose() + require.NoError(t, db.Close()) + } +} + +func TestMCPOAuthRepositoryAuthorizationSerializesLimit(t *testing.T) { + for _, atLimit := range []bool{false, true} { + t.Run(map[bool]string{false: "create", true: "at limit"}[atLimit], func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Now().UTC() + grant := &service.MCPOAuthGrant{ID: "grant", ClientID: "client", UserID: 7, TokenVersion: 42, Resource: "https://example.com/mcp", Scope: "user", ReadOnly: true, CreatedAt: now, ExpiresAt: now.Add(time.Hour)} + code := &service.MCPOAuthCode{Hash: "code-digest", GrantID: grant.ID, ClientID: grant.ClientID, RedirectURI: "https://client.example/cb", Resource: grant.Resource, CodeChallenge: "challenge", ExpiresAt: now.Add(time.Minute)} + count := 19 + if atLimit { + count = 20 + } + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM users WHERE id=\$1 AND deleted_at IS NULL FOR UPDATE`).WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7)) + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM mcp_oauth_grants WHERE user_id=\$1 AND revoked_at IS NULL AND expires_at>\$2`).WithArgs(int64(7), now).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count)) + if atLimit { + mock.ExpectRollback() + require.ErrorIs(t, repo.CreateAuthorization(context.Background(), grant, code, 20), service.ErrMCPOAuthGrantLimit) + } else { + mock.ExpectExec(`INSERT INTO mcp_oauth_grants`).WithArgs(grant.ID, grant.ClientID, grant.UserID, grant.TokenVersion, grant.Resource, grant.Scope, grant.ReadOnly, grant.CreatedAt, grant.ExpiresAt).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO mcp_oauth_codes`).WithArgs(code.Hash, code.GrantID, code.ClientID, code.RedirectURI, code.Resource, code.CodeChallenge, code.ExpiresAt).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + require.NoError(t, repo.CreateAuthorization(context.Background(), grant, code, 20)) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestMCPOAuthRepositoryOwnerAndRevocationBoundaries(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Now().UTC() + mock.ExpectExec(`UPDATE mcp_oauth_grants SET revoked_at=COALESCE\(revoked_at,\$3\) WHERE id=\$1 AND user_id=\$2`).WithArgs("grant", int64(8), now).WillReturnResult(sqlmock.NewResult(0, 0)) + require.ErrorIs(t, repo.RevokeGrant(context.Background(), 8, "grant", now), service.ErrMCPOAuthNotFound) + mock.ExpectExec(`UPDATE mcp_oauth_grants SET last_used_at=\$2 WHERE id=\$1 AND revoked_at IS NULL AND expires_at>\$2`).WithArgs("grant", now).WillReturnResult(sqlmock.NewResult(0, 0)) + require.ErrorIs(t, repo.TouchGrant(context.Background(), "grant", now), service.ErrMCPOAuthNotFound) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index f176e56eeab8..786392193ff2 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -66,6 +66,7 @@ func ProvideSchedulerCache(rdb *redis.Client, cfg *config.Config) service.Schedu // ProviderSet is the Wire provider set for all repositories var ProviderSet = wire.NewSet( NewUserRepository, + NewMCPOAuthRepository, NewAPIKeyRepository, NewGroupRepository, NewAdminGroupRepository, diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 30f399eea39b..968cd80b3af2 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -52,6 +52,7 @@ func SkipAudit(c *gin.Context) { // scalar, non-secret operation summaries. Request bodies and arbitrary maps // are never accepted through this channel. var auditExtraAllowedKeys = map[string]struct{}{ + "mcp_grant_id": {}, "mcp_tool": {}, "result": {}, "error_code": {}, "enabled": {}, "blocking_enabled": {}, "config_version": {}, "endpoint_count": {}, "scanner_count": {}, "all_groups": {}, "group_count": {}, "guard_endpoint_id": {}, diff --git a/backend/internal/server/middleware/cors.go b/backend/internal/server/middleware/cors.go index fcf6d332bc59..cf63a29c9161 100644 --- a/backend/internal/server/middleware/cors.go +++ b/backend/internal/server/middleware/cors.go @@ -53,6 +53,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc { allowHeaders := []string{ "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key", "X-Admin-UI-Request", "X-User-UI-Request", + "MCP-Protocol-Version", "MCP-Session-Id", } // OpenAI Node SDK 会发送 x-stainless-* 请求头,需在 CORS 中显式放行。 openAIProperties := []string{ @@ -65,6 +66,28 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc { allowHeadersValue := strings.Join(allowHeaders, ", ") return func(c *gin.Context) { + // OAuth discovery and public-client protocol endpoints use no browser + // session credentials. Keep this exception exact so consent and account + // management APIs retain the configured origin policy. + if method := publicMCPOAuthMethod(c.Request.URL.EscapedPath()); method != "" && + (c.Request.Method == method || c.Request.Method == http.MethodOptions) { + if requested := c.GetHeader("Access-Control-Request-Method"); c.Request.Method == http.MethodOptions && requested != "" && requested != method { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Header("Access-Control-Allow-Origin", "*") + c.Writer.Header().Del("Access-Control-Allow-Credentials") + c.Header("Access-Control-Allow-Headers", "Content-Type") + c.Header("Access-Control-Allow-Methods", method+", OPTIONS") + c.Header("Access-Control-Expose-Headers", "WWW-Authenticate") + c.Header("Access-Control-Max-Age", "86400") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + return + } origin := strings.TrimSpace(c.GetHeader("Origin")) originAllowed := allowAll if origin != "" && !allowAll { @@ -83,7 +106,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc { } c.Writer.Header().Set("Access-Control-Allow-Headers", allowHeadersValue) c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") - c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag, Server-Timing") + c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag, Server-Timing, WWW-Authenticate") c.Writer.Header().Set("Access-Control-Max-Age", "86400") } // 处理预检请求 @@ -100,6 +123,17 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc { } } +func publicMCPOAuthMethod(path string) string { + switch path { + case "/.well-known/oauth-protected-resource/api/v1/mcp", "/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server": + return http.MethodGet + case "/api/v1/mcp/oauth/register", "/api/v1/mcp/oauth/token", "/api/v1/mcp/oauth/revoke": + return http.MethodPost + default: + return "" + } +} + func normalizeOrigins(values []string) []string { if len(values) == 0 { return nil diff --git a/backend/internal/server/middleware/cors_test.go b/backend/internal/server/middleware/cors_test.go index 88283898603a..d6eb8c612dd4 100644 --- a/backend/internal/server/middleware/cors_test.go +++ b/backend/internal/server/middleware/cors_test.go @@ -108,6 +108,7 @@ func TestCORS_AllowedOrigin_HasAllowHeaders(t *testing.T) { assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Methods"), "允许的 origin 应收到 Allow-Methods") assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "Server-Timing") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate") assert.Equal(t, "86400", w.Header().Get("Access-Control-Max-Age"), "允许的 origin 应收到 Max-Age=86400") assert.Equal(t, "https://allowed.example.com", w.Header().Get("Access-Control-Allow-Origin"), @@ -116,6 +117,102 @@ func TestCORS_AllowedOrigin_HasAllowHeaders(t *testing.T) { } } +func TestCORSPublicMCPOAuthEndpoints(t *testing.T) { + endpoints := []struct{ path, method string }{ + {"/.well-known/oauth-protected-resource/api/v1/mcp", http.MethodGet}, + {"/.well-known/oauth-protected-resource", http.MethodGet}, + {"/.well-known/oauth-authorization-server", http.MethodGet}, + {"/api/v1/mcp/oauth/register", http.MethodPost}, + {"/api/v1/mcp/oauth/token", http.MethodPost}, + {"/api/v1/mcp/oauth/revoke", http.MethodPost}, + } + for _, allowedOrigins := range [][]string{nil, {"https://console.example.com"}, {"*"}} { + middleware := CORS(config.CORSConfig{AllowedOrigins: allowedOrigins, AllowCredentials: true}) + for _, endpoint := range endpoints { + for _, origin := range []string{"https://client.example.com", "https://console.example.com"} { + for _, method := range []string{endpoint.method, http.MethodOptions} { + t.Run(endpoint.path+"/"+method+"/"+origin, func(t *testing.T) { + w := httptest.NewRecorder() + // Public OAuth responses must never inherit credentialed CORS. + w.Header().Set("Access-Control-Allow-Credentials", "true") + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, endpoint.path, nil) + c.Request.Header.Set("Origin", origin) + if method == http.MethodOptions { + c.Request.Header.Set("Access-Control-Request-Method", endpoint.method) + c.Request.Header.Set("Access-Control-Request-Headers", "content-type") + } + + middleware(c) + + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "Content-Type", w.Header().Get("Access-Control-Allow-Headers")) + assert.Equal(t, endpoint.method+", OPTIONS", w.Header().Get("Access-Control-Allow-Methods")) + if method == http.MethodOptions { + assert.Equal(t, http.StatusNoContent, w.Code) + assert.True(t, c.IsAborted()) + } else { + assert.False(t, c.IsAborted()) + } + }) + } + } + } + } +} + +func TestCORSPublicMCPOAuthExceptionDoesNotCoverOtherPaths(t *testing.T) { + for _, path := range []string{ + "/api/v1/mcp", "/api/v1/mcp/oauth/authorize", "/api/v1/mcp/oauth/consent", + "/api/v1/user/mcp/grants", "/api/v1/user/profile", "/settings/mcp", + "/api/v1/mcp/oauth/token/", "/api/v1/mcp/oauth/token-extra", "/api/v1/mcp/oauth/%74oken", + "/.well-known/oauth-protected-resource/other", "/.well-known/oauth-authorization-server/", + } { + t.Run(path, func(t *testing.T) { + middleware := CORS(config.CORSConfig{AllowedOrigins: []string{"https://console.example.com"}, AllowCredentials: true}) + for _, origin := range []string{"https://client.example.com", "https://console.example.com"} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodOptions, path, nil) + c.Request.Header.Set("Origin", origin) + c.Request.Header.Set("Access-Control-Request-Method", http.MethodPost) + + middleware(c) + + if origin == "https://console.example.com" { + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + } else { + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) + } + } + }) + } +} + +func TestCORSPublicMCPOAuthPreflightRejectsUnsupportedMethods(t *testing.T) { + for _, tc := range []struct{ path, method string }{ + {"/.well-known/oauth-protected-resource/api/v1/mcp", http.MethodPost}, + {"/api/v1/mcp/oauth/token", http.MethodDelete}, + } { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodOptions, tc.path, nil) + c.Request.Header.Set("Origin", "https://client.example.com") + c.Request.Header.Set("Access-Control-Request-Method", tc.method) + + CORS(config.CORSConfig{AllowCredentials: true})(c) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) + } +} + func TestCORS_PreflightDisallowedOrigin_ReturnsForbidden(t *testing.T) { cfg := config.CORSConfig{ AllowedOrigins: []string{"https://allowed.example.com"}, diff --git a/backend/internal/server/middleware/panel_rate_limit.go b/backend/internal/server/middleware/panel_rate_limit.go index b9f2453d0f14..8f0eac5efa3e 100644 --- a/backend/internal/server/middleware/panel_rate_limit.go +++ b/backend/internal/server/middleware/panel_rate_limit.go @@ -54,6 +54,12 @@ func (p *PanelRateLimiter) Global() gin.HandlerFunc { return p.userScoped("global", func(s service.PanelRateLimitSettings) int { return s.UserRPM }) } +// MCPTransport bounds protocol requests, including discovery, separately from +// the global budget charged by each dispatched management operation. +func (p *PanelRateLimiter) MCPTransport() gin.HandlerFunc { + return p.userScoped("mcp", func(s service.PanelRateLimitSettings) int { return s.UserRPM }) +} + // Heavy 重查询接口的按用户限流(严格档,覆盖 usage/dashboard 等聚合统计端点)。 // 与 Global 叠加计数:一次重查询同时消耗两档额度。 func (p *PanelRateLimiter) Heavy() gin.HandlerFunc { diff --git a/backend/internal/server/middleware/step_up.go b/backend/internal/server/middleware/step_up.go index 31a320e3fdbb..6545aa455c72 100644 --- a/backend/internal/server/middleware/step_up.go +++ b/backend/internal/server/middleware/step_up.go @@ -101,6 +101,11 @@ func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader s return true } + if c.GetString("auth_method") == "mcp_oauth" { + AbortWithError(c, 403, "STEP_UP_MCP_OAUTH_FORBIDDEN", + "MCP OAuth credentials cannot perform this operation; use a two-factor verified browser session") + return false + } if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", "Admin API key cannot access this endpoint; a two-factor verified admin session is required") diff --git a/backend/internal/server/middleware/step_up_test.go b/backend/internal/server/middleware/step_up_test.go index d817d4659a38..f4d2f37adc6f 100644 --- a/backend/internal/server/middleware/step_up_test.go +++ b/backend/internal/server/middleware/step_up_test.go @@ -63,6 +63,16 @@ func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") } +func TestEnforceStepUpRejectsMCPOAuthEvenWithUserGrant(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set("auth_method", "mcp_oauth") + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}, stepUpEnabled) + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_MCP_OAUTH_FORBIDDEN") +} + func TestEnforceStepUpRequiresAuthSubject(t *testing.T) { c, rec := newStepUpTestContext(t) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index c0dde5c426ed..99458853a4e0 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -128,6 +128,8 @@ func registerRoutes( routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter) routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter) routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter) + routes.RegisterMCPRoutes(v1, h, auditLog, stepUpAuth, settingService, panelRateLimiter, cfg.CORS.AllowedOrigins) + routes.RegisterMCPOAuthRoutes(r, v1, h, redisClient) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter, redisClient) diff --git a/backend/internal/server/routes/mcp.go b/backend/internal/server/routes/mcp.go new file mode 100644 index 000000000000..2e7049d91502 --- /dev/null +++ b/backend/internal/server/routes/mcp.go @@ -0,0 +1,45 @@ +package routes + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/managementmcp" + ratelimit "github.com/Wei-Shaw/sub2api/internal/middleware" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// RegisterMCPRoutes exposes MCP over HTTP and reuses the normal management +// routes in a private router, preserving ownership checks, guards and audit. +func RegisterMCPRoutes(v1 *gin.RouterGroup, h *handler.Handlers, audit middleware.AuditLogMiddleware, + stepUp middleware.StepUpAuthMiddleware, settings *service.SettingService, + limiter *middleware.PanelRateLimiter, allowedOrigins []string, +) { + inner := gin.New() + inner.Use(gin.Recovery()) + api := inner.Group("/api/v1") + RegisterUserRoutes(api, h, middleware.JWTAuthMiddleware(managementmcp.DispatchAuth(false)), audit, settings, limiter) + RegisterAdminRoutes(api, h, middleware.AdminAuthMiddleware(managementmcp.DispatchAuth(true)), audit, stepUp, settings, limiter) + transport := managementmcp.NewHandler(inner) + mcp := v1.Group("/mcp", managementmcp.Authenticate(h.MCPOAuth.Service(), allowedOrigins, h.MCPOAuth.ResourceURL), limiter.MCPTransport(), middleware.BackendModeUserGuard(settings)) + for _, method := range []string{"POST", "GET", "DELETE"} { + mcp.Handle(method, "", gin.WrapH(transport)) + } +} + +// RegisterMCPOAuthRoutes exposes OAuth discovery and public-client protocol +// endpoints. Browser consent and grant management live in the JWT user router. +func RegisterMCPOAuthRoutes(r *gin.Engine, v1 *gin.RouterGroup, h *handler.Handlers, redisClient *redis.Client) { + r.GET("/.well-known/oauth-protected-resource/api/v1/mcp", h.MCPOAuth.ResourceMetadata) + r.GET("/.well-known/oauth-protected-resource", h.MCPOAuth.ResourceMetadata) + r.GET("/.well-known/oauth-authorization-server", h.MCPOAuth.AuthorizationMetadata) + limit := ratelimit.NewRateLimiter(redisClient) + closed := ratelimit.RateLimitOptions{FailureMode: ratelimit.RateLimitFailClose} + oauth := v1.Group("/mcp/oauth") + oauth.POST("/register", limit.LimitWithOptions("mcp-oauth-register", 10, time.Minute, closed), h.MCPOAuth.Register) + oauth.POST("/token", limit.LimitWithOptions("mcp-oauth-token", 60, time.Minute, closed), h.MCPOAuth.Token) + oauth.POST("/revoke", limit.LimitWithOptions("mcp-oauth-revoke", 60, time.Minute, closed), h.MCPOAuth.RevokeToken) +} diff --git a/backend/internal/server/routes/mcp_oauth_sdk_test.go b/backend/internal/server/routes/mcp_oauth_sdk_test.go new file mode 100644 index 000000000000..6649cdc08973 --- /dev/null +++ b/backend/internal/server/routes/mcp_oauth_sdk_test.go @@ -0,0 +1,120 @@ +//go:build mcp_go_client_oauth + +package routes + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "github.com/stretchr/testify/require" +) + +// The SDK drives discovery, dynamic registration, PKCE generation, code +// exchange, transport authentication, and refresh. Only the browser UI is +// simulated through the same preview/consent REST API used by the SPA. +func TestMCPOAuthOfficialGoSDKInteroperability(t *testing.T) { + f := newMCPOAuthHTTPFixture(t, service.RoleUser) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var browserApprovals atomic.Int32 + oauth, err := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandlerConfig{ + DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + ClientName: "Official Go SDK", RedirectURIs: []string{"http://127.0.0.1:37777/callback"}, + TokenEndpointAuthMethod: "none", GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, + }, + }, + AuthorizationCodeFetcher: func(_ context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + authorizationURL, err := url.Parse(args.URL) + if err != nil { + return nil, err + } + if authorizationURL.Scheme+"://"+authorizationURL.Host+authorizationURL.Path != f.server.URL+"/oauth/mcp/authorize" { + return nil, fmt.Errorf("unexpected authorization endpoint") + } + q := authorizationURL.Query() + if q.Get("resource") != f.resource || q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" { + return nil, fmt.Errorf("SDK authorization did not bind resource and S256 PKCE") + } + preview := f.request(t, http.MethodGet, "/api/v1/user/mcp/authorize?"+q.Encode(), "browser-session", "", nil, false) + if preview.status != http.StatusOK { + return nil, fmt.Errorf("authorization preview returned %d: %s", preview.status, preview.body) + } + input := service.MCPOAuthConsentInput{Approved: true, Scope: service.MCPScopeUser, + Authorization: service.MCPOAuthAuthorizeInput{ + ResponseType: q.Get("response_type"), ClientID: q.Get("client_id"), RedirectURI: q.Get("redirect_uri"), + Scope: q.Get("scope"), State: q.Get("state"), Resource: q.Get("resource"), + CodeChallenge: q.Get("code_challenge"), CodeChallengeMethod: q.Get("code_challenge_method"), + }, + } + decided := f.json(t, http.MethodPost, "/api/v1/user/mcp/authorize", "browser-session", input) + if decided.status != http.StatusOK { + return nil, fmt.Errorf("authorization consent returned %d: %s", decided.status, decided.body) + } + var result struct { + Data struct { + RedirectURL string `json:"redirect_url"` + } `json:"data"` + } + if err := json.Unmarshal(decided.body, &result); err != nil { + return nil, err + } + redirect, err := url.Parse(result.Data.RedirectURL) + if err != nil { + return nil, err + } + browserApprovals.Add(1) + return &auth.AuthorizationResult{Code: redirect.Query().Get("code"), State: redirect.Query().Get("state")}, nil + }, + }) + require.NoError(t, err) + client := mcp.NewClient(&mcp.Implementation{Name: "oauth-interop-test", Version: "1.0"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: f.resource, HTTPClient: f.server.Client(), OAuthHandler: oauth}, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + require.Equal(t, int32(1), browserApprovals.Load()) + listed, err := session.ListTools(ctx, nil) + require.NoError(t, err) + names := make([]string, 0, len(listed.Tools)) + for _, tool := range listed.Tools { + names = append(names, tool.Name) + } + require.Contains(t, names, "user_get_api_key") + require.NotContains(t, names, "user_create_api_key") + require.NotContains(t, names, "admin_get_dashboard") + called, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "user_get_api_key", Arguments: map[string]any{"id": 9}}) + require.NoError(t, err) + require.False(t, called.IsError) + encoded, err := json.Marshal(called) + require.NoError(t, err) + require.Contains(t, string(encoded), "owner-key") + require.NotContains(t, string(encoded), "must-not-leak") + source, err := oauth.TokenSource(ctx) + require.NoError(t, err) + initial, err := source.Token() + require.NoError(t, err) + oldAccess, oldRefresh := initial.AccessToken, initial.RefreshToken + require.NotEmpty(t, oldRefresh) + // oauth2 caches the returned token pointer. Expire that cache entry to drive + // the SDK's real refresh request without sleeping for the access lifetime. + initial.Expiry = time.Now().Add(-time.Minute) + _, err = session.ListTools(ctx, nil) + require.NoError(t, err) + rotated, err := source.Token() + require.NoError(t, err) + require.NotEqual(t, oldAccess, rotated.AccessToken) + require.NotEqual(t, oldRefresh, rotated.RefreshToken) + require.Equal(t, int32(1), browserApprovals.Load(), "refresh must not require another browser authorization") + _, err = f.oauth.Authenticate(ctx, oldAccess) + require.NoError(t, err, "ordinary refresh preserves in-flight access tokens") +} diff --git a/backend/internal/server/routes/mcp_oauth_test.go b/backend/internal/server/routes/mcp_oauth_test.go new file mode 100644 index 000000000000..e6512664281f --- /dev/null +++ b/backend/internal/server/routes/mcp_oauth_test.go @@ -0,0 +1,491 @@ +package routes + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +// This repository exercises real OAuth services and HTTP handlers without a +// database. SQL locking/rollback behavior is covered by repository unit tests. +type mcpOAuthRouteRepo struct { + mu sync.Mutex + clients map[string]*service.MCPOAuthClient + grants map[string]*service.MCPOAuthGrant + codes map[string]*service.MCPOAuthCode + credentials map[string]*service.MCPOAuthCredential +} + +func (r *mcpOAuthRouteRepo) CreateClient(_ context.Context, client *service.MCPOAuthClient) error { + r.mu.Lock() + defer r.mu.Unlock() + r.clients[client.ClientID] = client + return nil +} + +func (r *mcpOAuthRouteRepo) GetClient(_ context.Context, id string) (*service.MCPOAuthClient, error) { + r.mu.Lock() + defer r.mu.Unlock() + if item := r.clients[id]; item != nil { + copy := *item + return ©, nil + } + return nil, service.ErrMCPOAuthNotFound +} + +func (r *mcpOAuthRouteRepo) CreateAuthorization(_ context.Context, grant *service.MCPOAuthGrant, code *service.MCPOAuthCode, limit int) error { + r.mu.Lock() + defer r.mu.Unlock() + count := 0 + for _, item := range r.grants { + if item.UserID == grant.UserID && item.RevokedAt == nil && item.ExpiresAt.After(grant.CreatedAt) { + count++ + } + } + if count >= limit { + return service.ErrMCPOAuthGrantLimit + } + r.grants[grant.ID], r.codes[code.Hash] = grant, code + return nil +} + +func (r *mcpOAuthRouteRepo) GetCode(_ context.Context, hash string) (*service.MCPOAuthCode, error) { + r.mu.Lock() + defer r.mu.Unlock() + if item := r.codes[hash]; item != nil { + copy := *item + return ©, nil + } + return nil, service.ErrMCPOAuthNotFound +} + +func (r *mcpOAuthRouteRepo) GetCredential(_ context.Context, hash string) (*service.MCPOAuthCredential, error) { + r.mu.Lock() + defer r.mu.Unlock() + if item := r.credentials[hash]; item != nil { + copy := *item + return ©, nil + } + return nil, service.ErrMCPOAuthNotFound +} + +func (r *mcpOAuthRouteRepo) GetGrant(_ context.Context, id string) (*service.MCPOAuthGrant, error) { + r.mu.Lock() + defer r.mu.Unlock() + if item := r.grants[id]; item != nil { + copy := *item + return ©, nil + } + return nil, service.ErrMCPOAuthNotFound +} + +func (r *mcpOAuthRouteRepo) consume(hash, id string, now time.Time, access, refresh *service.MCPOAuthCredential, rotating bool) error { + r.mu.Lock() + defer r.mu.Unlock() + grant := r.grants[id] + if grant == nil || grant.RevokedAt != nil || !now.Before(grant.ExpiresAt) { + return service.ErrMCPOAuthNotFound + } + var expiresAt time.Time + var usedAt **time.Time + if rotating { + item := r.credentials[hash] + if item == nil || item.GrantID != id || item.Kind != "refresh" { + return service.ErrMCPOAuthNotFound + } + expiresAt, usedAt = item.ExpiresAt, &item.UsedAt + } else { + item := r.codes[hash] + if item == nil || item.GrantID != id { + return service.ErrMCPOAuthNotFound + } + expiresAt, usedAt = item.ExpiresAt, &item.ConsumedAt + } + if *usedAt != nil { + grant.RevokedAt = &now + return service.ErrMCPOAuthNotFound + } + if !now.Before(expiresAt) { + return service.ErrMCPOAuthNotFound + } + *usedAt = &now + r.credentials[access.Hash] = access + if refresh != nil { + r.credentials[refresh.Hash] = refresh + } + return nil +} + +func (r *mcpOAuthRouteRepo) ExchangeCode(_ context.Context, hash, id string, now time.Time, access, refresh *service.MCPOAuthCredential) error { + return r.consume(hash, id, now, access, refresh, false) +} + +func (r *mcpOAuthRouteRepo) RotateRefresh(_ context.Context, hash, id string, now time.Time, access, refresh *service.MCPOAuthCredential) error { + return r.consume(hash, id, now, access, refresh, true) +} + +func (r *mcpOAuthRouteRepo) TouchGrant(_ context.Context, id string, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + item := r.grants[id] + if item == nil || item.RevokedAt != nil || !now.Before(item.ExpiresAt) { + return service.ErrMCPOAuthNotFound + } + item.LastUsedAt = &now + return nil +} + +func (r *mcpOAuthRouteRepo) ListGrants(_ context.Context, userID int64) ([]*service.MCPOAuthGrant, error) { + r.mu.Lock() + defer r.mu.Unlock() + items := []*service.MCPOAuthGrant{} + for _, item := range r.grants { + if item.UserID == userID { + copy := *item + items = append(items, ©) + } + } + return items, nil +} + +func (r *mcpOAuthRouteRepo) RevokeGrant(_ context.Context, userID int64, id string, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + item := r.grants[id] + if item == nil || item.UserID != userID { + return service.ErrMCPOAuthNotFound + } + if item.RevokedAt == nil { + item.RevokedAt = &now + } + return nil +} + +type mcpOAuthRouteUsers struct { + service.UserRepository + user *service.User +} + +func (r *mcpOAuthRouteUsers) GetByID(_ context.Context, id int64) (*service.User, error) { + if r.user == nil || r.user.ID != id { + return nil, service.ErrUserNotFound + } + copy := *r.user + return ©, nil +} + +func newMCPRouteOAuthService(role string) (*service.MCPOAuthService, *mcpOAuthRouteRepo, *mcpOAuthRouteUsers) { + repo := &mcpOAuthRouteRepo{ + clients: map[string]*service.MCPOAuthClient{}, grants: map[string]*service.MCPOAuthGrant{}, + codes: map[string]*service.MCPOAuthCode{}, credentials: map[string]*service.MCPOAuthCredential{}, + } + users := &mcpOAuthRouteUsers{user: &service.User{ID: 7, Role: role, Status: service.StatusActive, PasswordHash: "test-password", Email: "mcp@example.com"}} + return service.NewMCPOAuthService(repo, users), repo, users +} + +const mcpOAuthRouteVerifier = "a-valid-43-character-pkce-verifier-for-testing-only" + +func mcpOAuthRouteAuthorization(clientID, resource string) service.MCPOAuthAuthorizeInput { + sum := sha256.Sum256([]byte(mcpOAuthRouteVerifier)) + return service.MCPOAuthAuthorizeInput{ + ResponseType: "code", ClientID: clientID, RedirectURI: "http://127.0.0.1:37777/callback", + Scope: "mcp", State: "opaque-state-with&symbols", CodeChallenge: base64.RawURLEncoding.EncodeToString(sum[:]), + CodeChallengeMethod: "S256", Resource: resource, + } +} + +func issueMCPRouteOAuthToken(t *testing.T, oauth *service.MCPOAuthService, resource, scope string, readOnly bool) string { + t.Helper() + client, err := oauth.Register(context.Background(), service.MCPOAuthRegisterInput{RedirectURIs: []string{"http://127.0.0.1:37777/callback"}}) + require.NoError(t, err) + authorization := mcpOAuthRouteAuthorization(client.ClientID, resource) + redirect, err := oauth.Decide(context.Background(), 7, service.MCPOAuthConsentInput{Authorization: authorization, Approved: true, Scope: scope, ReadOnly: &readOnly}, resource) + require.NoError(t, err) + u, err := url.Parse(redirect) + require.NoError(t, err) + tokens, err := oauth.Token(context.Background(), service.MCPOAuthTokenInput{GrantType: "authorization_code", ClientID: client.ClientID, Code: u.Query().Get("code"), RedirectURI: authorization.RedirectURI, CodeVerifier: mcpOAuthRouteVerifier, Resource: resource}, resource) + require.NoError(t, err) + return tokens.AccessToken +} + +type mcpOAuthHTTPFixture struct { + server *httptest.Server + router *gin.Engine + resource string + cfg *config.Config + settings *service.SettingService + handlers *handler.Handlers + oauth *service.MCPOAuthService + repo *mcpOAuthRouteRepo + users *mcpOAuthRouteUsers +} + +func newMCPOAuthHTTPFixture(t *testing.T, role string) *mcpOAuthHTTPFixture { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + server := httptest.NewUnstartedServer(router) + base := "http://" + server.Listener.Addr().String() + cfg := &config.Config{MCP: config.MCPConfig{PublicURL: base + "/api/v1/mcp"}} + cfg.Server.FrontendURL = base + cfg.JWT.Secret = "test-jwt-secret-at-least-thirty-two-bytes" + settings := service.NewSettingService(&mcpRouteSettingsRepo{values: map[string]string{service.SettingKeyStepUpEnabled: "false"}}, cfg) + oauth, repo, users := newMCPRouteOAuthService(role) + h := mcpRouteHandlers() + h.MCPOAuth = handler.NewMCPOAuthHandler(oauth, nil, nil, settings, cfg) + h.APIKey = handler.NewAPIKeyHandler(service.NewAPIKeyService(&mcpRouteAPIKeyRepo{}, nil, nil, nil, nil, nil, nil)) + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + browserAuth := middleware.JWTAuthMiddleware(func(c *gin.Context) { + if c.GetHeader("Authorization") != "Bearer browser-session" { + middleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Browser login required") + return + } + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 7}) + c.Set(string(middleware.ContextKeyUserRole), role) + c.Set(middleware.ContextKeySessionID, "browser-session") + c.Next() + }) + v1 := router.Group("/api/v1") + RegisterUserRoutes(v1, h, browserAuth, middleware.AuditLogMiddleware(mcpRouteNext), settings, nil) + RegisterMCPOAuthRoutes(router, v1, h, rdb) + RegisterMCPRoutes(v1, h, middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), settings, nil, nil) + server.Start() + t.Cleanup(server.Close) + return &mcpOAuthHTTPFixture{server: server, router: router, resource: cfg.MCP.PublicURL, cfg: cfg, settings: settings, handlers: h, oauth: oauth, repo: repo, users: users} +} + +type mcpOAuthHTTPResult struct { + status int + header http.Header + body []byte +} + +func (f *mcpOAuthHTTPFixture) request(t *testing.T, method, path, auth, contentType string, body []byte, spoofHost bool) mcpOAuthHTTPResult { + t.Helper() + req, err := http.NewRequest(method, f.server.URL+path, bytes.NewReader(body)) + require.NoError(t, err) + if auth != "" { + req.Header.Set("Authorization", "Bearer "+auth) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", "2025-06-18") + if spoofHost { + req.Host = "attacker.example" + req.Header.Set("X-Forwarded-Host", "attacker.example") + req.Header.Set("X-Forwarded-Proto", "https") + } + res, err := f.server.Client().Do(req) + require.NoError(t, err) + defer func() { _ = res.Body.Close() }() + raw, err := io.ReadAll(res.Body) + require.NoError(t, err) + return mcpOAuthHTTPResult{status: res.StatusCode, header: res.Header.Clone(), body: raw} +} + +func (f *mcpOAuthHTTPFixture) json(t *testing.T, method, path, auth string, body any) mcpOAuthHTTPResult { + t.Helper() + raw, err := json.Marshal(body) + require.NoError(t, err) + return f.request(t, method, path, auth, "application/json", raw, false) +} + +func (f *mcpOAuthHTTPFixture) form(t *testing.T, path string, body url.Values) mcpOAuthHTTPResult { + t.Helper() + return f.request(t, http.MethodPost, path, "", "application/x-www-form-urlencoded", []byte(body.Encode()), false) +} + +func (f *mcpOAuthHTTPFixture) rpc(t *testing.T, access, method string, params any) mcpOAuthHTTPResult { + t.Helper() + return f.json(t, http.MethodPost, "/api/v1/mcp", access, map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) +} + +func (f *mcpOAuthHTTPFixture) authorize(t *testing.T, scope string, readOnly bool) (service.MCPOAuthClient, service.MCPOAuthTokenResponse) { + t.Helper() + registration := f.json(t, http.MethodPost, "/api/v1/mcp/oauth/register", "", map[string]any{ + "client_name": "Integration client", "redirect_uris": []string{"http://127.0.0.1:37777/callback"}, + "token_endpoint_auth_method": "none", "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, + }) + require.Equal(t, http.StatusCreated, registration.status, string(registration.body)) + var client service.MCPOAuthClient + require.NoError(t, json.Unmarshal(registration.body, &client)) + authorization := mcpOAuthRouteAuthorization(client.ClientID, f.resource) + query := url.Values{"response_type": {authorization.ResponseType}, "client_id": {authorization.ClientID}, "redirect_uri": {authorization.RedirectURI}, "scope": {authorization.Scope}, "state": {authorization.State}, "code_challenge": {authorization.CodeChallenge}, "code_challenge_method": {authorization.CodeChallengeMethod}, "resource": {authorization.Resource}} + before, err := f.oauth.ListGrants(context.Background(), 7) + require.NoError(t, err) + preview := f.request(t, http.MethodGet, "/api/v1/user/mcp/authorize?"+query.Encode(), "browser-session", "", nil, false) + require.Equal(t, http.StatusOK, preview.status, string(preview.body)) + require.Contains(t, string(preview.body), "Integration client") + afterPreview, err := f.oauth.ListGrants(context.Background(), 7) + require.NoError(t, err) + require.Len(t, afterPreview, len(before), "preview must not create an authorization") + decided := f.json(t, http.MethodPost, "/api/v1/user/mcp/authorize", "browser-session", service.MCPOAuthConsentInput{Authorization: authorization, Approved: true, Scope: scope, ReadOnly: &readOnly}) + require.Equal(t, http.StatusOK, decided.status, string(decided.body)) + var consent struct { + Data struct { + RedirectURL string `json:"redirect_url"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(decided.body, &consent)) + redirect, err := url.Parse(consent.Data.RedirectURL) + require.NoError(t, err) + require.Equal(t, authorization.State, redirect.Query().Get("state")) + require.Equal(t, "127.0.0.1:37777", redirect.Host) + after, err := f.oauth.ListGrants(context.Background(), 7) + require.NoError(t, err) + require.Len(t, after, len(before)+1) + token := f.form(t, "/api/v1/mcp/oauth/token", url.Values{"grant_type": {"authorization_code"}, "client_id": {client.ClientID}, "code": {redirect.Query().Get("code")}, "redirect_uri": {authorization.RedirectURI}, "code_verifier": {mcpOAuthRouteVerifier}, "resource": {f.resource}}) + require.Equal(t, http.StatusOK, token.status, string(token.body)) + require.Equal(t, "no-store", token.header.Get("Cache-Control")) + var tokens service.MCPOAuthTokenResponse + require.NoError(t, json.Unmarshal(token.body, &tokens)) + require.NotEmpty(t, tokens.AccessToken) + require.NotEmpty(t, tokens.RefreshToken) + return client, tokens +} + +func TestMCPOAuthHTTPFlowDiscoveryConsentRefreshAndRevocation(t *testing.T) { + f := newMCPOAuthHTTPFixture(t, service.RoleUser) + for _, path := range []string{"/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/api/v1/mcp"} { + metadata := f.request(t, http.MethodGet, path, "", "", nil, true) + require.Equal(t, http.StatusOK, metadata.status, string(metadata.body)) + var value struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + } + require.NoError(t, json.Unmarshal(metadata.body, &value)) + require.Equal(t, f.resource, value.Resource) + require.Equal(t, []string{f.server.URL}, value.AuthorizationServers) + require.NotContains(t, string(metadata.body), "attacker.example") + } + authorizationMetadata := f.request(t, http.MethodGet, "/.well-known/oauth-authorization-server", "", "", nil, true) + require.Equal(t, http.StatusOK, authorizationMetadata.status, string(authorizationMetadata.body)) + var endpoints map[string]any + require.NoError(t, json.Unmarshal(authorizationMetadata.body, &endpoints)) + require.Equal(t, f.server.URL, endpoints["issuer"]) + require.Equal(t, f.server.URL+"/oauth/mcp/authorize", endpoints["authorization_endpoint"]) + require.Equal(t, f.server.URL+"/api/v1/mcp/oauth/token", endpoints["token_endpoint"]) + unauthorized := f.request(t, http.MethodPost, "/api/v1/mcp", "", "application/json", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`), true) + require.Equal(t, http.StatusUnauthorized, unauthorized.status) + require.Contains(t, unauthorized.header.Get("WWW-Authenticate"), f.server.URL+"/.well-known/oauth-protected-resource/api/v1/mcp") + require.NotContains(t, unauthorized.header.Get("WWW-Authenticate"), "attacker.example") + client, tokens := f.authorize(t, service.MCPScopeUser, true) + initialized := f.rpc(t, tokens.AccessToken, "initialize", map[string]any{ + "protocolVersion": "2025-06-18", "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "oauth-integration", "version": "1.0"}, + }) + require.Equal(t, http.StatusOK, initialized.status, string(initialized.body)) + require.Contains(t, string(initialized.body), "protocolVersion") + tools := f.rpc(t, tokens.AccessToken, "tools/list", map[string]any{}) + require.Equal(t, http.StatusOK, tools.status, string(tools.body)) + require.Contains(t, string(tools.body), "user_get_api_key") + require.NotContains(t, string(tools.body), "user_create_api_key") + require.NotContains(t, string(tools.body), "admin_get_dashboard") + call := f.rpc(t, tokens.AccessToken, "tools/call", map[string]any{"name": "user_get_api_key", "arguments": map[string]any{"id": 9}}) + require.Equal(t, http.StatusOK, call.status, string(call.body)) + var result mcpRouteToolResult + require.NoError(t, json.Unmarshal(call.body, &result)) + require.Equal(t, http.StatusOK, result.Result.StructuredContent.HTTPStatus) + require.Contains(t, string(call.body), "owner-key") + require.NotContains(t, string(call.body), "must-not-leak") + refresh := f.form(t, "/api/v1/mcp/oauth/token", url.Values{"grant_type": {"refresh_token"}, "client_id": {client.ClientID}, "refresh_token": {tokens.RefreshToken}}) + require.Equal(t, http.StatusOK, refresh.status, string(refresh.body)) + var rotated service.MCPOAuthTokenResponse + require.NoError(t, json.Unmarshal(refresh.body, &rotated)) + require.NotEqual(t, tokens.AccessToken, rotated.AccessToken) + require.Equal(t, http.StatusOK, f.rpc(t, rotated.AccessToken, "tools/list", map[string]any{}).status) + revoked := f.form(t, "/api/v1/mcp/oauth/revoke", url.Values{"client_id": {client.ClientID}, "token": {rotated.RefreshToken}}) + require.Equal(t, http.StatusOK, revoked.status, string(revoked.body)) + require.Equal(t, http.StatusUnauthorized, f.rpc(t, rotated.AccessToken, "tools/list", map[string]any{}).status) + require.Equal(t, http.StatusUnauthorized, f.rpc(t, tokens.AccessToken, "tools/list", map[string]any{}).status) +} + +func TestMCPOAuthHTTPPermissionAndCredentialBoundaries(t *testing.T) { + f := newMCPOAuthHTTPFixture(t, service.RoleAdmin) + _, user := f.authorize(t, service.MCPScopeUser, true) + userTools := f.rpc(t, user.AccessToken, "tools/list", map[string]any{}) + require.NotContains(t, string(userTools.body), "admin_get_dashboard") + _, admin := f.authorize(t, service.MCPScopeAdmin, false) + adminTools := f.rpc(t, admin.AccessToken, "tools/list", map[string]any{}) + require.Equal(t, http.StatusOK, adminTools.status, string(adminTools.body)) + require.Contains(t, string(adminTools.body), "admin_get_dashboard") + require.Contains(t, string(adminTools.body), "user_create_api_key") + for _, tool := range []string{"admin_get_dashboard", "user_delete_api_key"} { + blocked := f.rpc(t, user.AccessToken, "tools/call", map[string]any{"name": tool, "arguments": map[string]any{"id": 9}}) + var result mcpRouteToolResult + require.NoError(t, json.Unmarshal(blocked.body, &result)) + require.NotEmpty(t, result.Error, string(blocked.body)) + } + for _, credential := range []string{"browser-session", "sk-inference-key", "mcp_legacy_personal_token", admin.RefreshToken} { + require.Equal(t, http.StatusUnauthorized, f.rpc(t, credential, "tools/list", map[string]any{}).status, credential) + } + // Use the real production JWT middleware to prove MCP access credentials + // cannot become browser REST credentials, even for an administrator owner. + rest := gin.New() + auth := service.NewAuthService(nil, nil, nil, nil, f.cfg, f.settings, nil, nil, nil, nil, nil, nil, nil) + jwtAuth := middleware.NewJWTAuthMiddleware(auth, nil, f.settings, nil) + RegisterUserRoutes(rest.Group("/api/v1"), f.handlers, jwtAuth, middleware.AuditLogMiddleware(mcpRouteNext), f.settings, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/mcp/grants", nil) + req.Header.Set("Authorization", "Bearer "+admin.AccessToken) + w := httptest.NewRecorder() + rest.ServeHTTP(w, req) + require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), "INVALID_TOKEN") +} + +func TestMCPOAuthHTTPConsentRequiresBrowserAndExplicitApproval(t *testing.T) { + f := newMCPOAuthHTTPFixture(t, service.RoleUser) + client, err := f.oauth.Register(context.Background(), service.MCPOAuthRegisterInput{RedirectURIs: []string{"http://127.0.0.1:37777/callback"}}) + require.NoError(t, err) + input := service.MCPOAuthConsentInput{Authorization: mcpOAuthRouteAuthorization(client.ClientID, f.resource), Approved: true, Scope: service.MCPScopeAdmin} + unauthenticated := f.json(t, http.MethodPost, "/api/v1/user/mcp/authorize", "", input) + require.Equal(t, http.StatusUnauthorized, unauthenticated.status) + escalated := f.json(t, http.MethodPost, "/api/v1/user/mcp/authorize", "browser-session", input) + require.Equal(t, http.StatusForbidden, escalated.status, string(escalated.body)) + input.Approved = false + denied := f.json(t, http.MethodPost, "/api/v1/user/mcp/authorize", "browser-session", input) + require.Equal(t, http.StatusOK, denied.status, string(denied.body)) + require.Contains(t, string(denied.body), "access_denied") + grants, err := f.oauth.ListGrants(context.Background(), 7) + require.NoError(t, err) + require.Empty(t, grants) + legacy := f.request(t, http.MethodPost, "/api/v1/user/mcp-tokens", "browser-session", "application/json", []byte(`{"name":"legacy"}`), false) + require.Equal(t, http.StatusNotFound, legacy.status) +} + +func TestMCPOAuthHTTPRejectsAmbiguousTokenForms(t *testing.T) { + f := newMCPOAuthHTTPFixture(t, service.RoleUser) + for _, form := range []string{ + "grant_type=authorization_code&client_id=first&client_id=second", + "grant_type=authorization_code&resource=a&resource=b", + "grant_type=authorization_code&client_secret=not-supported", + } { + result := f.request(t, http.MethodPost, "/api/v1/mcp/oauth/token", "", "application/x-www-form-urlencoded", []byte(form), false) + require.Equal(t, http.StatusBadRequest, result.status, string(result.body)) + require.Equal(t, "no-store", result.header.Get("Cache-Control")) + require.True(t, strings.Contains(string(result.body), "invalid_request") || strings.Contains(string(result.body), "invalid_client")) + } +} diff --git a/backend/internal/server/routes/mcp_rate_limit_test.go b/backend/internal/server/routes/mcp_rate_limit_test.go new file mode 100644 index 000000000000..723708718146 --- /dev/null +++ b/backend/internal/server/routes/mcp_rate_limit_test.go @@ -0,0 +1,117 @@ +package routes + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func newMCPRateLimitEndpoint(t *testing.T, role string, rpm int) (*gin.Engine, string, *miniredis.Miniredis) { + t.Helper() + gin.SetMode(gin.TestMode) + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + settingsJSON, err := json.Marshal(service.PanelRateLimitSettings{Enabled: true, UserRPM: rpm, HeavyRPM: 1}) + require.NoError(t, err) + cfg := &config.Config{MCP: config.MCPConfig{PublicURL: "https://mcp.example/api/v1/mcp"}} + settings := service.NewSettingService(&mcpRouteSettingsRepo{values: map[string]string{ + service.SettingKeyPanelRateLimitSettings: string(settingsJSON), + }}, cfg) + oauth, _, _ := newMCPRouteOAuthService(role) + scope := service.MCPScopeUser + if role == service.RoleAdmin { + scope = service.MCPScopeAdmin + } + token := issueMCPRouteOAuthToken(t, oauth, cfg.MCP.PublicURL, scope, true) + h := mcpRouteHandlers() + h.MCPOAuth = handler.NewMCPOAuthHandler(oauth, nil, nil, settings, cfg) + h.APIKey = handler.NewAPIKeyHandler(service.NewAPIKeyService(&mcpRouteAPIKeyRepo{}, nil, nil, nil, nil, nil, nil)) + router := gin.New() + RegisterMCPRoutes(router.Group("/api/v1"), h, middleware.AuditLogMiddleware(mcpRouteNext), + middleware.StepUpAuthMiddleware(mcpRouteNext), settings, middleware.NewPanelRateLimiter(rdb, settings), nil) + return router, token, mr +} + +func mcpRateLimitRPC(t *testing.T, endpoint http.Handler, token, method string, params any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", "2025-06-18") + w := httptest.NewRecorder() + endpoint.ServeHTTP(w, req) + return w +} + +func TestMCPToolCallsConsumeGlobalBudgetOnce(t *testing.T) { + for _, tc := range []struct { + role, tool string + args map[string]any + status int + }{ + {service.RoleUser, "user_get_api_key", map[string]any{"id": 9}, http.StatusOK}, + // The compliance response proves the request passed the admin limiter. + {service.RoleAdmin, "admin_get_dashboard", map[string]any{}, http.StatusLocked}, + } { + t.Run(tc.role, func(t *testing.T) { + endpoint, token, mr := newMCPRateLimitEndpoint(t, tc.role, 2) + params := map[string]any{"name": tc.tool, "arguments": tc.args} + for i := 0; i < 2; i++ { + w := mcpRateLimitRPC(t, endpoint, token, "tools/call", params) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var result mcpRouteToolResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + require.Equal(t, tc.status, result.Result.StructuredContent.HTTPStatus, w.Body.String()) + } + count, err := mr.Get("rate_limit:panel:global:user:7") + require.NoError(t, err) + require.Equal(t, "2", count) + w := mcpRateLimitRPC(t, endpoint, token, "tools/call", params) + require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) + }) + } +} + +func TestMCPDiscoveryHasItsOwnBoundedBudget(t *testing.T) { + endpoint, token, mr := newMCPRateLimitEndpoint(t, service.RoleUser, 2) + for i := 0; i < 2; i++ { + w := mcpRateLimitRPC(t, endpoint, token, "tools/list", map[string]any{}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + require.False(t, mr.Exists("rate_limit:panel:global:user:7"), "discovery must not spend the shared REST budget") + w := mcpRateLimitRPC(t, endpoint, token, "tools/list", map[string]any{}) + require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) +} + +func TestMCPDispatchRetainsHeavyRateLimit(t *testing.T) { + endpoint, token, mr := newMCPRateLimitEndpoint(t, service.RoleUser, 10) + params := map[string]any{"name": "user_get_usage_stats", "arguments": map[string]any{ + // A malformed date reaches the real handler without needing a usage DB. + "query": map[string]any{"start_date": "2026-99-99"}, + }} + for _, status := range []int{http.StatusBadRequest, http.StatusTooManyRequests} { + w := mcpRateLimitRPC(t, endpoint, token, "tools/call", params) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var result mcpRouteToolResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + require.Equal(t, status, result.Result.StructuredContent.HTTPStatus, w.Body.String()) + } + count, err := mr.Get("rate_limit:panel:global:user:7") + require.NoError(t, err) + require.Equal(t, "2", count) +} diff --git a/backend/internal/server/routes/mcp_test.go b/backend/internal/server/routes/mcp_test.go new file mode 100644 index 000000000000..d540f31064ae --- /dev/null +++ b/backend/internal/server/routes/mcp_test.go @@ -0,0 +1,190 @@ +package routes + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/managementmcp" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func mcpRouteHandlers() *handler.Handlers { + // Nil method receivers are sufficient for route registration. Individual + // behavior tests populate only the handlers whose dependencies they exercise. + return &handler.Handlers{ + Admin: &handler.AdminHandlers{}, + MCPOAuth: handler.NewMCPOAuthHandler(nil, nil, nil, nil, &config.Config{MCP: config.MCPConfig{PublicURL: "https://platform.example/api/v1/mcp"}}), + } +} + +func mcpRouteNext(c *gin.Context) { c.Next() } + +func TestMCPToolCatalogTargetsRegisteredManagementRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + v1 := router.Group("/api/v1") + h := mcpRouteHandlers() + require.NotPanics(t, func() { + RegisterUserRoutes(v1, h, middleware.JWTAuthMiddleware(mcpRouteNext), middleware.AuditLogMiddleware(mcpRouteNext), nil, nil) + RegisterAdminRoutes(v1, h, middleware.AdminAuthMiddleware(mcpRouteNext), middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), nil, nil) + }) + registered := map[string]bool{} + for _, route := range router.Routes() { + registered[route.Method+" "+route.Path] = true + } + catalog := managementmcp.ToolCatalog() + require.NotEmpty(t, catalog) + for _, tool := range catalog { + require.True(t, registered[tool.Method+" "+tool.Path], "%s targets missing route %s %s", tool.Name, tool.Method, tool.Path) + } + + // Exercise the real factory as well: transport setup must not introduce + // duplicate Gin paths or panic while building its permission-filtered servers. + mcpRouter := gin.New() + require.NotPanics(t, func() { + RegisterMCPRoutes(mcpRouter.Group("/api/v1"), h, middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), nil, nil, nil) + }) + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + w := httptest.NewRecorder() + mcpRouter.ServeHTTP(w, httptest.NewRequest(method, "/api/v1/mcp", nil)) + require.Equal(t, http.StatusUnauthorized, w.Code, method) + } +} + +type mcpRouteTokenAuth struct{ principal *service.MCPPrincipal } + +func (a mcpRouteTokenAuth) Authenticate(context.Context, string) (*service.MCPPrincipal, error) { + copy := *a.principal + copy.Resource = "https://platform.example/api/v1/mcp" + return ©, nil +} + +func newMCPRouteTestEndpoint(h *handler.Handlers, principal *service.MCPPrincipal, settings *service.SettingService, audit middleware.AuditLogMiddleware) *gin.Engine { + inner := gin.New() + v1 := inner.Group("/api/v1") + RegisterUserRoutes(v1, h, middleware.JWTAuthMiddleware(managementmcp.DispatchAuth(false)), audit, settings, nil) + RegisterAdminRoutes(v1, h, middleware.AdminAuthMiddleware(managementmcp.DispatchAuth(true)), audit, middleware.StepUpAuthMiddleware(mcpRouteNext), settings, nil) + outer := gin.New() + outer.POST("/api/v1/mcp", managementmcp.Authenticate(mcpRouteTokenAuth{principal: principal}, nil, h.MCPOAuth.ResourceURL), gin.WrapH(managementmcp.NewHandler(inner))) + return outer +} + +type mcpRouteToolResult struct { + Result struct { + IsError bool `json:"isError"` + StructuredContent struct { + HTTPStatus int `json:"http_status"` + Result json.RawMessage `json:"result"` + } `json:"structuredContent"` + } `json:"result"` + Error json.RawMessage `json:"error"` +} + +func callMCPRouteTool(t *testing.T, endpoint http.Handler, name string, args map[string]any) mcpRouteToolResult { + t.Helper() + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", "2025-06-18") + w := httptest.NewRecorder() + endpoint.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var result mcpRouteToolResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result), w.Body.String()) + require.Empty(t, result.Error, w.Body.String()) + return result +} + +type mcpRouteAPIKeyRepo struct{ service.APIKeyRepository } + +func (r *mcpRouteAPIKeyRepo) GetByID(context.Context, int64) (*service.APIKey, error) { + return &service.APIKey{ID: 9, UserID: 7, Name: "owner-key", Key: "must-not-leak", Status: service.StatusActive}, nil +} + +func TestMCPDispatchRetainsRealAPIKeyOwnershipAndAudit(t *testing.T) { + gin.SetMode(gin.TestMode) + h := mcpRouteHandlers() + h.APIKey = handler.NewAPIKeyHandler(service.NewAPIKeyService(&mcpRouteAPIKeyRepo{}, nil, nil, nil, nil, nil, nil)) + for _, userID := range []int64{7, 8} { + principal := &service.MCPPrincipal{GrantID: "test", UserID: userID, Role: service.RoleUser, Scope: service.MCPScopeUser, ReadOnly: true} + auditCalls := 0 + audit := middleware.AuditLogMiddleware(func(c *gin.Context) { + subject, ok := middleware.GetAuthSubjectFromContext(c) + require.True(t, ok) + require.Equal(t, userID, subject.UserID) + require.Equal(t, "/api/v1/keys/:id", c.FullPath()) + require.Equal(t, managementmcp.AuthMethod, c.GetString("auth_method")) + auditCalls++ + c.Next() + }) + result := callMCPRouteTool(t, newMCPRouteTestEndpoint(h, principal, nil, audit), "user_get_api_key", map[string]any{"id": 9}) + require.Equal(t, 1, auditCalls) + require.NotContains(t, string(result.Result.StructuredContent.Result), "must-not-leak") + if userID == 7 { + require.False(t, result.Result.IsError) + require.Equal(t, http.StatusOK, result.Result.StructuredContent.HTTPStatus) + require.Contains(t, string(result.Result.StructuredContent.Result), "owner-key") + } else { + require.True(t, result.Result.IsError) + require.Equal(t, http.StatusNotFound, result.Result.StructuredContent.HTTPStatus) + require.NotContains(t, string(result.Result.StructuredContent.Result), "owner-key") + } + } +} + +type mcpRouteSettingsRepo struct { + service.SettingRepository + values map[string]string +} + +func (r *mcpRouteSettingsRepo) GetValue(_ context.Context, key string) (string, error) { + if value, ok := r.values[key]; ok { + return value, nil + } + return "", service.ErrSettingNotFound +} + +func (r *mcpRouteSettingsRepo) SetMultiple(_ context.Context, values map[string]string) error { + for key, value := range values { + r.values[key] = value + } + return nil +} + +func TestMCPDispatchRetainsBackendModeAndAdminCompliance(t *testing.T) { + gin.SetMode(gin.TestMode) + settings := service.NewSettingService(&mcpRouteSettingsRepo{values: map[string]string{}}, &config.Config{}) + // UpdateSettings also updates the global backend-mode cache. Reset it on + // cleanup so this integration test cannot affect unrelated route tests. + require.NoError(t, settings.UpdateSettings(context.Background(), &service.SystemSettings{BackendModeEnabled: true})) + t.Cleanup(func() { + require.NoError(t, settings.UpdateSettings(context.Background(), &service.SystemSettings{BackendModeEnabled: false})) + }) + h := mcpRouteHandlers() + user := &service.MCPPrincipal{GrantID: "user", UserID: 7, Role: service.RoleUser, Scope: service.MCPScopeUser, ReadOnly: true} + result := callMCPRouteTool(t, newMCPRouteTestEndpoint(h, user, settings, middleware.AuditLogMiddleware(mcpRouteNext)), "user_get_profile", map[string]any{}) + require.True(t, result.Result.IsError) + require.Equal(t, http.StatusForbidden, result.Result.StructuredContent.HTTPStatus) + require.Contains(t, string(result.Result.StructuredContent.Result), "Backend mode is active") + + admin := &service.MCPPrincipal{GrantID: "admin", UserID: 8, Role: service.RoleAdmin, Scope: service.MCPScopeAdmin, ReadOnly: true} + result = callMCPRouteTool(t, newMCPRouteTestEndpoint(h, admin, settings, middleware.AuditLogMiddleware(mcpRouteNext)), "admin_get_dashboard", map[string]any{}) + require.True(t, result.Result.IsError) + require.Equal(t, http.StatusLocked, result.Result.StructuredContent.HTTPStatus) + require.Contains(t, string(result.Result.StructuredContent.Result), "ADMIN_COMPLIANCE_ACK_REQUIRED") +} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 65ecaa037d8c..6b995c1a7274 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -29,6 +29,13 @@ func RegisterUserRoutes( user := authenticated.Group("/user") { user.GET("/profile", h.User.GetProfile) + if h.MCPOAuth != nil { + user.GET("/mcp/config", h.MCPOAuth.ConnectionConfig) + user.GET("/mcp/grants", h.MCPOAuth.ListGrants) + user.DELETE("/mcp/grants/:id", h.MCPOAuth.RevokeGrant) + user.GET("/mcp/authorize", h.MCPOAuth.Preview) + user.POST("/mcp/authorize", h.MCPOAuth.Decide) + } user.PUT("/password", h.User.ChangePassword) user.PUT("", h.User.UpdateProfile) user.GET("/aff", h.User.GetAffiliate) diff --git a/backend/internal/service/mcp_oauth_service.go b/backend/internal/service/mcp_oauth_service.go new file mode 100644 index 000000000000..f74200f74425 --- /dev/null +++ b/backend/internal/service/mcp_oauth_service.go @@ -0,0 +1,609 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "net/url" + "strings" + "time" + "unicode/utf8" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + MCPScopeUser = "user" + MCPScopeAdmin = "admin" + MCPOAuthScope = "mcp" + MCPOAuthGrantLimit = 20 + MCPOAuthAccessLifetime = 10 * time.Minute + MCPOAuthCodeLifetime = 5 * time.Minute + MCPOAuthGrantLifetime = 30 * 24 * time.Hour +) + +var ( + ErrMCPOAuthInvalidToken = infraerrors.Unauthorized("INVALID_MCP_TOKEN", "MCP access token is invalid or expired") + ErrMCPOAuthNotFound = errors.New("MCP OAuth record not found") + ErrMCPOAuthGrantLimit = infraerrors.Conflict("MCP_GRANT_LIMIT", "Revoke an existing MCP authorization before authorizing another client (maximum 20 active authorizations)") + ErrMCPOAuthGrantNotFound = infraerrors.NotFound("MCP_GRANT_NOT_FOUND", "MCP authorization not found") +) + +type MCPOAuthError struct { + Code string `json:"error"` + Description string `json:"error_description"` +} + +func (e *MCPOAuthError) Error() string { return e.Code + ": " + e.Description } + +func mcpOAuthError(code, description string) error { + return &MCPOAuthError{Code: code, Description: description} +} + +type MCPOAuthRegisterInput struct { + ClientName string `json:"client_name"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` +} + +type MCPOAuthClient struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + ClientIDIssuedAt int64 `json:"client_id_issued_at"` +} + +type MCPOAuthAuthorizeInput struct { + ResponseType string `json:"response_type"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Scope string `json:"scope"` + State string `json:"state"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + Resource string `json:"resource"` +} + +type MCPOAuthConsentInput struct { + Authorization MCPOAuthAuthorizeInput `json:"authorization"` + Approved bool `json:"approved"` + Scope string `json:"scope"` + ReadOnly *bool `json:"read_only"` +} + +type MCPOAuthConsent struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + RedirectURI string `json:"redirect_uri"` + Resource string `json:"resource"` + Scope string `json:"scope"` +} + +type MCPOAuthGrant struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + Scope string `json:"scope"` + ReadOnly bool `json:"read_only"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at"` + RevokedAt *time.Time `json:"revoked_at"` + Invalidated bool `json:"invalidated"` + UserID int64 `json:"-"` + TokenVersion int64 `json:"-"` + Resource string `json:"-"` +} + +type MCPPrincipal struct { + GrantID string + UserID int64 + Role string + Scope string + ReadOnly bool + Concurrency int + Email string + Resource string +} + +type MCPOAuthTokenInput struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + Code string `json:"code"` + RedirectURI string `json:"redirect_uri"` + CodeVerifier string `json:"code_verifier"` + RefreshToken string `json:"refresh_token"` + Resource string `json:"resource"` + Scope string `json:"scope"` +} + +type MCPOAuthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` +} + +// Authorization codes and credentials contain digests only; bearer secrets are +// returned directly to the client and never reach persistence. +type MCPOAuthCode struct { + Hash, GrantID, ClientID, RedirectURI, Resource, CodeChallenge string + ExpiresAt time.Time + ConsumedAt *time.Time +} + +type MCPOAuthCredential struct { + Hash, GrantID, Kind string + ExpiresAt time.Time + UsedAt *time.Time +} + +type MCPOAuthRepository interface { + CreateClient(context.Context, *MCPOAuthClient) error + GetClient(context.Context, string) (*MCPOAuthClient, error) + CreateAuthorization(context.Context, *MCPOAuthGrant, *MCPOAuthCode, int) error + GetCode(context.Context, string) (*MCPOAuthCode, error) + GetCredential(context.Context, string) (*MCPOAuthCredential, error) + GetGrant(context.Context, string) (*MCPOAuthGrant, error) + // ExchangeCode and RotateRefresh atomically consume and issue credentials. + // Replays revoke the entire grant and COMMIT that revocation before erroring. + ExchangeCode(context.Context, string, string, time.Time, *MCPOAuthCredential, *MCPOAuthCredential) error + RotateRefresh(context.Context, string, string, time.Time, *MCPOAuthCredential, *MCPOAuthCredential) error + TouchGrant(context.Context, string, time.Time) error + ListGrants(context.Context, int64) ([]*MCPOAuthGrant, error) + RevokeGrant(context.Context, int64, string, time.Time) error +} + +type MCPOAuthService struct { + repo MCPOAuthRepository + users UserRepository +} + +func NewMCPOAuthService(repo MCPOAuthRepository, users UserRepository) *MCPOAuthService { + return &MCPOAuthService{repo: repo, users: users} +} + +func (s *MCPOAuthService) Register(ctx context.Context, input MCPOAuthRegisterInput) (*MCPOAuthClient, error) { + input.ClientName = strings.TrimSpace(input.ClientName) + if input.ClientName == "" { + input.ClientName = "MCP client" + } + if !utf8.ValidString(input.ClientName) || utf8.RuneCountInString(input.ClientName) > 100 { + return nil, mcpOAuthError("invalid_client_metadata", "client_name must contain at most 100 characters") + } + if input.TokenEndpointAuthMethod != "" && input.TokenEndpointAuthMethod != "none" { + return nil, mcpOAuthError("invalid_client_metadata", "Only public clients using token_endpoint_auth_method=none are supported") + } + if len(input.RedirectURIs) < 1 || len(input.RedirectURIs) > 10 { + return nil, mcpOAuthError("invalid_redirect_uri", "Register between 1 and 10 redirect URIs") + } + redirects := make([]string, 0, len(input.RedirectURIs)) + for _, uri := range input.RedirectURIs { + if !validMCPRedirectURI(uri) { + return nil, mcpOAuthError("invalid_redirect_uri", "Redirect URIs must use HTTPS or HTTP loopback, without fragments, credentials, or wildcards") + } + if !containsMCPString(redirects, uri) { + redirects = append(redirects, uri) + } + } + grants := input.GrantTypes + if len(grants) == 0 { + grants = []string{"authorization_code", "refresh_token"} + } + if len(grants) > 2 || !containsMCPString(grants, "authorization_code") { + return nil, mcpOAuthError("invalid_client_metadata", "authorization_code is required") + } + for _, grant := range grants { + if grant != "authorization_code" && grant != "refresh_token" { + return nil, mcpOAuthError("invalid_client_metadata", "Unsupported grant type") + } + } + if len(input.ResponseTypes) != 0 && (len(input.ResponseTypes) != 1 || input.ResponseTypes[0] != "code") { + return nil, mcpOAuthError("invalid_client_metadata", "Only the code response type is supported") + } + id, err := mcpOAuthSecret("mcp_client_") + if err != nil { + return nil, err + } + client := &MCPOAuthClient{ + ClientID: id, ClientName: input.ClientName, RedirectURIs: redirects, + GrantTypes: append([]string(nil), grants...), ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "none", ClientIDIssuedAt: time.Now().UTC().Unix(), + } + if err := s.repo.CreateClient(ctx, client); err != nil { + return nil, err + } + return client, nil +} + +func validMCPRedirectURI(raw string) bool { + if len(raw) > 2048 || strings.ContainsAny(raw, "*#") { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" || u.Fragment != "" { + return false + } + if u.Scheme == "https" { + return true + } + if u.Scheme != "http" { + return false + } + switch strings.ToLower(u.Hostname()) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func containsMCPString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func validMCPOAuthScope(scope string) bool { + fields := strings.Fields(scope) + return len(fields) == 0 || (len(fields) == 1 && fields[0] == MCPOAuthScope) +} + +func (s *MCPOAuthService) validateAuthorization(ctx context.Context, userID int64, input MCPOAuthAuthorizeInput, resource string) (*MCPOAuthClient, *User, error) { + client, err := s.repo.GetClient(ctx, input.ClientID) + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil, nil, mcpOAuthError("invalid_client", "Unknown client") + } + if err != nil { + return nil, nil, err + } + if client == nil || !containsMCPString(client.RedirectURIs, input.RedirectURI) { + return nil, nil, mcpOAuthError("invalid_request", "redirect_uri must exactly match a registered URI") + } + if input.ResponseType != "code" { + return nil, nil, mcpOAuthError("unsupported_response_type", "Only response_type=code is supported") + } + if !validMCPOAuthScope(input.Scope) { + return nil, nil, mcpOAuthError("invalid_scope", "Only the mcp OAuth scope is supported") + } + if resource == "" || input.Resource != resource { + return nil, nil, mcpOAuthError("invalid_target", "resource must identify this MCP endpoint") + } + challenge, decodeErr := base64.RawURLEncoding.DecodeString(input.CodeChallenge) + if input.CodeChallengeMethod != "S256" || decodeErr != nil || len(challenge) != sha256.Size || base64.RawURLEncoding.EncodeToString(challenge) != input.CodeChallenge { + return nil, nil, mcpOAuthError("invalid_request", "A valid S256 PKCE code challenge is required") + } + if len(input.State) > 2048 { + return nil, nil, mcpOAuthError("invalid_request", "state is too long") + } + user, err := s.users.GetByID(ctx, userID) + if errors.Is(err, ErrUserNotFound) || (err == nil && (user == nil || !user.IsActive() || user.DeletedAt != nil)) { + return nil, nil, mcpOAuthError("access_denied", "User account is not active") + } + if err != nil { + return nil, nil, err + } + return client, user, nil +} + +func (s *MCPOAuthService) Preview(ctx context.Context, userID int64, input MCPOAuthAuthorizeInput, resource string) (*MCPOAuthConsent, error) { + client, _, err := s.validateAuthorization(ctx, userID, input, resource) + if err != nil { + return nil, err + } + return &MCPOAuthConsent{ClientID: client.ClientID, ClientName: client.ClientName, RedirectURI: input.RedirectURI, Resource: resource, Scope: MCPOAuthScope}, nil +} + +func (s *MCPOAuthService) Decide(ctx context.Context, userID int64, input MCPOAuthConsentInput, resource string) (string, error) { + client, user, err := s.validateAuthorization(ctx, userID, input.Authorization, resource) + if err != nil { + return "", err + } + redirect, _ := url.Parse(input.Authorization.RedirectURI) + query := redirect.Query() + // Registered URI queries are retained; OAuth response fields are authoritative. + query.Del("code") + query.Del("error") + query.Del("error_description") + query.Del("state") + if input.Authorization.State != "" { + query.Set("state", input.Authorization.State) + } + if !input.Approved { + query.Set("error", "access_denied") + redirect.RawQuery = query.Encode() + return redirect.String(), nil + } + if input.Scope == "" { + input.Scope = MCPScopeUser + } + if input.Scope != MCPScopeUser && input.Scope != MCPScopeAdmin { + return "", mcpOAuthError("invalid_scope", "Management permission must be user or admin") + } + if input.Scope == MCPScopeAdmin && !user.IsAdmin() { + return "", mcpOAuthError("access_denied", "Only administrators can authorize admin access") + } + id, err := mcpOAuthSecret("") + if err != nil { + return "", err + } + rawCode, err := mcpOAuthSecret("mcp_code_") + if err != nil { + return "", err + } + now := time.Now().UTC() + grant := &MCPOAuthGrant{ID: id, ClientID: client.ClientID, ClientName: client.ClientName, + UserID: user.ID, TokenVersion: resolvedTokenVersion(user), Resource: resource, + Scope: input.Scope, ReadOnly: true, CreatedAt: now, ExpiresAt: now.Add(MCPOAuthGrantLifetime)} + if input.ReadOnly != nil { + grant.ReadOnly = *input.ReadOnly + } + code := &MCPOAuthCode{Hash: hashMCPOAuthSecret(rawCode), GrantID: grant.ID, ClientID: client.ClientID, + RedirectURI: input.Authorization.RedirectURI, Resource: resource, CodeChallenge: input.Authorization.CodeChallenge, + ExpiresAt: now.Add(MCPOAuthCodeLifetime)} + if err := s.repo.CreateAuthorization(ctx, grant, code, MCPOAuthGrantLimit); err != nil { + return "", err + } + query.Set("code", rawCode) + redirect.RawQuery = query.Encode() + return redirect.String(), nil +} + +func (s *MCPOAuthService) Token(ctx context.Context, input MCPOAuthTokenInput, resource string) (*MCPOAuthTokenResponse, error) { + client, err := s.repo.GetClient(ctx, input.ClientID) + if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && client == nil) { + return nil, mcpOAuthError("invalid_client", "Unknown client") + } + if err != nil { + return nil, err + } + if !validMCPOAuthScope(input.Scope) { + return nil, mcpOAuthError("invalid_scope", "Only the mcp OAuth scope is supported") + } + if input.GrantType != "authorization_code" && input.GrantType != "refresh_token" { + return nil, mcpOAuthError("unsupported_grant_type", "Unsupported grant type") + } + if !containsMCPString(client.GrantTypes, input.GrantType) { + return nil, mcpOAuthError("unauthorized_client", "Client is not registered for this grant type") + } + var grantID, credentialHash string + if input.GrantType == "authorization_code" { + if !validMCPOAuthSecret(input.Code, "mcp_code_") || !validMCPCodeVerifier(input.CodeVerifier) { + return nil, mcpOAuthError("invalid_grant", "Invalid authorization code or verifier") + } + credentialHash = hashMCPOAuthSecret(input.Code) + code, err := s.repo.GetCode(ctx, credentialHash) + if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && code == nil) { + return nil, mcpOAuthError("invalid_grant", "Invalid authorization code") + } + if err != nil { + return nil, err + } + sum := sha256.Sum256([]byte(input.CodeVerifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + if code.ClientID != input.ClientID || code.RedirectURI != input.RedirectURI || resource == "" || input.Resource != resource || code.Resource != resource || subtle.ConstantTimeCompare([]byte(challenge), []byte(code.CodeChallenge)) != 1 { + return nil, mcpOAuthError("invalid_grant", "Authorization code binding or PKCE verification failed") + } + grantID = code.GrantID + } else { + if !validMCPOAuthSecret(input.RefreshToken, "mcp_rt_") { + return nil, mcpOAuthError("invalid_grant", "Invalid refresh token") + } + credentialHash = hashMCPOAuthSecret(input.RefreshToken) + credential, err := s.repo.GetCredential(ctx, credentialHash) + if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && (credential == nil || credential.Kind != "refresh")) { + return nil, mcpOAuthError("invalid_grant", "Invalid refresh token") + } + if err != nil { + return nil, err + } + grantID = credential.GrantID + } + grant, err := s.repo.GetGrant(ctx, grantID) + if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && grant == nil) { + return nil, mcpOAuthError("invalid_grant", "Authorization is no longer valid") + } + if err != nil { + return nil, err + } + if grant.ClientID != input.ClientID || resource == "" || grant.Resource != resource || (input.Resource != "" && input.Resource != grant.Resource) { + return nil, mcpOAuthError("invalid_grant", "Token is not bound to this client and resource") + } + if _, err := s.validateGrant(ctx, grant); err != nil { + if errors.Is(err, ErrMCPOAuthInvalidToken) { + return nil, mcpOAuthError("invalid_grant", "Authorization is no longer valid") + } + return nil, err + } + now := time.Now().UTC() + result, access, refresh, err := newMCPOAuthTokens(grant, now, containsMCPString(client.GrantTypes, "refresh_token")) + if err != nil { + return nil, err + } + if input.GrantType == "authorization_code" { + err = s.repo.ExchangeCode(ctx, credentialHash, grant.ID, now, access, refresh) + } else { + err = s.repo.RotateRefresh(ctx, credentialHash, grant.ID, now, access, refresh) + } + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil, mcpOAuthError("invalid_grant", "Code or refresh token was expired, revoked, or already used") + } + if err != nil { + return nil, err + } + return result, nil +} + +func newMCPOAuthTokens(grant *MCPOAuthGrant, now time.Time, includeRefresh bool) (*MCPOAuthTokenResponse, *MCPOAuthCredential, *MCPOAuthCredential, error) { + access, err := mcpOAuthSecret("mcp_at_") + if err != nil { + return nil, nil, nil, err + } + expiresAt := now.Add(MCPOAuthAccessLifetime) + if grant.ExpiresAt.Before(expiresAt) { + expiresAt = grant.ExpiresAt + } + result := &MCPOAuthTokenResponse{AccessToken: access, TokenType: "Bearer", ExpiresIn: int64(expiresAt.Sub(now) / time.Second), Scope: MCPOAuthScope} + accessRecord := &MCPOAuthCredential{Hash: hashMCPOAuthSecret(access), GrantID: grant.ID, Kind: "access", ExpiresAt: expiresAt} + var refreshRecord *MCPOAuthCredential + if includeRefresh { + refresh, err := mcpOAuthSecret("mcp_rt_") + if err != nil { + return nil, nil, nil, err + } + result.RefreshToken = refresh + refreshRecord = &MCPOAuthCredential{Hash: hashMCPOAuthSecret(refresh), GrantID: grant.ID, Kind: "refresh", ExpiresAt: grant.ExpiresAt} + } + return result, accessRecord, refreshRecord, nil +} + +func validMCPCodeVerifier(value string) bool { + if len(value) < 43 || len(value) > 128 { + return false + } + for _, ch := range value { + if (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') && ch != '-' && ch != '.' && ch != '_' && ch != '~' { + return false + } + } + return true +} + +func (s *MCPOAuthService) validateGrant(ctx context.Context, grant *MCPOAuthGrant) (*User, error) { + if grant == nil || grant.RevokedAt != nil || !time.Now().Before(grant.ExpiresAt) || (grant.Scope != MCPScopeUser && grant.Scope != MCPScopeAdmin) { + return nil, ErrMCPOAuthInvalidToken + } + user, err := s.users.GetByID(ctx, grant.UserID) + if errors.Is(err, ErrUserNotFound) { + return nil, ErrMCPOAuthInvalidToken + } + if err != nil { + return nil, err + } + if mcpGrantInvalidated(grant, user) { + return nil, ErrMCPOAuthInvalidToken + } + return user, nil +} + +func mcpGrantInvalidated(grant *MCPOAuthGrant, user *User) bool { + return user == nil || !user.IsActive() || user.DeletedAt != nil || grant.TokenVersion != resolvedTokenVersion(user) || (grant.Scope == MCPScopeAdmin && !user.IsAdmin()) +} + +func (s *MCPOAuthService) Authenticate(ctx context.Context, raw string) (*MCPPrincipal, error) { + if !validMCPOAuthSecret(raw, "mcp_at_") { + return nil, ErrMCPOAuthInvalidToken + } + credential, err := s.repo.GetCredential(ctx, hashMCPOAuthSecret(raw)) + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil, ErrMCPOAuthInvalidToken + } + if err != nil { + return nil, err + } + now := time.Now().UTC() + if credential == nil || credential.Kind != "access" || !now.Before(credential.ExpiresAt) { + return nil, ErrMCPOAuthInvalidToken + } + grant, err := s.repo.GetGrant(ctx, credential.GrantID) + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil, ErrMCPOAuthInvalidToken + } + if err != nil { + return nil, err + } + user, err := s.validateGrant(ctx, grant) + if err != nil { + return nil, err + } + if err := s.repo.TouchGrant(ctx, grant.ID, now); err != nil { + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil, ErrMCPOAuthInvalidToken + } + return nil, err + } + return &MCPPrincipal{GrantID: grant.ID, UserID: user.ID, Role: user.Role, Scope: grant.Scope, + ReadOnly: grant.ReadOnly, Concurrency: user.Concurrency, Email: user.Email, Resource: grant.Resource}, nil +} + +func (s *MCPOAuthService) ListGrants(ctx context.Context, userID int64) ([]*MCPOAuthGrant, error) { + user, err := s.users.GetByID(ctx, userID) + if err != nil { + return nil, err + } + grants, err := s.repo.ListGrants(ctx, userID) + if err != nil { + return nil, err + } + for _, grant := range grants { + grant.Invalidated = mcpGrantInvalidated(grant, user) + } + return grants, nil +} + +func (s *MCPOAuthService) RevokeGrant(ctx context.Context, userID int64, id string) error { + err := s.repo.RevokeGrant(ctx, userID, id, time.Now().UTC()) + if errors.Is(err, ErrMCPOAuthNotFound) { + return ErrMCPOAuthGrantNotFound + } + return err +} + +func (s *MCPOAuthService) RevokeToken(ctx context.Context, clientID, raw string) error { + if !validMCPOAuthSecret(raw, "mcp_at_") && !validMCPOAuthSecret(raw, "mcp_rt_") { + return nil + } + credential, err := s.repo.GetCredential(ctx, hashMCPOAuthSecret(raw)) + if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && credential == nil) { + return nil + } + if err != nil { + return err + } + grant, err := s.repo.GetGrant(ctx, credential.GrantID) + if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && (grant == nil || grant.ClientID != clientID)) { + return nil + } + if err != nil { + return err + } + err = s.repo.RevokeGrant(ctx, grant.UserID, grant.ID, time.Now().UTC()) + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil + } + return err +} + +func mcpOAuthSecret(prefix string) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(raw), nil +} + +func validMCPOAuthSecret(raw, prefix string) bool { + if len(raw) != len(prefix)+43 || !strings.HasPrefix(raw, prefix) { + return false + } + decoded, err := base64.RawURLEncoding.DecodeString(raw[len(prefix):]) + return err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == raw[len(prefix):] +} + +func hashMCPOAuthSecret(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/service/mcp_oauth_service_test.go b/backend/internal/service/mcp_oauth_service_test.go new file mode 100644 index 000000000000..1abf3d3c04d7 --- /dev/null +++ b/backend/internal/service/mcp_oauth_service_test.go @@ -0,0 +1,552 @@ +//go:build unit + +package service + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type mcpOAuthMemoryRepo struct { + mu sync.Mutex + clients map[string]*MCPOAuthClient + grants map[string]*MCPOAuthGrant + codes map[string]*MCPOAuthCode + credentials map[string]*MCPOAuthCredential +} + +func (r *mcpOAuthMemoryRepo) CreateClient(_ context.Context, client *MCPOAuthClient) error { + r.mu.Lock() + defer r.mu.Unlock() + r.clients[client.ClientID] = client + return nil +} + +func (r *mcpOAuthMemoryRepo) GetClient(_ context.Context, id string) (*MCPOAuthClient, error) { + r.mu.Lock() + defer r.mu.Unlock() + if client := r.clients[id]; client != nil { + copy := *client + return ©, nil + } + return nil, ErrMCPOAuthNotFound +} + +func (r *mcpOAuthMemoryRepo) CreateAuthorization(_ context.Context, grant *MCPOAuthGrant, code *MCPOAuthCode, limit int) error { + r.mu.Lock() + defer r.mu.Unlock() + count := 0 + for _, existing := range r.grants { + if existing.UserID == grant.UserID && existing.RevokedAt == nil && existing.ExpiresAt.After(grant.CreatedAt) { + count++ + } + } + if count >= limit { + return ErrMCPOAuthGrantLimit + } + r.grants[grant.ID], r.codes[code.Hash] = grant, code + return nil +} + +func (r *mcpOAuthMemoryRepo) GetCode(_ context.Context, hash string) (*MCPOAuthCode, error) { + r.mu.Lock() + defer r.mu.Unlock() + if code := r.codes[hash]; code != nil { + copy := *code + return ©, nil + } + return nil, ErrMCPOAuthNotFound +} + +func (r *mcpOAuthMemoryRepo) GetCredential(_ context.Context, hash string) (*MCPOAuthCredential, error) { + r.mu.Lock() + defer r.mu.Unlock() + if credential := r.credentials[hash]; credential != nil { + copy := *credential + return ©, nil + } + return nil, ErrMCPOAuthNotFound +} + +func (r *mcpOAuthMemoryRepo) GetGrant(_ context.Context, id string) (*MCPOAuthGrant, error) { + r.mu.Lock() + defer r.mu.Unlock() + if grant := r.grants[id]; grant != nil { + copy := *grant + return ©, nil + } + return nil, ErrMCPOAuthNotFound +} + +func (r *mcpOAuthMemoryRepo) consume(hash, grantID string, now time.Time, access, refresh *MCPOAuthCredential, rotating bool) error { + r.mu.Lock() + defer r.mu.Unlock() + grant := r.grants[grantID] + if grant == nil || grant.RevokedAt != nil || !now.Before(grant.ExpiresAt) { + return ErrMCPOAuthNotFound + } + var expiry time.Time + var used **time.Time + if rotating { + old := r.credentials[hash] + if old == nil || old.GrantID != grantID || old.Kind != "refresh" { + return ErrMCPOAuthNotFound + } + expiry, used = old.ExpiresAt, &old.UsedAt + } else { + code := r.codes[hash] + if code == nil || code.GrantID != grantID { + return ErrMCPOAuthNotFound + } + expiry, used = code.ExpiresAt, &code.ConsumedAt + } + if *used != nil { + grant.RevokedAt = &now + return ErrMCPOAuthNotFound + } + if !now.Before(expiry) { + return ErrMCPOAuthNotFound + } + *used = &now + r.credentials[access.Hash] = access + if refresh != nil { + r.credentials[refresh.Hash] = refresh + } + return nil +} + +func (r *mcpOAuthMemoryRepo) ExchangeCode(_ context.Context, hash, grantID string, now time.Time, access, refresh *MCPOAuthCredential) error { + return r.consume(hash, grantID, now, access, refresh, false) +} + +func (r *mcpOAuthMemoryRepo) RotateRefresh(_ context.Context, hash, grantID string, now time.Time, access, refresh *MCPOAuthCredential) error { + return r.consume(hash, grantID, now, access, refresh, true) +} + +func (r *mcpOAuthMemoryRepo) TouchGrant(_ context.Context, id string, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + grant := r.grants[id] + if grant == nil || grant.RevokedAt != nil || !now.Before(grant.ExpiresAt) { + return ErrMCPOAuthNotFound + } + grant.LastUsedAt = &now + return nil +} + +func (r *mcpOAuthMemoryRepo) ListGrants(_ context.Context, userID int64) ([]*MCPOAuthGrant, error) { + r.mu.Lock() + defer r.mu.Unlock() + grants := []*MCPOAuthGrant{} + for _, grant := range r.grants { + if grant.UserID == userID { + copy := *grant + grants = append(grants, ©) + } + } + return grants, nil +} + +func (r *mcpOAuthMemoryRepo) RevokeGrant(_ context.Context, userID int64, id string, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + grant := r.grants[id] + if grant == nil || grant.UserID != userID { + return ErrMCPOAuthNotFound + } + if grant.RevokedAt == nil { + grant.RevokedAt = &now + } + return nil +} + +type mcpOAuthUserRepo struct { + UserRepository + user *User +} + +func (r *mcpOAuthUserRepo) GetByID(_ context.Context, id int64) (*User, error) { + if r.user == nil || r.user.ID != id { + return nil, ErrUserNotFound + } + copy := *r.user + return ©, nil +} + +const mcpTestResource = "https://platform.example/api/v1/mcp" + +type mcpOAuthFixture struct { + s *MCPOAuthService + r *mcpOAuthMemoryRepo + u *mcpOAuthUserRepo + client *MCPOAuthClient + authorize MCPOAuthAuthorizeInput + verifier string +} + +func newMCPOAuthFixture(t *testing.T) *mcpOAuthFixture { + t.Helper() + r := &mcpOAuthMemoryRepo{clients: map[string]*MCPOAuthClient{}, grants: map[string]*MCPOAuthGrant{}, codes: map[string]*MCPOAuthCode{}, credentials: map[string]*MCPOAuthCredential{}} + u := &mcpOAuthUserRepo{user: &User{ID: 7, Role: RoleUser, Status: StatusActive, Email: "user@example.com", PasswordHash: "original", Concurrency: 3}} + s := NewMCPOAuthService(r, u) + client, err := s.Register(context.Background(), MCPOAuthRegisterInput{ClientName: "My MCP client", RedirectURIs: []string{"http://127.0.0.1:2345/callback?existing=1", "https://client.example/callback"}}) + require.NoError(t, err) + verifier := strings.Repeat("a", 43) + sum := sha256.Sum256([]byte(verifier)) + return &mcpOAuthFixture{s: s, r: r, u: u, client: client, verifier: verifier, + authorize: MCPOAuthAuthorizeInput{ResponseType: "code", ClientID: client.ClientID, RedirectURI: client.RedirectURIs[0], Scope: "mcp", State: "opaque&state", CodeChallenge: base64.RawURLEncoding.EncodeToString(sum[:]), CodeChallengeMethod: "S256", Resource: mcpTestResource}} +} + +func (f *mcpOAuthFixture) code(t *testing.T, scope string, readOnly *bool) MCPOAuthTokenInput { + t.Helper() + redirect, err := f.s.Decide(context.Background(), 7, MCPOAuthConsentInput{Authorization: f.authorize, Approved: true, Scope: scope, ReadOnly: readOnly}, mcpTestResource) + require.NoError(t, err) + u, err := url.Parse(redirect) + require.NoError(t, err) + require.Equal(t, "opaque&state", u.Query().Get("state")) + require.Equal(t, "1", u.Query().Get("existing")) + return MCPOAuthTokenInput{GrantType: "authorization_code", ClientID: f.client.ClientID, Code: u.Query().Get("code"), RedirectURI: f.authorize.RedirectURI, CodeVerifier: f.verifier, Resource: mcpTestResource} +} + +func (f *mcpOAuthFixture) tokens(t *testing.T) *MCPOAuthTokenResponse { + t.Helper() + tokens, err := f.s.Token(context.Background(), f.code(t, "", nil), mcpTestResource) + require.NoError(t, err) + return tokens +} + +func requireMCPOAuthError(t *testing.T, err error, code string) { + t.Helper() + var oauthErr *MCPOAuthError + require.ErrorAs(t, err, &oauthErr) + require.Equal(t, code, oauthErr.Code) +} + +func TestMCPOAuthRegistrationValidatesRedirectsAndPublicClientMetadata(t *testing.T) { + f := newMCPOAuthFixture(t) + require.Equal(t, "none", f.client.TokenEndpointAuthMethod) + require.Equal(t, []string{"authorization_code", "refresh_token"}, f.client.GrantTypes) + for _, uri := range []string{"https://client.example/callback", "http://localhost:3000/cb", "http://[::1]:3000/cb", "http://127.0.0.1/cb"} { + _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{uri}}) + require.NoError(t, err, uri) + } + for _, uri := range []string{"http://client.example/cb", "https://client.example/cb#fragment", "https://user:password@client.example/cb", "https://*.example/cb", "file:///callback", "https:///callback", "http://127.0.0.1.evil.example/cb", "https://client.example/cb#"} { + _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{uri}}) + requireMCPOAuthError(t, err, "invalid_redirect_uri") + } + _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{"https://client.example/cb"}, TokenEndpointAuthMethod: "client_secret_post"}) + requireMCPOAuthError(t, err, "invalid_client_metadata") +} + +func TestMCPOAuthAuthorizationRequiresPKCEResourceAndRegisteredRedirect(t *testing.T) { + f := newMCPOAuthFixture(t) + preview, err := f.s.Preview(context.Background(), 7, f.authorize, mcpTestResource) + require.NoError(t, err) + require.Equal(t, MCPOAuthScope, preview.Scope) + for _, tc := range []struct { + name, code string + mutate func(*MCPOAuthAuthorizeInput) + }{ + {"redirect", "invalid_request", func(a *MCPOAuthAuthorizeInput) { a.RedirectURI += "x" }}, + {"client", "invalid_client", func(a *MCPOAuthAuthorizeInput) { a.ClientID = "unknown" }}, + {"response", "unsupported_response_type", func(a *MCPOAuthAuthorizeInput) { a.ResponseType = "token" }}, + {"scope", "invalid_scope", func(a *MCPOAuthAuthorizeInput) { a.Scope = "mcp admin" }}, + {"resource", "invalid_target", func(a *MCPOAuthAuthorizeInput) { a.Resource = "https://other.example/mcp" }}, + {"missing resource", "invalid_target", func(a *MCPOAuthAuthorizeInput) { a.Resource = "" }}, + {"plain PKCE", "invalid_request", func(a *MCPOAuthAuthorizeInput) { a.CodeChallengeMethod = "plain" }}, + {"missing PKCE", "invalid_request", func(a *MCPOAuthAuthorizeInput) { a.CodeChallenge = "" }}, + } { + t.Run(tc.name, func(t *testing.T) { + input := f.authorize + tc.mutate(&input) + _, err := f.s.Preview(context.Background(), 7, input, mcpTestResource) + requireMCPOAuthError(t, err, tc.code) + }) + } + redirect, err := f.s.Decide(context.Background(), 7, MCPOAuthConsentInput{Authorization: f.authorize}, mcpTestResource) + require.NoError(t, err) + u, err := url.Parse(redirect) + require.NoError(t, err) + require.Equal(t, "access_denied", u.Query().Get("error")) + require.Equal(t, f.authorize.State, u.Query().Get("state")) + require.Empty(t, f.r.grants) +} + +func TestMCPOAuthCodeBindingFailuresDoNotConsumeCode(t *testing.T) { + for _, name := range []string{"client", "redirect", "resource", "missing resource", "verifier"} { + t.Run(name, func(t *testing.T) { + f := newMCPOAuthFixture(t) + valid := f.code(t, "", nil) + invalid := valid + switch name { + case "client": + client, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: f.client.RedirectURIs}) + require.NoError(t, err) + invalid.ClientID = client.ClientID + case "redirect": + invalid.RedirectURI = f.client.RedirectURIs[1] + case "resource": + invalid.Resource = "https://other.example/mcp" + case "missing resource": + invalid.Resource = "" + case "verifier": + invalid.CodeVerifier = strings.Repeat("b", 43) + } + _, err := f.s.Token(context.Background(), invalid, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + _, err = f.s.Token(context.Background(), valid, mcpTestResource) + require.NoError(t, err) + }) + } +} + +func TestMCPOAuthCodeReplayRevokesEntireGrant(t *testing.T) { + f := newMCPOAuthFixture(t) + input := f.code(t, "", nil) + tokens, err := f.s.Token(context.Background(), input, mcpTestResource) + require.NoError(t, err) + principal, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.NoError(t, err) + require.Equal(t, MCPScopeUser, principal.Scope) + require.True(t, principal.ReadOnly) + require.Equal(t, mcpTestResource, principal.Resource) + require.Equal(t, int64(600), tokens.ExpiresIn) + _, err = f.s.Token(context.Background(), input, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + _, err = f.s.Authenticate(context.Background(), tokens.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + _, err = f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: tokens.RefreshToken}, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") +} + +func TestMCPOAuthRefreshRotationInheritsResourceAndDetectsReplay(t *testing.T) { + f := newMCPOAuthFixture(t) + first := f.tokens(t) + input := MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: first.RefreshToken} + second, err := f.s.Token(context.Background(), input, mcpTestResource) + require.NoError(t, err) + require.NotEqual(t, first.AccessToken, second.AccessToken) + require.NotEqual(t, first.RefreshToken, second.RefreshToken) + _, err = f.s.Authenticate(context.Background(), first.AccessToken) + require.NoError(t, err, "ordinary refresh must preserve in-flight access credentials") + _, err = f.s.Authenticate(context.Background(), second.AccessToken) + require.NoError(t, err) + require.Equal(t, f.r.credentials[hashMCPOAuthSecret(first.RefreshToken)].ExpiresAt, f.r.credentials[hashMCPOAuthSecret(second.RefreshToken)].ExpiresAt) + _, err = f.s.Token(context.Background(), input, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + _, err = f.s.Authenticate(context.Background(), second.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + input.RefreshToken = second.RefreshToken + _, err = f.s.Token(context.Background(), input, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") +} + +func TestMCPOAuthRefreshRejectsOtherClientResourceAndScope(t *testing.T) { + f := newMCPOAuthFixture(t) + tokens := f.tokens(t) + other, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: f.client.RedirectURIs}) + require.NoError(t, err) + for _, tc := range []struct{ client, resource, scope, code string }{ + {other.ClientID, "", "", "invalid_grant"}, + {f.client.ClientID, "https://other.example/mcp", "", "invalid_grant"}, + {f.client.ClientID, "", "admin", "invalid_scope"}, + } { + _, err := f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: tc.client, RefreshToken: tokens.RefreshToken, Resource: tc.resource, Scope: tc.scope}, mcpTestResource) + requireMCPOAuthError(t, err, tc.code) + } + _, err = f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: tokens.RefreshToken, Resource: mcpTestResource}, mcpTestResource) + require.NoError(t, err) +} + +func TestMCPOAuthRechecksLiveOwnerOnExchangeRefreshAndAccess(t *testing.T) { + for _, operation := range []string{"exchange", "refresh", "access"} { + for _, change := range []string{"password", "email", "disabled", "deleted", "missing", "demoted"} { + t.Run(operation+"/"+change, func(t *testing.T) { + f := newMCPOAuthFixture(t) + f.u.user.Role = RoleAdmin + code := f.code(t, MCPScopeAdmin, nil) + var tokens *MCPOAuthTokenResponse + if operation != "exchange" { + var err error + tokens, err = f.s.Token(context.Background(), code, mcpTestResource) + require.NoError(t, err) + } + switch change { + case "password": + f.u.user.PasswordHash = "changed" + case "email": + f.u.user.Email = "new@example.com" + case "disabled": + f.u.user.Status = StatusDisabled + case "deleted": + now := time.Now() + f.u.user.DeletedAt = &now + case "missing": + f.u.user = nil + case "demoted": + f.u.user.Role = RoleUser + } + switch operation { + case "exchange": + _, err := f.s.Token(context.Background(), code, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + case "refresh": + _, err := f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: tokens.RefreshToken}, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + case "access": + _, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + } + }) + } + } +} + +func TestMCPOAuthExpiryAndCredentialTypeBoundaries(t *testing.T) { + t.Run("code", func(t *testing.T) { + f := newMCPOAuthFixture(t) + input := f.code(t, "", nil) + f.r.codes[hashMCPOAuthSecret(input.Code)].ExpiresAt = time.Now().Add(-time.Second) + _, err := f.s.Token(context.Background(), input, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + }) + for _, kind := range []string{"access", "refresh", "grant"} { + t.Run(kind, func(t *testing.T) { + f := newMCPOAuthFixture(t) + tokens := f.tokens(t) + principal, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.NoError(t, err) + past := time.Now().Add(-time.Second) + switch kind { + case "access": + f.r.credentials[hashMCPOAuthSecret(tokens.AccessToken)].ExpiresAt = past + case "refresh": + f.r.credentials[hashMCPOAuthSecret(tokens.RefreshToken)].ExpiresAt = past + case "grant": + f.r.grants[principal.GrantID].ExpiresAt = past + } + if kind != "refresh" { + _, err = f.s.Authenticate(context.Background(), tokens.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + } + if kind != "access" { + _, err = f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: tokens.RefreshToken}, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + } + }) + } + f := newMCPOAuthFixture(t) + tokens := f.tokens(t) + _, err := f.s.Authenticate(context.Background(), tokens.RefreshToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + _, err = f.s.Authenticate(context.Background(), "mcp_legacy_personal_token") + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) +} + +func TestMCPOAuthConsentPermissionsRevocationAndNoSecretPersistence(t *testing.T) { + f := newMCPOAuthFixture(t) + _, err := f.s.Decide(context.Background(), 7, MCPOAuthConsentInput{Authorization: f.authorize, Approved: true, Scope: MCPScopeAdmin}, mcpTestResource) + requireMCPOAuthError(t, err, "access_denied") + f.u.user.Role = RoleAdmin + readOnly := false + input := f.code(t, MCPScopeAdmin, &readOnly) + tokens, err := f.s.Token(context.Background(), input, mcpTestResource) + require.NoError(t, err) + principal, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.NoError(t, err) + require.Equal(t, MCPScopeAdmin, principal.Scope) + require.False(t, principal.ReadOnly) + grants, err := f.s.ListGrants(context.Background(), 7) + require.NoError(t, err) + require.Len(t, grants, 1) + require.NotNil(t, grants[0].LastUsedAt) + encoded, err := json.Marshal(grants) + require.NoError(t, err) + for _, secret := range []string{input.Code, tokens.AccessToken, tokens.RefreshToken, "token_version", "user_id", "resource"} { + require.NotContains(t, string(encoded), secret) + } + require.NotContains(t, f.r.credentials, tokens.AccessToken) + require.NotContains(t, f.r.credentials, tokens.RefreshToken) + require.NotContains(t, f.r.codes, input.Code) + require.ErrorIs(t, f.s.RevokeGrant(context.Background(), 8, principal.GrantID), ErrMCPOAuthGrantNotFound) + require.NoError(t, f.s.RevokeToken(context.Background(), "other-client", tokens.RefreshToken)) + _, err = f.s.Authenticate(context.Background(), tokens.AccessToken) + require.NoError(t, err) + require.NoError(t, f.s.RevokeGrant(context.Background(), 7, principal.GrantID)) + require.NoError(t, f.s.RevokeGrant(context.Background(), 7, principal.GrantID)) + _, err = f.s.Authenticate(context.Background(), tokens.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) +} + +func TestMCPOAuthListReportsInvalidatedAndGrantLimit(t *testing.T) { + f := newMCPOAuthFixture(t) + for i := 0; i < MCPOAuthGrantLimit; i++ { + f.code(t, "", nil) + } + _, err := f.s.Decide(context.Background(), 7, MCPOAuthConsentInput{Authorization: f.authorize, Approved: true}, mcpTestResource) + require.ErrorIs(t, err, ErrMCPOAuthGrantLimit) + f.u.user.PasswordHash = "changed" + grants, err := f.s.ListGrants(context.Background(), 7) + require.NoError(t, err) + for _, grant := range grants { + require.True(t, grant.Invalidated) + } +} + +func TestMCPOAuthClientRevocationInvalidatesAccessAndRefresh(t *testing.T) { + f := newMCPOAuthFixture(t) + tokens := f.tokens(t) + require.NoError(t, f.s.RevokeToken(context.Background(), f.client.ClientID, tokens.RefreshToken)) + _, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + _, err = f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: tokens.RefreshToken}, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + require.NoError(t, f.s.RevokeToken(context.Background(), f.client.ClientID, tokens.RefreshToken)) + require.NoError(t, f.s.RevokeToken(context.Background(), f.client.ClientID, "unknown")) +} + +func TestMCPOAuthCodeOnlyClientReceivesNoRefreshToken(t *testing.T) { + f := newMCPOAuthFixture(t) + f.r.clients[f.client.ClientID].GrantTypes = []string{"authorization_code"} + tokens := f.tokens(t) + require.NotEmpty(t, tokens.AccessToken) + require.Empty(t, tokens.RefreshToken) + encoded, err := json.Marshal(tokens) + require.NoError(t, err) + require.NotContains(t, string(encoded), "refresh_token") + require.Len(t, f.r.credentials, 1) +} + +func TestMCPOAuthConcurrentCodeExchangeAllowsAtMostOneIssuance(t *testing.T) { + f := newMCPOAuthFixture(t) + input := f.code(t, "", nil) + start := make(chan struct{}) + results := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + <-start + _, err := f.s.Token(context.Background(), input, mcpTestResource) + results <- err + }() + } + close(start) + successes := 0 + for i := 0; i < 2; i++ { + if err := <-results; err == nil { + successes++ + } else { + requireMCPOAuthError(t, err, "invalid_grant") + } + } + require.Equal(t, 1, successes) + require.Len(t, f.r.credentials, 2, "only one credential pair may be persisted") +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 472046b04721..5fae3b0536d2 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -861,6 +861,7 @@ var ProviderSet = wire.NewSet( ProvideAuthService, NewPasskeyService, NewUserService, + NewMCPOAuthService, ProvideAPIKeyService, ProvideAPIKeyAuthCacheInvalidator, ProvideAuthCacheInvalidationWorker, diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index d16b1c4d8015..03e5e5f50ee9 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -355,6 +355,9 @@ func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool { func shouldBypassEmbeddedFrontend(path string) bool { trimmed := strings.TrimSpace(path) return strings.HasPrefix(trimmed, "/api/") || + trimmed == "/.well-known/oauth-protected-resource" || + trimmed == "/.well-known/oauth-protected-resource/api/v1/mcp" || + trimmed == "/.well-known/oauth-authorization-server" || strings.HasPrefix(trimmed, "/v1/") || strings.HasPrefix(trimmed, "/v1beta/") || strings.HasPrefix(trimmed, "/backend-api/") || diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go index 965ee6cb0eb6..75dac764fb8b 100644 --- a/backend/internal/web/embed_test.go +++ b/backend/internal/web/embed_test.go @@ -528,6 +528,9 @@ func TestFrontendServer_Middleware(t *testing.T) { "/antigravity/test", "/setup/init", "/health", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/api/v1/mcp", + "/.well-known/oauth-authorization-server", "/responses", "/responses/compact", "/chat/completions", @@ -827,6 +830,9 @@ func TestServeEmbeddedFrontend(t *testing.T) { "/antigravity/test", "/setup/init", "/health", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/api/v1/mcp", + "/.well-known/oauth-authorization-server", "/responses", "/responses/compact", "/chat/completions", diff --git a/backend/migrations/249_mcp_oauth.sql b/backend/migrations/249_mcp_oauth.sql new file mode 100644 index 000000000000..fde8ca68a4d8 --- /dev/null +++ b/backend/migrations/249_mcp_oauth.sql @@ -0,0 +1,50 @@ +-- OAuth clients use public-client authorization code + S256 PKCE. No client +-- secrets, authorization codes, access tokens, or refresh tokens are stored raw. +CREATE TABLE IF NOT EXISTS mcp_oauth_clients ( + client_id VARCHAR(64) PRIMARY KEY, + client_name VARCHAR(100) NOT NULL, + redirect_uris JSONB NOT NULL, + grant_types JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_clients_created ON mcp_oauth_clients(created_at); + +CREATE TABLE IF NOT EXISTS mcp_oauth_grants ( + id VARCHAR(43) PRIMARY KEY, + client_id VARCHAR(64) NOT NULL REFERENCES mcp_oauth_clients(client_id) ON DELETE RESTRICT, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_version BIGINT NOT NULL, + resource TEXT NOT NULL, + scope VARCHAR(16) NOT NULL CHECK (scope IN ('user', 'admin')), + read_only BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_grants_user_created ON mcp_oauth_grants(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_grants_client ON mcp_oauth_grants(client_id); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_grants_expiry ON mcp_oauth_grants(expires_at); + +CREATE TABLE IF NOT EXISTS mcp_oauth_codes ( + code_hash VARCHAR(64) PRIMARY KEY, + grant_id VARCHAR(43) NOT NULL REFERENCES mcp_oauth_grants(id) ON DELETE CASCADE, + client_id VARCHAR(64) NOT NULL, + redirect_uri TEXT NOT NULL, + resource TEXT NOT NULL, + code_challenge VARCHAR(43) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_codes_grant ON mcp_oauth_codes(grant_id); + +-- Consumed refresh digests remain until the grant is pruned, allowing a replay +-- to revoke the entire family, including access tokens issued after rotation. +CREATE TABLE IF NOT EXISTS mcp_oauth_credentials ( + key_hash VARCHAR(64) PRIMARY KEY, + grant_id VARCHAR(43) NOT NULL REFERENCES mcp_oauth_grants(id) ON DELETE CASCADE, + kind VARCHAR(8) NOT NULL CHECK (kind IN ('access', 'refresh')), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_credentials_grant ON mcp_oauth_credentials(grant_id); diff --git a/deploy/.env.example b/deploy/.env.example index fa10e83e7910..4aec7ec545c3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -30,6 +30,11 @@ UPDATE_GITHUB_TOKEN= # Return Server-Timing for authenticated requests made by the Admin web UI ENABLE_SERVER_TIMING=false +# Canonical public MCP endpoint for OAuth discovery, e.g. https://example.com/api/v1/mcp. +# MCP OAuth 固定对外地址;留空使用已配置的前端地址来源,不使用请求中的 Host。 +# HTTPS required except localhost/loopback development; exact /api/v1/mcp path, no trailing slash. +MCP_PUBLIC_URL= + # Apple container image overrides (ignored by Docker Compose). Pin release tags # or digests for repeatable operator-managed deployments. APPLE_CONTAINER_SUB2API_IMAGE=weishaw/sub2api:latest diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index d4ccebd235d1..c7eb2b469e2d 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -120,6 +120,19 @@ cors: # 允许携带凭证(cookies/授权头)。不能与 "*" 通配符同时使用。 allow_credentials: true +# ============================================================================= +# MCP OAuth Configuration / MCP OAuth 配置 +# ============================================================================= +mcp: + # Canonical public MCP endpoint, e.g. https://example.com/api/v1/mcp. + # 固定的 MCP 对外地址,例如 https://example.com/api/v1/mcp。 + # Leave empty to use the trusted frontend URL's origin with /api/v1/mcp. + # 留空时使用已配置的前端地址来源并追加 /api/v1/mcp;不使用请求中的 Host。 + # HTTPS is required except localhost/loopback development; no trailing slash. + # 除 localhost/回环 IP 开发环境外必须使用 HTTPS,路径末尾不加斜杠。 + # Environment / 环境变量: MCP_PUBLIC_URL + public_url: "" + # ============================================================================= # Security Configuration # 安全配置 diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 45789f48abf4..54953ba2f8d5 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -29,6 +29,7 @@ services: - SERVER_PORT=8080 - SERVER_MODE=debug - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} + - MCP_PUBLIC_URL=${MCP_PUBLIC_URL:-} - RUN_MODE=${RUN_MODE:-standard} - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED=${SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED:-false} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 32bc439f4d86..6ca816a548de 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -54,6 +54,7 @@ services: - SERVER_PORT=8080 - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} + - MCP_PUBLIC_URL=${MCP_PUBLIC_URL:-} - RUN_MODE=${RUN_MODE:-standard} - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED=${SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED:-false} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index fe9619df15e0..6711bcbdb430 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -40,6 +40,7 @@ services: - SERVER_PORT=8080 - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} + - MCP_PUBLIC_URL=${MCP_PUBLIC_URL:-} - RUN_MODE=${RUN_MODE:-standard} - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED=${SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED:-false} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 84c98643eac2..51e3cb5dedbb 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -50,6 +50,7 @@ services: - SERVER_PORT=8080 - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} + - MCP_PUBLIC_URL=${MCP_PUBLIC_URL:-} - RUN_MODE=${RUN_MODE:-standard} - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED=${SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED:-false} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000000..7fe8402312d7 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,171 @@ +# 平台管理 MCP(OAuth) + +平台内置远程 MCP 服务,通过 **Streamable HTTP + OAuth 授权码 / S256 PKCE** 接入。客户端只需配置 MCP 地址;连接时打开浏览器登录平台,再明确同意个人或管理员范围、只读或读写权限。无需创建、复制或保存静态令牌。 + +## 部署 + +配置稳定、可信的公开 MCP 地址,例如: + +```sh +MCP_PUBLIC_URL=https://api.example.com/api/v1/mcp +``` + +也可以在配置文件中设置: + +```yaml +mcp: + public_url: https://api.example.com/api/v1/mcp +``` + +地址必须以 `/api/v1/mcp` 结束,不带尾斜杠、查询参数或片段。生产环境使用 HTTPS;本机开发允许 localhost 或回环 IP 的 HTTP 地址。未显式配置时,可使用平台已配置的前端 URL 的 origin 加 `/api/v1/mcp`;服务不会从请求 Host 或转发头推断 OAuth issuer。没有可信公开地址时,OAuth 接口会返回配置错误。 + +同域部署只需上述 MCP 地址。前后端分域时,另外配置平台前端 URL(管理设置或 `SERVER_FRONTEND_URL`),用于定位浏览器授权页。反向代理需转发 `/api/v1/mcp`、`/api/v1/mcp/oauth/*` 及下列 `/.well-known/*` 发现端点,保留 Authorization、Accept 和 MCP 协议请求头。浏览器客户端访问 MCP 资源仍需符合平台的 CORS/Origin 白名单。 + +## 连接 + +1. 在支持远程 MCP OAuth 的客户端中添加 `https://api.example.com/api/v1/mcp`。 +2. 客户端发现授权服务器,注册公共客户端,并自动生成 PKCE 参数。 +3. 浏览器打开平台登录与授权页面。检查应用名称和返回地址,选择授权范围及权限,再点击同意。 +4. 客户端自动交换授权码并刷新访问令牌。 + +适用于使用 `mcpServers` 和 `url` 配置的客户端: + +```json +{ + "mcpServers": { + "sub2api": { + "url": "https://api.example.com/api/v1/mcp" + } + } +} +``` + +不同客户端的配置结构可能不同;传输使用 Streamable HTTP,认证使用 OAuth。平台不接受模型调用 API Key、浏览器 JWT、管理员 API Key 或静态 MCP 令牌作为 MCP 凭证。 + +## 授权与撤销 + +默认授权为**个人范围、只读**。管理员可以在明确同意时选择管理员范围;启用平台敏感操作二次验证时,授予管理员范围同样需要 step-up。 + +- **个人范围**:只操作当前用户的数据,即使授权人是管理员也不会开放管理工具。 +- **管理员范围**:仅管理员可授予,可使用目录内管理工具及自己的个人工具。 +- **只读**:不列出写工具,直接调用写工具同样会被拒绝。 +- **读写**:开放对应范围内的修改工具,包括删除 API Key、调整余额、停用账户和撤销订阅等操作。 + +OAuth scope 为 `mcp`,实际工具权限由浏览器中确认的授权范围与读写选项决定。刷新不会扩大权限,改变权限需要重新授权。客户端应用名来自注册请求,并不代表平台验证了该应用;授权页面同时显示其回调地址。 + +授权码有效期 5 分钟且只能兑换一次。访问令牌有效期最多 10 分钟;授权及刷新令牌最长 30 天,刷新令牌每次使用都会轮换。重复使用授权码或已消费的刷新令牌会撤销该授权。服务端仅保存凭证摘要。 + +登录平台后,侧栏 **MCP 接入**(`/settings/mcp`)可查看并撤销已授权应用。每用户最多 20 个有效授权。撤销、密码或登录标识变更、用户停用都会阻止后续调用及刷新;管理员失去角色后,其管理员授权也失效。页面不显示访问或刷新令牌,MCP 工具无法自行授予新授权。 + +## 工具目录 + +客户端通过 `tools/list` 获取当前 OAuth 授权可用的工具及完整参数说明。 + +| 范围 | 工具 | 用途 | +| --- | --- | --- | +| 个人 | `user_get_profile`、`user_get_platform_quotas` | 查看个人资料、余额、限额和平台额度 | +| 个人 | `user_list_groups`、`user_list_channels` | 查看可用分组、渠道及模型 | +| 个人 | `user_list_api_keys`、`user_get_api_key` | 查询自己的 API Key 元数据和限额 | +| 个人 | `user_create_api_key`、`user_update_api_key`、`user_delete_api_key` | 创建、修改、停用或删除自己的 API Key | +| 个人 | `user_list_usage`、`user_get_usage_stats`、`user_get_dashboard` | 查询请求明细、用量统计和仪表盘 | +| 个人 | `user_list_subscriptions`、`user_get_subscription_summary` | 查看自己的订阅和剩余额度 | +| 管理员 | `admin_get_dashboard`、`admin_get_ops_overview` | 平台经营统计和运维概览 | +| 管理员 | `admin_list_users`、`admin_get_user`、`admin_update_user`、`admin_update_user_balance` | 查询用户,调整状态、限额、分组权限和余额 | +| 管理员 | `admin_list_groups`、`admin_get_group`、`admin_create_group`、`admin_update_group`、`admin_delete_group` | 管理分组基础配置、价格倍率、额度和模型白名单 | +| 管理员 | `admin_list_accounts`、`admin_get_account`、`admin_update_account`、`admin_set_account_schedulable`、`admin_clear_account_error` | 管理上游账户的状态、调度、并发、优先级和有效期 | +| 管理员 | `admin_list_usage`、`admin_get_usage_stats` | 查询全站请求明细和用量统计 | +| 管理员 | `admin_list_subscriptions`、`admin_get_subscription`、`admin_assign_subscription`、`admin_extend_subscription`、`admin_revoke_subscription` | 查询、分配、延长和撤销订阅 | + +工具只开放明确列出的字段;不提供任意 HTTP 请求、任意 URL、密码/角色修改、身份绑定、上游凭证管理、备份恢复或支付工具。普通查询和修改结果会去除 API Key、上游令牌等凭证。`user_create_api_key` 的创建结果包含新 API Key,便于配置应用;不要将该结果公开分享。 + +现有业务权限、资源归属、平台模式限制、管理合规确认、限流和操作审计继续生效。模型返回的建议不会绕过这些规则。 + +## 参数示例 + +工具参数使用 `id`、`query`、`body` 三种字段。路径中的资源 ID 放在顶层 `id`,查询条件放在 `query`,修改字段放在 `body`。未提供的更新字段保持原值;未知字段会被拒绝。分页从 1 开始,每页最多 100 条。 + +创建 API Key、调整用户余额和延长订阅还要求顶层 `idempotency_key`,可使用 UUID。同一次操作重试时保持该值和参数一致;新的操作使用新值。平台沿用已有幂等机制,避免超时重试造成重复创建或重复扣加款。 + +查询自己的用量: + +```json +{ + "name": "user_list_usage", + "arguments": { + "query": { + "page": 1, + "page_size": 20, + "start_date": "2026-10-01", + "end_date": "2026-10-28", + "timezone": "Asia/Shanghai" + } + } +} +``` + +日期格式为 `YYYY-MM-DD`,起止日期均包含在统计范围内;时区使用 IANA 名称。管理员自定义统计范围请同时提供起止日期。 + +创建一个有额度和有效期的个人 API Key: + +```json +{ + "name": "user_create_api_key", + "arguments": { + "idempotency_key": "create-development-key-20261028-1", + "body": { + "name": "development", + "group_id": 12, + "quota": 10, + "expires_in_days": 7 + } + } +} +``` + +先使用 `user_list_groups` 确认可用分组 ID。上述操作要求个人或管理员范围的读写授权。 + +暂停一个上游账户的调度: + +```json +{ + "name": "admin_set_account_schedulable", + "arguments": { + "id": 42, + "body": { "schedulable": false } + } +} +``` + +余额调整和订阅分配会改变实际计费权益。发送前确认目标和金额/天数;客户端超时后先查询结果,避免盲目重复调用。 + +## 协议与发现 + +| 端点 | 用途 | +| --- | --- | +| `GET /.well-known/oauth-protected-resource/api/v1/mcp` | RFC 9728 资源元数据;根路径 `/.well-known/oauth-protected-resource` 也可发现 | +| `GET /.well-known/oauth-authorization-server` | RFC 8414 授权服务器元数据 | +| `POST /api/v1/mcp/oauth/register` | RFC 7591 公共客户端动态注册,返回 HTTP 201 | +| `/oauth/mcp/authorize` | 前端登录与明确同意页面 | +| `POST /api/v1/mcp/oauth/token` | 授权码兑换与刷新,表单编码 | +| `POST /api/v1/mcp/oauth/revoke` | RFC 7009 撤销,表单编码 | + +未认证 MCP 请求返回 401,`WWW-Authenticate` 提供 `resource_metadata` 和 `scope="mcp"`。客户端只支持 `token_endpoint_auth_method=none`、`response_type=code`、S256 PKCE,以及授权码/刷新两种 grant type。回调 URI 必须与注册值精确匹配,使用 HTTPS 或本机 HTTP;不支持通配符。当前不提供远程 client metadata URL 自动抓取。 + +授权请求和授权码兑换必须携带精确匹配 MCP 地址的 `resource`。刷新可省略 `resource`,此时沿用原授权;传入不同资源会被拒绝。`state` 会原样返回客户端。授权码与客户端、回调、资源和 PKCE 绑定;客户端应安全保管 verifier 和刷新令牌。 + +查看发现信息: + +```sh +curl 'https://api.example.com/.well-known/oauth-protected-resource/api/v1/mcp' +curl 'https://api.example.com/.well-known/oauth-authorization-server' +``` + +服务使用官方 Go MCP SDK 的无状态 Streamable HTTP 模式,POST 返回 JSON;GET 事件流和 DELETE 会话操作返回 405。每次 HTTP 请求重新校验授权,响应禁止缓存。业务 API 失败以工具结果 `isError: true` 返回,原 HTTP 状态位于 `structuredContent.http_status`。工具执行限制 30 秒并传递客户端断开取消;写操作超时后应查询状态再决定是否重试。 + +MCP 协议请求使用独立入口限流桶;每个实际工具调用只消耗一次管理面全局额度,聚合统计仍受 Heavy 限流。动态注册、兑换和撤销接口有独立 IP 限流,Redis 不可用时拒绝请求。 + +## 开发 + +工具目录位于 `backend/internal/managementmcp/catalog.go`。新增工具应绑定已有管理路由,使用封闭参数 schema,并覆盖权限、资源归属、参数边界及凭证脱敏测试。 + +OAuth 存储由 `249_mcp_oauth.sql` 创建。注册时有界清理过期 7 天以上的授权,以及 90 天前注册且无授权关联的客户端。浏览器使用 JWT 保护的 `/api/v1/user/mcp/config` 获取服务端配置的连接地址、`/api/v1/user/mcp/authorize` 预览/决定授权,以及 `/api/v1/user/mcp/grants` 列表、撤销接口。客户端协议端点返回标准 OAuth JSON,浏览器接口沿用平台响应格式。 From cf330063361b265c932b0b11cab2c59323687549 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 28 Oct 2026 05:55:02 +0800 Subject: [PATCH 2/8] feat(frontend): add MCP OAuth consent and connections --- .gitignore | 2 + docs/mcp-oauth-connections.png | Bin 0 -> 209397 bytes docs/mcp-oauth-consent.png | Bin 0 -> 96232 bytes frontend/src/api/mcp.ts | 64 ++++++ frontend/src/components/layout/AppSidebar.vue | 2 + frontend/src/i18n/locales/en/index.ts | 2 + frontend/src/i18n/locales/en/mcp.ts | 60 +++++ frontend/src/i18n/locales/zh/index.ts | 2 + frontend/src/i18n/locales/zh/mcp.ts | 60 +++++ .../src/router/__tests__/mcp-route.spec.ts | 33 +++ frontend/src/router/index.ts | 23 ++ .../src/views/auth/MCPOAuthAuthorizeView.vue | 202 ++++++++++++++++ .../auth/__tests__/MCPLoginRedirect.spec.ts | 105 +++++++++ .../__tests__/MCPOAuthAuthorizeView.spec.ts | 215 ++++++++++++++++++ frontend/src/views/user/MCPView.vue | 179 +++++++++++++++ .../src/views/user/__tests__/MCPView.spec.ts | 150 ++++++++++++ 16 files changed, 1099 insertions(+) create mode 100644 docs/mcp-oauth-connections.png create mode 100644 docs/mcp-oauth-consent.png create mode 100644 frontend/src/api/mcp.ts create mode 100644 frontend/src/i18n/locales/en/mcp.ts create mode 100644 frontend/src/i18n/locales/zh/mcp.ts create mode 100644 frontend/src/router/__tests__/mcp-route.spec.ts create mode 100644 frontend/src/views/auth/MCPOAuthAuthorizeView.vue create mode 100644 frontend/src/views/auth/__tests__/MCPLoginRedirect.spec.ts create mode 100644 frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts create mode 100644 frontend/src/views/user/MCPView.vue create mode 100644 frontend/src/views/user/__tests__/MCPView.spec.ts diff --git a/.gitignore b/.gitignore index 3ee8dfee88b3..533b3fca9f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,8 @@ vite.config.js docs/* !docs/admin-api-keys.md !docs/mcp.md +!docs/mcp-oauth-connections.png +!docs/mcp-oauth-consent.png !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/docs/mcp-oauth-connections.png b/docs/mcp-oauth-connections.png new file mode 100644 index 0000000000000000000000000000000000000000..48925d1efdf3db6c350617a2d540b1d0acb31a2c GIT binary patch literal 209397 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

FUX1 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 df6T7oU=ViRb1Vl;+dWzBYc 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�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 OiZ4%BKbLh*2~7Zq@eTC= literal 0 HcmV?d00001 diff --git a/frontend/src/api/mcp.ts b/frontend/src/api/mcp.ts new file mode 100644 index 000000000000..7693d0c35b83 --- /dev/null +++ b/frontend/src/api/mcp.ts @@ -0,0 +1,64 @@ +import { apiClient } from './client' + +export type MCPAccessScope = 'user' | 'admin' + +export interface MCPGrant { + id: string + client_id: string + client_name: string + scope: MCPAccessScope + read_only: boolean + created_at: string + expires_at: string + last_used_at: string | null + revoked_at: string | null + invalidated: boolean +} + +export interface MCPAuthorization { + response_type: string + client_id: string + redirect_uri: string + scope?: string + state?: string + code_challenge: string + code_challenge_method: string + resource: string +} + +export interface MCPConsent { + client_id: string + client_name: string + redirect_uri: string + resource: string + scope: 'mcp' +} + +export interface MCPAuthorizationDecision { + authorization: MCPAuthorization + approved: boolean + scope: MCPAccessScope + read_only: boolean +} + +export const mcpAPI = { + async getConnectionConfig(): Promise<{ resource: string }> { + const { data } = await apiClient.get<{ resource: string }>('/user/mcp/config') + return data + }, + async listGrants(): Promise { + const { data } = await apiClient.get('/user/mcp/grants') + return data + }, + async revokeGrant(id: string): Promise { + await apiClient.delete(`/user/mcp/grants/${encodeURIComponent(id)}`) + }, + async previewAuthorization(authorization: MCPAuthorization): Promise { + const { data } = await apiClient.get('/user/mcp/authorize', { params: authorization }) + return data + }, + async authorize(input: MCPAuthorizationDecision): Promise<{ redirect_url: string }> { + const { data } = await apiClient.post<{ redirect_url: string }>('/user/mcp/authorize', input) + return data + } +} diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 1d20fef8ff7f..3ef95d4cafff 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -734,6 +734,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate }, { path: '/profile', label: t('nav.profile'), icon: UserIcon }, + { path: '/settings/mcp', label: t('mcp.title'), icon: PluginIcon }, ...customMenuItemsForUser.value.map((item): NavItem => ({ path: `/custom/${item.id}`, label: item.label, @@ -845,6 +846,7 @@ const adminNavItems = computed((): NavItem[] => { if (authStore.isSimpleMode) { const filtered = visible.filter(item => !item.hideInSimpleMode) filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) + filtered.push({ path: '/settings/mcp', label: t('mcp.title'), icon: PluginIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts index 1b111b8b2fac..f53c6384924d 100644 --- a/frontend/src/i18n/locales/en/index.ts +++ b/frontend/src/i18n/locales/en/index.ts @@ -1,4 +1,5 @@ import adminAccessKeys from './adminAccessKeys' +import mcp from './mcp' import landing from './landing' import common from './common' import dashboard from './dashboard' @@ -9,6 +10,7 @@ import misc from './misc' export default { adminAccessKeys, + mcp, ...landing, ...common, ...dashboard, diff --git a/frontend/src/i18n/locales/en/mcp.ts b/frontend/src/i18n/locales/en/mcp.ts new file mode 100644 index 000000000000..f30119fdc7ea --- /dev/null +++ b/frontend/src/i18n/locales/en/mcp.ts @@ -0,0 +1,60 @@ +export default { + title: 'MCP Connection', + description: 'Connect an AI assistant with OAuth and manage its access to your account.', + connectionTitle: 'Connect your AI assistant', + connectionDescription: 'Use a client that supports remote MCP over Streamable HTTP and OAuth sign-in.', + endpoint: 'Server URL', + copyEndpoint: 'Copy URL', + connectionLoading: 'Loading server URL…', + connectionLoadFailed: 'Unable to load the MCP server URL. Please try again.', + connectStep1: 'Add a remote MCP server in your AI client and enter this server URL.', + connectStep2: 'Choose Connect or Sign in. Your client opens this platform in your browser.', + connectStep3: 'Sign in, check the application and return address, then choose its permissions and approve access.', + capabilities: 'Personal access lets an application view your balance, usage and subscriptions, and manage your API keys. Platform access also supports managing users, groups and upstream accounts. Available tools follow the permissions you approve.', + grantsTitle: 'Authorized applications', + grantsDescription: 'Review the applications you have connected. Revoking access disconnects the application; reconnect from its client to authorize it again.', + scope: 'Access scope', + userScope: 'Personal account', + adminScope: 'Platform management', + userScopeHint: 'Access only your own resources, subject to existing platform permissions.', + adminScopeHint: 'Access platform management tools while your account remains an active administrator. Approval may require two-factor verification.', + permissions: 'Permissions', + readOnly: 'Read only', + readWrite: 'Allow changes', + readOnlyHint: 'Expose query tools only, for analyzing usage and checking status.', + readWriteHint: 'Allow creating, updating and deleting resources within this scope, including API keys and billing entitlements. Only approve this for an application you trust.', + empty: 'No authorized applications yet. Start a connection from your AI client using the server URL above.', + loading: 'Loading authorized applications…', + loadFailed: 'Failed to load authorized applications. Please try again.', + revokeFailed: 'Failed to revoke application access.', + createdAt: 'Authorized {date}', + expiresAt: 'Expires: {date}', + lastUsedAt: 'Last used: {date}', + neverUsed: 'Never used', + active: 'Active', + expired: 'Expired', + invalidated: 'Invalidated (account permissions or password changed)', + revoked: 'Revoked', + revoke: 'Revoke access', + revokeTitle: 'Revoke application access', + revokeConfirm: '“{name}” will lose access to the platform. To connect it again, start a new sign-in from that application.', + revokeSuccess: 'Application access revoked', + consentTitle: 'Authorize an MCP application', + consentDescription: 'Choose what this application can access on your behalf.', + consentLoading: 'Checking the authorization request…', + consentLoadFailed: 'Unable to verify this authorization request.', + consentRetryHint: 'Return to your AI client and start a new connection.', + backToConnections: 'Manage MCP connections', + applicationName: 'Application name (provided by the client)', + clientId: 'Client ID', + returnAddress: 'Return address', + requestedAccess: 'Requested access', + requestedAccessDescription: 'MCP access to this platform, limited to the scope and permissions you select below.', + untrustedApplicationHint: 'Application names are supplied by their clients and are not verified. Check the return address and approve only if you intended to connect this application.', + signedInAs: 'Signed in as {email}', + approve: 'Approve access', + deny: 'Deny', + invalidRequest: 'This authorization request is invalid or incomplete. No access has been granted.', + invalidRedirect: 'The server returned an unexpected return address. Please reconnect from your client.', + consentSubmitFailed: 'Unable to complete authorization. Please try again.' +} diff --git a/frontend/src/i18n/locales/zh/index.ts b/frontend/src/i18n/locales/zh/index.ts index 1b111b8b2fac..f53c6384924d 100644 --- a/frontend/src/i18n/locales/zh/index.ts +++ b/frontend/src/i18n/locales/zh/index.ts @@ -1,4 +1,5 @@ import adminAccessKeys from './adminAccessKeys' +import mcp from './mcp' import landing from './landing' import common from './common' import dashboard from './dashboard' @@ -9,6 +10,7 @@ import misc from './misc' export default { adminAccessKeys, + mcp, ...landing, ...common, ...dashboard, diff --git a/frontend/src/i18n/locales/zh/mcp.ts b/frontend/src/i18n/locales/zh/mcp.ts new file mode 100644 index 000000000000..cd5375434102 --- /dev/null +++ b/frontend/src/i18n/locales/zh/mcp.ts @@ -0,0 +1,60 @@ +export default { + title: 'MCP 接入', + description: '通过 OAuth 连接 AI 助手,并管理它对你账户的访问权限。', + connectionTitle: '连接你的 AI 助手', + connectionDescription: '请使用支持 Streamable HTTP 远程 MCP 和 OAuth 登录的客户端。', + endpoint: '服务地址', + copyEndpoint: '复制地址', + connectionLoading: '正在加载服务地址…', + connectionLoadFailed: '无法加载 MCP 服务地址,请重试。', + connectStep1: '在 AI 客户端中添加远程 MCP 服务,并填写此服务地址。', + connectStep2: '选择连接或登录,客户端会在浏览器中打开本平台。', + connectStep3: '登录后核对应用及返回地址,选择访问范围和操作权限,再确认授权。', + capabilities: '个人权限可查看余额、用量和订阅,并管理自己的 API 密钥。管理员权限还可管理用户、分组和上游账号。实际可用工具由你批准的权限决定。', + grantsTitle: '已授权应用', + grantsDescription: '查看你已连接的应用。撤销授权后应用将失去访问权限;如需再次连接,请从客户端重新发起授权。', + scope: '访问范围', + userScope: '个人账户', + adminScope: '平台管理', + userScopeHint: '只能访问你自己的资源,沿用平台现有的权限限制。', + adminScopeHint: '允许访问平台管理工具,仅在你仍是有效管理员时可用。授权时可能需要两步验证。', + permissions: '操作权限', + readOnly: '只读', + readWrite: '允许修改', + readOnlyHint: '只开放查询工具,适合分析用量、查看状态。', + readWriteHint: '允许创建、修改和删除范围内的资源,包括 API 密钥和计费权益。请只授权给你信任的应用。', + empty: '尚未授权任何应用。请在 AI 客户端中使用上方服务地址发起连接。', + loading: '正在加载已授权应用…', + loadFailed: '加载已授权应用失败,请重试。', + revokeFailed: '撤销应用授权失败。', + createdAt: '授权于 {date}', + expiresAt: '到期时间:{date}', + lastUsedAt: '上次使用:{date}', + neverUsed: '尚未使用', + active: '有效', + expired: '已过期', + invalidated: '已失效(账户权限或密码已变更)', + revoked: '已撤销', + revoke: '撤销授权', + revokeTitle: '撤销应用授权', + revokeConfirm: '“{name}”将失去平台访问权限。如需再次连接,请从该应用重新发起登录授权。', + revokeSuccess: '应用授权已撤销', + consentTitle: '授权 MCP 应用', + consentDescription: '选择此应用可以代表你访问的资源和操作权限。', + consentLoading: '正在验证授权请求…', + consentLoadFailed: '无法验证此授权请求。', + consentRetryHint: '请返回 AI 客户端,重新发起连接。', + backToConnections: '管理 MCP 连接', + applicationName: '应用名称(由客户端提供)', + clientId: '客户端 ID', + returnAddress: '返回地址', + requestedAccess: '申请访问', + requestedAccessDescription: '通过 MCP 访问此平台,权限受下方选择的访问范围和操作权限限制。', + untrustedApplicationHint: '应用名称由客户端自行提供,未经平台认证。请核对返回地址,仅在你主动连接此应用时确认授权。', + signedInAs: '当前登录账户:{email}', + approve: '确认授权', + deny: '拒绝', + invalidRequest: '授权请求无效或不完整,尚未授予任何访问权限。', + invalidRedirect: '服务器返回了非预期的地址,请从客户端重新发起连接。', + consentSubmitFailed: '无法完成授权,请重试。' +} diff --git a/frontend/src/router/__tests__/mcp-route.spec.ts b/frontend/src/router/__tests__/mcp-route.spec.ts new file mode 100644 index 000000000000..1277b058111e --- /dev/null +++ b/frontend/src/router/__tests__/mcp-route.spec.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ checkAuth: vi.fn(), isAuthenticated: false, isAdmin: false, isSimpleMode: false }) })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ siteName: 'Sub2API', backendModeEnabled: false, cachedPublicSettings: null }) })) +vi.mock('@/stores/adminSettings', () => ({ useAdminSettingsStore: () => ({ customMenuItems: [] }) })) +vi.mock('@/stores/adminCompliance', () => ({ useAdminComplianceStore: () => ({}) })) +vi.mock('@/composables/useNavigationLoading', () => ({ + useNavigationLoadingState: () => ({ startNavigation: vi.fn(), endNavigation: vi.fn() }) +})) +vi.mock('@/composables/useRoutePrefetch', () => ({ useRoutePrefetch: () => ({ triggerPrefetch: vi.fn() }) })) +vi.mock('@/views/auth/LoginView.vue', () => ({ default: { template: '

' } })) +vi.mock('@/router/title', () => ({ resolveRouteDocumentTitle: () => 'MCP OAuth' })) + +afterEach(() => { vi.restoreAllMocks() }) + +describe('MCP OAuth route', () => { + it('requires login and preserves the entire authorization query in the login redirect', async () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + const { default: router } = await import('@/router') + const record = router.getRoutes().find(route => route.name === 'MCPOAuthAuthorize') + expect(record?.path).toBe('/oauth/mcp/authorize') + expect(record?.meta.requiresAuth).toBe(true) + expect(record?.meta.requiresAdmin).toBe(false) + const path = '/oauth/mcp/authorize?' + new URLSearchParams({ + response_type: 'code', client_id: 'desktop', redirect_uri: 'http://127.0.0.1:54321/callback', + resource: 'https://api.example.com/api/v1/mcp', state: 'a+b&x=1', + code_challenge: 'a'.repeat(43), code_challenge_method: 'S256' + }).toString() + await router.push(path) + expect(router.currentRoute.value.path).toBe('/login') + expect(router.currentRoute.value.query.redirect).toBe(path) + }) +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1881ff393c0f..d163d9cba2f5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -276,6 +276,29 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'availableChannels.description' } }, + { + path: '/settings/mcp', + name: 'MCP', + component: () => import('@/views/user/MCPView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: false, + title: 'MCP Connection', + titleKey: 'mcp.title', + descriptionKey: 'mcp.description' + } + }, + { + path: '/oauth/mcp/authorize', + name: 'MCPOAuthAuthorize', + component: () => import('@/views/auth/MCPOAuthAuthorizeView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: false, + title: 'Authorize MCP Application', + titleKey: 'mcp.consentTitle' + } + }, { path: '/profile', name: 'Profile', diff --git a/frontend/src/views/auth/MCPOAuthAuthorizeView.vue b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue new file mode 100644 index 000000000000..8d60d778bf10 --- /dev/null +++ b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/src/views/auth/__tests__/MCPLoginRedirect.spec.ts b/frontend/src/views/auth/__tests__/MCPLoginRedirect.spec.ts new file mode 100644 index 000000000000..de6e6fb8527d --- /dev/null +++ b/frontend/src/views/auth/__tests__/MCPLoginRedirect.spec.ts @@ -0,0 +1,105 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LoginView from '../LoginView.vue' +import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue' +import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' +import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue' +import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue' +import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue' +import OAuthCallbackView from '../OAuthCallbackView.vue' + +const { route, login, push, replace, exchangeCompletion } = vi.hoisted(() => ({ + route: { path: '/login', query: {} as Record }, + login: vi.fn(), push: vi.fn(), replace: vi.fn(), exchangeCompletion: vi.fn() +})) +vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push, replace, currentRoute: { value: route } }) })) +vi.mock('vue-i18n', () => ({ + createI18n: () => ({ global: { t: (key: string) => key } }), + useI18n: () => ({ t: (key: string) => key, locale: { value: 'en' } }) +})) +vi.mock('@/stores', () => ({ + useAuthStore: () => ({ login, loginWithPasskey: vi.fn(), login2FA: vi.fn(), setToken: vi.fn() }), + useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn(), showWarning: vi.fn(), cachedPublicSettings: {}, publicSettingsLoaded: true }) +})) +vi.mock('@/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('@/api/auth', async () => { + const actual = await vi.importActual('@/api/auth') + return { + ...actual, + getPublicSettings: vi.fn().mockResolvedValue({ registration_enabled: false }), + isTotp2FARequired: () => false, + resolveWeChatOAuthStart: () => ({ mode: 'open' }), + exchangePendingOAuthCompletion: exchangeCompletion, + persistOAuthTokenContext: vi.fn() + } +}) +vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard: vi.fn() }) })) + +const authorizationPath = '/oauth/mcp/authorize?' + new URLSearchParams({ + response_type: 'code', client_id: 'desktop', redirect_uri: 'http://127.0.0.1:54321/callback', + resource: 'https://api.example.com/api/v1/mcp', state: 'opaque+a&b=c', + code_challenge: 'a'.repeat(43), code_challenge_method: 'S256' +}).toString() +const mounted: ReturnType[] = [] +const originalLocation = window.location +beforeEach(() => { + vi.clearAllMocks() + route.path = '/login' + route.query = { redirect: authorizationPath } + login.mockResolvedValue({}) + Object.defineProperty(window, 'location', { configurable: true, value: { ...originalLocation, href: 'https://platform.example.com/auth/callback', hash: '' } }) +}) +afterEach(() => { + mounted.splice(0).forEach(wrapper => wrapper.unmount()) + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }) +}) + +describe('MCP authorization through existing sign-in flows', () => { + it('returns to the original OAuth query after password login', async () => { + const wrapper = mount(LoginView, { + global: { stubs: { + AuthLayout: { template: '
' }, + DingTalkOAuthSection: true, EmailOAuthButtons: true, Icon: true, LinuxDoOAuthSection: true, + LoginAgreementPrompt: true, OidcOAuthSection: true, RouterLink: true, TotpLoginModal: true, + TurnstileWidget: true, WechatOAuthSection: true + } } + }) + mounted.push(wrapper) + await flushPromises() + await wrapper.get('#email').setValue('user@example.com') + await wrapper.get('#password').setValue('local-test-password') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(login).toHaveBeenCalledTimes(1) + expect(push).toHaveBeenCalledWith(authorizationPath) + }) + + it.each([ + ['github', EmailOAuthButtons, { githubEnabled: true }], + ['google', EmailOAuthButtons, { googleEnabled: true }], + ['linuxdo', LinuxDoOAuthSection, {}], + ['oidc', OidcOAuthSection, {}], + ['dingtalk', DingTalkOAuthSection, {}], + ['wechat', WechatOAuthSection, {}] + ] as const)('preserves the entire return path when starting %s sign-in', async (provider, component, props) => { + const wrapper = mount(component, { props, global: { stubs: { Icon: true } } }) + mounted.push(wrapper) + await wrapper.get('button').trigger('click') + const event = wrapper.emitted('start')?.[0]?.[0] as { provider: string; params: Record } + expect(event.provider).toBe(provider) + expect(event.params.redirect).toBe(authorizationPath) + const { buildOAuthLoginStartURL } = await import('@/api/auth') + const start = new URL(buildOAuthLoginStartURL(event), 'https://platform.example.com') + expect(start.searchParams.get('redirect')).toBe(authorizationPath) + }) + + it('returns from successful social sign-in to the intact OAuth consent request', async () => { + route.path = '/auth/oauth/callback' + route.query = {} + exchangeCompletion.mockResolvedValue({ access_token: 'test-session', redirect: authorizationPath }) + const wrapper = mount(OAuthCallbackView) + mounted.push(wrapper) + await flushPromises() + expect(replace).toHaveBeenCalledWith(authorizationPath) + }) +}) diff --git a/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts b/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts new file mode 100644 index 000000000000..452c0ef46358 --- /dev/null +++ b/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts @@ -0,0 +1,215 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { reactive } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import MCPOAuthAuthorizeView from '../MCPOAuthAuthorizeView.vue' +import type { MCPAuthorization, MCPConsent } from '@/api/mcp' + +const { auth, previewAuthorization, authorize, routeState, assign } = vi.hoisted(() => ({ + auth: { isAdmin: false, user: { email: 'user@example.com' } }, + previewAuthorization: vi.fn(), authorize: vi.fn(), assign: vi.fn(), + routeState: { current: {} as { fullPath: string; query: Record } } +})) +vi.mock('vue-router', () => ({ useRoute: () => routeState.current })) +vi.mock('@/api/mcp', () => ({ mcpAPI: { previewAuthorization, authorize } })) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +vi.mock('@/components/auth/TotpStepUpDialog.vue', () => ({ + default: { + props: ['controller'], + template: '
' + } +})) +const request: MCPAuthorization = { + response_type: 'code', client_id: 'client-1', redirect_uri: 'https://client.example.com/callback?workspace=team', + scope: 'mcp', state: 'opaque+value&with=reserved?characters', code_challenge: 'a'.repeat(43), + code_challenge_method: 'S256', resource: 'https://api.example.com/api/v1/mcp' +} +const consent: MCPConsent = { + client_id: request.client_id, client_name: 'Desktop assistant', redirect_uri: request.redirect_uri, + resource: request.resource, scope: 'mcp' +} +const redirectURL = `${request.redirect_uri}&code=opaque-code&state=encoded-state` +const originalLocation = window.location +const mounted: ReturnType[] = [] +function render() { + const wrapper = mount(MCPOAuthAuthorizeView, { global: { stubs: { Icon: true, RouterLink: true } } }) + mounted.push(wrapper) + return wrapper +} +beforeEach(() => { + vi.clearAllMocks() + auth.isAdmin = false + routeState.current = reactive({ fullPath: '/oauth/mcp/authorize?request=1', query: { ...request } }) + previewAuthorization.mockResolvedValue({ ...consent }) + authorize.mockResolvedValue({ redirect_url: redirectURL }) + Object.defineProperty(window, 'location', { configurable: true, value: { ...originalLocation, assign } }) +}) +afterEach(() => { + mounted.splice(0).forEach(wrapper => wrapper.unmount()) + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }) +}) + +describe('MCP OAuth consent', () => { + it('previews the exact query, displays the client and full return address, and waits for explicit consent', async () => { + const wrapper = render() + await flushPromises() + expect(previewAuthorization).toHaveBeenCalledWith(request) + expect(wrapper.get('[data-testid="client-name"]').text()).toBe(consent.client_name) + expect(wrapper.get('[data-testid="redirect-origin"]').text()).toBe('https://client.example.com') + expect(wrapper.get('[data-testid="redirect-uri"]').text()).toBe(request.redirect_uri) + expect(wrapper.text()).toContain('mcp.untrustedApplicationHint') + expect(authorize).not.toHaveBeenCalled() + expect(assign).not.toHaveBeenCalled() + expect(wrapper.find('#mcp-consent-scope option[value="admin"]').exists()).toBe(false) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(authorize).toHaveBeenCalledWith({ authorization: request, approved: true, scope: 'user', read_only: true }) + expect(assign).toHaveBeenCalledWith(redirectURL) + }) + + it('allows an omitted OAuth scope without adding or changing request parameters', async () => { + delete routeState.current.query.scope + const wrapper = render() + await flushPromises() + await wrapper.get('form').trigger('submit') + await flushPromises() + const { scope: _scope, ...expected } = request + expect(previewAuthorization).toHaveBeenCalledWith(expected) + expect(authorize.mock.calls[0][0].authorization).toEqual(expected) + }) + + it('escapes untrusted application names', async () => { + previewAuthorization.mockResolvedValue({ ...consent, client_name: '' }) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[data-testid="client-name"]').text()).toBe('') + expect(wrapper.find('[data-testid="client-name"] img').exists()).toBe(false) + }) + + it.each([ + { client_id: ['client-1', 'attacker'] }, + { state: null }, + { resource: undefined }, + { response_type: 'token' }, + { code_challenge_method: 'plain' }, + { code_challenge: 'short' }, + { scope: 'mcp admin' }, + { extra: 'unsupported' } + ])('blocks malformed requests before preview: %j', async (override) => { + Object.assign(routeState.current.query, override) + const wrapper = render() + await flushPromises() + expect(previewAuthorization).not.toHaveBeenCalled() + expect(wrapper.get('[role="alert"]').text()).toBe('mcp.invalidRequest') + expect(wrapper.find('[data-testid="approve"]').exists()).toBe(false) + expect(authorize).not.toHaveBeenCalled() + expect(assign).not.toHaveBeenCalled() + }) + + it('does not offer approval or follow client redirect when preview is rejected', async () => { + previewAuthorization.mockRejectedValue({ message: 'Unregistered redirect URI' }) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('Unregistered redirect URI') + expect(wrapper.find('[data-testid="approve"]').exists()).toBe(false) + expect(authorize).not.toHaveBeenCalled() + expect(assign).not.toHaveBeenCalled() + }) + + it('rejects a preview whose client or redirect differs from the request', async () => { + previewAuthorization.mockResolvedValue({ ...consent, redirect_uri: 'https://unexpected.example/callback' }) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('mcp.invalidRequest') + expect(wrapper.find('form').exists()).toBe(false) + }) + + it('denies with the original authorization and follows only the server result', async () => { + const deniedURL = `${request.redirect_uri}&error=access_denied&state=encoded-state` + authorize.mockResolvedValue({ redirect_url: deniedURL }) + const wrapper = render() + await flushPromises() + await wrapper.get('[data-testid="deny"]').trigger('click') + await flushPromises() + expect(authorize).toHaveBeenCalledWith({ authorization: request, approved: false, scope: 'user', read_only: true }) + expect(assign).toHaveBeenCalledWith(deniedURL) + }) + + it('requires admin step-up before retrying approval and keeps selected permissions', async () => { + auth.isAdmin = true + authorize.mockRejectedValueOnce({ reason: 'STEP_UP_REQUIRED' }).mockResolvedValueOnce({ redirect_url: redirectURL }) + const wrapper = render() + await flushPromises() + expect((wrapper.get('#mcp-consent-scope').element as HTMLSelectElement).value).toBe('user') + await wrapper.get('#mcp-consent-scope').setValue('admin') + await wrapper.get('#mcp-consent-permissions').setValue('false') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(authorize).toHaveBeenCalledTimes(1) + expect(assign).not.toHaveBeenCalled() + await wrapper.get('[data-testid="step-up-verify"]').trigger('click') + await flushPromises() + expect(authorize).toHaveBeenCalledTimes(2) + expect(authorize.mock.calls[1][0]).toEqual({ authorization: request, approved: true, scope: 'admin', read_only: false }) + expect(assign).toHaveBeenCalledWith(redirectURL) + }) + + it('cancels admin step-up without granting access', async () => { + auth.isAdmin = true + authorize.mockRejectedValue({ reason: 'STEP_UP_REQUIRED' }) + const wrapper = render() + await flushPromises() + await wrapper.get('#mcp-consent-scope').setValue('admin') + await wrapper.get('form').trigger('submit') + await flushPromises() + await wrapper.get('[data-testid="step-up-cancel"]').trigger('click') + await flushPromises() + expect(authorize).toHaveBeenCalledTimes(1) + expect(assign).not.toHaveBeenCalled() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect((wrapper.get('[data-testid="approve"]').element as HTMLButtonElement).disabled).toBe(false) + }) + + it('reports submission failures without navigating', async () => { + authorize.mockRejectedValue({ message: 'Permission denied' }) + const wrapper = render() + await flushPromises() + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('Permission denied') + expect(assign).not.toHaveBeenCalled() + }) + + it.each(['https://attacker.example/callback', 'javascript:alert(1)', 'https://client.example.com/other'])('rejects unexpected server redirects: %s', async (destination) => { + authorize.mockResolvedValue({ redirect_url: destination }) + const wrapper = render() + await flushPromises() + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('mcp.invalidRedirect') + expect(assign).not.toHaveBeenCalled() + }) + + it('submits the query captured at preview even if its source object changes', async () => { + const wrapper = render() + await flushPromises() + routeState.current.query.state = 'changed-after-preview' + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(authorize.mock.calls[0][0].authorization).toEqual(request) + }) + + it('clears old consent on route changes and ignores a late preview response', async () => { + let resolveFirst!: (value: MCPConsent) => void + previewAuthorization.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve })) + const wrapper = render() + routeState.current.query = { ...request, client_id: 'client-2' } + routeState.current.fullPath = '/oauth/mcp/authorize?request=2' + previewAuthorization.mockResolvedValueOnce({ ...consent, client_id: 'client-2', client_name: 'Second client' }) + await flushPromises() + expect(wrapper.get('[data-testid="client-name"]').text()).toBe('Second client') + resolveFirst(consent) + await flushPromises() + expect(wrapper.get('[data-testid="client-name"]').text()).toBe('Second client') + }) +}) diff --git a/frontend/src/views/user/MCPView.vue b/frontend/src/views/user/MCPView.vue new file mode 100644 index 000000000000..2411fc0bab2a --- /dev/null +++ b/frontend/src/views/user/MCPView.vue @@ -0,0 +1,179 @@ + + + diff --git a/frontend/src/views/user/__tests__/MCPView.spec.ts b/frontend/src/views/user/__tests__/MCPView.spec.ts new file mode 100644 index 000000000000..61fef89c6116 --- /dev/null +++ b/frontend/src/views/user/__tests__/MCPView.spec.ts @@ -0,0 +1,150 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import MCPView from '../MCPView.vue' +import type { MCPGrant } from '@/api/mcp' + +const { getConnectionConfig, listGrants, revokeGrant, copyToClipboard, showSuccess } = vi.hoisted(() => ({ + getConnectionConfig: vi.fn(), listGrants: vi.fn(), revokeGrant: vi.fn(), copyToClipboard: vi.fn(), showSuccess: vi.fn() +})) +vi.mock('@/api/mcp', () => ({ mcpAPI: { getConnectionConfig, listGrants, revokeGrant } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess }) })) +vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard }) })) +vi.mock('@/utils/format', () => ({ formatDate: (value: string) => value })) +vi.mock('@/components/layout/AppLayout.vue', () => ({ default: { template: '
' } })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +const item: MCPGrant = { + id: 'grant-1', client_id: 'client-1', client_name: 'Desktop assistant', scope: 'user', read_only: true, + created_at: '2026-10-28T00:00:00Z', expires_at: '2099-11-27T00:00:00Z', last_used_at: null, + revoked_at: null, invalidated: false +} +const mounted: ReturnType[] = [] +function render() { + const wrapper = mount(MCPView, { + global: { + stubs: { + BaseDialog: { + props: ['show', 'title'], emits: ['close'], + template: '
' + }, + Icon: true + } + } + }) + mounted.push(wrapper) + return wrapper +} +beforeEach(() => { + vi.clearAllMocks() + getConnectionConfig.mockResolvedValue({ resource: 'https://mcp.example.com/api/v1/mcp' }) + listGrants.mockResolvedValue([]) + revokeGrant.mockResolvedValue(undefined) +}) +afterEach(() => { mounted.splice(0).forEach(wrapper => wrapper.unmount()) }) + +describe('MCP OAuth connections', () => { + it('shows OAuth instructions and only copies the server URL, without static credential creation', async () => { + const wrapper = render() + await flushPromises() + expect(getConnectionConfig).toHaveBeenCalledTimes(1) + expect(listGrants).toHaveBeenCalledTimes(1) + expect((wrapper.get('#mcp-endpoint').element as HTMLInputElement).value).toBe('https://mcp.example.com/api/v1/mcp') + expect(wrapper.text()).toContain('mcp.connectStep2') + expect(wrapper.text()).toContain('mcp.empty') + expect(wrapper.find('form').exists()).toBe(false) + expect(wrapper.find('[data-testid="create-token"]').exists()).toBe(false) + expect(wrapper.find('textarea').exists()).toBe(false) + expect(wrapper.html()).not.toMatch(/Bearer|mcp-new-token|mcp-secret/) + await wrapper.get('[data-testid="copy-endpoint"]').trigger('click') + expect(copyToClipboard).toHaveBeenCalledWith('https://mcp.example.com/api/v1/mcp') + }) + + it('does not guess the resource when server configuration fails and keeps grants usable', async () => { + getConnectionConfig.mockRejectedValueOnce({ message: 'MCP public URL is not configured correctly' }) + listGrants.mockResolvedValue([item]) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('MCP public URL is not configured correctly') + expect((wrapper.get('#mcp-endpoint').element as HTMLInputElement).value).toBe('') + expect((wrapper.get('[data-testid="copy-endpoint"]').element as HTMLButtonElement).disabled).toBe(true) + expect(wrapper.find('[data-grant-id="grant-1"] button').exists()).toBe(true) + await wrapper.get('[data-testid="retry-connection"]').trigger('click') + await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect((wrapper.get('#mcp-endpoint').element as HTMLInputElement).value).toBe('https://mcp.example.com/api/v1/mcp') + expect((wrapper.get('[data-testid="copy-endpoint"]').element as HTMLButtonElement).disabled).toBe(false) + expect(listGrants).toHaveBeenCalledTimes(1) + }) + + it('disables URL copying while configuration is loading independently from grants', async () => { + let resolveConfig!: (value: { resource: string }) => void + getConnectionConfig.mockReturnValueOnce(new Promise<{ resource: string }>(resolve => { resolveConfig = resolve })) + const wrapper = render() + await flushPromises() + expect(wrapper.text()).toContain('mcp.empty') + expect((wrapper.get('[data-testid="copy-endpoint"]').element as HTMLButtonElement).disabled).toBe(true) + resolveConfig({ resource: 'https://configured.example.com/api/v1/mcp' }) + await flushPromises() + expect((wrapper.get('#mcp-endpoint').element as HTMLInputElement).value).toBe('https://configured.example.com/api/v1/mcp') + }) + + it('loads authorized applications and shows their permissions', async () => { + listGrants.mockResolvedValue([item, { ...item, id: 'grant-2', scope: 'admin', read_only: false }]) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[data-grant-id="grant-1"]').text()).toContain('Desktop assistant') + expect(wrapper.get('[data-grant-id="grant-1"]').text()).toContain('mcp.readOnly') + expect(wrapper.get('[data-grant-id="grant-2"]').text()).toContain('mcp.adminScope') + expect(wrapper.get('[data-grant-id="grant-2"]').text()).toContain('mcp.readWrite') + }) + + it('keeps loading failures visible and can refresh', async () => { + listGrants.mockRejectedValueOnce({ message: 'Service unavailable' }).mockResolvedValueOnce([item]) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('Service unavailable') + await wrapper.get('[data-testid="refresh-grants"]').trigger('click') + await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(wrapper.find('[data-grant-id="grant-1"]').exists()).toBe(true) + }) + + it('revokes only after confirmation and updates the application state', async () => { + listGrants.mockResolvedValue([item]) + const wrapper = render() + await flushPromises() + await wrapper.get('[data-grant-id="grant-1"] button').trigger('click') + expect(revokeGrant).not.toHaveBeenCalled() + await wrapper.get('[data-testid="confirm-revoke"]').trigger('click') + await flushPromises() + expect(revokeGrant).toHaveBeenCalledWith(item.id) + expect(wrapper.get('[data-grant-id="grant-1"]').text()).toContain('mcp.revoked') + expect(wrapper.find('[data-grant-id="grant-1"] button').exists()).toBe(false) + expect(showSuccess).toHaveBeenCalledWith('mcp.revokeSuccess') + }) + + it('keeps the revoke dialog and grant active when revocation fails', async () => { + listGrants.mockResolvedValue([item]) + revokeGrant.mockRejectedValue({ message: 'Try again later' }) + const wrapper = render() + await flushPromises() + await wrapper.get('[data-grant-id="grant-1"] button').trigger('click') + await wrapper.get('[data-testid="confirm-revoke"]').trigger('click') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('Try again later') + expect(wrapper.get('[data-grant-id="grant-1"]').text()).toContain('mcp.active') + expect(wrapper.find('[data-testid="confirm-revoke"]').exists()).toBe(true) + }) + + it('shows invalidated and expired grants as inactive while allowing revocation', async () => { + listGrants.mockResolvedValue([ + { ...item, invalidated: true }, + { ...item, id: 'grant-2', expires_at: '2000-01-01T00:00:00Z' } + ]) + const wrapper = render() + await flushPromises() + expect(wrapper.get('[data-grant-id="grant-1"]').text()).toContain('mcp.invalidated') + expect(wrapper.get('[data-grant-id="grant-2"]').text()).toContain('mcp.expired') + expect(wrapper.find('[data-grant-id="grant-1"] button').exists()).toBe(true) + }) +}) From ee71c7f379d56d8b28595beb032883acac8c438d Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 28 Oct 2026 09:30:23 -0400 Subject: [PATCH 3/8] fix(backend): harden management MCP per review - Reject JSON-RPC batches and non-object bodies before the SDK; return each tool result once as text and bound the encoded payload (H1). - Enable MCP only when MCP_PUBLIC_URL is set; drop the frontend URL fallback and expose a read-only mcp_enabled public setting (M1). - Expire never-authorized clients after 24h, cap pending registrations globally, aggregate MCP IP limits by IPv6 /64, and limit registration bodies to 4 KiB (M2). - Revoke MCP grants with revoke-all-sessions and always require step-up for administrator write grants (M3). - Rate limit /api/v1/mcp per IP before authentication and write last_used_at at most once per minute (M4). - Persistently revoke grants when an account is disabled or demoted, derive result redaction from SensitiveCredentialKeys, allowlist browser sessions for step-up, audit every MCP read, reject invisible characters in client_name, and document site-root-only deployment (L1-L5, L7). Co-Authored-By: Claude Opus 5.5 --- backend/cmd/server/wire_gen.go | 6 +- backend/internal/config/config.go | 6 +- .../handler/admin/audit_log_handler.go | 8 +- .../handler/admin/setting_handler_update.go | 8 +- backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/mcp_oauth_handler.go | 42 ++++--- .../handler/mcp_oauth_handler_test.go | 67 +++++++++-- backend/internal/handler/setting_handler.go | 1 + backend/internal/managementmcp/auth.go | 2 +- backend/internal/managementmcp/server.go | 57 +++++++--- backend/internal/managementmcp/server_test.go | 83 ++++++++++++++ backend/internal/middleware/rate_limiter.go | 21 +++- .../internal/middleware/rate_limiter_test.go | 40 +++++++ backend/internal/repository/mcp_oauth.go | 42 ++++++- .../repository/mcp_oauth_integration_test.go | 65 +++++++++++ backend/internal/repository/mcp_oauth_test.go | 58 +++++++--- backend/internal/repository/user_repo.go | 12 ++ .../internal/server/middleware/audit_log.go | 6 +- .../server/middleware/audit_log_test.go | 36 ++++++ backend/internal/server/middleware/step_up.go | 28 +++-- .../server/middleware/step_up_test.go | 34 ++++-- backend/internal/server/router.go | 2 +- backend/internal/server/routes/mcp.go | 23 +++- .../internal/server/routes/mcp_oauth_test.go | 35 +++++- .../server/routes/mcp_rate_limit_test.go | 12 +- backend/internal/server/routes/mcp_test.go | 57 ++++++---- backend/internal/service/audit_log.go | 2 + backend/internal/service/auth_service.go | 17 +++ backend/internal/service/mcp_oauth_service.go | 49 ++++++-- .../service/mcp_oauth_service_test.go | 107 +++++++++++++++++- backend/internal/service/setting_public.go | 3 + .../service/setting_service_public_test.go | 25 ++++ backend/internal/service/settings_view.go | 1 + backend/internal/service/wire.go | 4 + deploy/.env.example | 5 +- deploy/config.example.yaml | 8 +- docs/mcp.md | 18 +-- 37 files changed, 833 insertions(+), 158 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a59093ed0461..62fed0cc8b84 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -83,7 +83,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) affiliateRepository := repository.NewAffiliateRepository(client, db) affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService) - authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) + mcpoAuthRepository := repository.NewMCPOAuthRepository(db) + mcpoAuthService := service.NewMCPOAuthService(mcpoAuthRepository, userRepository) + authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository, mcpoAuthService) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) @@ -98,8 +100,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService) userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository) - mcpoAuthRepository := repository.NewMCPOAuthRepository(db) - mcpoAuthService := service.NewMCPOAuthService(mcpoAuthRepository, userRepository) mcpoAuthHandler := handler.NewMCPOAuthHandler(mcpoAuthService, totpService, userService, settingService, configConfig) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 55b335c304dc..d270ef972865 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -120,12 +120,16 @@ type SimpleModeConfig struct { } // MCPConfig fixes the public resource URL used by OAuth discovery and tokens. +// Setting it is also the explicit opt-in that enables MCP. type MCPConfig struct { PublicURL string `mapstructure:"public_url"` } +// Enabled reports whether the operator opted in to the management MCP endpoint. +func (c MCPConfig) Enabled() bool { return strings.TrimSpace(c.PublicURL) != "" } + // Validate normalizes the configured resource URL without trusting request headers. -// An empty URL leaves selection of the trusted frontend URL to the MCP handler. +// An empty URL keeps MCP and its OAuth endpoints disabled. func (c *MCPConfig) Validate() error { raw := strings.TrimSpace(c.PublicURL) if raw == "" { diff --git a/backend/internal/handler/admin/audit_log_handler.go b/backend/internal/handler/admin/audit_log_handler.go index a54135c86e90..bb5bca8e2e2b 100644 --- a/backend/internal/handler/admin/audit_log_handler.go +++ b/backend/internal/handler/admin/audit_log_handler.go @@ -110,13 +110,13 @@ type auditLogClearRequest struct { // 安全要求(与需求对齐): // 1. 每次清空都必须现场验证 TOTP 码(不复用 step-up sudo 窗口) // 2. 未启用 TOTP 的管理员不允许清空 -// 3. admin API key(机器凭证)不允许清空 +// 3. 机器凭证(admin API key、MCP OAuth 等非浏览器会话)不允许清空 // 4. 清空完成后同步写入一条留痕记录(操作者、IP、UA、删除行数) func (h *AuditLogHandler) Clear(c *gin.Context) { - if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { + if reason, machine := middleware.MachineCredentialReason(c); machine { response.ErrorWithDetails(c, http.StatusForbidden, - "Admin API key cannot clear audit logs; a two-factor verified admin session is required", - "STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil) + "Machine credentials cannot clear audit logs; a two-factor verified admin session is required", + reason, nil) return } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 0947c43a5834..673e08a11b8b 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -396,13 +396,13 @@ type UpdateSettingsRequest struct { // UpdateSettings 更新系统设置 // PUT /api/v1/admin/settings // ensureActorTotpForStepUp 校验当前操作者具备开启 step-up 门控的条件: -// 必须是真人管理员会话(admin API key 无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。 +// 必须是真人管理员会话(admin API key、MCP OAuth 等机器凭证无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。 // 校验失败时写入错误响应并返回 false。 func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool { - if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { + if reason, machine := middleware.MachineCredentialReason(c); machine { response.ErrorWithDetails(c, http.StatusForbidden, - "Admin API key cannot enable step-up verification; use an admin session with TOTP enabled", - "STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil) + "Machine credentials cannot enable step-up verification; use an admin session with TOTP enabled", + reason, nil) return false } subject, ok := middleware.GetAuthSubjectFromContext(c) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 96dcc82ec655..fb119c1cdad9 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -415,6 +415,7 @@ type PublicSettings struct { GitHubOAuthEnabled bool `json:"github_oauth_enabled"` GoogleOAuthEnabled bool `json:"google_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` + MCPEnabled bool `json:"mcp_enabled"` PaymentEnabled bool `json:"payment_enabled"` // PaymentBalanceDisabled mirrors the payment-config BALANCE_PAYMENT_DISABLED switch so the // user shell can derive the site billing mode (recharge & subscription / recharge only / diff --git a/backend/internal/handler/mcp_oauth_handler.go b/backend/internal/handler/mcp_oauth_handler.go index a0b95665463c..6c81b63d7ea2 100644 --- a/backend/internal/handler/mcp_oauth_handler.go +++ b/backend/internal/handler/mcp_oauth_handler.go @@ -33,21 +33,13 @@ func NewMCPOAuthHandler(oauth *service.MCPOAuthService, totp *service.TotpServic func (h *MCPOAuthHandler) Service() *service.MCPOAuthService { return h.oauth } // ResourceURL is anchored to operator configuration, never the request Host or -// forwarded headers. A separate API origin can override the frontend fallback. -func (h *MCPOAuthHandler) ResourceURL(ctx context.Context) (string, error) { - var raw string - if h.cfg != nil { - raw = h.cfg.MCP.PublicURL - } - if strings.TrimSpace(raw) == "" { - frontend := h.frontendURL(ctx) - u, err := url.Parse(frontend) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(frontend, "#") { - return "", errors.New("configure MCP_PUBLIC_URL or the platform frontend URL to enable MCP OAuth") - } - raw = u.Scheme + "://" + u.Host + "/api/v1/mcp" +// forwarded headers. MCP stays disabled until MCP_PUBLIC_URL is set explicitly, +// so upgrading never exposes client registration or management tools. +func (h *MCPOAuthHandler) ResourceURL(_ context.Context) (string, error) { + if h.cfg == nil || !h.cfg.MCP.Enabled() { + return "", errors.New("MCP is disabled; set MCP_PUBLIC_URL to enable it") } - configured := config.MCPConfig{PublicURL: raw} + configured := config.MCPConfig{PublicURL: h.cfg.MCP.PublicURL} if err := configured.Validate(); err != nil || configured.PublicURL == "" { return "", errors.New("MCP public URL is not configured correctly") } @@ -130,6 +122,11 @@ func (h *MCPOAuthHandler) Register(c *gin.Context) { return } client, err := h.oauth.Register(c.Request.Context(), input) + if errors.Is(err, service.ErrMCPOAuthClientLimit) { + c.Header("Retry-After", "3600") + mcpOAuthErrorResponse(c, http.StatusServiceUnavailable, "temporarily_unavailable", "Too many clients are awaiting authorization; retry later") + return + } if mcpOAuthHandleError(c, err, true) { return } @@ -229,7 +226,15 @@ func (h *MCPOAuthHandler) Decide(c *gin.Context) { response.Forbidden(c, "Only administrators can authorize admin MCP access") return } - if !middleware.EnforceStepUp(c, h.totp, h.users, h.settings) { + // Administrator write grants outlive browser sessions, so they always + // need a fresh TOTP verification even when the global switch is off. + var verified bool + if input.ReadOnly != nil && !*input.ReadOnly { + verified = middleware.EnforceStepUpAlways(c, h.totp, h.users) + } else { + verified = middleware.EnforceStepUp(c, h.totp, h.users, h.settings) + } + if !verified { return } } @@ -327,7 +332,12 @@ func mcpOAuthHandleError(c *gin.Context, err error, protocol bool) bool { func mcpOAuthReadJSON(c *gin.Context, out any, browser bool) bool { mediaType, _, _ := mime.ParseMediaType(c.GetHeader("Content-Type")) if mediaType == "application/json" { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + // Unauthenticated registrations are persisted, so they get a tighter bound. + limit := int64(4 << 10) + if browser { + limit = 16 << 10 + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) decoder := json.NewDecoder(c.Request.Body) if browser { decoder.DisallowUnknownFields() diff --git a/backend/internal/handler/mcp_oauth_handler_test.go b/backend/internal/handler/mcp_oauth_handler_test.go index c6d6012154e1..1fc9675fe08a 100644 --- a/backend/internal/handler/mcp_oauth_handler_test.go +++ b/backend/internal/handler/mcp_oauth_handler_test.go @@ -28,6 +28,12 @@ type handlerMCPRepo struct { client *service.MCPOAuthClient reads, writes int grant *service.MCPOAuthGrant + createErr error +} + +func (r *handlerMCPRepo) CreateClient(context.Context, *service.MCPOAuthClient, int) error { + r.writes++ + return r.createErr } func (r *handlerMCPRepo) GetClient(_ context.Context, id string) (*service.MCPOAuthClient, error) { @@ -124,23 +130,18 @@ func TestMCPOAuthHandlerResourceURLTrustedConfiguration(t *testing.T) { for _, tt := range []struct{ name, public, frontend, stored, want string }{ {"explicit API origin", handlerMCPResource, "https://platform.example.com", "", handlerMCPResource}, {"normalize explicit", " https://API.EXAMPLE.COM/api/v1/mcp ", "", "", handlerMCPResource}, - {"frontend fallback uses origin", "", "https://platform.example.com/console/", "", "https://platform.example.com/api/v1/mcp"}, - {"stored frontend wins", "", "https://configured.example.com", "https://stored.example.com/ui", "https://stored.example.com/api/v1/mcp"}, {"explicit resource wins", handlerMCPResource, "", "https://stored.example.com", handlerMCPResource}, - {"loopback development", "", "http://127.0.0.1:8080", "", "http://127.0.0.1:8080/api/v1/mcp"}, + {"loopback development", "http://127.0.0.1:8080/api/v1/mcp", "", "", "http://127.0.0.1:8080/api/v1/mcp"}, {"missing configuration", "", "", "", ""}, + // A configured frontend must not implicitly enable MCP after an upgrade. + {"configured frontend stays disabled", "", "https://platform.example.com", "", ""}, + {"stored frontend stays disabled", "", "", "https://stored.example.com", ""}, {"insecure public URL", "http://api.example.com/api/v1/mcp", "", "", ""}, {"wrong resource path", "https://api.example.com/mcp", "", "", ""}, + {"sub-path deployment", "https://example.com/sub2api/api/v1/mcp", "", "", ""}, {"resource userinfo", "https://user:password@api.example.com/api/v1/mcp", "", "", ""}, {"resource query", handlerMCPResource + "?q=1", "", "", ""}, {"resource fragment", handlerMCPResource + "#x", "", "", ""}, - {"relative frontend", "", "/console", "", ""}, - {"frontend credentials", "", "https://user:password@platform.example.com", "", ""}, - {"frontend query", "", "https://platform.example.com?q=1", "", ""}, - {"frontend fragment", "", "https://platform.example.com#fragment", "", ""}, - {"frontend empty query", "", "https://platform.example.com?", "", ""}, - {"frontend empty fragment", "", "https://platform.example.com#", "", ""}, - {"insecure frontend", "", "http://platform.example.com", "", ""}, } { t.Run(tt.name, func(t *testing.T) { cfg := &config.Config{MCP: config.MCPConfig{PublicURL: tt.public}} @@ -268,6 +269,8 @@ func TestMCPOAuthHandlerJSONValidation(t *testing.T) { {"oversized browser", "application/json", `{"authorization":{"state":"` + strings.Repeat("x", 17<<10) + `"}}`, true, false}, {"registration extension metadata", "application/json", `{"client_name":"example","client_uri":"https://client.example.com"}`, false, true}, {"oversized registration", "application/json", `{"client_name":"` + strings.Repeat("x", 17<<10) + `"}`, false, false}, + {"registration above 4 KiB", "application/json", `{"client_name":"x","client_uri":"` + strings.Repeat("x", 4<<10) + `"}`, false, false}, + {"browser body above registration limit", "application/json", `{"authorization":{"state":"` + strings.Repeat("x", 8<<10) + `"}}`, true, true}, {"malformed registration", "application/json", `{`, false, false}, } { t.Run(tt.name, func(t *testing.T) { @@ -375,7 +378,7 @@ func TestMCPOAuthHandlerAdminDecisionGatesBeforePersistence(t *testing.T) { {name: "no recent grant", role: service.RoleAdmin, totp: true, status: 403, marker: "STEP_UP_REQUIRED"}, {name: "step-up unavailable", role: service.RoleAdmin, totp: true, cacheErr: errors.New("cache unavailable"), status: 503, marker: "STEP_UP_UNAVAILABLE"}, {name: "admin API key", role: service.RoleAdmin, authMethod: service.AuditAuthMethodAdminAPIKey, totp: true, granted: true, status: 403, marker: "STEP_UP_ADMIN_API_KEY_FORBIDDEN"}, - {name: "MCP credential", role: service.RoleAdmin, authMethod: "mcp_oauth", totp: true, granted: true, status: 403, marker: "STEP_UP_MCP_OAUTH_FORBIDDEN"}, + {name: "MCP credential", role: service.RoleAdmin, authMethod: service.AuditAuthMethodMCPOAuth, totp: true, granted: true, status: 403, marker: "STEP_UP_MCP_OAUTH_FORBIDDEN"}, } { t.Run(tt.name, func(t *testing.T) { h, repo, users, cache := newHandlerMCPFixture(t) @@ -395,6 +398,48 @@ func TestMCPOAuthHandlerAdminDecisionGatesBeforePersistence(t *testing.T) { } } +func TestMCPOAuthHandlerAdminWriteAlwaysRequiresStepUp(t *testing.T) { + for _, tt := range []struct { + name string + readOnly *bool + totp, granted bool + status, writes int + marker string + }{ + {name: "write without TOTP", readOnly: boolPtr(false), status: 403, marker: "STEP_UP_TOTP_NOT_ENABLED"}, + {name: "write without recent verification", readOnly: boolPtr(false), totp: true, status: 403, marker: "STEP_UP_REQUIRED"}, + {name: "verified write", readOnly: boolPtr(false), totp: true, granted: true, status: 200, writes: 1}, + {name: "read-only follows the switch", readOnly: boolPtr(true), status: 200, writes: 1}, + {name: "default read-only follows the switch", status: 200, writes: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + h, repo, users, cache := newHandlerMCPFixture(t) + // Global step-up is disabled; write grants still need verification. + h.settings = service.NewSettingService(&handlerMCPSettings{values: map[string]string{service.SettingKeyStepUpEnabled: "false"}}, h.cfg) + users.user.TotpEnabled = tt.totp + cache.granted = tt.granted + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: true, Scope: service.MCPScopeAdmin, ReadOnly: tt.readOnly} + c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") + handlerMCPSubject(c, service.RoleAdmin) + h.Decide(c) + require.Equal(t, tt.status, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), tt.marker) + require.Equal(t, tt.writes, repo.writes) + }) + } +} + +func TestMCPOAuthHandlerRegistrationCapacityIsTemporary(t *testing.T) { + h, repo, _, _ := newHandlerMCPFixture(t) + repo.createErr = service.ErrMCPOAuthClientLimit + c, rec := handlerMCPContext(t, http.MethodPost, "/api/v1/mcp/oauth/register", `{"redirect_uris":["https://client.example.com/cb"]}`, "application/json") + h.Register(c) + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Equal(t, "temporarily_unavailable", handlerMCPJSON(t, rec)["error"]) + require.NotEmpty(t, rec.Header().Get("Retry-After")) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) +} + func TestMCPOAuthHandlerAdminApprovalUsesBrowserSession(t *testing.T) { h, repo, _, cache := newHandlerMCPFixture(t) cache.granted = true diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index aa204923f84e..1dd35924551d 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -94,6 +94,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { GitHubOAuthEnabled: settings.GitHubOAuthEnabled, GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, + MCPEnabled: settings.MCPEnabled, PaymentEnabled: settings.PaymentEnabled, PaymentBalanceDisabled: settings.PaymentBalanceDisabled, Version: h.version, diff --git a/backend/internal/managementmcp/auth.go b/backend/internal/managementmcp/auth.go index dbe7240a1ea0..3bd7ad6dc77b 100644 --- a/backend/internal/managementmcp/auth.go +++ b/backend/internal/managementmcp/auth.go @@ -13,7 +13,7 @@ import ( "github.com/gin-gonic/gin" ) -const AuthMethod = "mcp_oauth" +const AuthMethod = service.AuditAuthMethodMCPOAuth type TokenAuthenticator interface { Authenticate(context.Context, string) (*service.MCPPrincipal, error) diff --git a/backend/internal/managementmcp/server.go b/backend/internal/managementmcp/server.go index 71984dc92dc5..395692b0ccf3 100644 --- a/backend/internal/managementmcp/server.go +++ b/backend/internal/managementmcp/server.go @@ -64,15 +64,20 @@ func NewHandler(dispatcher http.Handler) http.Handler { http.Error(w, "This stateless MCP endpoint only supports POST", http.StatusMethodNotAllowed) return } - if r.Method == http.MethodPost { - // Bound the JSON-RPC envelope before the SDK allocates for it. - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytes)) - if err != nil { - http.Error(w, "MCP request body exceeds the limit or cannot be read", http.StatusRequestEntityTooLarge) - return - } - r.Body = io.NopCloser(bytes.NewReader(body)) + // Bound the JSON-RPC envelope before the SDK allocates for it. + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytes)) + if err != nil { + http.Error(w, "MCP request body exceeds the limit or cannot be read", http.StatusRequestEntityTooLarge) + return } + // Accept exactly one JSON-RPC object per HTTP request. Without this, the + // SDK accepts 2025-03-26 batches and buffers every result in JSON + // response mode, so one request could fan out into thousands of calls. + if trimmed := bytes.TrimLeft(body, " \t\r\n"); len(trimmed) == 0 || trimmed[0] != '{' { + http.Error(w, "MCP requests must contain a single JSON-RPC object; batches are not supported", http.StatusBadRequest) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) transport.ServeHTTP(noStoreResponse{w}, r) }) } @@ -151,13 +156,17 @@ func executeTool(ctx context.Context, dispatcher http.Handler, spec ToolSpec, ar return toolError(fmt.Sprintf("Management API returned HTTP %d without a JSON result", response.status)) } redactResult(result, spec.RevealCreatedKey && response.status >= 200 && response.status < 300, "") - out := map[string]any{"http_status": response.status, "result": result} - encoded, err := json.Marshal(out) + encoded, err := json.Marshal(map[string]any{"http_status": response.status, "result": result}) if err != nil { return toolError("Cannot encode management result") } + // Re-encoding can expand escaped characters, so bound the final payload. + // The result is returned once, as text: tools declare no output schema. + if len(encoded) > maxResultBytes { + return toolError("Result exceeds 2 MiB; narrow the query or reduce page_size") + } return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: string(encoded)}}, StructuredContent: out, + Content: []mcp.Content{&mcp.TextContent{Text: string(encoded)}}, IsError: response.status < 200 || response.status >= 300, } } @@ -184,18 +193,30 @@ func (w noStoreResponse) Flush() { } func (w noStoreResponse) Unwrap() http.ResponseWriter { return w.ResponseWriter } +// redactedResultKeys derives from the authoritative credential table so new +// upstream secret kinds are removed from tool results without a second list. +var redactedResultKeys = func() map[string]struct{} { + keys := map[string]struct{}{} + for _, key := range append([]string{ + "credentials", "password_hash", "session_token", "client_secret", "secret", + "totp_secret", "totp_secret_encrypted", + }, service.SensitiveCredentialKeys...) { + keys[strings.ToLower(key)] = struct{}{} + } + return keys +}() + func redactResult(value any, revealCreatedKey bool, path string) { switch v := value.(type) { case map[string]any: for key, child := range v { childPath := path + "/" + key - switch strings.ToLower(key) { - case "key": - if !revealCreatedKey || childPath != "/data/key" { - delete(v, key) - continue - } - case "credentials", "password", "password_hash", "api_key", "access_token", "refresh_token", "id_token", "session_token", "session_key", "client_secret", "secret", "totp_secret", "totp_secret_encrypted": + lower := strings.ToLower(key) + if lower == "key" && (!revealCreatedKey || childPath != "/data/key") { + delete(v, key) + continue + } + if _, sensitive := redactedResultKeys[lower]; sensitive { delete(v, key) continue } diff --git a/backend/internal/managementmcp/server_test.go b/backend/internal/managementmcp/server_test.go index bfe1723df0fa..5dbae89bae71 100644 --- a/backend/internal/managementmcp/server_test.go +++ b/backend/internal/managementmcp/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -323,3 +324,85 @@ func TestMCPResultLimitAndCancellation(t *testing.T) { t.Fatal("HTTP cancellation did not reach dispatcher") } } + +func TestMCPRejectsJSONRPCBatchesBeforeDispatch(t *testing.T) { + calls := 0 + r, _ := newTestEndpoint(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + call := `{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":"user_get_profile","arguments":{}}}` + list := `{"jsonrpc":"2.0","id":%d,"method":"tools/list"}` + batch := "[" + strings.Join([]string{fmt.Sprintf(call, 1), fmt.Sprintf(list, 2), fmt.Sprintf(call, 3)}, ",") + "]" + for _, tc := range []struct{ name, body, version string }{ + // Without a protocol header the SDK assumes 2025-03-26, which allows batches. + {"legacy batch", batch, ""}, + {"current batch", batch, "2025-06-18"}, + {"whitespace batch", " \r\n\t" + batch, ""}, + {"scalar", `"tools/list"`, ""}, + {"empty", " ", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "http://platform.example/api/v1/mcp", strings.NewReader(tc.body)) + req.Header.Set("Authorization", "Bearer admin") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if tc.version != "" { + req.Header.Set("MCP-Protocol-Version", tc.version) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + }) + } + require.Zero(t, calls) + // A single request without the protocol header is still served. + req := httptest.NewRequest("POST", "http://platform.example/api/v1/mcp", strings.NewReader(fmt.Sprintf(call, 1))) + req.Header.Set("Authorization", "Bearer admin") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, 1, calls) +} + +func TestMCPToolResultIsReturnedOnceAndBoundedAfterEncoding(t *testing.T) { + payload := `{"data":{"name":"ok"}}` + r, _ := newTestEndpoint(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(payload)) + })) + _, result := rpc(t, r, "reader", "tools/call", map[string]any{"name": "user_get_profile", "arguments": map[string]any{}}) + toolResult := rpcObject(t, result["result"]) + require.NotContains(t, toolResult, "structuredContent", "results must not be duplicated") + content, ok := toolResult["content"].([]any) + require.True(t, ok) + require.Len(t, content, 1) + require.JSONEq(t, `{"http_status":200,"result":{"data":{"name":"ok"}}}`, rpcObject(t, content[0])["text"].(string)) + + // Under the capture limit as received, but HTML escaping expands it 6x. + payload = `{"data":"` + strings.Repeat("<", maxResultBytes/3) + `"}` + _, result = rpc(t, r, "reader", "tools/call", map[string]any{"name": "user_get_profile", "arguments": map[string]any{}}) + toolResult = rpcObject(t, result["result"]) + require.Equal(t, true, toolResult["isError"]) + encoded, _ := json.Marshal(toolResult) + require.Less(t, len(encoded), 1<<10) +} + +func TestMCPRedactsEveryAuthoritativeCredentialKey(t *testing.T) { + data := map[string]any{"name": "visible"} + nested := map[string]any{} + for _, key := range service.SensitiveCredentialKeys { + data[key] = "canary-" + key + nested[strings.ToUpper(key)] = "canary-upper-" + key + } + data["credentials"] = map[string]any{"nested": nested} + data["items"] = []any{nested} + value := map[string]any{"data": data} + redactResult(value, false, "") + encoded, err := json.Marshal(value) + require.NoError(t, err) + require.NotContains(t, string(encoded), "canary") + require.Contains(t, string(encoded), "visible") +} diff --git a/backend/internal/middleware/rate_limiter.go b/backend/internal/middleware/rate_limiter.go index df18a0234e26..392236fde9f4 100644 --- a/backend/internal/middleware/rate_limiter.go +++ b/backend/internal/middleware/rate_limiter.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "net/netip" "strconv" "time" @@ -25,6 +26,8 @@ const ( // RateLimitOptions 限流可选配置 type RateLimitOptions struct { FailureMode RateLimitFailureMode + // IPv6PrefixBits 按网段(如 64)合并 IPv6 客户端计数,避免轮换同一分配内的地址绕过限流;0 表示按单个地址计数。 + IPv6PrefixBits int } var rateLimitScript = redis.NewScript(` @@ -120,6 +123,22 @@ func clientIPForRateLimit(c *gin.Context) string { return c.ClientIP() } +// rateLimitClientKey 把 IPv6 地址归并到指定前缀的网段;IPv4 与无法解析的值保持原样。 +func rateLimitClientKey(ip string, ipv6PrefixBits int) string { + if ipv6PrefixBits <= 0 || ipv6PrefixBits >= 128 { + return ip + } + addr, err := netip.ParseAddr(ip) + if err != nil || !addr.Is6() || addr.Is4In6() { + return ip + } + prefix, err := addr.WithZone("").Prefix(ipv6PrefixBits) + if err != nil { + return ip + } + return prefix.String() +} + // Limit 返回速率限制中间件 // key: 限制类型标识 // limit: 时间窗口内最大请求数 @@ -136,7 +155,7 @@ func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Durati } return func(c *gin.Context) { - result, err := r.Allow(c.Request.Context(), key+":"+clientIPForRateLimit(c), limit, window) + result, err := r.Allow(c.Request.Context(), key+":"+rateLimitClientKey(clientIPForRateLimit(c), opts.IPv6PrefixBits), limit, window) if err != nil { log.Printf("[RateLimit] redis error: key=%s mode=%s err=%v", r.prefix+key, failureModeLabel(failureMode), err) if failureMode == RateLimitFailClose { diff --git a/backend/internal/middleware/rate_limiter_test.go b/backend/internal/middleware/rate_limiter_test.go index ab1e587851d6..c76839dbbed2 100644 --- a/backend/internal/middleware/rate_limiter_test.go +++ b/backend/internal/middleware/rate_limiter_test.go @@ -224,3 +224,43 @@ func TestRateLimiterSuccessAndLimit(t *testing.T) { router.ServeHTTP(recorder, req) require.Equal(t, http.StatusTooManyRequests, recorder.Code) } + +func TestRateLimitClientKeyAggregatesIPv6Prefix(t *testing.T) { + require.Equal(t, "2001:db8:1:2::/64", rateLimitClientKey("2001:db8:1:2:aaaa:bbbb:cccc:dddd", 64)) + require.Equal(t, "2001:db8:1:2::/64", rateLimitClientKey("2001:db8:1:2::1", 64)) + require.Equal(t, "2001:db8:1:3::/64", rateLimitClientKey("2001:db8:1:3::1", 64)) + require.Equal(t, "2001:db8:1:2::1", rateLimitClientKey("2001:db8:1:2::1", 0), "per-address keys stay the default") + for _, ip := range []string{"203.0.113.9", "::ffff:203.0.113.9", "not-an-ip", ""} { + require.Equal(t, ip, rateLimitClientKey(ip, 64)) + } +} + +func TestRateLimiterIPv6PrefixSharesOneBudget(t *testing.T) { + gin.SetMode(gin.TestMode) + callCounts := make(map[string]int64) + originalRun := rateLimitRun + rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) { + callCounts[key]++ + return callCounts[key], false, nil + } + t.Cleanup(func() { rateLimitRun = originalRun }) + + limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})) + router := gin.New() + router.Use(limiter.LimitWithOptions("api", 1, time.Second, RateLimitOptions{IPv6PrefixBits: 64})) + router.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) }) + for _, tc := range []struct { + addr string + status int + }{ + {"[2001:db8::1]:1234", http.StatusOK}, + {"[2001:db8::2]:1234", http.StatusTooManyRequests}, // same /64, rotated address + {"[2001:db8:0:1::1]:1234", http.StatusOK}, // different /64 + } { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tc.addr + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, tc.status, rec.Code, tc.addr) + } +} diff --git a/backend/internal/repository/mcp_oauth.go b/backend/internal/repository/mcp_oauth.go index 9f840b2e7058..30022c5d9a56 100644 --- a/backend/internal/repository/mcp_oauth.go +++ b/backend/internal/repository/mcp_oauth.go @@ -17,7 +17,7 @@ func NewMCPOAuthRepository(db *sql.DB) service.MCPOAuthRepository { return &mcpOAuthRepository{db: db} } -func (r *mcpOAuthRepository) CreateClient(ctx context.Context, client *service.MCPOAuthClient) error { +func (r *mcpOAuthRepository) CreateClient(ctx context.Context, client *service.MCPOAuthClient, pendingLimit int) error { redirects, err := json.Marshal(client.RedirectURIs) if err != nil { return err @@ -26,19 +26,25 @@ func (r *mcpOAuthRepository) CreateClient(ctx context.Context, client *service.M if err != nil { return err } - // Registration also performs bounded retention work. Active authorizations - // protect their client registration; used refresh hashes remain for the - // entire grant lifetime so replay detection is never shortened by cleanup. + // Registration is unauthenticated, so it performs bounded retention work and + // caps clients awaiting authorization. Active authorizations protect their + // client registration; used refresh hashes remain for the entire grant + // lifetime so replay detection is never shortened by cleanup. now := time.Unix(client.ClientIDIssuedAt, 0).UTC() + pendingSince := now.Add(-service.MCPOAuthPendingClientLifetime) tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return err } defer func() { _ = tx.Rollback() }() + // Serialize registrations so concurrent requests cannot overshoot the cap. + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('mcp_oauth_client_registration'))`); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `DELETE FROM mcp_oauth_grants WHERE id IN (SELECT id FROM mcp_oauth_grants WHERE expires_at<$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`, now.Add(-7*24*time.Hour)); err != nil { return err } - rows, err := tx.QueryContext(ctx, `SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`, now.Add(-90*24*time.Hour)) + rows, err := tx.QueryContext(ctx, `SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`, pendingSince) if err != nil { return err } @@ -66,6 +72,17 @@ func (r *mcpOAuthRepository) CreateClient(ctx context.Context, client *service.M return err } } + var pending int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_oauth_clients c WHERE c.created_at>=$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id)`, pendingSince).Scan(&pending); err != nil { + return err + } + if pending >= pendingLimit { + // Keep the retention work so capacity recovers as registrations expire. + if err := tx.Commit(); err != nil { + return err + } + return service.ErrMCPOAuthClientLimit + } if _, err := tx.ExecContext(ctx, `INSERT INTO mcp_oauth_clients (client_id,client_name,redirect_uris,grant_types,created_at) VALUES ($1,$2,$3,$4,$5)`, client.ClientID, client.ClientName, string(redirects), string(grants), now); err != nil { return err } @@ -247,6 +264,21 @@ func (r *mcpOAuthRepository) RevokeGrant(ctx context.Context, userID int64, id s return mcpOAuthResult(r.db.ExecContext(ctx, `UPDATE mcp_oauth_grants SET revoked_at=COALESCE(revoked_at,$3) WHERE id=$1 AND user_id=$2`, id, userID, at)) } +func (r *mcpOAuthRepository) RevokeUserGrants(ctx context.Context, userID int64, at time.Time) error { + return revokeMCPGrants(ctx, r.db, userID, false, at) +} + +// revokeMCPGrants persists revocation instead of relying on per-request user +// checks, so restoring an account or role cannot revive earlier grants. +func revokeMCPGrants(ctx context.Context, exec sqlExecutor, userID int64, adminOnly bool, at time.Time) error { + query := `UPDATE mcp_oauth_grants SET revoked_at=$2 WHERE user_id=$1 AND revoked_at IS NULL` + if adminOnly { + query += ` AND scope='admin'` + } + _, err := exec.ExecContext(ctx, query, userID, at) + return err +} + func mcpOAuthSQLError(err error) error { if errors.Is(err, sql.ErrNoRows) { return service.ErrMCPOAuthNotFound diff --git a/backend/internal/repository/mcp_oauth_integration_test.go b/backend/internal/repository/mcp_oauth_integration_test.go index 8a6bda5d58aa..6682a08c0b5f 100644 --- a/backend/internal/repository/mcp_oauth_integration_test.go +++ b/backend/internal/repository/mcp_oauth_integration_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" "github.com/stretchr/testify/require" ) @@ -201,3 +202,67 @@ func TestMCPOAuthIntegrationConcurrentCodeExchange(t *testing.T) { }, mcpIntegrationResource) requireMCPIntegrationInvalidGrant(t, err) } + +func TestMCPOAuthIntegrationAccountChangesRevokeGrantsPersistently(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + f := newMCPOAuthIntegrationFixture(t, ctx) + users := NewUserRepository(integrationEntClient, integrationDB) + user, err := users.GetByID(ctx, f.userID) + require.NoError(t, err) + user.Role = service.RoleAdmin + require.NoError(t, users.Update(ctx, user, service.UserUpdateFields{Role: true})) + now := time.Now().UTC() + adminGrant := &service.MCPOAuthGrant{ + ID: "admin-" + strings.Repeat("g", 20), ClientID: f.input.ClientID, UserID: f.userID, TokenVersion: 1, + Resource: mcpIntegrationResource, Scope: service.MCPScopeAdmin, ReadOnly: true, CreatedAt: now, ExpiresAt: now.Add(time.Hour), + } + require.NoError(t, f.repo.CreateAuthorization(ctx, adminGrant, &service.MCPOAuthCode{ + Hash: mcpIntegrationDigest(adminGrant.ID), GrantID: adminGrant.ID, ClientID: f.input.ClientID, RedirectURI: f.input.RedirectURI, + Resource: mcpIntegrationResource, CodeChallenge: strings.Repeat("c", 43), ExpiresAt: now.Add(time.Minute), + }, service.MCPOAuthGrantLimit)) + + requireRevoked := func(id string, revoked bool) { + t.Helper() + grant, err := f.repo.GetGrant(ctx, id) + require.NoError(t, err) + require.Equal(t, revoked, grant.RevokedAt != nil, id) + } + // Demotion revokes administrator grants only; personal access remains. + user.Role = service.RoleUser + require.NoError(t, users.Update(ctx, user, service.UserUpdateFields{Role: true})) + requireRevoked(adminGrant.ID, true) + requireRevoked(f.grantID, false) + + // Disabling revokes everything, and re-enabling must not revive it. + user.Status = service.StatusDisabled + require.NoError(t, users.Update(ctx, user, service.UserUpdateFields{Status: true})) + user.Status = service.StatusActive + require.NoError(t, users.Update(ctx, user, service.UserUpdateFields{Status: true})) + requireRevoked(f.grantID, true) + _, err = f.oauth.Token(ctx, f.input, mcpIntegrationResource) + requireMCPIntegrationInvalidGrant(t, err) +} + +func TestMCPOAuthIntegrationRegistrationStopsAtPendingLimit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + repo := NewMCPOAuthRepository(integrationDB) + var pending int + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM mcp_oauth_clients c WHERE c.created_at>=$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id)`, + time.Now().Add(-service.MCPOAuthPendingClientLifetime)).Scan(&pending)) + ids := []string{"mcp_client_pending_limit_a", "mcp_client_pending_limit_b"} + t.Cleanup(func() { + _, err := integrationDB.ExecContext(context.Background(), `DELETE FROM mcp_oauth_clients WHERE client_id=ANY($1)`, pq.Array(ids)) + require.NoError(t, err) + }) + newClient := func(id string) *service.MCPOAuthClient { + return &service.MCPOAuthClient{ClientID: id, ClientName: "Pending", RedirectURIs: []string{"https://client.example/cb"}, + GrantTypes: []string{"authorization_code"}, ClientIDIssuedAt: time.Now().Unix()} + } + require.NoError(t, repo.CreateClient(ctx, newClient(ids[0]), pending+1)) + require.ErrorIs(t, repo.CreateClient(ctx, newClient(ids[1]), pending+1), service.ErrMCPOAuthClientLimit) + _, err := repo.GetClient(ctx, ids[1]) + require.ErrorIs(t, err, service.ErrMCPOAuthNotFound) +} diff --git a/backend/internal/repository/mcp_oauth_test.go b/backend/internal/repository/mcp_oauth_test.go index 40f227952fe0..83fdcdc00cc1 100644 --- a/backend/internal/repository/mcp_oauth_test.go +++ b/backend/internal/repository/mcp_oauth_test.go @@ -14,20 +14,37 @@ import ( ) func TestMCPOAuthRepositoryRegistrationPrunesOnlyOldUnreferencedDataInBatches(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - repo := NewMCPOAuthRepository(db) - now := time.Unix(1800000000, 0).UTC() - client := &service.MCPOAuthClient{ClientID: "client", ClientName: "Client", RedirectURIs: []string{"https://client.example/cb"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ClientIDIssuedAt: now.Unix()} - mock.ExpectBegin() - mock.ExpectExec(`DELETE FROM mcp_oauth_grants WHERE id IN \(SELECT id FROM mcp_oauth_grants WHERE expires_at<\$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED\)`).WithArgs(now.Add(-7 * 24 * time.Hour)).WillReturnResult(sqlmock.NewResult(0, 2)) - mock.ExpectQuery(`SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<\$1 AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`).WithArgs(now.Add(-90 * 24 * time.Hour)).WillReturnRows(sqlmock.NewRows([]string{"client_id"}).AddRow("stale-client")) - mock.ExpectExec(`DELETE FROM mcp_oauth_clients c WHERE c.client_id=ANY\(\$1\) AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\)`).WithArgs(`{"stale-client"}`).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`INSERT INTO mcp_oauth_clients`).WithArgs(client.ClientID, client.ClientName, `["https://client.example/cb"]`, `["authorization_code","refresh_token"]`, now).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - require.NoError(t, repo.CreateClient(context.Background(), client)) - require.NoError(t, mock.ExpectationsWereMet()) + for _, atLimit := range []bool{false, true} { + t.Run(map[bool]string{false: "create", true: "at pending limit"}[atLimit], func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Unix(1800000000, 0).UTC() + pendingSince := now.Add(-service.MCPOAuthPendingClientLifetime) + client := &service.MCPOAuthClient{ClientID: "client", ClientName: "Client", RedirectURIs: []string{"https://client.example/cb"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ClientIDIssuedAt: now.Unix()} + pending := 999 + if atLimit { + pending = 1000 + } + mock.ExpectBegin() + mock.ExpectExec(`SELECT pg_advisory_xact_lock\(hashtext\('mcp_oauth_client_registration'\)\)`).WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`DELETE FROM mcp_oauth_grants WHERE id IN \(SELECT id FROM mcp_oauth_grants WHERE expires_at<\$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED\)`).WithArgs(now.Add(-7 * 24 * time.Hour)).WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectQuery(`SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<\$1 AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`).WithArgs(pendingSince).WillReturnRows(sqlmock.NewRows([]string{"client_id"}).AddRow("stale-client")) + mock.ExpectExec(`DELETE FROM mcp_oauth_clients c WHERE c.client_id=ANY\(\$1\) AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\)`).WithArgs(`{"stale-client"}`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM mcp_oauth_clients c WHERE c.created_at>=\$1 AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\)`).WithArgs(pendingSince).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(pending)) + if atLimit { + // Cleanup still commits so capacity recovers; nothing is inserted. + mock.ExpectCommit() + require.ErrorIs(t, repo.CreateClient(context.Background(), client, 1000), service.ErrMCPOAuthClientLimit) + } else { + mock.ExpectExec(`INSERT INTO mcp_oauth_clients`).WithArgs(client.ClientID, client.ClientName, `["https://client.example/cb"]`, `["authorization_code","refresh_token"]`, now).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + require.NoError(t, repo.CreateClient(context.Background(), client, 1000)) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } } func TestMCPOAuthRepositoryConsumptionCommitsReplayRevocation(t *testing.T) { @@ -175,3 +192,16 @@ func TestMCPOAuthRepositoryOwnerAndRevocationBoundaries(t *testing.T) { require.ErrorIs(t, repo.TouchGrant(context.Background(), "grant", now), service.ErrMCPOAuthNotFound) require.NoError(t, mock.ExpectationsWereMet()) } + +func TestMCPOAuthRepositoryRevokeUserGrantsCoversEveryActiveGrant(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewMCPOAuthRepository(db) + now := time.Now().UTC() + mock.ExpectExec(`UPDATE mcp_oauth_grants SET revoked_at=\$2 WHERE user_id=\$1 AND revoked_at IS NULL$`).WithArgs(int64(8), now).WillReturnResult(sqlmock.NewResult(0, 0)) + require.NoError(t, repo.RevokeUserGrants(context.Background(), 8, now), "no active grants is not an error") + mock.ExpectExec(`UPDATE mcp_oauth_grants SET revoked_at=\$2 WHERE user_id=\$1 AND revoked_at IS NULL AND scope='admin'`).WithArgs(int64(8), now).WillReturnResult(sqlmock.NewResult(0, 3)) + require.NoError(t, revokeMCPGrants(context.Background(), db, 8, true, now)) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index a0d700661628..fb9fc18966fe 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -351,6 +351,18 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User, field return err } } + // 停用或降级时在同一事务内吊销 MCP 授权,恢复账号或角色后旧授权不会复活。 + disabled := fields.Status && updated.Status != existing.Status && updated.Status != service.StatusActive + demoted := fields.Role && updated.Role != existing.Role && updated.Role != service.RoleAdmin + if disabled || demoted { + exec := txAwareSQLExecutor(txCtx, r.sql, r.client) + if exec == nil { + return fmt.Errorf("sql executor is not configured") + } + if err := revokeMCPGrants(txCtx, exec, updated.ID, !disabled, time.Now().UTC()); err != nil { + return err + } + } // 始终以库中的邮箱为准补齐 email 身份:未改邮箱时 updated.Email == oldEmail, // 这里退化为幂等的身份补写,与改邮箱前的行为一致。 if err := replaceEmailAuthIdentityWithClient(txCtx, txClient, updated.ID, oldEmail, updated.Email, "user_repo_update"); err != nil { diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 968cd80b3af2..b35ac3d48516 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -161,7 +161,7 @@ var auditBodyOmittedRoutes = map[string]struct{}{ } // NewAuditLogMiddleware 创建审计中间件。 -// 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。 +// 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取 + MCP OAuth 的全部读取。 // 挂载位置:admin / user / admin-payment 组挂在各自认证中间件之后(只审计已认证请求, // 未过认证的 401/403 不入库);auth 组(登录/注册/刷新)无前置认证,天然记录失败尝试。 func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddleware { @@ -180,6 +180,10 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle if v, ok := auditSensitiveReads[routeKey]; ok { record = true action = v + } else if c.GetString("auth_method") == service.AuditAuthMethodMCPOAuth { + // Machine credentials can read user data in bulk; every MCP tool + // call is attributable to its grant via audit extra fields. + record = true } } if !record { diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 3ff14cd914b8..3328e4216acf 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -188,3 +188,39 @@ func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) { require.Equal(t, "", logs[0].RequestBody) require.NotContains(t, logs[0].RequestBody, "audit-canary") } + +// MCP OAuth 是机器凭证:只读工具同样可以批量读取邮箱等数据,必须全部入审计并带上 grant。 +func TestAuditRecordsEveryMCPOAuthReadWithGrant(t *testing.T) { + gin.SetMode(gin.TestMode) + repository := &auditCaptureRepository{} + auditService := service.NewAuditLogService(repository, nil) + auditService.Start() + + for _, authMethod := range []string{service.AuditAuthMethodMCPOAuth, ""} { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: 77}) + c.Set(string(ContextKeyUserRole), service.RoleAdmin) + if authMethod != "" { + c.Set("auth_method", authMethod) + SetAuditExtra(c, map[string]any{"mcp_grant_id": "grant-1", "mcp_tool": "admin_list_users"}) + } + c.Next() + }) + router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService))) + router.GET("/api/v1/admin/users", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": []any{}}) }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/admin/users?search=a", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + } + auditService.Stop() + + repository.mu.Lock() + logs := append([]*service.AuditLog(nil), repository.logs...) + repository.mu.Unlock() + require.Len(t, logs, 1, "browser reads stay outside the audit log") + require.Equal(t, service.AuditAuthMethodMCPOAuth, logs[0].AuthMethod) + require.Equal(t, "admin.users.read", logs[0].Action) + require.Equal(t, "grant-1", logs[0].Extra["mcp_grant_id"]) + require.Equal(t, "admin_list_users", logs[0].Extra["mcp_tool"]) +} diff --git a/backend/internal/server/middleware/step_up.go b/backend/internal/server/middleware/step_up.go index 6545aa455c72..f6fcba8edcaa 100644 --- a/backend/internal/server/middleware/step_up.go +++ b/backend/internal/server/middleware/step_up.go @@ -94,6 +94,23 @@ func EnforceStepUpAlways( return enforceStepUp(c, totpService, userService, nil) } +// MachineCredentialReason allowlists interactive browser sessions: the user +// router leaves auth_method empty and the admin router sets "jwt". Every other +// method (admin API keys, MCP OAuth, future credential types) is a machine +// credential and is denied by default; the reason distinguishes known kinds. +func MachineCredentialReason(c *gin.Context) (string, bool) { + switch c.GetString("auth_method") { + case "", service.AuditAuthMethodJWT: + return "", false + case service.AuditAuthMethodAdminAPIKey: + return "STEP_UP_ADMIN_API_KEY_FORBIDDEN", true + case service.AuditAuthMethodMCPOAuth: + return "STEP_UP_MCP_OAUTH_FORBIDDEN", true + default: + return "STEP_UP_MACHINE_CREDENTIAL_FORBIDDEN", true + } +} + func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader stepUpUserReader, settings stepUpSettingReader) bool { // 功能开关关闭时直接放行(含 admin API key),恢复门控引入前的行为。 // settings 为 nil 时保持门控(fail-closed):正常装配不会出现 nil。 @@ -101,14 +118,9 @@ func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader s return true } - if c.GetString("auth_method") == "mcp_oauth" { - AbortWithError(c, 403, "STEP_UP_MCP_OAUTH_FORBIDDEN", - "MCP OAuth credentials cannot perform this operation; use a two-factor verified browser session") - return false - } - if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { - AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", - "Admin API key cannot access this endpoint; a two-factor verified admin session is required") + if reason, machine := MachineCredentialReason(c); machine { + AbortWithError(c, 403, reason, + "Machine credentials cannot access this endpoint; a two-factor verified browser session is required") return false } diff --git a/backend/internal/server/middleware/step_up_test.go b/backend/internal/server/middleware/step_up_test.go index f4d2f37adc6f..65c3de8888d0 100644 --- a/backend/internal/server/middleware/step_up_test.go +++ b/backend/internal/server/middleware/step_up_test.go @@ -63,14 +63,32 @@ func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") } -func TestEnforceStepUpRejectsMCPOAuthEvenWithUserGrant(t *testing.T) { - c, rec := newStepUpTestContext(t) - c.Set("auth_method", "mcp_oauth") - c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) - ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}, stepUpEnabled) - require.False(t, ok) - require.Equal(t, http.StatusForbidden, rec.Code) - require.Contains(t, rec.Body.String(), "STEP_UP_MCP_OAUTH_FORBIDDEN") +func TestEnforceStepUpRejectsMachineCredentialsEvenWithUserGrant(t *testing.T) { + for method, reason := range map[string]string{ + service.AuditAuthMethodMCPOAuth: "STEP_UP_MCP_OAUTH_FORBIDDEN", + // Unknown credential types are denied by default, not silently allowed. + "future_machine_token": "STEP_UP_MACHINE_CREDENTIAL_FORBIDDEN", + } { + c, rec := newStepUpTestContext(t) + c.Set("auth_method", method) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}, stepUpEnabled) + require.False(t, ok, method) + require.Equal(t, http.StatusForbidden, rec.Code, method) + require.Contains(t, rec.Body.String(), reason, method) + } +} + +func TestMachineCredentialReasonAllowlistsBrowserSessions(t *testing.T) { + for method, want := range map[string]bool{ + "": false, service.AuditAuthMethodJWT: false, + service.AuditAuthMethodAdminAPIKey: true, service.AuditAuthMethodMCPOAuth: true, "unknown": true, + } { + c, _ := newStepUpTestContext(t) + c.Set("auth_method", method) + _, machine := MachineCredentialReason(c) + require.Equal(t, want, machine, method) + } } func TestEnforceStepUpRequiresAuthSubject(t *testing.T) { diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 99458853a4e0..8dcc7ab9423f 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -128,7 +128,7 @@ func registerRoutes( routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter) routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter) routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter) - routes.RegisterMCPRoutes(v1, h, auditLog, stepUpAuth, settingService, panelRateLimiter, cfg.CORS.AllowedOrigins) + routes.RegisterMCPRoutes(v1, h, auditLog, stepUpAuth, settingService, panelRateLimiter, cfg.CORS.AllowedOrigins, redisClient) routes.RegisterMCPOAuthRoutes(r, v1, h, redisClient) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter, redisClient) diff --git a/backend/internal/server/routes/mcp.go b/backend/internal/server/routes/mcp.go index 2e7049d91502..763f8a55c58a 100644 --- a/backend/internal/server/routes/mcp.go +++ b/backend/internal/server/routes/mcp.go @@ -12,11 +12,15 @@ import ( "github.com/redis/go-redis/v9" ) +// MCP limits fail closed and count an IPv6 /64 as one client, so rotating +// addresses within one allocation does not multiply the budget. +var mcpRateLimitOptions = ratelimit.RateLimitOptions{FailureMode: ratelimit.RateLimitFailClose, IPv6PrefixBits: 64} + // RegisterMCPRoutes exposes MCP over HTTP and reuses the normal management // routes in a private router, preserving ownership checks, guards and audit. func RegisterMCPRoutes(v1 *gin.RouterGroup, h *handler.Handlers, audit middleware.AuditLogMiddleware, stepUp middleware.StepUpAuthMiddleware, settings *service.SettingService, - limiter *middleware.PanelRateLimiter, allowedOrigins []string, + limiter *middleware.PanelRateLimiter, allowedOrigins []string, redisClient *redis.Client, ) { inner := gin.New() inner.Use(gin.Recovery()) @@ -24,7 +28,15 @@ func RegisterMCPRoutes(v1 *gin.RouterGroup, h *handler.Handlers, audit middlewar RegisterUserRoutes(api, h, middleware.JWTAuthMiddleware(managementmcp.DispatchAuth(false)), audit, settings, limiter) RegisterAdminRoutes(api, h, middleware.AdminAuthMiddleware(managementmcp.DispatchAuth(true)), audit, stepUp, settings, limiter) transport := managementmcp.NewHandler(inner) - mcp := v1.Group("/mcp", managementmcp.Authenticate(h.MCPOAuth.Service(), allowedOrigins, h.MCPOAuth.ResourceURL), limiter.MCPTransport(), middleware.BackendModeUserGuard(settings)) + var handlers []gin.HandlerFunc + if redisClient != nil { + // Bound token probes per network before authentication reaches the database. + handlers = append(handlers, ratelimit.NewRateLimiter(redisClient).LimitWithOptions("mcp-transport", 600, time.Minute, mcpRateLimitOptions)) + } + handlers = append(handlers, + managementmcp.Authenticate(h.MCPOAuth.Service(), allowedOrigins, h.MCPOAuth.ResourceURL), + limiter.MCPTransport(), middleware.BackendModeUserGuard(settings)) + mcp := v1.Group("/mcp", handlers...) for _, method := range []string{"POST", "GET", "DELETE"} { mcp.Handle(method, "", gin.WrapH(transport)) } @@ -37,9 +49,8 @@ func RegisterMCPOAuthRoutes(r *gin.Engine, v1 *gin.RouterGroup, h *handler.Handl r.GET("/.well-known/oauth-protected-resource", h.MCPOAuth.ResourceMetadata) r.GET("/.well-known/oauth-authorization-server", h.MCPOAuth.AuthorizationMetadata) limit := ratelimit.NewRateLimiter(redisClient) - closed := ratelimit.RateLimitOptions{FailureMode: ratelimit.RateLimitFailClose} oauth := v1.Group("/mcp/oauth") - oauth.POST("/register", limit.LimitWithOptions("mcp-oauth-register", 10, time.Minute, closed), h.MCPOAuth.Register) - oauth.POST("/token", limit.LimitWithOptions("mcp-oauth-token", 60, time.Minute, closed), h.MCPOAuth.Token) - oauth.POST("/revoke", limit.LimitWithOptions("mcp-oauth-revoke", 60, time.Minute, closed), h.MCPOAuth.RevokeToken) + oauth.POST("/register", limit.LimitWithOptions("mcp-oauth-register", 10, time.Minute, mcpRateLimitOptions), h.MCPOAuth.Register) + oauth.POST("/token", limit.LimitWithOptions("mcp-oauth-token", 60, time.Minute, mcpRateLimitOptions), h.MCPOAuth.Token) + oauth.POST("/revoke", limit.LimitWithOptions("mcp-oauth-revoke", 60, time.Minute, mcpRateLimitOptions), h.MCPOAuth.RevokeToken) } diff --git a/backend/internal/server/routes/mcp_oauth_test.go b/backend/internal/server/routes/mcp_oauth_test.go index e6512664281f..03118a01a8bb 100644 --- a/backend/internal/server/routes/mcp_oauth_test.go +++ b/backend/internal/server/routes/mcp_oauth_test.go @@ -35,7 +35,7 @@ type mcpOAuthRouteRepo struct { credentials map[string]*service.MCPOAuthCredential } -func (r *mcpOAuthRouteRepo) CreateClient(_ context.Context, client *service.MCPOAuthClient) error { +func (r *mcpOAuthRouteRepo) CreateClient(_ context.Context, client *service.MCPOAuthClient, _ int) error { r.mu.Lock() defer r.mu.Unlock() r.clients[client.ClientID] = client @@ -180,6 +180,17 @@ func (r *mcpOAuthRouteRepo) RevokeGrant(_ context.Context, userID int64, id stri return nil } +func (r *mcpOAuthRouteRepo) RevokeUserGrants(_ context.Context, userID int64, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, item := range r.grants { + if item.UserID == userID && item.RevokedAt == nil { + item.RevokedAt = &now + } + } + return nil +} + type mcpOAuthRouteUsers struct { service.UserRepository user *service.User @@ -193,6 +204,10 @@ func (r *mcpOAuthRouteUsers) GetByID(_ context.Context, id int64) (*service.User return ©, nil } +func (r *mcpOAuthRouteUsers) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) { + return nil, nil +} + func newMCPRouteOAuthService(role string) (*service.MCPOAuthService, *mcpOAuthRouteRepo, *mcpOAuthRouteUsers) { repo := &mcpOAuthRouteRepo{ clients: map[string]*service.MCPOAuthClient{}, grants: map[string]*service.MCPOAuthGrant{}, @@ -227,6 +242,12 @@ func issueMCPRouteOAuthToken(t *testing.T, oauth *service.MCPOAuthService, resou return tokens.AccessToken } +type mcpOAuthRouteStepUpCache struct{ service.TotpCache } + +func (mcpOAuthRouteStepUpCache) HasStepUpGrant(_ context.Context, _ int64, session string) (bool, error) { + return session == "browser-session", nil +} + type mcpOAuthHTTPFixture struct { server *httptest.Server router *gin.Engine @@ -250,8 +271,11 @@ func newMCPOAuthHTTPFixture(t *testing.T, role string) *mcpOAuthHTTPFixture { cfg.JWT.Secret = "test-jwt-secret-at-least-thirty-two-bytes" settings := service.NewSettingService(&mcpRouteSettingsRepo{values: map[string]string{service.SettingKeyStepUpEnabled: "false"}}, cfg) oauth, repo, users := newMCPRouteOAuthService(role) + // Administrator write grants always pass the real step-up gate. + users.user.TotpEnabled = true + totp := service.NewTotpService(users, nil, mcpOAuthRouteStepUpCache{}, settings, nil, nil) h := mcpRouteHandlers() - h.MCPOAuth = handler.NewMCPOAuthHandler(oauth, nil, nil, settings, cfg) + h.MCPOAuth = handler.NewMCPOAuthHandler(oauth, totp, service.NewUserService(users, nil, nil, nil), settings, cfg) h.APIKey = handler.NewAPIKeyHandler(service.NewAPIKeyService(&mcpRouteAPIKeyRepo{}, nil, nil, nil, nil, nil, nil)) mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) @@ -269,7 +293,7 @@ func newMCPOAuthHTTPFixture(t *testing.T, role string) *mcpOAuthHTTPFixture { v1 := router.Group("/api/v1") RegisterUserRoutes(v1, h, browserAuth, middleware.AuditLogMiddleware(mcpRouteNext), settings, nil) RegisterMCPOAuthRoutes(router, v1, h, rdb) - RegisterMCPRoutes(v1, h, middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), settings, nil, nil) + RegisterMCPRoutes(v1, h, middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), settings, nil, nil, rdb) server.Start() t.Cleanup(server.Close) return &mcpOAuthHTTPFixture{server: server, router: router, resource: cfg.MCP.PublicURL, cfg: cfg, settings: settings, handlers: h, oauth: oauth, repo: repo, users: users} @@ -406,9 +430,8 @@ func TestMCPOAuthHTTPFlowDiscoveryConsentRefreshAndRevocation(t *testing.T) { require.NotContains(t, string(tools.body), "admin_get_dashboard") call := f.rpc(t, tokens.AccessToken, "tools/call", map[string]any{"name": "user_get_api_key", "arguments": map[string]any{"id": 9}}) require.Equal(t, http.StatusOK, call.status, string(call.body)) - var result mcpRouteToolResult - require.NoError(t, json.Unmarshal(call.body, &result)) - require.Equal(t, http.StatusOK, result.Result.StructuredContent.HTTPStatus) + result := decodeMCPRouteToolResult(t, call.body) + require.Equal(t, http.StatusOK, result.Result.Payload.HTTPStatus) require.Contains(t, string(call.body), "owner-key") require.NotContains(t, string(call.body), "must-not-leak") refresh := f.form(t, "/api/v1/mcp/oauth/token", url.Values{"grant_type": {"refresh_token"}, "client_id": {client.ClientID}, "refresh_token": {tokens.RefreshToken}}) diff --git a/backend/internal/server/routes/mcp_rate_limit_test.go b/backend/internal/server/routes/mcp_rate_limit_test.go index 723708718146..c75ac4534229 100644 --- a/backend/internal/server/routes/mcp_rate_limit_test.go +++ b/backend/internal/server/routes/mcp_rate_limit_test.go @@ -40,7 +40,7 @@ func newMCPRateLimitEndpoint(t *testing.T, role string, rpm int) (*gin.Engine, s h.APIKey = handler.NewAPIKeyHandler(service.NewAPIKeyService(&mcpRouteAPIKeyRepo{}, nil, nil, nil, nil, nil, nil)) router := gin.New() RegisterMCPRoutes(router.Group("/api/v1"), h, middleware.AuditLogMiddleware(mcpRouteNext), - middleware.StepUpAuthMiddleware(mcpRouteNext), settings, middleware.NewPanelRateLimiter(rdb, settings), nil) + middleware.StepUpAuthMiddleware(mcpRouteNext), settings, middleware.NewPanelRateLimiter(rdb, settings), nil, rdb) return router, token, mr } @@ -74,9 +74,8 @@ func TestMCPToolCallsConsumeGlobalBudgetOnce(t *testing.T) { for i := 0; i < 2; i++ { w := mcpRateLimitRPC(t, endpoint, token, "tools/call", params) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var result mcpRouteToolResult - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) - require.Equal(t, tc.status, result.Result.StructuredContent.HTTPStatus, w.Body.String()) + result := decodeMCPRouteToolResult(t, w.Body.Bytes()) + require.Equal(t, tc.status, result.Result.Payload.HTTPStatus, w.Body.String()) } count, err := mr.Get("rate_limit:panel:global:user:7") require.NoError(t, err) @@ -107,9 +106,8 @@ func TestMCPDispatchRetainsHeavyRateLimit(t *testing.T) { for _, status := range []int{http.StatusBadRequest, http.StatusTooManyRequests} { w := mcpRateLimitRPC(t, endpoint, token, "tools/call", params) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var result mcpRouteToolResult - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) - require.Equal(t, status, result.Result.StructuredContent.HTTPStatus, w.Body.String()) + result := decodeMCPRouteToolResult(t, w.Body.Bytes()) + require.Equal(t, status, result.Result.Payload.HTTPStatus, w.Body.String()) } count, err := mr.Get("rate_limit:panel:global:user:7") require.NoError(t, err) diff --git a/backend/internal/server/routes/mcp_test.go b/backend/internal/server/routes/mcp_test.go index d540f31064ae..f95b08cbe176 100644 --- a/backend/internal/server/routes/mcp_test.go +++ b/backend/internal/server/routes/mcp_test.go @@ -51,7 +51,7 @@ func TestMCPToolCatalogTargetsRegisteredManagementRoutes(t *testing.T) { // duplicate Gin paths or panic while building its permission-filtered servers. mcpRouter := gin.New() require.NotPanics(t, func() { - RegisterMCPRoutes(mcpRouter.Group("/api/v1"), h, middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), nil, nil, nil) + RegisterMCPRoutes(mcpRouter.Group("/api/v1"), h, middleware.AuditLogMiddleware(mcpRouteNext), middleware.StepUpAuthMiddleware(mcpRouteNext), nil, nil, nil, nil) }) for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { w := httptest.NewRecorder() @@ -78,17 +78,39 @@ func newMCPRouteTestEndpoint(h *handler.Handlers, principal *service.MCPPrincipa return outer } +type mcpRouteToolPayload struct { + HTTPStatus int `json:"http_status"` + Result json.RawMessage `json:"result"` +} + type mcpRouteToolResult struct { Result struct { - IsError bool `json:"isError"` - StructuredContent struct { - HTTPStatus int `json:"http_status"` - Result json.RawMessage `json:"result"` - } `json:"structuredContent"` + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + // Payload is decoded from the single text block that carries the result. + Payload mcpRouteToolPayload `json:"-"` } `json:"result"` Error json.RawMessage `json:"error"` } +// decodeMCPRouteToolResult requires exactly one JSON text block, so results are +// never duplicated into structuredContent. +func decodeMCPRouteToolResult(t *testing.T, raw []byte) mcpRouteToolResult { + t.Helper() + var result mcpRouteToolResult + require.NoError(t, json.Unmarshal(raw, &result), string(raw)) + require.Empty(t, result.Error, string(raw)) + require.Empty(t, result.Result.StructuredContent, string(raw)) + require.Len(t, result.Result.Content, 1, string(raw)) + require.Equal(t, "text", result.Result.Content[0].Type) + require.NoError(t, json.Unmarshal([]byte(result.Result.Content[0].Text), &result.Result.Payload), string(raw)) + return result +} + func callMCPRouteTool(t *testing.T, endpoint http.Handler, name string, args map[string]any) mcpRouteToolResult { t.Helper() body, err := json.Marshal(map[string]any{ @@ -104,10 +126,7 @@ func callMCPRouteTool(t *testing.T, endpoint http.Handler, name string, args map w := httptest.NewRecorder() endpoint.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var result mcpRouteToolResult - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result), w.Body.String()) - require.Empty(t, result.Error, w.Body.String()) - return result + return decodeMCPRouteToolResult(t, w.Body.Bytes()) } type mcpRouteAPIKeyRepo struct{ service.APIKeyRepository } @@ -134,15 +153,15 @@ func TestMCPDispatchRetainsRealAPIKeyOwnershipAndAudit(t *testing.T) { }) result := callMCPRouteTool(t, newMCPRouteTestEndpoint(h, principal, nil, audit), "user_get_api_key", map[string]any{"id": 9}) require.Equal(t, 1, auditCalls) - require.NotContains(t, string(result.Result.StructuredContent.Result), "must-not-leak") + require.NotContains(t, string(result.Result.Payload.Result), "must-not-leak") if userID == 7 { require.False(t, result.Result.IsError) - require.Equal(t, http.StatusOK, result.Result.StructuredContent.HTTPStatus) - require.Contains(t, string(result.Result.StructuredContent.Result), "owner-key") + require.Equal(t, http.StatusOK, result.Result.Payload.HTTPStatus) + require.Contains(t, string(result.Result.Payload.Result), "owner-key") } else { require.True(t, result.Result.IsError) - require.Equal(t, http.StatusNotFound, result.Result.StructuredContent.HTTPStatus) - require.NotContains(t, string(result.Result.StructuredContent.Result), "owner-key") + require.Equal(t, http.StatusNotFound, result.Result.Payload.HTTPStatus) + require.NotContains(t, string(result.Result.Payload.Result), "owner-key") } } } @@ -179,12 +198,12 @@ func TestMCPDispatchRetainsBackendModeAndAdminCompliance(t *testing.T) { user := &service.MCPPrincipal{GrantID: "user", UserID: 7, Role: service.RoleUser, Scope: service.MCPScopeUser, ReadOnly: true} result := callMCPRouteTool(t, newMCPRouteTestEndpoint(h, user, settings, middleware.AuditLogMiddleware(mcpRouteNext)), "user_get_profile", map[string]any{}) require.True(t, result.Result.IsError) - require.Equal(t, http.StatusForbidden, result.Result.StructuredContent.HTTPStatus) - require.Contains(t, string(result.Result.StructuredContent.Result), "Backend mode is active") + require.Equal(t, http.StatusForbidden, result.Result.Payload.HTTPStatus) + require.Contains(t, string(result.Result.Payload.Result), "Backend mode is active") admin := &service.MCPPrincipal{GrantID: "admin", UserID: 8, Role: service.RoleAdmin, Scope: service.MCPScopeAdmin, ReadOnly: true} result = callMCPRouteTool(t, newMCPRouteTestEndpoint(h, admin, settings, middleware.AuditLogMiddleware(mcpRouteNext)), "admin_get_dashboard", map[string]any{}) require.True(t, result.Result.IsError) - require.Equal(t, http.StatusLocked, result.Result.StructuredContent.HTTPStatus) - require.Contains(t, string(result.Result.StructuredContent.Result), "ADMIN_COMPLIANCE_ACK_REQUIRED") + require.Equal(t, http.StatusLocked, result.Result.Payload.HTTPStatus) + require.Contains(t, string(result.Result.Payload.Result), "ADMIN_COMPLIANCE_ACK_REQUIRED") } diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go index 46a59013e00e..db2ff3ab6bf1 100644 --- a/backend/internal/service/audit_log.go +++ b/backend/internal/service/audit_log.go @@ -20,6 +20,8 @@ const ( AuditAuthMethodJWT = "jwt" AuditAuthMethodAdminAPIKey = "admin_api_key" AuditAuthMethodPasskey = "passkey" + // AuditAuthMethodMCPOAuth 标记管理 MCP 的 OAuth 访问令牌(机器凭证)。 + AuditAuthMethodMCPOAuth = "mcp_oauth" // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。 auditRequestBodyMaxBytes = 16 * 1024 diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 0774923e6c70..d121e2301e7b 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -86,6 +86,7 @@ type AuthService struct { affiliateService *AffiliateService defaultSubAssigner DefaultSubscriptionAssigner userPlatformQuotaRepo UserPlatformQuotaRepository + mcpGrantRevoker MCPGrantRevoker } type CaptchaProof struct { @@ -95,6 +96,11 @@ type CaptchaProof struct { TencentRandstr string } +// MCPGrantRevoker revokes MCP authorizations, which outlive browser sessions. +type MCPGrantRevoker interface { + RevokeAllGrants(ctx context.Context, userID int64) error +} + type DefaultSubscriptionAssigner interface { AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) } @@ -154,6 +160,10 @@ func (s *AuthService) SetAliyunCaptchaService(aliyunCaptchaService *AliyunCaptch s.aliyunCaptchaService = aliyunCaptchaService } +func (s *AuthService) SetMCPGrantRevoker(revoker MCPGrantRevoker) { + s.mcpGrantRevoker = revoker +} + // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { return s.RegisterWithVerification(ctx, email, password, "", "", "", "") @@ -1907,6 +1917,13 @@ func (s *AuthService) RevokeAllUserTokens(ctx context.Context, userID int64) err if err := s.RevokeAllUserSessions(ctx, userID); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after token invalidation for user %d: %v", userID, err) } + // A browser JWT can mint a long-lived MCP grant, so revoking every session + // must also revoke those grants. Fail loudly: the caller can retry. + if s.mcpGrantRevoker != nil { + if err := s.mcpGrantRevoker.RevokeAllGrants(ctx, userID); err != nil { + return fmt.Errorf("revoke MCP grants: %w", err) + } + } return nil } diff --git a/backend/internal/service/mcp_oauth_service.go b/backend/internal/service/mcp_oauth_service.go index f74200f74425..179d40020695 100644 --- a/backend/internal/service/mcp_oauth_service.go +++ b/backend/internal/service/mcp_oauth_service.go @@ -11,6 +11,7 @@ import ( "net/url" "strings" "time" + "unicode" "unicode/utf8" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -24,6 +25,12 @@ const ( MCPOAuthAccessLifetime = 10 * time.Minute MCPOAuthCodeLifetime = 5 * time.Minute MCPOAuthGrantLifetime = 30 * 24 * time.Hour + // Registration is unauthenticated: clients that never obtain a grant expire + // quickly, and the number awaiting authorization is capped globally. + MCPOAuthPendingClientLifetime = 24 * time.Hour + MCPOAuthPendingClientLimit = 1000 + // last_used_at is advisory; writing it once per interval keeps requests read-only. + MCPOAuthTouchInterval = time.Minute ) var ( @@ -31,6 +38,7 @@ var ( ErrMCPOAuthNotFound = errors.New("MCP OAuth record not found") ErrMCPOAuthGrantLimit = infraerrors.Conflict("MCP_GRANT_LIMIT", "Revoke an existing MCP authorization before authorizing another client (maximum 20 active authorizations)") ErrMCPOAuthGrantNotFound = infraerrors.NotFound("MCP_GRANT_NOT_FOUND", "MCP authorization not found") + ErrMCPOAuthClientLimit = errors.New("too many MCP OAuth clients are awaiting authorization") ) type MCPOAuthError struct { @@ -149,7 +157,9 @@ type MCPOAuthCredential struct { } type MCPOAuthRepository interface { - CreateClient(context.Context, *MCPOAuthClient) error + // CreateClient returns ErrMCPOAuthClientLimit when the given number of + // recently registered clients without any grant already exists. + CreateClient(context.Context, *MCPOAuthClient, int) error GetClient(context.Context, string) (*MCPOAuthClient, error) CreateAuthorization(context.Context, *MCPOAuthGrant, *MCPOAuthCode, int) error GetCode(context.Context, string) (*MCPOAuthCode, error) @@ -162,6 +172,7 @@ type MCPOAuthRepository interface { TouchGrant(context.Context, string, time.Time) error ListGrants(context.Context, int64) ([]*MCPOAuthGrant, error) RevokeGrant(context.Context, int64, string, time.Time) error + RevokeUserGrants(context.Context, int64, time.Time) error } type MCPOAuthService struct { @@ -178,8 +189,8 @@ func (s *MCPOAuthService) Register(ctx context.Context, input MCPOAuthRegisterIn if input.ClientName == "" { input.ClientName = "MCP client" } - if !utf8.ValidString(input.ClientName) || utf8.RuneCountInString(input.ClientName) > 100 { - return nil, mcpOAuthError("invalid_client_metadata", "client_name must contain at most 100 characters") + if !validMCPClientName(input.ClientName) { + return nil, mcpOAuthError("invalid_client_metadata", "client_name must contain at most 100 printable characters") } if input.TokenEndpointAuthMethod != "" && input.TokenEndpointAuthMethod != "none" { return nil, mcpOAuthError("invalid_client_metadata", "Only public clients using token_endpoint_auth_method=none are supported") @@ -220,12 +231,26 @@ func (s *MCPOAuthService) Register(ctx context.Context, input MCPOAuthRegisterIn GrantTypes: append([]string(nil), grants...), ResponseTypes: []string{"code"}, TokenEndpointAuthMethod: "none", ClientIDIssuedAt: time.Now().UTC().Unix(), } - if err := s.repo.CreateClient(ctx, client); err != nil { + if err := s.repo.CreateClient(ctx, client, MCPOAuthPendingClientLimit); err != nil { return nil, err } return client, nil } +func validMCPClientName(name string) bool { + if !utf8.ValidString(name) || utf8.RuneCountInString(name) > 100 { + return false + } + for _, r := range name { + // Bidi overrides, zero-width and control characters can make the consent + // page show a self-reported name that looks like a different client. + if unicode.In(r, unicode.Cc, unicode.Cf, unicode.Co, unicode.Zl, unicode.Zp) { + return false + } + } + return true +} + func validMCPRedirectURI(raw string) bool { if len(raw) > 2048 || strings.ContainsAny(raw, "*#") { return false @@ -529,11 +554,13 @@ func (s *MCPOAuthService) Authenticate(ctx context.Context, raw string) (*MCPPri if err != nil { return nil, err } - if err := s.repo.TouchGrant(ctx, grant.ID, now); err != nil { - if errors.Is(err, ErrMCPOAuthNotFound) { - return nil, ErrMCPOAuthInvalidToken + if grant.LastUsedAt == nil || !now.Before(grant.LastUsedAt.Add(MCPOAuthTouchInterval)) { + if err := s.repo.TouchGrant(ctx, grant.ID, now); err != nil { + if errors.Is(err, ErrMCPOAuthNotFound) { + return nil, ErrMCPOAuthInvalidToken + } + return nil, err } - return nil, err } return &MCPPrincipal{GrantID: grant.ID, UserID: user.ID, Role: user.Role, Scope: grant.Scope, ReadOnly: grant.ReadOnly, Concurrency: user.Concurrency, Email: user.Email, Resource: grant.Resource}, nil @@ -562,6 +589,12 @@ func (s *MCPOAuthService) RevokeGrant(ctx context.Context, userID int64, id stri return err } +// RevokeAllGrants persistently revokes every MCP authorization of the user, +// including refresh families, e.g. when all browser sessions are revoked. +func (s *MCPOAuthService) RevokeAllGrants(ctx context.Context, userID int64) error { + return s.repo.RevokeUserGrants(ctx, userID, time.Now().UTC()) +} + func (s *MCPOAuthService) RevokeToken(ctx context.Context, clientID, raw string) error { if !validMCPOAuthSecret(raw, "mcp_at_") && !validMCPOAuthSecret(raw, "mcp_rt_") { return nil diff --git a/backend/internal/service/mcp_oauth_service_test.go b/backend/internal/service/mcp_oauth_service_test.go index 1abf3d3c04d7..ec8df8aeb89b 100644 --- a/backend/internal/service/mcp_oauth_service_test.go +++ b/backend/internal/service/mcp_oauth_service_test.go @@ -7,6 +7,8 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" + "fmt" "net/url" "strings" "sync" @@ -22,11 +24,25 @@ type mcpOAuthMemoryRepo struct { grants map[string]*MCPOAuthGrant codes map[string]*MCPOAuthCode credentials map[string]*MCPOAuthCredential + touches int } -func (r *mcpOAuthMemoryRepo) CreateClient(_ context.Context, client *MCPOAuthClient) error { +func (r *mcpOAuthMemoryRepo) CreateClient(_ context.Context, client *MCPOAuthClient, pendingLimit int) error { r.mu.Lock() defer r.mu.Unlock() + pending := 0 + for id := range r.clients { + authorized := false + for _, grant := range r.grants { + authorized = authorized || grant.ClientID == id + } + if !authorized { + pending++ + } + } + if pending >= pendingLimit { + return ErrMCPOAuthClientLimit + } r.clients[client.ClientID] = client return nil } @@ -140,6 +156,7 @@ func (r *mcpOAuthMemoryRepo) TouchGrant(_ context.Context, id string, now time.T return ErrMCPOAuthNotFound } grant.LastUsedAt = &now + r.touches++ return nil } @@ -169,6 +186,17 @@ func (r *mcpOAuthMemoryRepo) RevokeGrant(_ context.Context, userID int64, id str return nil } +func (r *mcpOAuthMemoryRepo) RevokeUserGrants(_ context.Context, userID int64, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, grant := range r.grants { + if grant.UserID == userID && grant.RevokedAt == nil { + grant.RevokedAt = &now + } + } + return nil +} + type mcpOAuthUserRepo struct { UserRepository user *User @@ -550,3 +578,80 @@ func TestMCPOAuthConcurrentCodeExchangeAllowsAtMostOneIssuance(t *testing.T) { require.Equal(t, 1, successes) require.Len(t, f.r.credentials, 2, "only one credential pair may be persisted") } + +func TestMCPOAuthRegistrationRejectsDisguisedClientNames(t *testing.T) { + f := newMCPOAuthFixture(t) + for _, name := range []string{"Claude\u202eedualC", "Clau\u200bde", "Claude\x07", "Claude\u2028Desktop", "Claude\ue000", strings.Repeat("x", 101)} { + _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{ClientName: name, RedirectURIs: []string{"https://client.example/cb"}}) + requireMCPOAuthError(t, err, "invalid_client_metadata") + } + client, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{ClientName: " 桌面助手 · Desktop (beta) ✨ ", RedirectURIs: []string{"https://client.example/cb"}}) + require.NoError(t, err) + require.Equal(t, "桌面助手 · Desktop (beta) ✨", client.ClientName) +} + +func TestMCPOAuthRegistrationStopsAtPendingClientLimit(t *testing.T) { + f := newMCPOAuthFixture(t) + f.code(t, "", nil) // An authorized client does not count as pending. + for i := 0; i < MCPOAuthPendingClientLimit; i++ { + f.r.clients[fmt.Sprintf("pending-%d", i)] = &MCPOAuthClient{} + } + _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{"https://client.example/cb"}}) + require.ErrorIs(t, err, ErrMCPOAuthClientLimit) + require.Len(t, f.r.clients, MCPOAuthPendingClientLimit+1) +} + +func TestMCPOAuthAuthenticateThrottlesLastUsedWrites(t *testing.T) { + f := newMCPOAuthFixture(t) + tokens := f.tokens(t) + for i := 0; i < 3; i++ { + _, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.NoError(t, err) + } + require.Equal(t, 1, f.r.touches) + for _, grant := range f.r.grants { + stale := time.Now().Add(-MCPOAuthTouchInterval - time.Second) + grant.LastUsedAt = &stale + } + _, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.NoError(t, err) + require.Equal(t, 2, f.r.touches) +} + +func TestMCPOAuthRevokeAllGrantsEndsEveryFamily(t *testing.T) { + f := newMCPOAuthFixture(t) + first, second := f.tokens(t), f.tokens(t) + require.NoError(t, f.s.RevokeAllGrants(context.Background(), 7)) + for _, tokens := range []*MCPOAuthTokenResponse{first, second} { + _, err := f.s.Authenticate(context.Background(), tokens.AccessToken) + require.ErrorIs(t, err, ErrMCPOAuthInvalidToken) + _, err = f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "refresh_token", ClientID: f.client.ClientID, RefreshToken: tokens.RefreshToken}, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_grant") + } + grants, err := f.s.ListGrants(context.Background(), 7) + require.NoError(t, err) + require.Len(t, grants, 2) + for _, grant := range grants { + require.NotNil(t, grant.RevokedAt) + } +} + +type mcpGrantRevokerStub struct { + userIDs []int64 + err error +} + +func (r *mcpGrantRevokerStub) RevokeAllGrants(_ context.Context, userID int64) error { + r.userIDs = append(r.userIDs, userID) + return r.err +} + +func TestRevokeAllUserTokensAlsoRevokesMCPGrants(t *testing.T) { + revoker := &mcpGrantRevokerStub{} + auth := &AuthService{userRepo: &mcpOAuthUserRepo{user: &User{ID: 7, Status: StatusActive}}} + auth.SetMCPGrantRevoker(revoker) + require.NoError(t, auth.RevokeAllUserTokens(context.Background(), 7)) + require.Equal(t, []int64{7}, revoker.userIDs) + revoker.err = errors.New("database unavailable") + require.ErrorIs(t, auth.RevokeAllUserTokens(context.Background(), 7), revoker.err) +} diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 9bf3c7d9155f..35d379b5d67f 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -346,6 +346,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings WeChatOAuthMPEnabled: weChatMPEnabled, WeChatOAuthMobileEnabled: weChatMobileEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + MCPEnabled: s.cfg != nil && s.cfg.MCP.Enabled(), PaymentEnabled: settings[SettingPaymentEnabled] == "true", PaymentBalanceDisabled: settings[SettingBalancePayDisabled] == "true", OIDCOAuthEnabled: oidcEnabled, @@ -609,6 +610,7 @@ type PublicSettingsInjectionPayload struct { GitHubOAuthEnabled bool `json:"github_oauth_enabled"` GoogleOAuthEnabled bool `json:"google_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` + MCPEnabled bool `json:"mcp_enabled"` PaymentEnabled bool `json:"payment_enabled"` PaymentBalanceDisabled bool `json:"payment_balance_disabled"` Version string `json:"version"` @@ -703,6 +705,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any GitHubOAuthEnabled: settings.GitHubOAuthEnabled, GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, + MCPEnabled: settings.MCPEnabled, PaymentEnabled: settings.PaymentEnabled, PaymentBalanceDisabled: settings.PaymentBalanceDisabled, Version: s.version, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index d2485639c3d8..dcda6b73b4b8 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -4,6 +4,8 @@ package service import ( "context" + "encoding/json" + "fmt" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -309,3 +311,26 @@ func TestSettingService_GetPublicSettings_PaymentBalanceDisabledStrictTrue(t *te }) } } + +func TestSettingService_GetPublicSettings_MCPEnabledFollowsExplicitPublicURL(t *testing.T) { + for _, tc := range []struct { + publicURL, frontendURL string + want bool + }{ + {"https://api.example.com/api/v1/mcp", "", true}, + // A frontend URL alone never enables MCP. + {"", "https://platform.example.com", false}, + {" ", "", false}, + } { + cfg := &config.Config{MCP: config.MCPConfig{PublicURL: tc.publicURL}} + cfg.Server.FrontendURL = tc.frontendURL + settings, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, cfg).GetPublicSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, tc.want, settings.MCPEnabled, tc) + payload, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, cfg).GetPublicSettingsForInjection(context.Background()) + require.NoError(t, err) + encoded, err := json.Marshal(payload) + require.NoError(t, err) + require.Contains(t, string(encoded), fmt.Sprintf(`"mcp_enabled":%t`, tc.want)) + } +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8535fb6b8b15..7d94a5397de5 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -375,6 +375,7 @@ type PublicSettings struct { WeChatOAuthMPEnabled bool WeChatOAuthMobileEnabled bool BackendModeEnabled bool + MCPEnabled bool // 由部署配置 MCP_PUBLIC_URL 决定,不可在后台修改 PaymentEnabled bool PaymentBalanceDisabled bool OIDCOAuthEnabled bool diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 5fae3b0536d2..d24d33a1a406 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -71,6 +71,7 @@ func ProvideAuthService( defaultSubAssigner DefaultSubscriptionAssigner, affiliateService *AffiliateService, userPlatformQuotaRepo UserPlatformQuotaRepository, + mcpOAuthService *MCPOAuthService, ) *AuthService { svc := NewAuthService( entClient, @@ -89,6 +90,9 @@ func ProvideAuthService( ) svc.SetTencentCaptchaService(tencentCaptchaService) svc.SetAliyunCaptchaService(aliyunCaptchaService) + if mcpOAuthService != nil { + svc.SetMCPGrantRevoker(mcpOAuthService) + } return svc } diff --git a/deploy/.env.example b/deploy/.env.example index 4aec7ec545c3..0e8ce5304ebe 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -31,8 +31,9 @@ UPDATE_GITHUB_TOKEN= ENABLE_SERVER_TIMING=false # Canonical public MCP endpoint for OAuth discovery, e.g. https://example.com/api/v1/mcp. -# MCP OAuth 固定对外地址;留空使用已配置的前端地址来源,不使用请求中的 Host。 -# HTTPS required except localhost/loopback development; exact /api/v1/mcp path, no trailing slash. +# Leave empty to keep the management MCP and its OAuth endpoints disabled (default). +# MCP OAuth 固定对外地址;留空则关闭 MCP(默认),不会回落到前端地址或请求中的 Host。 +# HTTPS required except localhost/loopback development; exact /api/v1/mcp path at the site root, no trailing slash. MCP_PUBLIC_URL= # Apple container image overrides (ignored by Docker Compose). Pin release tags diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index c7eb2b469e2d..a4a32027bd84 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -126,10 +126,10 @@ cors: mcp: # Canonical public MCP endpoint, e.g. https://example.com/api/v1/mcp. # 固定的 MCP 对外地址,例如 https://example.com/api/v1/mcp。 - # Leave empty to use the trusted frontend URL's origin with /api/v1/mcp. - # 留空时使用已配置的前端地址来源并追加 /api/v1/mcp;不使用请求中的 Host。 - # HTTPS is required except localhost/loopback development; no trailing slash. - # 除 localhost/回环 IP 开发环境外必须使用 HTTPS,路径末尾不加斜杠。 + # Leave empty to keep the management MCP and its OAuth endpoints disabled (default). + # 留空则关闭 MCP 及其 OAuth 接口(默认),不会回落到前端地址或请求中的 Host。 + # HTTPS is required except localhost/loopback development; site-root /api/v1/mcp path, no trailing slash. + # 除 localhost/回环 IP 开发环境外必须使用 HTTPS;路径固定为站点根下的 /api/v1/mcp,末尾不加斜杠。 # Environment / 环境变量: MCP_PUBLIC_URL public_url: "" diff --git a/docs/mcp.md b/docs/mcp.md index 7fe8402312d7..567545c515e4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,7 +4,7 @@ ## 部署 -配置稳定、可信的公开 MCP 地址,例如: +MCP 默认关闭。只有显式配置稳定、可信的公开 MCP 地址后,才会开放 MCP 资源端点、OAuth 发现及客户端注册接口,侧栏也才会显示 **MCP 接入**。例如: ```sh MCP_PUBLIC_URL=https://api.example.com/api/v1/mcp @@ -17,7 +17,7 @@ mcp: public_url: https://api.example.com/api/v1/mcp ``` -地址必须以 `/api/v1/mcp` 结束,不带尾斜杠、查询参数或片段。生产环境使用 HTTPS;本机开发允许 localhost 或回环 IP 的 HTTP 地址。未显式配置时,可使用平台已配置的前端 URL 的 origin 加 `/api/v1/mcp`;服务不会从请求 Host 或转发头推断 OAuth issuer。没有可信公开地址时,OAuth 接口会返回配置错误。 +地址必须是站点根路径下的 `/api/v1/mcp`,不带尾斜杠、查询参数或片段;OAuth 发现端点同样位于站点根路径,因此不支持挂在子路径(如 `https://example.com/sub2api/`)下部署。生产环境使用 HTTPS;本机开发允许 localhost 或回环 IP 的 HTTP 地址。服务不会从前端 URL、请求 Host 或转发头推断该地址;未配置时 MCP 及其 OAuth 接口均返回 503。清空该配置即可关闭 MCP,已签发的令牌随之无法使用。 同域部署只需上述 MCP 地址。前后端分域时,另外配置平台前端 URL(管理设置或 `SERVER_FRONTEND_URL`),用于定位浏览器授权页。反向代理需转发 `/api/v1/mcp`、`/api/v1/mcp/oauth/*` 及下列 `/.well-known/*` 发现端点,保留 Authorization、Accept 和 MCP 协议请求头。浏览器客户端访问 MCP 资源仍需符合平台的 CORS/Origin 白名单。 @@ -44,7 +44,7 @@ mcp: ## 授权与撤销 -默认授权为**个人范围、只读**。管理员可以在明确同意时选择管理员范围;启用平台敏感操作二次验证时,授予管理员范围同样需要 step-up。 +默认授权为**个人范围、只读**。管理员可以在明确同意时选择管理员范围;启用平台敏感操作二次验证时,授予管理员范围同样需要 step-up。授予**管理员范围的读写**权限时,无论是否启用该开关,都必须已开启 TOTP 并完成一次 step-up 验证。 - **个人范围**:只操作当前用户的数据,即使授权人是管理员也不会开放管理工具。 - **管理员范围**:仅管理员可授予,可使用目录内管理工具及自己的个人工具。 @@ -55,7 +55,7 @@ OAuth scope 为 `mcp`,实际工具权限由浏览器中确认的授权范围 授权码有效期 5 分钟且只能兑换一次。访问令牌有效期最多 10 分钟;授权及刷新令牌最长 30 天,刷新令牌每次使用都会轮换。重复使用授权码或已消费的刷新令牌会撤销该授权。服务端仅保存凭证摘要。 -登录平台后,侧栏 **MCP 接入**(`/settings/mcp`)可查看并撤销已授权应用。每用户最多 20 个有效授权。撤销、密码或登录标识变更、用户停用都会阻止后续调用及刷新;管理员失去角色后,其管理员授权也失效。页面不显示访问或刷新令牌,MCP 工具无法自行授予新授权。 +登录平台后,侧栏 **MCP 接入**(`/settings/mcp`)可查看并撤销已授权应用。每用户最多 20 个有效授权。撤销、密码或登录标识变更、用户停用都会阻止后续调用及刷新;管理员失去角色后,其管理员授权也失效。“撤销全部会话”会同时撤销该用户的全部 MCP 授权。停用用户会持久撤销其全部授权,取消管理员角色会持久撤销其管理员授权,之后重新启用或重新授予角色也不会恢复旧授权。页面不显示访问或刷新令牌,MCP 工具无法自行授予新授权。 ## 工具目录 @@ -78,7 +78,7 @@ OAuth scope 为 `mcp`,实际工具权限由浏览器中确认的授权范围 工具只开放明确列出的字段;不提供任意 HTTP 请求、任意 URL、密码/角色修改、身份绑定、上游凭证管理、备份恢复或支付工具。普通查询和修改结果会去除 API Key、上游令牌等凭证。`user_create_api_key` 的创建结果包含新 API Key,便于配置应用;不要将该结果公开分享。 -现有业务权限、资源归属、平台模式限制、管理合规确认、限流和操作审计继续生效。模型返回的建议不会绕过这些规则。 +现有业务权限、资源归属、平台模式限制、管理合规确认、限流和操作审计继续生效。模型返回的建议不会绕过这些规则。MCP 的每次工具调用(包括只读查询)都会写入操作审计,认证方式为 `mcp_oauth`,并附带 `mcp_grant_id` 和 `mcp_tool`。 ## 参数示例 @@ -149,7 +149,7 @@ OAuth scope 为 `mcp`,实际工具权限由浏览器中确认的授权范围 | `POST /api/v1/mcp/oauth/token` | 授权码兑换与刷新,表单编码 | | `POST /api/v1/mcp/oauth/revoke` | RFC 7009 撤销,表单编码 | -未认证 MCP 请求返回 401,`WWW-Authenticate` 提供 `resource_metadata` 和 `scope="mcp"`。客户端只支持 `token_endpoint_auth_method=none`、`response_type=code`、S256 PKCE,以及授权码/刷新两种 grant type。回调 URI 必须与注册值精确匹配,使用 HTTPS 或本机 HTTP;不支持通配符。当前不提供远程 client metadata URL 自动抓取。 +未认证 MCP 请求返回 401,`WWW-Authenticate` 提供 `resource_metadata` 和 `scope="mcp"`。客户端只支持 `token_endpoint_auth_method=none`、`response_type=code`、S256 PKCE,以及授权码/刷新两种 grant type。回调 URI 必须与注册值精确匹配,使用 HTTPS 或本机 HTTP;不支持通配符。当前不提供远程 client metadata URL 自动抓取。注册请求体不超过 4 KiB;`client_name` 最多 100 个字符,且不得包含控制字符、双向文本控制符或零宽字符等不可见字符。 授权请求和授权码兑换必须携带精确匹配 MCP 地址的 `resource`。刷新可省略 `resource`,此时沿用原授权;传入不同资源会被拒绝。`state` 会原样返回客户端。授权码与客户端、回调、资源和 PKCE 绑定;客户端应安全保管 verifier 和刷新令牌。 @@ -160,12 +160,12 @@ curl 'https://api.example.com/.well-known/oauth-protected-resource/api/v1/mcp' curl 'https://api.example.com/.well-known/oauth-authorization-server' ``` -服务使用官方 Go MCP SDK 的无状态 Streamable HTTP 模式,POST 返回 JSON;GET 事件流和 DELETE 会话操作返回 405。每次 HTTP 请求重新校验授权,响应禁止缓存。业务 API 失败以工具结果 `isError: true` 返回,原 HTTP 状态位于 `structuredContent.http_status`。工具执行限制 30 秒并传递客户端断开取消;写操作超时后应查询状态再决定是否重试。 +服务使用官方 Go MCP SDK 的无状态 Streamable HTTP 模式,POST 返回 JSON;GET 事件流和 DELETE 会话操作返回 405。每个 HTTP 请求只能包含一个 JSON-RPC 对象,JSON-RPC 批量请求(包括未声明 `MCP-Protocol-Version` 的旧版本请求)一律返回 400。每次 HTTP 请求重新校验授权,响应禁止缓存。工具结果以单个文本块返回 JSON `{"http_status": …, "result": …}`,不再重复写入 `structuredContent`;编码后超过 2 MiB 的结果会被拒绝,请缩小查询范围或分页大小。业务 API 失败以工具结果 `isError: true` 返回,原 HTTP 状态位于 `http_status`。工具执行限制 30 秒并传递客户端断开取消;写操作超时后应查询状态再决定是否重试。 -MCP 协议请求使用独立入口限流桶;每个实际工具调用只消耗一次管理面全局额度,聚合统计仍受 Heavy 限流。动态注册、兑换和撤销接口有独立 IP 限流,Redis 不可用时拒绝请求。 +`/api/v1/mcp` 在认证前先按客户端 IP 限流(每分钟 600 次),认证后再使用独立的按用户入口限流桶;每个实际工具调用只消耗一次管理面全局额度,聚合统计仍受 Heavy 限流。授权的最近使用时间每分钟最多写入一次。动态注册、兑换和撤销接口有独立 IP 限流。上述 IP 限流把同一 IPv6 /64 网段视为一个客户端,Redis 不可用时拒绝请求。 ## 开发 工具目录位于 `backend/internal/managementmcp/catalog.go`。新增工具应绑定已有管理路由,使用封闭参数 schema,并覆盖权限、资源归属、参数边界及凭证脱敏测试。 -OAuth 存储由 `249_mcp_oauth.sql` 创建。注册时有界清理过期 7 天以上的授权,以及 90 天前注册且无授权关联的客户端。浏览器使用 JWT 保护的 `/api/v1/user/mcp/config` 获取服务端配置的连接地址、`/api/v1/user/mcp/authorize` 预览/决定授权,以及 `/api/v1/user/mcp/grants` 列表、撤销接口。客户端协议端点返回标准 OAuth JSON,浏览器接口沿用平台响应格式。 +OAuth 存储由 `249_mcp_oauth.sql` 创建。注册时有界清理过期 7 天以上的授权,以及 24 小时前注册且从未获得授权的客户端。24 小时内注册但尚未获得授权的客户端全站最多 1000 个,达到上限后注册返回 503 `temporarily_unavailable`,待旧注册过期后自动恢复。浏览器使用 JWT 保护的 `/api/v1/user/mcp/config` 获取服务端配置的连接地址、`/api/v1/user/mcp/authorize` 预览/决定授权,以及 `/api/v1/user/mcp/grants` 列表、撤销接口。客户端协议端点返回标准 OAuth JSON,浏览器接口沿用平台响应格式。 From 8a34f1ecf30bbcd60a7243e8bbbf3050c07eba7b Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 28 Oct 2026 09:30:23 -0400 Subject: [PATCH 4/8] fix(frontend): use the MCP logo and hide MCP when disabled - Replace the cube icon with the official MCP logo in the sidebar and on the MCP connection and consent pages. - Show the MCP entry only when the deployment enables MCP (mcp_enabled). - Explain that administrator write grants always need TOTP verification, and add MCP OAuth to the audit log auth-method filter. - Remove the unreferenced screenshots and their .gitignore exceptions (L6). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 -- docs/mcp-oauth-connections.png | Bin 209397 -> 0 bytes docs/mcp-oauth-consent.png | Bin 96232 -> 0 bytes frontend/src/components/icons/Icon.vue | 6 +++++- frontend/src/components/layout/AppSidebar.vue | 9 +++++++-- .../layout/__tests__/AppSidebar.spec.ts | 10 ++++++++++ frontend/src/i18n/locales/en/mcp.ts | 1 + frontend/src/i18n/locales/zh/mcp.ts | 1 + frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 2 ++ .../src/utils/__tests__/featureFlags.spec.ts | 16 ++++++++++++++++ frontend/src/utils/featureFlags.ts | 6 ++++++ frontend/src/views/admin/AuditLogView.vue | 3 ++- .../src/views/auth/MCPOAuthAuthorizeView.vue | 3 ++- .../__tests__/MCPOAuthAuthorizeView.spec.ts | 13 +++++++++++++ frontend/src/views/user/MCPView.vue | 2 +- 16 files changed, 67 insertions(+), 8 deletions(-) delete mode 100644 docs/mcp-oauth-connections.png delete mode 100644 docs/mcp-oauth-consent.png diff --git a/.gitignore b/.gitignore index 533b3fca9f9b..3ee8dfee88b3 100644 --- a/.gitignore +++ b/.gitignore @@ -135,8 +135,6 @@ vite.config.js docs/* !docs/admin-api-keys.md !docs/mcp.md -!docs/mcp-oauth-connections.png -!docs/mcp-oauth-consent.png !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/docs/mcp-oauth-connections.png b/docs/mcp-oauth-connections.png deleted file mode 100644 index 48925d1efdf3db6c350617a2d540b1d0acb31a2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 209397 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

FUX1 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 df6T7oU=ViRb1Vl;+dWzBYc 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�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 OiZ4%BKbLh*2~7Zq@eTC= diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 7859adee7012..e02e1330d67b 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -132,7 +132,11 @@ const icons = { calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z', fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z', badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z', - brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z' + brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z', + + // Brands + // Model Context Protocol logo (modelcontextprotocol.io), scaled to the 24px stroke grid. + mcp: 'M3.98 11.4L11.59 3.79C12.64 2.74 14.34 2.74 15.39 3.79C16.44 4.84 16.44 6.54 15.39 7.59L9.64 13.34M9.72 13.26L15.39 7.59C16.44 6.54 18.14 6.54 19.2 7.59L19.24 7.63C20.29 8.68 20.29 10.39 19.24 11.44L12.35 18.32C12 18.67 12 19.24 12.35 19.59L13.77 21M13.49 5.69L7.86 11.32C6.81 12.37 6.81 14.07 7.86 15.12C8.91 16.17 10.61 16.17 11.67 15.12L17.29 9.49' } as const const iconPath = computed(() => icons[props.name]) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3ef95d4cafff..72e0f06b1abb 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -481,6 +481,10 @@ const PluginIcon = { render: () => h(Icon, { name: 'cube' }) } +const MCPIcon = { + render: () => h(Icon, { name: 'mcp' }) +} + const BellIcon = { render: () => h( @@ -708,6 +712,7 @@ const purchaseNavLabel = computed(() => { const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate) const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl) const flagPluginManagement = makeSidebarFlag(FeatureFlags.pluginManagement) +const flagMCP = makeSidebarFlag(FeatureFlags.mcp) const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled const flagAdminPayment = () => adminSettingsStore.paymentEnabled const flagBatchImageAccess = () => canUseBatchImage.value @@ -734,7 +739,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate }, { path: '/profile', label: t('nav.profile'), icon: UserIcon }, - { path: '/settings/mcp', label: t('mcp.title'), icon: PluginIcon }, + { path: '/settings/mcp', label: t('mcp.title'), icon: MCPIcon, featureFlag: flagMCP }, ...customMenuItemsForUser.value.map((item): NavItem => ({ path: `/custom/${item.id}`, label: item.label, @@ -846,7 +851,7 @@ const adminNavItems = computed((): NavItem[] => { if (authStore.isSimpleMode) { const filtered = visible.filter(item => !item.hideInSimpleMode) filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) - filtered.push({ path: '/settings/mcp', label: t('mcp.title'), icon: PluginIcon }) + if (flagMCP()) filtered.push({ path: '/settings/mcp', label: t('mcp.title'), icon: MCPIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) diff --git a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts index 0ecb56a9f546..636fc9d724ef 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts @@ -80,3 +80,13 @@ describe('AppSidebar subscription feature flag', () => { expect(componentSource).toMatch(/path: '\/purchase'[^\n]*label: purchaseNavLabel\.value/) }) }) + +describe('AppSidebar MCP entry', () => { + it('uses the MCP logo and shows the entry only when the deployment enables MCP', () => { + expect(componentSource).toContain("render: () => h(Icon, { name: 'mcp' })") + expect(componentSource).toContain('const flagMCP = makeSidebarFlag(FeatureFlags.mcp)') + expect(componentSource).toMatch(/path: '\/settings\/mcp'[^\n]*icon: MCPIcon, featureFlag: flagMCP/) + expect(componentSource).toMatch(/if \(flagMCP\(\)\) filtered\.push\(\{ path: '\/settings\/mcp'[^\n]*icon: MCPIcon/) + expect(componentSource).not.toMatch(/path: '\/settings\/mcp'[^\n]*PluginIcon/) + }) +}) diff --git a/frontend/src/i18n/locales/en/mcp.ts b/frontend/src/i18n/locales/en/mcp.ts index f30119fdc7ea..9abc39fc80b4 100644 --- a/frontend/src/i18n/locales/en/mcp.ts +++ b/frontend/src/i18n/locales/en/mcp.ts @@ -23,6 +23,7 @@ export default { readWrite: 'Allow changes', readOnlyHint: 'Expose query tools only, for analyzing usage and checking status.', readWriteHint: 'Allow creating, updating and deleting resources within this scope, including API keys and billing entitlements. Only approve this for an application you trust.', + adminWriteHint: 'Platform write access always requires two-factor authentication (TOTP) to be enabled and verified when you approve.', empty: 'No authorized applications yet. Start a connection from your AI client using the server URL above.', loading: 'Loading authorized applications…', loadFailed: 'Failed to load authorized applications. Please try again.', diff --git a/frontend/src/i18n/locales/zh/mcp.ts b/frontend/src/i18n/locales/zh/mcp.ts index cd5375434102..e8c9e09a4e09 100644 --- a/frontend/src/i18n/locales/zh/mcp.ts +++ b/frontend/src/i18n/locales/zh/mcp.ts @@ -23,6 +23,7 @@ export default { readWrite: '允许修改', readOnlyHint: '只开放查询工具,适合分析用量、查看状态。', readWriteHint: '允许创建、修改和删除范围内的资源,包括 API 密钥和计费权益。请只授权给你信任的应用。', + adminWriteHint: '平台管理的修改权限始终需要你已开启两步验证(TOTP),并在授权时完成验证。', empty: '尚未授权任何应用。请在 AI 客户端中使用上方服务地址发起连接。', loading: '正在加载已授权应用…', loadFailed: '加载已授权应用失败,请重试。', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index a24044197057..40a8d62fd9fb 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -380,6 +380,7 @@ export const useAppStore = defineStore('app', () => { service_quota_enabled: false, affiliate_enabled: false, allow_user_view_error_requests: false, + mcp_enabled: false, }) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 25ac0fb936dc..a33cb95401e8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -287,6 +287,8 @@ export interface PublicSettings { service_quota_enabled: boolean affiliate_enabled: boolean allow_user_view_error_requests?: boolean + /** True when the deployment configures MCP_PUBLIC_URL; MCP is disabled otherwise. */ + mcp_enabled?: boolean } export interface AuthResponse { diff --git a/frontend/src/utils/__tests__/featureFlags.spec.ts b/frontend/src/utils/__tests__/featureFlags.spec.ts index c5695dfaae1e..c31d980ca578 100644 --- a/frontend/src/utils/__tests__/featureFlags.spec.ts +++ b/frontend/src/utils/__tests__/featureFlags.spec.ts @@ -63,3 +63,19 @@ describe('resolveFeatureFlag', () => { expect(isFeatureFlagEnabled(FeatureFlags.subscription)).toBe(false) }) }) + +describe('FeatureFlags.mcp', () => { + beforeEach(() => { + setActivePinia(createPinia()) + delete (window as any).__APP_CONFIG__ + }) + + it('stays hidden unless the deployment explicitly enables MCP', () => { + expect(FeatureFlags.mcp.key).toBe('mcp_enabled') + expect(FeatureFlags.mcp.mode).toBe('opt-in') + expect(isFeatureFlagEnabled(FeatureFlags.mcp)).toBe(false) + expect(resolveFeatureFlag({} as PublicSettings, FeatureFlags.mcp)).toBe(false) + expect(resolveFeatureFlag({ mcp_enabled: false } as PublicSettings, FeatureFlags.mcp)).toBe(false) + expect(resolveFeatureFlag({ mcp_enabled: true } as PublicSettings, FeatureFlags.mcp)).toBe(true) + }) +}) diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts index cce27677269d..8493ed92782f 100644 --- a/frontend/src/utils/featureFlags.ts +++ b/frontend/src/utils/featureFlags.ts @@ -134,6 +134,12 @@ export const FeatureFlags = { mode: 'opt-in', label: 'Affiliate', }), + // Read-only: enabled by the deployment's MCP_PUBLIC_URL, not an admin toggle. + mcp: defineFlag({ + key: 'mcp_enabled', + mode: 'opt-in', + label: 'MCP', + }), } as const export type RegisteredFeatureFlag = keyof typeof FeatureFlags diff --git a/frontend/src/views/admin/AuditLogView.vue b/frontend/src/views/admin/AuditLogView.vue index b2bb2d099ab8..522b4e77f3f6 100644 --- a/frontend/src/views/admin/AuditLogView.vue +++ b/frontend/src/views/admin/AuditLogView.vue @@ -485,7 +485,8 @@ const methodOptions = computed(() => [ const authMethodOptions = computed(() => [ { value: '', label: t('admin.audit.filters.all') }, { value: 'jwt', label: 'JWT' }, - { value: 'admin_api_key', label: 'Admin API Key' } + { value: 'admin_api_key', label: 'Admin API Key' }, + { value: 'mcp_oauth', label: 'MCP OAuth' } ]) const resultOptions = computed(() => [ diff --git a/frontend/src/views/auth/MCPOAuthAuthorizeView.vue b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue index 8d60d778bf10..adf9e67bfcd7 100644 --- a/frontend/src/views/auth/MCPOAuthAuthorizeView.vue +++ b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue @@ -2,7 +2,7 @@

-
+

{{ t('mcp.consentTitle') }}

{{ t('mcp.consentDescription') }}

@@ -54,6 +54,7 @@

{{ readOnly ? t('mcp.readOnlyHint') : t('mcp.readWriteHint') }}

+

{{ t('mcp.adminWriteHint') }}

diff --git a/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts b/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts index 452c0ef46358..9ef63e66b3c6 100644 --- a/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts +++ b/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts @@ -154,6 +154,19 @@ describe('MCP OAuth consent', () => { expect(assign).toHaveBeenCalledWith(redirectURL) }) + it('explains that administrator write access always needs two-factor verification', async () => { + auth.isAdmin = true + const wrapper = render() + await flushPromises() + expect(wrapper.find('[data-testid="admin-write-hint"]').exists()).toBe(false) + await wrapper.get('#mcp-consent-permissions').setValue('false') + expect(wrapper.find('[data-testid="admin-write-hint"]').exists()).toBe(false) + await wrapper.get('#mcp-consent-scope').setValue('admin') + expect(wrapper.get('[data-testid="admin-write-hint"]').text()).toBe('mcp.adminWriteHint') + await wrapper.get('#mcp-consent-permissions').setValue('true') + expect(wrapper.find('[data-testid="admin-write-hint"]').exists()).toBe(false) + }) + it('cancels admin step-up without granting access', async () => { auth.isAdmin = true authorize.mockRejectedValue({ reason: 'STEP_UP_REQUIRED' }) diff --git a/frontend/src/views/user/MCPView.vue b/frontend/src/views/user/MCPView.vue index 2411fc0bab2a..2007332fbc1e 100644 --- a/frontend/src/views/user/MCPView.vue +++ b/frontend/src/views/user/MCPView.vue @@ -4,7 +4,7 @@
- +

{{ t('mcp.connectionTitle') }}

From 575dd9bc2b10825f27ddf2826cd4643df75aba20 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Thu, 29 Oct 2026 14:46:08 -0400 Subject: [PATCH 5/8] fix(backend): make MCP client registration stateless Registration was capped at 1000 pending clients site-wide while the limit per IP was 10 per minute, so one address could exhaust the cap and block every new MCP client. - Issue client IDs as HMAC-signed tokens carrying the client name, redirect URI digests and grant types. Registration writes nothing; rows are created only when a user approves a client, so there is no shared capacity to exhaust. - Persist the signing key in security_secrets via the existing get-or-create helper, cached lock-free, so instances share it. - Drop mcp_oauth_clients from migration 249 and keep the client name on each grant. Prune expired grants on authorization, before the owner row is locked. - Fix an unchecked type assertion in managementmcp tests flagged by golangci-lint v2.14.0. Co-Authored-By: Claude Opus 5.5 --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/handler/mcp_oauth_handler.go | 7 +- .../handler/mcp_oauth_handler_test.go | 54 ++++---- backend/internal/managementmcp/server_test.go | 4 +- backend/internal/repository/mcp_oauth.go | 127 +++++------------- .../repository/mcp_oauth_integration_test.go | 63 +++++---- backend/internal/repository/mcp_oauth_test.go | 81 +++++------ .../internal/server/routes/mcp_oauth_test.go | 22 +-- backend/internal/service/mcp_oauth_service.go | 126 ++++++++++++++--- .../service/mcp_oauth_service_test.go | 125 ++++++++++++----- backend/migrations/249_mcp_oauth.sql | 18 +-- docs/mcp.md | 2 +- 12 files changed, 342 insertions(+), 289 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 62fed0cc8b84..80bdf0c73248 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -83,7 +83,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) affiliateRepository := repository.NewAffiliateRepository(client, db) affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService) - mcpoAuthRepository := repository.NewMCPOAuthRepository(db) + mcpoAuthRepository := repository.NewMCPOAuthRepository(client, db) mcpoAuthService := service.NewMCPOAuthService(mcpoAuthRepository, userRepository) authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository, mcpoAuthService) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) diff --git a/backend/internal/handler/mcp_oauth_handler.go b/backend/internal/handler/mcp_oauth_handler.go index 6c81b63d7ea2..ffa27b1fc997 100644 --- a/backend/internal/handler/mcp_oauth_handler.go +++ b/backend/internal/handler/mcp_oauth_handler.go @@ -122,11 +122,6 @@ func (h *MCPOAuthHandler) Register(c *gin.Context) { return } client, err := h.oauth.Register(c.Request.Context(), input) - if errors.Is(err, service.ErrMCPOAuthClientLimit) { - c.Header("Retry-After", "3600") - mcpOAuthErrorResponse(c, http.StatusServiceUnavailable, "temporarily_unavailable", "Too many clients are awaiting authorization; retry later") - return - } if mcpOAuthHandleError(c, err, true) { return } @@ -332,7 +327,7 @@ func mcpOAuthHandleError(c *gin.Context, err error, protocol bool) bool { func mcpOAuthReadJSON(c *gin.Context, out any, browser bool) bool { mediaType, _, _ := mime.ParseMediaType(c.GetHeader("Content-Type")) if mediaType == "application/json" { - // Unauthenticated registrations are persisted, so they get a tighter bound. + // Unauthenticated registrations are embedded in client IDs, so they get a tighter bound. limit := int64(4 << 10) if browser { limit = 16 << 10 diff --git a/backend/internal/handler/mcp_oauth_handler_test.go b/backend/internal/handler/mcp_oauth_handler_test.go index 1fc9675fe08a..72399d69096b 100644 --- a/backend/internal/handler/mcp_oauth_handler_test.go +++ b/backend/internal/handler/mcp_oauth_handler_test.go @@ -25,23 +25,15 @@ const handlerMCPResource = "https://api.example.com/api/v1/mcp" type handlerMCPRepo struct { service.MCPOAuthRepository - client *service.MCPOAuthClient + clientID string reads, writes int grant *service.MCPOAuthGrant - createErr error } -func (r *handlerMCPRepo) CreateClient(context.Context, *service.MCPOAuthClient, int) error { - r.writes++ - return r.createErr -} - -func (r *handlerMCPRepo) GetClient(_ context.Context, id string) (*service.MCPOAuthClient, error) { +// Resolving a client ID needs its signing key, so key loads count as reads. +func (r *handlerMCPRepo) ClientIDKey(context.Context) ([]byte, error) { r.reads++ - if r.client == nil || r.client.ClientID != id { - return nil, service.ErrMCPOAuthNotFound - } - return r.client, nil + return []byte("handler-test-mcp-client-id-key-0"), nil } func (r *handlerMCPRepo) CreateAuthorization(_ context.Context, grant *service.MCPOAuthGrant, _ *service.MCPOAuthCode, _ int) error { r.writes++ @@ -86,12 +78,16 @@ func newHandlerMCPFixture(t *testing.T) (*MCPOAuthHandler, *handlerMCPRepo, *han t.Helper() cfg := &config.Config{MCP: config.MCPConfig{PublicURL: handlerMCPResource}} cfg.Server.FrontendURL = "https://platform.example.com" - repo := &handlerMCPRepo{client: &service.MCPOAuthClient{ClientID: "client-1", ClientName: "Example assistant", RedirectURIs: []string{"https://client.example.com/callback?workspace=one"}, GrantTypes: []string{"authorization_code", "refresh_token"}}} + repo := &handlerMCPRepo{} users := &handlerMCPUsers{user: &service.User{ID: 7, Role: service.RoleAdmin, Status: service.StatusActive, TotpEnabled: true}} settings := service.NewSettingService(&handlerMCPSettings{values: map[string]string{service.SettingKeyStepUpEnabled: "true"}}, cfg) cache := &handlerMCPCache{} totp := service.NewTotpService(users, nil, cache, settings, nil, nil) - return NewMCPOAuthHandler(service.NewMCPOAuthService(repo, users), totp, service.NewUserService(users, nil, nil, nil), settings, cfg), repo, users, cache + oauth := service.NewMCPOAuthService(repo, users) + client, err := oauth.Register(context.Background(), service.MCPOAuthRegisterInput{ClientName: "Example assistant", RedirectURIs: []string{"https://client.example.com/callback?workspace=one"}}) + require.NoError(t, err) + repo.clientID, repo.reads = client.ClientID, 0 + return NewMCPOAuthHandler(oauth, totp, service.NewUserService(users, nil, nil, nil), settings, cfg), repo, users, cache } func handlerMCPContext(t *testing.T, method, target, body, contentType string) (*gin.Context, *httptest.ResponseRecorder) { t.Helper() @@ -109,9 +105,9 @@ func handlerMCPSubject(c *gin.Context, role string) { c.Set(string(middleware.ContextKeyUserRole), role) c.Set(middleware.ContextKeySessionID, "browser-session-7") } -func handlerMCPAuthorization() service.MCPOAuthAuthorizeInput { +func handlerMCPAuthorization(clientID string) service.MCPOAuthAuthorizeInput { digest := sha256.Sum256([]byte(strings.Repeat("v", 43))) - return service.MCPOAuthAuthorizeInput{ResponseType: "code", ClientID: "client-1", RedirectURI: "https://client.example.com/callback?workspace=one", Scope: "mcp", State: "a+b&c=d", CodeChallenge: base64.RawURLEncoding.EncodeToString(digest[:]), CodeChallengeMethod: "S256", Resource: handlerMCPResource} + return service.MCPOAuthAuthorizeInput{ResponseType: "code", ClientID: clientID, RedirectURI: "https://client.example.com/callback?workspace=one", Scope: "mcp", State: "a+b&c=d", CodeChallenge: base64.RawURLEncoding.EncodeToString(digest[:]), CodeChallengeMethod: "S256", Resource: handlerMCPResource} } func handlerMCPBody(t *testing.T, input any) string { t.Helper() @@ -346,7 +342,7 @@ func TestMCPOAuthHandlerTokenEndpointsRejectConfidentialAuthentication(t *testin func TestMCPOAuthHandlerPreviewFrontendContract(t *testing.T) { h, repo, _, _ := newHandlerMCPFixture(t) - input := handlerMCPAuthorization() + input := handlerMCPAuthorization(repo.clientID) query := url.Values{"response_type": {input.ResponseType}, "client_id": {input.ClientID}, "redirect_uri": {input.RedirectURI}, "scope": {input.Scope}, "state": {input.State}, "code_challenge": {input.CodeChallenge}, "code_challenge_method": {input.CodeChallengeMethod}, "resource": {input.Resource}, "timezone": {"Asia/Shanghai"}} c, rec := handlerMCPContext(t, http.MethodGet, "/user/mcp/authorize?"+query.Encode(), "", "") handlerMCPSubject(c, service.RoleUser) @@ -355,7 +351,7 @@ func TestMCPOAuthHandlerPreviewFrontendContract(t *testing.T) { require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) body := handlerMCPJSON(t, rec) require.Equal(t, float64(0), body["code"]) - require.Equal(t, map[string]any{"client_id": "client-1", "client_name": "Example assistant", "redirect_uri": input.RedirectURI, "resource": handlerMCPResource, "scope": "mcp"}, body["data"]) + require.Equal(t, map[string]any{"client_id": repo.clientID, "client_name": "Example assistant", "redirect_uri": input.RedirectURI, "resource": handlerMCPResource, "scope": "mcp"}, body["data"]) require.Zero(t, repo.writes) query.Add("redirect_uri", "https://attacker.example/callback") c, rec = handlerMCPContext(t, http.MethodGet, "/user/mcp/authorize?"+query.Encode(), "", "") @@ -384,7 +380,7 @@ func TestMCPOAuthHandlerAdminDecisionGatesBeforePersistence(t *testing.T) { h, repo, users, cache := newHandlerMCPFixture(t) users.user.TotpEnabled = tt.totp cache.granted, cache.err = tt.granted, tt.cacheErr - input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: true, Scope: service.MCPScopeAdmin} + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(repo.clientID), Approved: true, Scope: service.MCPScopeAdmin} c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") handlerMCPSubject(c, tt.role) c.Set("auth_method", tt.authMethod) @@ -418,7 +414,7 @@ func TestMCPOAuthHandlerAdminWriteAlwaysRequiresStepUp(t *testing.T) { h.settings = service.NewSettingService(&handlerMCPSettings{values: map[string]string{service.SettingKeyStepUpEnabled: "false"}}, h.cfg) users.user.TotpEnabled = tt.totp cache.granted = tt.granted - input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: true, Scope: service.MCPScopeAdmin, ReadOnly: tt.readOnly} + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(repo.clientID), Approved: true, Scope: service.MCPScopeAdmin, ReadOnly: tt.readOnly} c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") handlerMCPSubject(c, service.RoleAdmin) h.Decide(c) @@ -429,22 +425,24 @@ func TestMCPOAuthHandlerAdminWriteAlwaysRequiresStepUp(t *testing.T) { } } -func TestMCPOAuthHandlerRegistrationCapacityIsTemporary(t *testing.T) { +func TestMCPOAuthHandlerRegistrationPersistsNothing(t *testing.T) { h, repo, _, _ := newHandlerMCPFixture(t) - repo.createErr = service.ErrMCPOAuthClientLimit - c, rec := handlerMCPContext(t, http.MethodPost, "/api/v1/mcp/oauth/register", `{"redirect_uris":["https://client.example.com/cb"]}`, "application/json") + c, rec := handlerMCPContext(t, http.MethodPost, "/api/v1/mcp/oauth/register", `{"client_name":"Example assistant","redirect_uris":["https://client.example.com/cb"]}`, "application/json") h.Register(c) - require.Equal(t, http.StatusServiceUnavailable, rec.Code) - require.Equal(t, "temporarily_unavailable", handlerMCPJSON(t, rec)["error"]) - require.NotEmpty(t, rec.Header().Get("Retry-After")) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + body := handlerMCPJSON(t, rec) + require.True(t, strings.HasPrefix(body["client_id"].(string), "mcp_client_")) + require.NotEqual(t, repo.clientID, body["client_id"]) + require.Equal(t, []any{"https://client.example.com/cb"}, body["redirect_uris"]) + require.Zero(t, repo.writes) } func TestMCPOAuthHandlerAdminApprovalUsesBrowserSession(t *testing.T) { h, repo, _, cache := newHandlerMCPFixture(t) cache.granted = true readOnly := false - input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: true, Scope: service.MCPScopeAdmin, ReadOnly: &readOnly} + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(repo.clientID), Approved: true, Scope: service.MCPScopeAdmin, ReadOnly: &readOnly} c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") handlerMCPSubject(c, service.RoleAdmin) h.Decide(c) @@ -467,7 +465,7 @@ func TestMCPOAuthHandlerAdminApprovalUsesBrowserSession(t *testing.T) { func TestMCPOAuthHandlerDenialSkipsStepUpAndPersistence(t *testing.T) { h, repo, _, cache := newHandlerMCPFixture(t) cache.err = errors.New("must not be called") - input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(), Approved: false, Scope: service.MCPScopeAdmin} + input := service.MCPOAuthConsentInput{Authorization: handlerMCPAuthorization(repo.clientID), Approved: false, Scope: service.MCPScopeAdmin} c, rec := handlerMCPContext(t, http.MethodPost, "/user/mcp/authorize", handlerMCPBody(t, input), "application/json") handlerMCPSubject(c, service.RoleUser) h.Decide(c) diff --git a/backend/internal/managementmcp/server_test.go b/backend/internal/managementmcp/server_test.go index 5dbae89bae71..e957d15b45c6 100644 --- a/backend/internal/managementmcp/server_test.go +++ b/backend/internal/managementmcp/server_test.go @@ -379,7 +379,9 @@ func TestMCPToolResultIsReturnedOnceAndBoundedAfterEncoding(t *testing.T) { content, ok := toolResult["content"].([]any) require.True(t, ok) require.Len(t, content, 1) - require.JSONEq(t, `{"http_status":200,"result":{"data":{"name":"ok"}}}`, rpcObject(t, content[0])["text"].(string)) + text, ok := rpcObject(t, content[0])["text"].(string) + require.True(t, ok) + require.JSONEq(t, `{"http_status":200,"result":{"data":{"name":"ok"}}}`, text) // Under the capture limit as received, but HTML escaping expands it 6x. payload = `{"data":"` + strings.Repeat("<", maxResultBytes/3) + `"}` diff --git a/backend/internal/repository/mcp_oauth.go b/backend/internal/repository/mcp_oauth.go index 30022c5d9a56..013a9b0697fa 100644 --- a/backend/internal/repository/mcp_oauth.go +++ b/backend/internal/repository/mcp_oauth.go @@ -3,112 +3,51 @@ package repository import ( "context" "database/sql" - "encoding/json" "errors" + "sync/atomic" "time" + dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/lib/pq" ) -type mcpOAuthRepository struct{ db *sql.DB } +const mcpOAuthClientIDKeyName = "mcp_oauth_client_id_key" -func NewMCPOAuthRepository(db *sql.DB) service.MCPOAuthRepository { - return &mcpOAuthRepository{db: db} +type mcpOAuthRepository struct { + client *dbent.Client + db *sql.DB + clientIDKey atomic.Pointer[[]byte] } -func (r *mcpOAuthRepository) CreateClient(ctx context.Context, client *service.MCPOAuthClient, pendingLimit int) error { - redirects, err := json.Marshal(client.RedirectURIs) - if err != nil { - return err - } - grants, err := json.Marshal(client.GrantTypes) - if err != nil { - return err - } - // Registration is unauthenticated, so it performs bounded retention work and - // caps clients awaiting authorization. Active authorizations protect their - // client registration; used refresh hashes remain for the entire grant - // lifetime so replay detection is never shortened by cleanup. - now := time.Unix(client.ClientIDIssuedAt, 0).UTC() - pendingSince := now.Add(-service.MCPOAuthPendingClientLifetime) - tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - // Serialize registrations so concurrent requests cannot overshoot the cap. - if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('mcp_oauth_client_registration'))`); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM mcp_oauth_grants WHERE id IN (SELECT id FROM mcp_oauth_grants WHERE expires_at<$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`, now.Add(-7*24*time.Hour)); err != nil { - return err - } - rows, err := tx.QueryContext(ctx, `SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`, pendingSince) - if err != nil { - return err - } - var staleClientIDs []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - _ = rows.Close() - return err - } - staleClientIDs = append(staleClientIDs, id) - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err - } - if len(staleClientIDs) != 0 { - // The second statement gets a fresh READ COMMITTED snapshot after the - // client rows are locked. A concurrent consent that committed just before - // those locks must protect its newly inserted grant from client cleanup. - if _, err := tx.ExecContext(ctx, `DELETE FROM mcp_oauth_clients c WHERE c.client_id=ANY($1) AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id)`, pq.Array(staleClientIDs)); err != nil { - return err - } - } - var pending int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_oauth_clients c WHERE c.created_at>=$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id)`, pendingSince).Scan(&pending); err != nil { - return err - } - if pending >= pendingLimit { - // Keep the retention work so capacity recovers as registrations expire. - if err := tx.Commit(); err != nil { - return err - } - return service.ErrMCPOAuthClientLimit - } - if _, err := tx.ExecContext(ctx, `INSERT INTO mcp_oauth_clients (client_id,client_name,redirect_uris,grant_types,created_at) VALUES ($1,$2,$3,$4,$5)`, client.ClientID, client.ClientName, string(redirects), string(grants), now); err != nil { - return err - } - return tx.Commit() +func NewMCPOAuthRepository(client *dbent.Client, db *sql.DB) service.MCPOAuthRepository { + return &mcpOAuthRepository{client: client, db: db} } -func (r *mcpOAuthRepository) GetClient(ctx context.Context, id string) (*service.MCPOAuthClient, error) { - client := &service.MCPOAuthClient{TokenEndpointAuthMethod: "none", ResponseTypes: []string{"code"}} - var redirects, grants []byte - var createdAt time.Time - err := r.db.QueryRowContext(ctx, `SELECT client_id,client_name,redirect_uris,grant_types,created_at FROM mcp_oauth_clients WHERE client_id=$1`, id). - Scan(&client.ClientID, &client.ClientName, &redirects, &grants, &createdAt) - if err != nil { - return nil, mcpOAuthSQLError(err) +// ClientIDKey loads the key that signs stateless client IDs, creating it on +// first use. It is persisted so issued client IDs and grants survive restarts, +// and instances racing to create it converge on the stored value. +func (r *mcpOAuthRepository) ClientIDKey(ctx context.Context) ([]byte, error) { + if key := r.clientIDKey.Load(); key != nil { + return *key, nil } - if err := json.Unmarshal(redirects, &client.RedirectURIs); err != nil { - return nil, err - } - if err := json.Unmarshal(grants, &client.GrantTypes); err != nil { + value, _, err := getOrCreateGeneratedSecuritySecret(ctx, r.client, mcpOAuthClientIDKeyName, 32) + if err != nil { return nil, err } - client.ClientIDIssuedAt = createdAt.Unix() - return client, nil + key := []byte(value) + r.clientIDKey.Store(&key) + return key, nil } func (r *mcpOAuthRepository) CreateAuthorization(ctx context.Context, grant *service.MCPOAuthGrant, code *service.MCPOAuthCode, limit int) error { + // Grants are the only rows that outlive a request, so each new one performs + // bounded retention work. It runs before the owner row is locked, keeping + // cascaded deletes from blocking other updates to that user. Used refresh + // hashes remain for the entire grant lifetime so replay detection is never + // shortened by cleanup. + if _, err := r.db.ExecContext(ctx, `DELETE FROM mcp_oauth_grants WHERE id IN (SELECT id FROM mcp_oauth_grants WHERE expires_at<$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`, grant.CreatedAt.Add(-7*24*time.Hour)); err != nil { + return err + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err @@ -126,8 +65,8 @@ func (r *mcpOAuthRepository) CreateAuthorization(ctx context.Context, grant *ser if count >= limit { return service.ErrMCPOAuthGrantLimit } - _, err = tx.ExecContext(ctx, `INSERT INTO mcp_oauth_grants (id,client_id,user_id,token_version,resource,scope,read_only,created_at,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, - grant.ID, grant.ClientID, grant.UserID, grant.TokenVersion, grant.Resource, grant.Scope, grant.ReadOnly, grant.CreatedAt, grant.ExpiresAt) + _, err = tx.ExecContext(ctx, `INSERT INTO mcp_oauth_grants (id,client_id,client_name,user_id,token_version,resource,scope,read_only,created_at,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + grant.ID, grant.ClientID, grant.ClientName, grant.UserID, grant.TokenVersion, grant.Resource, grant.Scope, grant.ReadOnly, grant.CreatedAt, grant.ExpiresAt) if err != nil { return err } @@ -159,7 +98,7 @@ func (r *mcpOAuthRepository) GetCredential(ctx context.Context, hash string) (*s return credential, nil } -const mcpOAuthGrantColumns = `g.id,g.client_id,c.client_name,g.user_id,g.token_version,g.resource,g.scope,g.read_only,g.created_at,g.expires_at,g.last_used_at,g.revoked_at` +const mcpOAuthGrantColumns = `id,client_id,client_name,user_id,token_version,resource,scope,read_only,created_at,expires_at,last_used_at,revoked_at` func scanMCPOAuthGrant(row interface{ Scan(...any) error }) (*service.MCPOAuthGrant, error) { grant := &service.MCPOAuthGrant{} @@ -171,11 +110,11 @@ func scanMCPOAuthGrant(row interface{ Scan(...any) error }) (*service.MCPOAuthGr } func (r *mcpOAuthRepository) GetGrant(ctx context.Context, id string) (*service.MCPOAuthGrant, error) { - return scanMCPOAuthGrant(r.db.QueryRowContext(ctx, `SELECT `+mcpOAuthGrantColumns+` FROM mcp_oauth_grants g JOIN mcp_oauth_clients c ON c.client_id=g.client_id WHERE g.id=$1`, id)) + return scanMCPOAuthGrant(r.db.QueryRowContext(ctx, `SELECT `+mcpOAuthGrantColumns+` FROM mcp_oauth_grants WHERE id=$1`, id)) } func (r *mcpOAuthRepository) ListGrants(ctx context.Context, userID int64) ([]*service.MCPOAuthGrant, error) { - rows, err := r.db.QueryContext(ctx, `SELECT `+mcpOAuthGrantColumns+` FROM mcp_oauth_grants g JOIN mcp_oauth_clients c ON c.client_id=g.client_id WHERE g.user_id=$1 ORDER BY g.created_at DESC`, userID) + rows, err := r.db.QueryContext(ctx, `SELECT `+mcpOAuthGrantColumns+` FROM mcp_oauth_grants WHERE user_id=$1 ORDER BY created_at DESC`, userID) if err != nil { return nil, err } diff --git a/backend/internal/repository/mcp_oauth_integration_test.go b/backend/internal/repository/mcp_oauth_integration_test.go index 6682a08c0b5f..c58cdb62a568 100644 --- a/backend/internal/repository/mcp_oauth_integration_test.go +++ b/backend/internal/repository/mcp_oauth_integration_test.go @@ -14,7 +14,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/lib/pq" "github.com/stretchr/testify/require" ) @@ -33,18 +32,13 @@ type mcpOAuthIntegrationFixture struct { func newMCPOAuthIntegrationFixture(t *testing.T, ctx context.Context) mcpOAuthIntegrationFixture { t.Helper() user := mustCreateUser(t, testEntClient(t), &service.User{}) - repo := NewMCPOAuthRepository(integrationDB) + repo := NewMCPOAuthRepository(integrationEntClient, integrationDB) oauth := service.NewMCPOAuthService(repo, NewUserRepository(integrationEntClient, integrationDB)) - var clientID string t.Cleanup(func() { cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err := integrationDB.ExecContext(cleanupCtx, `DELETE FROM users WHERE id=$1`, user.ID) require.NoError(t, err) - if clientID != "" { - _, err = integrationDB.ExecContext(cleanupCtx, `DELETE FROM mcp_oauth_clients WHERE client_id=$1`, clientID) - require.NoError(t, err) - } }) const redirectURI = "https://client.example/callback?channel=desktop" @@ -53,10 +47,7 @@ func newMCPOAuthIntegrationFixture(t *testing.T, ctx context.Context) mcpOAuthIn TokenEndpointAuthMethod: "none", }) require.NoError(t, err) - clientID = client.ClientID - registered, err := repo.GetClient(ctx, clientID) - require.NoError(t, err) - require.Equal(t, client, registered) + clientID := client.ClientID verifier := strings.Repeat("v", 64) challenge := sha256.Sum256([]byte(verifier)) @@ -84,6 +75,8 @@ func newMCPOAuthIntegrationFixture(t *testing.T, ctx context.Context) mcpOAuthIn require.Equal(t, service.MCPScopeUser, grants[0].Scope) require.True(t, grants[0].ReadOnly) require.False(t, grants[0].Invalidated) + require.Equal(t, clientID, grants[0].ClientID) + require.Equal(t, client.ClientName, grants[0].ClientName) return mcpOAuthIntegrationFixture{ oauth: oauth, repo: repo, userID: user.ID, grantID: grants[0].ID, input: service.MCPOAuthTokenInput{ @@ -244,25 +237,37 @@ func TestMCPOAuthIntegrationAccountChangesRevokeGrantsPersistently(t *testing.T) requireMCPIntegrationInvalidGrant(t, err) } -func TestMCPOAuthIntegrationRegistrationStopsAtPendingLimit(t *testing.T) { +func TestMCPOAuthIntegrationClientIDKeyConvergesAcrossInstances(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - repo := NewMCPOAuthRepository(integrationDB) - var pending int - require.NoError(t, integrationDB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM mcp_oauth_clients c WHERE c.created_at>=$1 AND NOT EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id)`, - time.Now().Add(-service.MCPOAuthPendingClientLifetime)).Scan(&pending)) - ids := []string{"mcp_client_pending_limit_a", "mcp_client_pending_limit_b"} - t.Cleanup(func() { - _, err := integrationDB.ExecContext(context.Background(), `DELETE FROM mcp_oauth_clients WHERE client_id=ANY($1)`, pq.Array(ids)) - require.NoError(t, err) - }) - newClient := func(id string) *service.MCPOAuthClient { - return &service.MCPOAuthClient{ClientID: id, ClientName: "Pending", RedirectURIs: []string{"https://client.example/cb"}, - GrantTypes: []string{"authorization_code"}, ClientIDIssuedAt: time.Now().Unix()} + // Start without a key so the instances race to create it. + _, err := integrationDB.ExecContext(ctx, `DELETE FROM security_secrets WHERE key=$1`, mcpOAuthClientIDKeyName) + require.NoError(t, err) + const instances = 8 + keys := make([][]byte, instances) + errs := make([]error, instances) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < instances; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + keys[i], errs[i] = NewMCPOAuthRepository(integrationEntClient, integrationDB).ClientIDKey(ctx) + }(i) + } + close(start) + wg.Wait() + for i := 0; i < instances; i++ { + require.NoError(t, errs[i]) + require.GreaterOrEqual(t, len(keys[i]), 32) + require.Equal(t, keys[0], keys[i]) } - require.NoError(t, repo.CreateClient(ctx, newClient(ids[0]), pending+1)) - require.ErrorIs(t, repo.CreateClient(ctx, newClient(ids[1]), pending+1), service.ErrMCPOAuthClientLimit) - _, err := repo.GetClient(ctx, ids[1]) - require.ErrorIs(t, err, service.ErrMCPOAuthNotFound) + + // A client registered by one instance is accepted by another. + users := NewUserRepository(integrationEntClient, integrationDB) + client, err := service.NewMCPOAuthService(NewMCPOAuthRepository(integrationEntClient, integrationDB), users).Register(ctx, service.MCPOAuthRegisterInput{RedirectURIs: []string{"https://client.example/cb"}}) + require.NoError(t, err) + _, err = service.NewMCPOAuthService(NewMCPOAuthRepository(integrationEntClient, integrationDB), users).Token(ctx, service.MCPOAuthTokenInput{GrantType: "authorization_code", ClientID: client.ClientID}, mcpIntegrationResource) + requireMCPIntegrationInvalidGrant(t, err) } diff --git a/backend/internal/repository/mcp_oauth_test.go b/backend/internal/repository/mcp_oauth_test.go index 83fdcdc00cc1..ace5c3ef2740 100644 --- a/backend/internal/repository/mcp_oauth_test.go +++ b/backend/internal/repository/mcp_oauth_test.go @@ -5,46 +5,45 @@ package repository import ( "context" "errors" + "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/ent/securitysecret" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" ) -func TestMCPOAuthRepositoryRegistrationPrunesOnlyOldUnreferencedDataInBatches(t *testing.T) { - for _, atLimit := range []bool{false, true} { - t.Run(map[bool]string{false: "create", true: "at pending limit"}[atLimit], func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - repo := NewMCPOAuthRepository(db) - now := time.Unix(1800000000, 0).UTC() - pendingSince := now.Add(-service.MCPOAuthPendingClientLifetime) - client := &service.MCPOAuthClient{ClientID: "client", ClientName: "Client", RedirectURIs: []string{"https://client.example/cb"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ClientIDIssuedAt: now.Unix()} - pending := 999 - if atLimit { - pending = 1000 - } - mock.ExpectBegin() - mock.ExpectExec(`SELECT pg_advisory_xact_lock\(hashtext\('mcp_oauth_client_registration'\)\)`).WillReturnResult(sqlmock.NewResult(0, 0)) - mock.ExpectExec(`DELETE FROM mcp_oauth_grants WHERE id IN \(SELECT id FROM mcp_oauth_grants WHERE expires_at<\$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED\)`).WithArgs(now.Add(-7 * 24 * time.Hour)).WillReturnResult(sqlmock.NewResult(0, 2)) - mock.ExpectQuery(`SELECT c.client_id FROM mcp_oauth_clients c WHERE c.created_at<\$1 AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\) ORDER BY c.created_at LIMIT 100 FOR UPDATE SKIP LOCKED`).WithArgs(pendingSince).WillReturnRows(sqlmock.NewRows([]string{"client_id"}).AddRow("stale-client")) - mock.ExpectExec(`DELETE FROM mcp_oauth_clients c WHERE c.client_id=ANY\(\$1\) AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\)`).WithArgs(`{"stale-client"}`).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM mcp_oauth_clients c WHERE c.created_at>=\$1 AND NOT EXISTS \(SELECT 1 FROM mcp_oauth_grants g WHERE g.client_id=c.client_id\)`).WithArgs(pendingSince).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(pending)) - if atLimit { - // Cleanup still commits so capacity recovers; nothing is inserted. - mock.ExpectCommit() - require.ErrorIs(t, repo.CreateClient(context.Background(), client, 1000), service.ErrMCPOAuthClientLimit) - } else { - mock.ExpectExec(`INSERT INTO mcp_oauth_clients`).WithArgs(client.ClientID, client.ClientName, `["https://client.example/cb"]`, `["authorization_code","refresh_token"]`, now).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - require.NoError(t, repo.CreateClient(context.Background(), client, 1000)) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } +func TestMCPOAuthRepositoryClientIDKeyIsPersistedAndCached(t *testing.T) { + ctx := context.Background() + client := newSecuritySecretTestClient(t) + repo := NewMCPOAuthRepository(client, nil) + key, err := repo.ClientIDKey(ctx) + require.NoError(t, err) + require.Len(t, key, 64) + other, err := NewMCPOAuthRepository(client, nil).ClientIDKey(ctx) + require.NoError(t, err) + require.Equal(t, key, other, "instances must share the stored key") + _, err = client.SecuritySecret.Delete().Where(securitysecret.KeyEQ(mcpOAuthClientIDKeyName)).Exec(ctx) + require.NoError(t, err) + cached, err := repo.ClientIDKey(ctx) + require.NoError(t, err) + require.Equal(t, key, cached, "a loaded key is served from memory") +} + +func TestMCPOAuthRepositoryClientIDKeyDoesNotCacheInvalidSecret(t *testing.T) { + ctx := context.Background() + client := newSecuritySecretTestClient(t) + require.NoError(t, client.SecuritySecret.Create().SetKey(mcpOAuthClientIDKeyName).SetValue("short").Exec(ctx)) + repo := NewMCPOAuthRepository(client, nil) + _, err := repo.ClientIDKey(ctx) + require.Error(t, err) + valid := strings.Repeat("c", 64) + require.NoError(t, client.SecuritySecret.Update().Where(securitysecret.KeyEQ(mcpOAuthClientIDKeyName)).SetValue(valid).Exec(ctx)) + key, err := repo.ClientIDKey(ctx) + require.NoError(t, err) + require.Equal(t, []byte(valid), key) } func TestMCPOAuthRepositoryConsumptionCommitsReplayRevocation(t *testing.T) { @@ -53,7 +52,7 @@ func TestMCPOAuthRepositoryConsumptionCommitsReplayRevocation(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() - repo := NewMCPOAuthRepository(db) + repo := NewMCPOAuthRepository(nil, db) now := time.Now().UTC() used := now.Add(-time.Minute) mock.ExpectBegin() @@ -87,7 +86,7 @@ func TestMCPOAuthRepositoryConsumptionAndIssuanceAreOneTransaction(t *testing.T) db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() - repo := NewMCPOAuthRepository(db) + repo := NewMCPOAuthRepository(nil, db) now := time.Now().UTC() access := &service.MCPOAuthCredential{Hash: "access-digest", GrantID: "grant", Kind: "access", ExpiresAt: now.Add(10 * time.Minute)} refresh := &service.MCPOAuthCredential{Hash: "refresh-digest", GrantID: "grant", Kind: "refresh", ExpiresAt: now.Add(time.Hour)} @@ -131,7 +130,7 @@ func TestMCPOAuthRepositoryRejectsExpiredAndRevokedFamilies(t *testing.T) { for _, revoked := range []bool{false, true} { db, mock, err := sqlmock.New() require.NoError(t, err) - repo := NewMCPOAuthRepository(db) + repo := NewMCPOAuthRepository(nil, db) now := time.Now().UTC() var revokedAt *time.Time expiry := now.Add(-time.Second) @@ -155,14 +154,16 @@ func TestMCPOAuthRepositoryAuthorizationSerializesLimit(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() - repo := NewMCPOAuthRepository(db) + repo := NewMCPOAuthRepository(nil, db) now := time.Now().UTC() - grant := &service.MCPOAuthGrant{ID: "grant", ClientID: "client", UserID: 7, TokenVersion: 42, Resource: "https://example.com/mcp", Scope: "user", ReadOnly: true, CreatedAt: now, ExpiresAt: now.Add(time.Hour)} + grant := &service.MCPOAuthGrant{ID: "grant", ClientID: "client", ClientName: "Client", UserID: 7, TokenVersion: 42, Resource: "https://example.com/mcp", Scope: "user", ReadOnly: true, CreatedAt: now, ExpiresAt: now.Add(time.Hour)} code := &service.MCPOAuthCode{Hash: "code-digest", GrantID: grant.ID, ClientID: grant.ClientID, RedirectURI: "https://client.example/cb", Resource: grant.Resource, CodeChallenge: "challenge", ExpiresAt: now.Add(time.Minute)} count := 19 if atLimit { count = 20 } + // Bounded retention runs before the owner row is locked. + mock.ExpectExec(`DELETE FROM mcp_oauth_grants WHERE id IN \(SELECT id FROM mcp_oauth_grants WHERE expires_at<\$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED\)`).WithArgs(now.Add(-7 * 24 * time.Hour)).WillReturnResult(sqlmock.NewResult(0, 2)) mock.ExpectBegin() mock.ExpectQuery(`SELECT id FROM users WHERE id=\$1 AND deleted_at IS NULL FOR UPDATE`).WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7)) mock.ExpectQuery(`SELECT COUNT\(\*\) FROM mcp_oauth_grants WHERE user_id=\$1 AND revoked_at IS NULL AND expires_at>\$2`).WithArgs(int64(7), now).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count)) @@ -170,7 +171,7 @@ func TestMCPOAuthRepositoryAuthorizationSerializesLimit(t *testing.T) { mock.ExpectRollback() require.ErrorIs(t, repo.CreateAuthorization(context.Background(), grant, code, 20), service.ErrMCPOAuthGrantLimit) } else { - mock.ExpectExec(`INSERT INTO mcp_oauth_grants`).WithArgs(grant.ID, grant.ClientID, grant.UserID, grant.TokenVersion, grant.Resource, grant.Scope, grant.ReadOnly, grant.CreatedAt, grant.ExpiresAt).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO mcp_oauth_grants`).WithArgs(grant.ID, grant.ClientID, grant.ClientName, grant.UserID, grant.TokenVersion, grant.Resource, grant.Scope, grant.ReadOnly, grant.CreatedAt, grant.ExpiresAt).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`INSERT INTO mcp_oauth_codes`).WithArgs(code.Hash, code.GrantID, code.ClientID, code.RedirectURI, code.Resource, code.CodeChallenge, code.ExpiresAt).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() require.NoError(t, repo.CreateAuthorization(context.Background(), grant, code, 20)) @@ -184,7 +185,7 @@ func TestMCPOAuthRepositoryOwnerAndRevocationBoundaries(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() - repo := NewMCPOAuthRepository(db) + repo := NewMCPOAuthRepository(nil, db) now := time.Now().UTC() mock.ExpectExec(`UPDATE mcp_oauth_grants SET revoked_at=COALESCE\(revoked_at,\$3\) WHERE id=\$1 AND user_id=\$2`).WithArgs("grant", int64(8), now).WillReturnResult(sqlmock.NewResult(0, 0)) require.ErrorIs(t, repo.RevokeGrant(context.Background(), 8, "grant", now), service.ErrMCPOAuthNotFound) @@ -197,7 +198,7 @@ func TestMCPOAuthRepositoryRevokeUserGrantsCoversEveryActiveGrant(t *testing.T) db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() - repo := NewMCPOAuthRepository(db) + repo := NewMCPOAuthRepository(nil, db) now := time.Now().UTC() mock.ExpectExec(`UPDATE mcp_oauth_grants SET revoked_at=\$2 WHERE user_id=\$1 AND revoked_at IS NULL$`).WithArgs(int64(8), now).WillReturnResult(sqlmock.NewResult(0, 0)) require.NoError(t, repo.RevokeUserGrants(context.Background(), 8, now), "no active grants is not an error") diff --git a/backend/internal/server/routes/mcp_oauth_test.go b/backend/internal/server/routes/mcp_oauth_test.go index 03118a01a8bb..f677e19c75eb 100644 --- a/backend/internal/server/routes/mcp_oauth_test.go +++ b/backend/internal/server/routes/mcp_oauth_test.go @@ -29,27 +29,13 @@ import ( // database. SQL locking/rollback behavior is covered by repository unit tests. type mcpOAuthRouteRepo struct { mu sync.Mutex - clients map[string]*service.MCPOAuthClient grants map[string]*service.MCPOAuthGrant codes map[string]*service.MCPOAuthCode credentials map[string]*service.MCPOAuthCredential } -func (r *mcpOAuthRouteRepo) CreateClient(_ context.Context, client *service.MCPOAuthClient, _ int) error { - r.mu.Lock() - defer r.mu.Unlock() - r.clients[client.ClientID] = client - return nil -} - -func (r *mcpOAuthRouteRepo) GetClient(_ context.Context, id string) (*service.MCPOAuthClient, error) { - r.mu.Lock() - defer r.mu.Unlock() - if item := r.clients[id]; item != nil { - copy := *item - return ©, nil - } - return nil, service.ErrMCPOAuthNotFound +func (r *mcpOAuthRouteRepo) ClientIDKey(context.Context) ([]byte, error) { + return []byte("route-test-mcp-client-id-key-000"), nil } func (r *mcpOAuthRouteRepo) CreateAuthorization(_ context.Context, grant *service.MCPOAuthGrant, code *service.MCPOAuthCode, limit int) error { @@ -210,8 +196,8 @@ func (r *mcpOAuthRouteUsers) GetUserAvatar(context.Context, int64) (*service.Use func newMCPRouteOAuthService(role string) (*service.MCPOAuthService, *mcpOAuthRouteRepo, *mcpOAuthRouteUsers) { repo := &mcpOAuthRouteRepo{ - clients: map[string]*service.MCPOAuthClient{}, grants: map[string]*service.MCPOAuthGrant{}, - codes: map[string]*service.MCPOAuthCode{}, credentials: map[string]*service.MCPOAuthCredential{}, + grants: map[string]*service.MCPOAuthGrant{}, codes: map[string]*service.MCPOAuthCode{}, + credentials: map[string]*service.MCPOAuthCredential{}, } users := &mcpOAuthRouteUsers{user: &service.User{ID: 7, Role: role, Status: service.StatusActive, PasswordHash: "test-password", Email: "mcp@example.com"}} return service.NewMCPOAuthService(repo, users), repo, users diff --git a/backend/internal/service/mcp_oauth_service.go b/backend/internal/service/mcp_oauth_service.go index 179d40020695..ff2fedefa715 100644 --- a/backend/internal/service/mcp_oauth_service.go +++ b/backend/internal/service/mcp_oauth_service.go @@ -2,11 +2,13 @@ package service import ( "context" + "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" + "encoding/json" "errors" "net/url" "strings" @@ -25,12 +27,12 @@ const ( MCPOAuthAccessLifetime = 10 * time.Minute MCPOAuthCodeLifetime = 5 * time.Minute MCPOAuthGrantLifetime = 30 * 24 * time.Hour - // Registration is unauthenticated: clients that never obtain a grant expire - // quickly, and the number awaiting authorization is capped globally. - MCPOAuthPendingClientLifetime = 24 * time.Hour - MCPOAuthPendingClientLimit = 1000 // last_used_at is advisory; writing it once per interval keeps requests read-only. MCPOAuthTouchInterval = time.Minute + + mcpOAuthClientIDPrefix = "mcp_client_" + // Registration bounds keep issued IDs well below this limit. + mcpOAuthClientIDMaxLength = 2048 ) var ( @@ -38,7 +40,6 @@ var ( ErrMCPOAuthNotFound = errors.New("MCP OAuth record not found") ErrMCPOAuthGrantLimit = infraerrors.Conflict("MCP_GRANT_LIMIT", "Revoke an existing MCP authorization before authorizing another client (maximum 20 active authorizations)") ErrMCPOAuthGrantNotFound = infraerrors.NotFound("MCP_GRANT_NOT_FOUND", "MCP authorization not found") - ErrMCPOAuthClientLimit = errors.New("too many MCP OAuth clients are awaiting authorization") ) type MCPOAuthError struct { @@ -157,10 +158,8 @@ type MCPOAuthCredential struct { } type MCPOAuthRepository interface { - // CreateClient returns ErrMCPOAuthClientLimit when the given number of - // recently registered clients without any grant already exists. - CreateClient(context.Context, *MCPOAuthClient, int) error - GetClient(context.Context, string) (*MCPOAuthClient, error) + // ClientIDKey returns the persistent key that signs stateless client IDs. + ClientIDKey(context.Context) ([]byte, error) CreateAuthorization(context.Context, *MCPOAuthGrant, *MCPOAuthCode, int) error GetCode(context.Context, string) (*MCPOAuthCode, error) GetCredential(context.Context, string) (*MCPOAuthCredential, error) @@ -222,19 +221,102 @@ func (s *MCPOAuthService) Register(ctx context.Context, input MCPOAuthRegisterIn if len(input.ResponseTypes) != 0 && (len(input.ResponseTypes) != 1 || input.ResponseTypes[0] != "code") { return nil, mcpOAuthError("invalid_client_metadata", "Only the code response type is supported") } - id, err := mcpOAuthSecret("mcp_client_") + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + claims := mcpOAuthClientClaims{Name: input.ClientName, GrantTypes: append([]string(nil), grants...)} + for _, uri := range redirects { + claims.RedirectDigests = append(claims.RedirectDigests, mcpOAuthRedirectDigest(uri)) + } + id, err := s.encodeClientID(ctx, base64.RawURLEncoding.EncodeToString(nonce), &claims) if err != nil { return nil, err } - client := &MCPOAuthClient{ + return &MCPOAuthClient{ ClientID: id, ClientName: input.ClientName, RedirectURIs: redirects, - GrantTypes: append([]string(nil), grants...), ResponseTypes: []string{"code"}, + GrantTypes: claims.GrantTypes, ResponseTypes: []string{"code"}, TokenEndpointAuthMethod: "none", ClientIDIssuedAt: time.Now().UTC().Unix(), + }, nil +} + +// Registration is unauthenticated, so it is never persisted: the client ID is +// the signed metadata itself. Nothing is stored until a user approves a client, +// leaving no storage or shared capacity for anonymous registrations to exhaust. +// Redirect URIs are embedded as digests to keep IDs short enough for URLs. +// +// Format: mcp_client_... +// The random nonce leads, so truncated IDs still tell clients apart. +type mcpOAuthClientClaims struct { + Name string `json:"n"` + RedirectDigests []string `json:"r"` + GrantTypes []string `json:"g"` +} + +func (c *mcpOAuthClientClaims) allowsRedirect(uri string) bool { + return containsMCPString(c.RedirectDigests, mcpOAuthRedirectDigest(uri)) +} + +func mcpOAuthRedirectDigest(uri string) string { + sum := sha256.Sum256([]byte(uri)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func (s *MCPOAuthService) encodeClientID(ctx context.Context, nonce string, claims *mcpOAuthClientClaims) (string, error) { + raw, err := json.Marshal(claims) + if err != nil { + return "", err + } + signed := nonce + "." + base64.RawURLEncoding.EncodeToString(raw) + signature, err := s.signClientID(ctx, signed) + if err != nil { + return "", err } - if err := s.repo.CreateClient(ctx, client, MCPOAuthPendingClientLimit); err != nil { + return mcpOAuthClientIDPrefix + signed + "." + signature, nil +} + +// client verifies a client ID and returns its metadata. IDs that were not +// issued by this server yield ErrMCPOAuthNotFound. +func (s *MCPOAuthService) client(ctx context.Context, id string) (*mcpOAuthClientClaims, error) { + if len(id) > mcpOAuthClientIDMaxLength || !strings.HasPrefix(id, mcpOAuthClientIDPrefix) { + return nil, ErrMCPOAuthNotFound + } + signed := id[len(mcpOAuthClientIDPrefix):] + dot := strings.LastIndexByte(signed, '.') + if dot < 0 { + return nil, ErrMCPOAuthNotFound + } + signed, signature := signed[:dot], signed[dot+1:] + expected, err := s.signClientID(ctx, signed) + if err != nil { return nil, err } - return client, nil + if !hmac.Equal([]byte(signature), []byte(expected)) { + return nil, ErrMCPOAuthNotFound + } + _, payload, ok := strings.Cut(signed, ".") + if !ok { + return nil, ErrMCPOAuthNotFound + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return nil, ErrMCPOAuthNotFound + } + var claims mcpOAuthClientClaims + if err := json.Unmarshal(raw, &claims); err != nil { + return nil, ErrMCPOAuthNotFound + } + return &claims, nil +} + +func (s *MCPOAuthService) signClientID(ctx context.Context, payload string) (string, error) { + key, err := s.repo.ClientIDKey(ctx) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil } func validMCPClientName(name string) bool { @@ -287,15 +369,15 @@ func validMCPOAuthScope(scope string) bool { return len(fields) == 0 || (len(fields) == 1 && fields[0] == MCPOAuthScope) } -func (s *MCPOAuthService) validateAuthorization(ctx context.Context, userID int64, input MCPOAuthAuthorizeInput, resource string) (*MCPOAuthClient, *User, error) { - client, err := s.repo.GetClient(ctx, input.ClientID) +func (s *MCPOAuthService) validateAuthorization(ctx context.Context, userID int64, input MCPOAuthAuthorizeInput, resource string) (*mcpOAuthClientClaims, *User, error) { + client, err := s.client(ctx, input.ClientID) if errors.Is(err, ErrMCPOAuthNotFound) { return nil, nil, mcpOAuthError("invalid_client", "Unknown client") } if err != nil { return nil, nil, err } - if client == nil || !containsMCPString(client.RedirectURIs, input.RedirectURI) { + if !client.allowsRedirect(input.RedirectURI) { return nil, nil, mcpOAuthError("invalid_request", "redirect_uri must exactly match a registered URI") } if input.ResponseType != "code" { @@ -329,7 +411,7 @@ func (s *MCPOAuthService) Preview(ctx context.Context, userID int64, input MCPOA if err != nil { return nil, err } - return &MCPOAuthConsent{ClientID: client.ClientID, ClientName: client.ClientName, RedirectURI: input.RedirectURI, Resource: resource, Scope: MCPOAuthScope}, nil + return &MCPOAuthConsent{ClientID: input.ClientID, ClientName: client.Name, RedirectURI: input.RedirectURI, Resource: resource, Scope: MCPOAuthScope}, nil } func (s *MCPOAuthService) Decide(ctx context.Context, userID int64, input MCPOAuthConsentInput, resource string) (string, error) { @@ -370,13 +452,13 @@ func (s *MCPOAuthService) Decide(ctx context.Context, userID int64, input MCPOAu return "", err } now := time.Now().UTC() - grant := &MCPOAuthGrant{ID: id, ClientID: client.ClientID, ClientName: client.ClientName, + grant := &MCPOAuthGrant{ID: id, ClientID: input.Authorization.ClientID, ClientName: client.Name, UserID: user.ID, TokenVersion: resolvedTokenVersion(user), Resource: resource, Scope: input.Scope, ReadOnly: true, CreatedAt: now, ExpiresAt: now.Add(MCPOAuthGrantLifetime)} if input.ReadOnly != nil { grant.ReadOnly = *input.ReadOnly } - code := &MCPOAuthCode{Hash: hashMCPOAuthSecret(rawCode), GrantID: grant.ID, ClientID: client.ClientID, + code := &MCPOAuthCode{Hash: hashMCPOAuthSecret(rawCode), GrantID: grant.ID, ClientID: grant.ClientID, RedirectURI: input.Authorization.RedirectURI, Resource: resource, CodeChallenge: input.Authorization.CodeChallenge, ExpiresAt: now.Add(MCPOAuthCodeLifetime)} if err := s.repo.CreateAuthorization(ctx, grant, code, MCPOAuthGrantLimit); err != nil { @@ -388,8 +470,8 @@ func (s *MCPOAuthService) Decide(ctx context.Context, userID int64, input MCPOAu } func (s *MCPOAuthService) Token(ctx context.Context, input MCPOAuthTokenInput, resource string) (*MCPOAuthTokenResponse, error) { - client, err := s.repo.GetClient(ctx, input.ClientID) - if errors.Is(err, ErrMCPOAuthNotFound) || (err == nil && client == nil) { + client, err := s.client(ctx, input.ClientID) + if errors.Is(err, ErrMCPOAuthNotFound) { return nil, mcpOAuthError("invalid_client", "Unknown client") } if err != nil { diff --git a/backend/internal/service/mcp_oauth_service_test.go b/backend/internal/service/mcp_oauth_service_test.go index ec8df8aeb89b..8cda7496d45f 100644 --- a/backend/internal/service/mcp_oauth_service_test.go +++ b/backend/internal/service/mcp_oauth_service_test.go @@ -20,41 +20,18 @@ import ( type mcpOAuthMemoryRepo struct { mu sync.Mutex - clients map[string]*MCPOAuthClient + clientIDKey []byte grants map[string]*MCPOAuthGrant codes map[string]*MCPOAuthCode credentials map[string]*MCPOAuthCredential touches int } -func (r *mcpOAuthMemoryRepo) CreateClient(_ context.Context, client *MCPOAuthClient, pendingLimit int) error { - r.mu.Lock() - defer r.mu.Unlock() - pending := 0 - for id := range r.clients { - authorized := false - for _, grant := range r.grants { - authorized = authorized || grant.ClientID == id - } - if !authorized { - pending++ - } - } - if pending >= pendingLimit { - return ErrMCPOAuthClientLimit +func (r *mcpOAuthMemoryRepo) ClientIDKey(context.Context) ([]byte, error) { + if r.clientIDKey == nil { + return []byte("mcp-oauth-service-test-client-id-key"), nil } - r.clients[client.ClientID] = client - return nil -} - -func (r *mcpOAuthMemoryRepo) GetClient(_ context.Context, id string) (*MCPOAuthClient, error) { - r.mu.Lock() - defer r.mu.Unlock() - if client := r.clients[id]; client != nil { - copy := *client - return ©, nil - } - return nil, ErrMCPOAuthNotFound + return r.clientIDKey, nil } func (r *mcpOAuthMemoryRepo) CreateAuthorization(_ context.Context, grant *MCPOAuthGrant, code *MCPOAuthCode, limit int) error { @@ -223,7 +200,7 @@ type mcpOAuthFixture struct { func newMCPOAuthFixture(t *testing.T) *mcpOAuthFixture { t.Helper() - r := &mcpOAuthMemoryRepo{clients: map[string]*MCPOAuthClient{}, grants: map[string]*MCPOAuthGrant{}, codes: map[string]*MCPOAuthCode{}, credentials: map[string]*MCPOAuthCredential{}} + r := &mcpOAuthMemoryRepo{grants: map[string]*MCPOAuthGrant{}, codes: map[string]*MCPOAuthCode{}, credentials: map[string]*MCPOAuthCredential{}} u := &mcpOAuthUserRepo{user: &User{ID: 7, Role: RoleUser, Status: StatusActive, Email: "user@example.com", PasswordHash: "original", Concurrency: 3}} s := NewMCPOAuthService(r, u) client, err := s.Register(context.Background(), MCPOAuthRegisterInput{ClientName: "My MCP client", RedirectURIs: []string{"http://127.0.0.1:2345/callback?existing=1", "https://client.example/callback"}}) @@ -544,7 +521,9 @@ func TestMCPOAuthClientRevocationInvalidatesAccessAndRefresh(t *testing.T) { func TestMCPOAuthCodeOnlyClientReceivesNoRefreshToken(t *testing.T) { f := newMCPOAuthFixture(t) - f.r.clients[f.client.ClientID].GrantTypes = []string{"authorization_code"} + client, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: f.client.RedirectURIs, GrantTypes: []string{"authorization_code"}}) + require.NoError(t, err) + f.client, f.authorize.ClientID = client, client.ClientID tokens := f.tokens(t) require.NotEmpty(t, tokens.AccessToken) require.Empty(t, tokens.RefreshToken) @@ -590,17 +569,89 @@ func TestMCPOAuthRegistrationRejectsDisguisedClientNames(t *testing.T) { require.Equal(t, "桌面助手 · Desktop (beta) ✨", client.ClientName) } -func TestMCPOAuthRegistrationStopsAtPendingClientLimit(t *testing.T) { +func TestMCPOAuthRegistrationIsStatelessAndSigned(t *testing.T) { f := newMCPOAuthFixture(t) - f.code(t, "", nil) // An authorized client does not count as pending. - for i := 0; i < MCPOAuthPendingClientLimit; i++ { - f.r.clients[fmt.Sprintf("pending-%d", i)] = &MCPOAuthClient{} + // Anonymous registration persists nothing, so there is no capacity to exhaust. + for i := 0; i < 100; i++ { + _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{"https://client.example/cb"}}) + require.NoError(t, err) } - _, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{"https://client.example/cb"}}) - require.ErrorIs(t, err, ErrMCPOAuthClientLimit) - require.Len(t, f.r.clients, MCPOAuthPendingClientLimit+1) + require.Empty(t, f.r.grants) + require.Empty(t, f.r.codes) + require.Empty(t, f.r.credentials) + twin, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{ClientName: f.client.ClientName, RedirectURIs: f.client.RedirectURIs}) + require.NoError(t, err) + // The random nonce leads, so even truncated IDs of identical clients differ. + visible := len(mcpOAuthClientIDPrefix) + 22 + require.NotEqual(t, f.client.ClientID[:visible], twin.ClientID[:visible], "identical metadata must still yield distinct clients") + + parts := strings.Split(strings.TrimPrefix(f.client.ClientID, mcpOAuthClientIDPrefix), ".") + require.Len(t, parts, 3) + nonce, payload, signature := parts[0], parts[1], parts[2] + twinNonce, _, _ := strings.Cut(strings.TrimPrefix(twin.ClientID, mcpOAuthClientIDPrefix), ".") + raw, err := base64.RawURLEncoding.DecodeString(payload) + require.NoError(t, err) + renamed := strings.Replace(string(raw), `"n":"My MCP client"`, `"n":"Trusted client"`, 1) + require.NotEqual(t, string(raw), renamed) + foreign, err := NewMCPOAuthService(&mcpOAuthMemoryRepo{clientIDKey: []byte("another-deployment-client-id-key")}, f.u). + Register(context.Background(), MCPOAuthRegisterInput{ClientName: f.client.ClientName, RedirectURIs: f.client.RedirectURIs}) + require.NoError(t, err) + tampered := []byte(f.client.ClientID) + tampered[len(tampered)-1] ^= 1 + for name, id := range map[string]string{ + "forged metadata": mcpOAuthClientIDPrefix + nonce + "." + base64.RawURLEncoding.EncodeToString([]byte(renamed)) + "." + signature, + "swapped nonce": mcpOAuthClientIDPrefix + twinNonce + "." + payload + "." + signature, + "other key": foreign.ClientID, + "bad signature": string(tampered), + "unsigned": mcpOAuthClientIDPrefix + nonce + "." + payload, + "missing prefix": strings.TrimPrefix(f.client.ClientID, mcpOAuthClientIDPrefix), + "oversized": f.client.ClientID + strings.Repeat("A", mcpOAuthClientIDMaxLength), + } { + t.Run(name, func(t *testing.T) { + input := f.authorize + input.ClientID = id + _, err := f.s.Preview(context.Background(), 7, input, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_client") + _, err = f.s.Token(context.Background(), MCPOAuthTokenInput{GrantType: "authorization_code", ClientID: id}, mcpTestResource) + requireMCPOAuthError(t, err, "invalid_client") + }) + } +} + +func TestMCPOAuthLargestRegistrationFitsClientIDLimit(t *testing.T) { + f := newMCPOAuthFixture(t) + redirects := make([]string, 10) + for i := range redirects { + redirects[i] = fmt.Sprintf("https://client.example/%d/%s", i, strings.Repeat("p", 2000)) + } + // "<" is JSON-escaped to six bytes, the worst case for an allowed name. + client, err := f.s.Register(context.Background(), MCPOAuthRegisterInput{ClientName: strings.Repeat("<", 100), RedirectURIs: redirects}) + require.NoError(t, err) + require.LessOrEqual(t, len(client.ClientID), mcpOAuthClientIDMaxLength) + input := f.authorize + input.ClientID, input.RedirectURI = client.ClientID, redirects[9] + preview, err := f.s.Preview(context.Background(), 7, input, mcpTestResource) + require.NoError(t, err) + require.Equal(t, client.ClientName, preview.ClientName) } +func TestMCPOAuthClientIDKeyErrorIsNotUnknownClient(t *testing.T) { + f := newMCPOAuthFixture(t) + unavailable := errors.New("database unavailable") + s := NewMCPOAuthService(&mcpOAuthKeyErrorRepo{mcpOAuthMemoryRepo: f.r, err: unavailable}, f.u) + _, err := s.Register(context.Background(), MCPOAuthRegisterInput{RedirectURIs: []string{"https://client.example/cb"}}) + require.ErrorIs(t, err, unavailable) + _, err = s.Preview(context.Background(), 7, f.authorize, mcpTestResource) + require.ErrorIs(t, err, unavailable) +} + +type mcpOAuthKeyErrorRepo struct { + *mcpOAuthMemoryRepo + err error +} + +func (r *mcpOAuthKeyErrorRepo) ClientIDKey(context.Context) ([]byte, error) { return nil, r.err } + func TestMCPOAuthAuthenticateThrottlesLastUsedWrites(t *testing.T) { f := newMCPOAuthFixture(t) tokens := f.tokens(t) diff --git a/backend/migrations/249_mcp_oauth.sql b/backend/migrations/249_mcp_oauth.sql index fde8ca68a4d8..8ba1595a7f61 100644 --- a/backend/migrations/249_mcp_oauth.sql +++ b/backend/migrations/249_mcp_oauth.sql @@ -1,17 +1,12 @@ -- OAuth clients use public-client authorization code + S256 PKCE. No client -- secrets, authorization codes, access tokens, or refresh tokens are stored raw. -CREATE TABLE IF NOT EXISTS mcp_oauth_clients ( - client_id VARCHAR(64) PRIMARY KEY, - client_name VARCHAR(100) NOT NULL, - redirect_uris JSONB NOT NULL, - grant_types JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX IF NOT EXISTS idx_mcp_oauth_clients_created ON mcp_oauth_clients(created_at); - +-- Client registration writes nothing: the client ID is signed and carries its +-- own metadata. Rows are created only when a user approves a client, and each +-- grant keeps the client name shown at consent. CREATE TABLE IF NOT EXISTS mcp_oauth_grants ( id VARCHAR(43) PRIMARY KEY, - client_id VARCHAR(64) NOT NULL REFERENCES mcp_oauth_clients(client_id) ON DELETE RESTRICT, + client_id TEXT NOT NULL, + client_name VARCHAR(100) NOT NULL, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_version BIGINT NOT NULL, resource TEXT NOT NULL, @@ -23,13 +18,12 @@ CREATE TABLE IF NOT EXISTS mcp_oauth_grants ( revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_mcp_oauth_grants_user_created ON mcp_oauth_grants(user_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_mcp_oauth_grants_client ON mcp_oauth_grants(client_id); CREATE INDEX IF NOT EXISTS idx_mcp_oauth_grants_expiry ON mcp_oauth_grants(expires_at); CREATE TABLE IF NOT EXISTS mcp_oauth_codes ( code_hash VARCHAR(64) PRIMARY KEY, grant_id VARCHAR(43) NOT NULL REFERENCES mcp_oauth_grants(id) ON DELETE CASCADE, - client_id VARCHAR(64) NOT NULL, + client_id TEXT NOT NULL, redirect_uri TEXT NOT NULL, resource TEXT NOT NULL, code_challenge VARCHAR(43) NOT NULL, diff --git a/docs/mcp.md b/docs/mcp.md index 567545c515e4..c8d7532b0e6f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -168,4 +168,4 @@ curl 'https://api.example.com/.well-known/oauth-authorization-server' 工具目录位于 `backend/internal/managementmcp/catalog.go`。新增工具应绑定已有管理路由,使用封闭参数 schema,并覆盖权限、资源归属、参数边界及凭证脱敏测试。 -OAuth 存储由 `249_mcp_oauth.sql` 创建。注册时有界清理过期 7 天以上的授权,以及 24 小时前注册且从未获得授权的客户端。24 小时内注册但尚未获得授权的客户端全站最多 1000 个,达到上限后注册返回 503 `temporarily_unavailable`,待旧注册过期后自动恢复。浏览器使用 JWT 保护的 `/api/v1/user/mcp/config` 获取服务端配置的连接地址、`/api/v1/user/mcp/authorize` 预览/决定授权,以及 `/api/v1/user/mcp/grants` 列表、撤销接口。客户端协议端点返回标准 OAuth JSON,浏览器接口沿用平台响应格式。 +OAuth 存储由 `249_mcp_oauth.sql` 创建。动态注册不写数据库:`client_id` 是服务端签名的无状态标识,自带客户端名称、回调 URI 摘要和 grant type,签名密钥首次使用时生成并保存在 `security_secrets`,多实例共享。只有用户同意授权后才写入授权记录,因此匿名注册不会占用存储,也没有可被占满的全站名额。每次新授权时有界清理过期 7 天以上的授权。浏览器使用 JWT 保护的 `/api/v1/user/mcp/config` 获取服务端配置的连接地址、`/api/v1/user/mcp/authorize` 预览/决定授权,以及 `/api/v1/user/mcp/grants` 列表、撤销接口。客户端协议端点返回标准 OAuth JSON,浏览器接口沿用平台响应格式。 From b3eb7194c29e82ae3738fec14eaec4164d6806cd Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Thu, 29 Oct 2026 14:46:08 -0400 Subject: [PATCH 6/8] fix(frontend): truncate long MCP client IDs Signed client IDs are now about 200 characters. Show them on one line on the consent page and in the connection list, with the full value in the title. The random part leads, so truncated IDs still differ. Co-Authored-By: Claude Opus 5.5 --- frontend/src/views/auth/MCPOAuthAuthorizeView.vue | 2 +- frontend/src/views/user/MCPView.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/auth/MCPOAuthAuthorizeView.vue b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue index adf9e67bfcd7..065a739272c7 100644 --- a/frontend/src/views/auth/MCPOAuthAuthorizeView.vue +++ b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue @@ -23,7 +23,7 @@
{{ t('mcp.clientId') }}
-
{{ consent.client_id }}
+
{{ consent.client_id }}
{{ t('mcp.returnAddress') }}
diff --git a/frontend/src/views/user/MCPView.vue b/frontend/src/views/user/MCPView.vue index 2007332fbc1e..3c50ca1d5142 100644 --- a/frontend/src/views/user/MCPView.vue +++ b/frontend/src/views/user/MCPView.vue @@ -51,7 +51,7 @@ {{ grant.read_only ? t('mcp.readOnly') : t('mcp.readWrite') }} {{ grantStatus(grant) }}
-

{{ grant.client_id }}

+

{{ grant.client_id }}

{{ t('mcp.createdAt', { date: formatDate(grant.created_at) }) }} {{ t('mcp.expiresAt', { date: formatDate(grant.expires_at) }) }} From fb22c92126b4d4877ce67caf39eb40cb88501b12 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 30 Oct 2026 09:36:53 +0800 Subject: [PATCH 7/8] fix(backend): bump MCP go-sdk to v1.4.1 for GO-2026-4770/4773 go-sdk v1.4.1 fixes GO-2026-4773 (cross-site tool execution) and GO-2026-4770 (null Unicode JSON parsing), which fail govulncheck. v1.4.1 also adds transport-level cross-origin and Content-Type checks that would reject requests Authenticate already accepts: - trust the same allowed origins in the SDK's CrossOriginProtection so CORS-allowlisted browser clients are not refused with 403; - accept application/json with parameters (e.g. charset) by normalizing the header, while still refusing non-JSON bodies with 415. --- backend/go.mod | 4 +- backend/go.sum | 8 ++-- backend/internal/managementmcp/server.go | 29 ++++++++++++++- backend/internal/managementmcp/server_test.go | 37 ++++++++++++++++++- backend/internal/server/routes/mcp.go | 2 +- backend/internal/server/routes/mcp_test.go | 2 +- 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 14bb121b6438..0d90a1822998 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -30,7 +30,7 @@ require ( github.com/imroc/req/v3 v3.59.0 github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 - github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.5.0 github.com/redis/go-redis/v9 v9.22.0 @@ -170,7 +170,7 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/smartwalle/ncrypto v1.0.4 // indirect github.com/smartwalle/ngx v1.1.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 5ce028114303..5f62bf5c24ac 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -351,8 +351,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= -github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -411,8 +411,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= diff --git a/backend/internal/managementmcp/server.go b/backend/internal/managementmcp/server.go index 395692b0ccf3..c011acb7c000 100644 --- a/backend/internal/managementmcp/server.go +++ b/backend/internal/managementmcp/server.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "mime" "net/http" "net/url" "strconv" @@ -25,7 +26,9 @@ const ( // NewHandler serves stateless Streamable HTTP. Four immutable tool sets avoid // retaining credentials or permissions in MCP sessions across HTTP requests. -func NewHandler(dispatcher http.Handler) http.Handler { +// allowedOrigins must match the list given to Authenticate so the SDK's +// cross-origin check accepts the same browser origins. +func NewHandler(dispatcher http.Handler, allowedOrigins []string) http.Handler { servers := map[string]*mcp.Server{} for _, scope := range []string{service.MCPScopeUser, service.MCPScopeAdmin} { for _, readOnly := range []bool{true, false} { @@ -56,7 +59,7 @@ func NewHandler(dispatcher http.Handler) http.Handler { return nil } return servers[serverKey(identity.principal.Scope, identity.principal.ReadOnly)] - }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true, CrossOriginProtection: crossOriginProtection(allowedOrigins)}) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodPost { @@ -77,11 +80,33 @@ func NewHandler(dispatcher http.Handler) http.Handler { http.Error(w, "MCP requests must contain a single JSON-RPC object; batches are not supported", http.StatusBadRequest) return } + // The SDK compares Content-Type verbatim; accept JSON with parameters such + // as charset, while form and text bodies are still refused. + if mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mediaType != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + r.Header.Set("Content-Type", "application/json") r.Body = io.NopCloser(bytes.NewReader(body)) transport.ServeHTTP(noStoreResponse{w}, r) }) } +// crossOriginProtection trusts the origins Authenticate accepts. Wildcard CORS +// does not relax MCP origin validation, so "*" is never trusted here either. +func crossOriginProtection(allowedOrigins []string) *http.CrossOriginProtection { + protection := http.NewCrossOriginProtection() + for _, origin := range allowedOrigins { + origin = strings.TrimSpace(origin) + if origin == "" || origin == "*" { + continue + } + // Authenticate matches origins verbatim, so malformed entries never match there either. + _ = protection.AddTrustedOrigin(origin) + } + return protection +} + func serverKey(scope string, readOnly bool) string { return scope + ":" + strconv.FormatBool(readOnly) } func executeTool(ctx context.Context, dispatcher http.Handler, spec ToolSpec, args map[string]any) *mcp.CallToolResult { diff --git a/backend/internal/managementmcp/server_test.go b/backend/internal/managementmcp/server_test.go index e957d15b45c6..8767f3114354 100644 --- a/backend/internal/managementmcp/server_test.go +++ b/backend/internal/managementmcp/server_test.go @@ -48,7 +48,7 @@ func newTestEndpoint(dispatcher http.Handler) (*gin.Engine, *testTokenAuth) { r := gin.New() r.Any("/api/v1/mcp", Authenticate(auth, []string{"https://console.example"}, func(context.Context) (string, error) { return "https://platform.example/api/v1/mcp", nil - }), gin.WrapH(NewHandler(dispatcher))) + }), gin.WrapH(NewHandler(dispatcher, []string{"https://console.example"}))) return r, auth } @@ -291,6 +291,41 @@ func TestMCPOriginAuthenticationAndRequestLimits(t *testing.T) { } } +// The SDK's own cross-origin and Content-Type checks must agree with +// Authenticate: allowlisted browser origins and JSON with parameters succeed. +func TestMCPTransportAcceptsAuthenticatedOriginsAndJSONMediaTypes(t *testing.T) { + r, _ := newTestEndpoint(http.NotFoundHandler()) + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` + for _, tc := range []struct { + name, origin, site, contentType string + status int + }{ + {"native", "", "", "application/json", 200}, + {"same host", "https://platform.example", "same-origin", "application/json", 200}, + {"allowlisted origin", "https://console.example", "cross-site", "application/json", 200}, + {"allowlisted origin without fetch metadata", "https://console.example", "", "application/json", 200}, + {"unlisted origin", "https://attacker.example", "cross-site", "application/json", 403}, + {"json with charset", "", "", "application/json; charset=utf-8", 200}, + {"text body", "", "", "text/plain", 415}, + {"form body", "", "", "application/x-www-form-urlencoded", 415}, + } { + req := httptest.NewRequest("POST", "http://platform.example/api/v1/mcp", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer reader") + req.Header.Set("Content-Type", tc.contentType) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", "2025-06-18") + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.site != "" { + req.Header.Set("Sec-Fetch-Site", tc.site) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, tc.status, w.Code, "%s: %s", tc.name, w.Body.String()) + } +} + func TestMCPResultLimitAndCancellation(t *testing.T) { r, _ := newTestEndpoint(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(strings.Repeat("x", maxResultBytes+1))) diff --git a/backend/internal/server/routes/mcp.go b/backend/internal/server/routes/mcp.go index 763f8a55c58a..535b9eec1c00 100644 --- a/backend/internal/server/routes/mcp.go +++ b/backend/internal/server/routes/mcp.go @@ -27,7 +27,7 @@ func RegisterMCPRoutes(v1 *gin.RouterGroup, h *handler.Handlers, audit middlewar api := inner.Group("/api/v1") RegisterUserRoutes(api, h, middleware.JWTAuthMiddleware(managementmcp.DispatchAuth(false)), audit, settings, limiter) RegisterAdminRoutes(api, h, middleware.AdminAuthMiddleware(managementmcp.DispatchAuth(true)), audit, stepUp, settings, limiter) - transport := managementmcp.NewHandler(inner) + transport := managementmcp.NewHandler(inner, allowedOrigins) var handlers []gin.HandlerFunc if redisClient != nil { // Bound token probes per network before authentication reaches the database. diff --git a/backend/internal/server/routes/mcp_test.go b/backend/internal/server/routes/mcp_test.go index f95b08cbe176..e1a1e583e60c 100644 --- a/backend/internal/server/routes/mcp_test.go +++ b/backend/internal/server/routes/mcp_test.go @@ -74,7 +74,7 @@ func newMCPRouteTestEndpoint(h *handler.Handlers, principal *service.MCPPrincipa RegisterUserRoutes(v1, h, middleware.JWTAuthMiddleware(managementmcp.DispatchAuth(false)), audit, settings, nil) RegisterAdminRoutes(v1, h, middleware.AdminAuthMiddleware(managementmcp.DispatchAuth(true)), audit, middleware.StepUpAuthMiddleware(mcpRouteNext), settings, nil) outer := gin.New() - outer.POST("/api/v1/mcp", managementmcp.Authenticate(mcpRouteTokenAuth{principal: principal}, nil, h.MCPOAuth.ResourceURL), gin.WrapH(managementmcp.NewHandler(inner))) + outer.POST("/api/v1/mcp", managementmcp.Authenticate(mcpRouteTokenAuth{principal: principal}, nil, h.MCPOAuth.ResourceURL), gin.WrapH(managementmcp.NewHandler(inner, nil))) return outer } From d851a38477597157e78450d30af2d8504764d085 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 30 Oct 2026 10:44:47 +0800 Subject: [PATCH 8/8] fix(frontend): upgrade vue-i18n to v11 and patch decode-uri-component Fixes frontend-security on main for two new high advisories: - GHSA-83rm-6c8r-g728 (vue-i18n / @intlify/* srcdoc XSS): v9 and v10 are end of life and will not be patched, so move vue-i18n and @intlify/message-compiler to 11.4.16. - GHSA-qr28-fpjm-wq7f (decode-uri-component quadratic DoS): add a pnpm override to >=0.5.1; its API and types are unchanged. vue-i18n 11 requires Node >= 22, so CI and release workflows move from Node 20 to Node 24, matching the Docker build image. JIT message compilation is always on since v10, so the obsolete __INTLIFY_JIT_COMPILATION__ define is removed; the build still has no new Function. SubscriptionPlanCard spec asserted raw keys that v9's runtime build returned; it now asserts the rendered test messages. --- .github/workflows/backend-ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/security-scan.yml | 2 +- frontend/package.json | 7 +- frontend/pnpm-lock.yaml | 71 +++++++++++-------- .../__tests__/SubscriptionPlanCard.spec.ts | 18 ++--- frontend/vite.config.ts | 8 +-- 7 files changed, 59 insertions(+), 51 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index c9a140f88633..d1b420aa94ca 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - name: Install frontend dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cef765b6237..9f2ef5dd7db4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: version: 9 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 88da184e5dc5..703e10354a28 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - name: Install dependencies diff --git a/frontend/package.json b/frontend/package.json index fa12747fa719..ef38431a73f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,12 +32,12 @@ "vue": "^3.5.43", "vue-chartjs": "^5.3.0", "vue-draggable-plus": "^0.6.1", - "vue-i18n": "^9.14.5", + "vue-i18n": "^11.4.16", "vue-router": "^4.2.5", "xlsx": "^0.18.5" }, "devDependencies": { - "@intlify/message-compiler": "9.14.5", + "@intlify/message-compiler": "11.4.16", "@types/dompurify": "^3.0.5", "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", @@ -67,7 +67,8 @@ "postcss@<8.5.18": ">=8.5.18", "dompurify@<3.4.17": ">=3.4.17", "source-map-js@<1.2.2": ">=1.2.2", - "follow-redirects@<1.16.1": ">=1.16.1" + "follow-redirects@<1.16.1": ">=1.16.1", + "decode-uri-component@<0.5.1": ">=0.5.1" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d2714896a99f..a6f4b6042526 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,7 @@ overrides: dompurify@<3.4.17: '>=3.4.17' source-map-js@<1.2.2: '>=1.2.2' follow-redirects@<1.16.1: '>=1.16.1' + decode-uri-component@<0.5.1: '>=0.5.1' importers: @@ -65,8 +66,8 @@ importers: specifier: ^0.6.1 version: 0.6.1(@types/sortablejs@1.15.9) vue-i18n: - specifier: ^9.14.5 - version: 9.14.5(vue@3.5.43(typescript@5.6.3)) + specifier: ^11.4.16 + version: 11.4.16(vue@3.5.43(typescript@5.6.3)) vue-router: specifier: ^4.2.5 version: 4.6.4(vue@3.5.43(typescript@5.6.3)) @@ -75,8 +76,8 @@ importers: version: 0.18.5 devDependencies: '@intlify/message-compiler': - specifier: 9.14.5 - version: 9.14.5 + specifier: 11.4.16 + version: 11.4.16 '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 @@ -626,17 +627,21 @@ packages: '@iconify/utils@3.1.0': resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} - '@intlify/core-base@9.14.5': - resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==} - engines: {node: '>= 16'} + '@intlify/core-base@11.4.16': + resolution: {integrity: sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==} + engines: {node: '>= 22'} - '@intlify/message-compiler@9.14.5': - resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==} - engines: {node: '>= 16'} + '@intlify/devtools-types@11.4.16': + resolution: {integrity: sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==} + engines: {node: '>= 22'} - '@intlify/shared@9.14.5': - resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} - engines: {node: '>= 16'} + '@intlify/message-compiler@11.4.16': + resolution: {integrity: sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.16': + resolution: {integrity: sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==} + engines: {node: '>= 22'} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -2285,8 +2290,8 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - decode-uri-component@0.4.1: - resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + decode-uri-component@0.5.1: + resolution: {integrity: sha512-vWHQkXgg2cE3BB4rR0c+PYw9BCQzLoNIRMI7AunIkZst74tw/vpPp7VLoYtsR/cy1f55g3XDWUslfeCZjiZJyA==} engines: {node: '>=14.16'} deep-eql@5.0.2: @@ -4411,10 +4416,9 @@ packages: peerDependencies: eslint: '>=6.0.0' - vue-i18n@9.14.5: - resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} - engines: {node: '>= 16'} - deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html + vue-i18n@11.4.16: + resolution: {integrity: sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==} + engines: {node: '>= 22'} peerDependencies: vue: ^3.0.0 @@ -5032,17 +5036,23 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@intlify/core-base@9.14.5': + '@intlify/core-base@11.4.16': + dependencies: + '@intlify/devtools-types': 11.4.16 + '@intlify/message-compiler': 11.4.16 + '@intlify/shared': 11.4.16 + + '@intlify/devtools-types@11.4.16': dependencies: - '@intlify/message-compiler': 9.14.5 - '@intlify/shared': 9.14.5 + '@intlify/core-base': 11.4.16 + '@intlify/shared': 11.4.16 - '@intlify/message-compiler@9.14.5': + '@intlify/message-compiler@11.4.16': dependencies: - '@intlify/shared': 9.14.5 + '@intlify/shared': 11.4.16 source-map-js: 1.2.2 - '@intlify/shared@9.14.5': {} + '@intlify/shared@11.4.16': {} '@isaacs/cliui@8.0.2': dependencies: @@ -6969,7 +6979,7 @@ snapshots: dependencies: character-entities: 2.0.2 - decode-uri-component@0.4.1: {} + decode-uri-component@0.5.1: {} deep-eql@5.0.2: {} @@ -8705,7 +8715,7 @@ snapshots: query-string@9.3.1: dependencies: - decode-uri-component: 0.4.1 + decode-uri-component: 0.5.1 filter-obj: 5.1.0 split-on-first: 3.0.0 @@ -9609,10 +9619,11 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@9.14.5(vue@3.5.43(typescript@5.6.3)): + vue-i18n@11.4.16(vue@3.5.43(typescript@5.6.3)): dependencies: - '@intlify/core-base': 9.14.5 - '@intlify/shared': 9.14.5 + '@intlify/core-base': 11.4.16 + '@intlify/devtools-types': 11.4.16 + '@intlify/shared': 11.4.16 '@vue/devtools-api': 6.6.4 vue: 3.5.43(typescript@5.6.3) diff --git a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts index 853af980ba8d..af7eceec4f73 100644 --- a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts +++ b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts @@ -69,13 +69,13 @@ describe("SubscriptionPlanCard", () => { }); // #4607:管理端保存的单位是复数(months/weeks),此前用户侧只匹配单数 - // 'month',「1 个月」的套餐卡片被显示成「1天」。测试环境的 vue-i18n 为 - // runtime-only 构建,t() 原样返回 key,故按 key 断言单位分支。 + // 'month',「1 个月」的套餐卡片被显示成「1天」。按上方测试消息渲染出的 + // 单位文案断言各分支。 it("renders plural admin-form validity units instead of mislabeled days (#4607)", () => { - expect(mountPlanCard("openai", { validity_days: 1, validity_unit: "months" }).text()).toContain("/ payment.perMonth"); - expect(mountPlanCard("openai", { validity_days: 3, validity_unit: "months" }).text()).toContain("/ 3payment.months"); - expect(mountPlanCard("openai", { validity_days: 2, validity_unit: "weeks" }).text()).toContain("/ 2payment.weeks"); - expect(mountPlanCard("openai", { validity_days: 30, validity_unit: "day" }).text()).toContain("/ 30payment.days"); + expect(mountPlanCard("openai", { validity_days: 1, validity_unit: "months" }).text()).toContain("/ month"); + expect(mountPlanCard("openai", { validity_days: 3, validity_unit: "months" }).text()).toContain("/ 3months"); + expect(mountPlanCard("openai", { validity_days: 2, validity_unit: "weeks" }).text()).toContain("/ 2weeks"); + expect(mountPlanCard("openai", { validity_days: 30, validity_unit: "day" }).text()).toContain("/ 30days"); }); it("uses the configured currency symbol while preserving USD for legacy plans", () => { @@ -126,11 +126,11 @@ describe("SubscriptionPlanCard", () => { "items-center", "justify-end", ])); - expect(badge?.element.parentElement?.textContent).toContain("/ 30payment.days"); + expect(badge?.element.parentElement?.textContent).toContain("/ 30days"); expect(badge?.element.parentElement?.parentElement?.classList).toContain("shrink-0"); expect(price?.element.parentElement?.parentElement?.classList).toContain("shrink-0"); expect(wrapper.get("p").text()).toBe("Includes advanced models and priority support."); - expect(wrapper.get("button").text()).toBe("payment.subscribeNow"); + expect(wrapper.get("button").text()).toBe("Subscribe now"); }); it("keeps short plan titles compact and aligned", () => { @@ -146,6 +146,6 @@ describe("SubscriptionPlanCard", () => { "items-center", "justify-end", ])); - expect(badge?.element.parentElement?.textContent).toContain("/ 30payment.days"); + expect(badge?.element.parentElement?.textContent).toContain("/ 30days"); }); }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5df99b8faa7e..5e109ead0941 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -94,15 +94,11 @@ export default defineConfig(({ mode }) => { resolve: { alias: { '@': resolve(__dirname, 'src'), - // 使用 vue-i18n 运行时版本,避免 CSP unsafe-eval 问题 + // 使用 vue-i18n 运行时版本,避免 CSP unsafe-eval 问题; + // v10 起 JIT 编译恒开启,消息编译为 AST 对象而非 JS 代码,无需 unsafe-eval 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js' } }, - define: { - // 启用 vue-i18n JIT 编译,在 CSP 环境下处理消息插值 - // JIT 编译器生成 AST 对象而非 JS 代码,无需 unsafe-eval - __INTLIFY_JIT_COMPILATION__: true - }, build: { outDir: '../backend/internal/web/dist', emptyOutDir: true,

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