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..80bdf0c73248 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) @@ -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(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) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) @@ -98,6 +100,7 @@ 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) + 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 c853d277e81d..0d90a1822998 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.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 @@ -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.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 @@ -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.49.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.12.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index ff16ab7d1383..5f62bf5c24ac 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.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= @@ -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.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= @@ -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= @@ -604,6 +614,8 @@ golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= 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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b072273de780..616b3d8455ac 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,54 @@ 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. +// 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 keeps MCP and its OAuth endpoints disabled. +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 { @@ -2086,6 +2137,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. @@ -2862,6 +2914,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/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/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..ffa27b1fc997 --- /dev/null +++ b/backend/internal/handler/mcp_oauth_handler.go @@ -0,0 +1,373 @@ +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. 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: h.cfg.MCP.PublicURL} + 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 + } + // 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 + } + } + 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" { + // Unauthenticated registrations are embedded in client IDs, 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() + } + 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..72399d69096b --- /dev/null +++ b/backend/internal/handler/mcp_oauth_handler_test.go @@ -0,0 +1,494 @@ +//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 + clientID string + reads, writes int + grant *service.MCPOAuthGrant +} + +// Resolving a client ID needs its signing key, so key loads count as reads. +func (r *handlerMCPRepo) ClientIDKey(context.Context) ([]byte, error) { + r.reads++ + 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++ + 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{} + 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) + 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() + 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(clientID string) service.MCPOAuthAuthorizeInput { + digest := sha256.Sum256([]byte(strings.Repeat("v", 43))) + 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() + 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}, + {"explicit resource wins", handlerMCPResource, "", "https://stored.example.com", handlerMCPResource}, + {"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", "", "", ""}, + } { + 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}, + {"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) { + 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(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) + 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": 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(), "", "") + 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: 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) + users.user.TotpEnabled = tt.totp + cache.granted, cache.err = tt.granted, tt.cacheErr + 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) + 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 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(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) + 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 TestMCPOAuthHandlerRegistrationPersistsNothing(t *testing.T) { + h, repo, _, _ := newHandlerMCPFixture(t) + 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.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(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) + 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(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) + 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/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/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..3bd7ad6dc77b --- /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 = service.AuditAuthMethodMCPOAuth + +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..c011acb7c000 --- /dev/null +++ b/backend/internal/managementmcp/server.go @@ -0,0 +1,282 @@ +package managementmcp + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "mime" + "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. +// 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} { + 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, 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 { + w.Header().Set("Allow", "POST") + http.Error(w, "This stateless MCP endpoint only supports POST", http.StatusMethodNotAllowed) + return + } + // 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 + } + // 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 { + 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, "") + 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)}}, + 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 } + +// 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 + lower := strings.ToLower(key) + if lower == "key" && (!revealCreatedKey || childPath != "/data/key") { + delete(v, key) + continue + } + if _, sensitive := redactedResultKeys[lower]; sensitive { + 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..8767f3114354 --- /dev/null +++ b/backend/internal/managementmcp/server_test.go @@ -0,0 +1,445 @@ +package managementmcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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, []string{"https://console.example"}))) + 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) + } +} + +// 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))) + })) + _, 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") + } +} + +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) + 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) + `"}` + _, 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 new file mode 100644 index 000000000000..013a9b0697fa --- /dev/null +++ b/backend/internal/repository/mcp_oauth.go @@ -0,0 +1,240 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "sync/atomic" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const mcpOAuthClientIDKeyName = "mcp_oauth_client_id_key" + +type mcpOAuthRepository struct { + client *dbent.Client + db *sql.DB + clientIDKey atomic.Pointer[[]byte] +} + +func NewMCPOAuthRepository(client *dbent.Client, db *sql.DB) service.MCPOAuthRepository { + return &mcpOAuthRepository{client: client, db: db} +} + +// 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 + } + value, _, err := getOrCreateGeneratedSecuritySecret(ctx, r.client, mcpOAuthClientIDKeyName, 32) + if err != nil { + return nil, err + } + 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 + } + 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,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 + } + _, 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 = `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{} + 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 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 WHERE user_id=$1 ORDER BY 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 (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 + } + 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..c58cdb62a568 --- /dev/null +++ b/backend/internal/repository/mcp_oauth_integration_test.go @@ -0,0 +1,273 @@ +//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(integrationEntClient, integrationDB) + oauth := service.NewMCPOAuthService(repo, NewUserRepository(integrationEntClient, integrationDB)) + 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) + }) + + 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 + + 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) + 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{ + 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) +} + +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 TestMCPOAuthIntegrationClientIDKeyConvergesAcrossInstances(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // 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]) + } + + // 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 new file mode 100644 index 000000000000..ace5c3ef2740 --- /dev/null +++ b/backend/internal/repository/mcp_oauth_test.go @@ -0,0 +1,208 @@ +//go:build unit + +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 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) { + 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(nil, 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(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)} + 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(nil, 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(nil, db) + now := time.Now().UTC() + 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)) + 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.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)) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestMCPOAuthRepositoryOwnerAndRevocationBoundaries(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + 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) + 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()) +} + +func TestMCPOAuthRepositoryRevokeUserGrantsCoversEveryActiveGrant(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + 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") + 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/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..b35ac3d48516 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": {}, @@ -160,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 { @@ -179,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/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..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,9 +118,9 @@ func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader s return true } - 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 d817d4659a38..65c3de8888d0 100644 --- a/backend/internal/server/middleware/step_up_test.go +++ b/backend/internal/server/middleware/step_up_test.go @@ -63,6 +63,34 @@ func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_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) { c, rec := newStepUpTestContext(t) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index c0dde5c426ed..8dcc7ab9423f 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, 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 new file mode 100644 index 000000000000..535b9eec1c00 --- /dev/null +++ b/backend/internal/server/routes/mcp.go @@ -0,0 +1,56 @@ +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" +) + +// 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, redisClient *redis.Client, +) { + 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, allowedOrigins) + 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)) + } +} + +// 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) + oauth := v1.Group("/mcp/oauth") + 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_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..f677e19c75eb --- /dev/null +++ b/backend/internal/server/routes/mcp_oauth_test.go @@ -0,0 +1,500 @@ +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 + grants map[string]*service.MCPOAuthGrant + codes map[string]*service.MCPOAuthCode + credentials map[string]*service.MCPOAuthCredential +} + +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 { + 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 +} + +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 +} + +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 (r *mcpOAuthRouteUsers) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) { + return nil, nil +} + +func newMCPRouteOAuthService(role string) (*service.MCPOAuthService, *mcpOAuthRouteRepo, *mcpOAuthRouteUsers) { + repo := &mcpOAuthRouteRepo{ + 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 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 + 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) + // 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, 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()}) + 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, 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} +} + +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)) + 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}}) + 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..c75ac4534229 --- /dev/null +++ b/backend/internal/server/routes/mcp_rate_limit_test.go @@ -0,0 +1,115 @@ +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, rdb) + 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()) + 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) + 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()) + 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) + 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..e1a1e583e60c --- /dev/null +++ b/backend/internal/server/routes/mcp_test.go @@ -0,0 +1,209 @@ +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, 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, nil))) + return outer +} + +type mcpRouteToolPayload struct { + HTTPStatus int `json:"http_status"` + Result json.RawMessage `json:"result"` +} + +type mcpRouteToolResult struct { + Result struct { + 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{ + "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()) + return decodeMCPRouteToolResult(t, w.Body.Bytes()) +} + +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.Payload.Result), "must-not-leak") + if userID == 7 { + require.False(t, result.Result.IsError) + 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.Payload.HTTPStatus) + require.NotContains(t, string(result.Result.Payload.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.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.Payload.HTTPStatus) + require.Contains(t, string(result.Result.Payload.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/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 new file mode 100644 index 000000000000..ff2fedefa715 --- /dev/null +++ b/backend/internal/service/mcp_oauth_service.go @@ -0,0 +1,724 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/url" + "strings" + "time" + "unicode" + "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 + // 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 ( + 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 { + // 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) + 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 + RevokeUserGrants(context.Context, int64, 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 !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") + } + 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") + } + 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 + } + return &MCPOAuthClient{ + ClientID: id, ClientName: input.ClientName, RedirectURIs: redirects, + 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 + } + 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 + } + 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 { + 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 + } + 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) (*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.allowsRedirect(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: 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) { + 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: 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: 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 { + 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.client(ctx, input.ClientID) + if errors.Is(err, ErrMCPOAuthNotFound) { + 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 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 &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 +} + +// 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 + } + 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..8cda7496d45f --- /dev/null +++ b/backend/internal/service/mcp_oauth_service_test.go @@ -0,0 +1,708 @@ +//go:build unit + +package service + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type mcpOAuthMemoryRepo struct { + mu sync.Mutex + clientIDKey []byte + grants map[string]*MCPOAuthGrant + codes map[string]*MCPOAuthCode + credentials map[string]*MCPOAuthCredential + touches int +} + +func (r *mcpOAuthMemoryRepo) ClientIDKey(context.Context) ([]byte, error) { + if r.clientIDKey == nil { + return []byte("mcp-oauth-service-test-client-id-key"), nil + } + return r.clientIDKey, nil +} + +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 + r.touches++ + 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 +} + +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 +} + +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{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) + 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) + 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") +} + +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 TestMCPOAuthRegistrationIsStatelessAndSigned(t *testing.T) { + f := newMCPOAuthFixture(t) + // 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) + } + 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) + 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 472046b04721..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 } @@ -861,6 +865,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..8ba1595a7f61 --- /dev/null +++ b/backend/migrations/249_mcp_oauth.sql @@ -0,0 +1,44 @@ +-- OAuth clients use public-client authorization code + S256 PKCE. No client +-- secrets, authorization codes, access tokens, or refresh tokens are stored raw. +-- 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 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, + 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_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 TEXT 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 268fe575c14a..86dc4c1217e4 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -30,6 +30,12 @@ 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. +# 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 # 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 da1693287d38..af80c9a303b3 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 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: "" + # ============================================================================= # Security Configuration # 安全配置 diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 1165d10b0b2f..fa04046a7580 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 3367caa72423..b6478c4c8c65 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 bb7539f1235a..e3d5f9d69675 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 d10aa00fc686..c1401be5de3e 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..c8d7532b0e6f --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,171 @@ +# 平台管理 MCP(OAuth) + +平台内置远程 MCP 服务,通过 **Streamable HTTP + OAuth 授权码 / S256 PKCE** 接入。客户端只需配置 MCP 地址;连接时打开浏览器登录平台,再明确同意个人或管理员范围、只读或读写权限。无需创建、复制或保存静态令牌。 + +## 部署 + +MCP 默认关闭。只有显式配置稳定、可信的公开 MCP 地址后,才会开放 MCP 资源端点、OAuth 发现及客户端注册接口,侧栏也才会显示 **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`,不带尾斜杠、查询参数或片段;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 白名单。 + +## 连接 + +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。授予**管理员范围的读写**权限时,无论是否启用该开关,都必须已开启 TOTP 并完成一次 step-up 验证。 + +- **个人范围**:只操作当前用户的数据,即使授权人是管理员也不会开放管理工具。 +- **管理员范围**:仅管理员可授予,可使用目录内管理工具及自己的个人工具。 +- **只读**:不列出写工具,直接调用写工具同样会被拒绝。 +- **读写**:开放对应范围内的修改工具,包括删除 API Key、调整余额、停用账户和撤销订阅等操作。 + +OAuth scope 为 `mcp`,实际工具权限由浏览器中确认的授权范围与读写选项决定。刷新不会扩大权限,改变权限需要重新授权。客户端应用名来自注册请求,并不代表平台验证了该应用;授权页面同时显示其回调地址。 + +授权码有效期 5 分钟且只能兑换一次。访问令牌有效期最多 10 分钟;授权及刷新令牌最长 30 天,刷新令牌每次使用都会轮换。重复使用授权码或已消费的刷新令牌会撤销该授权。服务端仅保存凭证摘要。 + +登录平台后,侧栏 **MCP 接入**(`/settings/mcp`)可查看并撤销已授权应用。每用户最多 20 个有效授权。撤销、密码或登录标识变更、用户停用都会阻止后续调用及刷新;管理员失去角色后,其管理员授权也失效。“撤销全部会话”会同时撤销该用户的全部 MCP 授权。停用用户会持久撤销其全部授权,取消管理员角色会持久撤销其管理员授权,之后重新启用或重新授予角色也不会恢复旧授权。页面不显示访问或刷新令牌,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,便于配置应用;不要将该结果公开分享。 + +现有业务权限、资源归属、平台模式限制、管理合规确认、限流和操作审计继续生效。模型返回的建议不会绕过这些规则。MCP 的每次工具调用(包括只读查询)都会写入操作审计,认证方式为 `mcp_oauth`,并附带 `mcp_grant_id` 和 `mcp_tool`。 + +## 参数示例 + +工具参数使用 `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 自动抓取。注册请求体不超过 4 KiB;`client_name` 最多 100 个字符,且不得包含控制字符、双向文本控制符或零宽字符等不可见字符。 + +授权请求和授权码兑换必须携带精确匹配 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 请求只能包含一个 JSON-RPC 对象,JSON-RPC 批量请求(包括未声明 `MCP-Protocol-Version` 的旧版本请求)一律返回 400。每次 HTTP 请求重新校验授权,响应禁止缓存。工具结果以单个文本块返回 JSON `{"http_status": …, "result": …}`,不再重复写入 `structuredContent`;编码后超过 2 MiB 的结果会被拒绝,请缩小查询范围或分页大小。业务 API 失败以工具结果 `isError: true` 返回,原 HTTP 状态位于 `http_status`。工具执行限制 30 秒并传递客户端断开取消;写操作超时后应查询状态再决定是否重试。 + +`/api/v1/mcp` 在认证前先按客户端 IP 限流(每分钟 600 次),认证后再使用独立的按用户入口限流桶;每个实际工具调用只消耗一次管理面全局额度,聚合统计仍受 Heavy 限流。授权的最近使用时间每分钟最多写入一次。动态注册、兑换和撤销接口有独立 IP 限流。上述 IP 限流把同一 IPv6 /64 网段视为一个客户端,Redis 不可用时拒绝请求。 + +## 开发 + +工具目录位于 `backend/internal/managementmcp/catalog.go`。新增工具应绑定已有管理路由,使用封闭参数 schema,并覆盖权限、资源归属、参数边界及凭证脱敏测试。 + +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,浏览器接口沿用平台响应格式。 diff --git a/frontend/package.json b/frontend/package.json index dfba4c37766b..ef38431a73f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,9 +3,6 @@ "private": true, "version": "1.0.0", "type": "module", - "engines": { - "node": ">=22" - }, "scripts": { "dev": "vite", "check:i18n": "vitest run src/i18n/__tests__/localeKeyCompleteness.spec.ts", @@ -35,12 +32,12 @@ "vue": "^3.5.43", "vue-chartjs": "^5.3.0", "vue-draggable-plus": "^0.6.1", - "vue-i18n": "11.4.14", + "vue-i18n": "^11.4.16", "vue-router": "^4.2.5", "xlsx": "^0.18.5" }, "devDependencies": { - "@intlify/message-compiler": "11.4.14", + "@intlify/message-compiler": "11.4.16", "@types/dompurify": "^3.0.5", "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", @@ -70,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 58d06fc5a70a..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: 11.4.14 - version: 11.4.14(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: 11.4.14 - version: 11.4.14 + specifier: 11.4.16 + version: 11.4.16 '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 @@ -626,20 +627,20 @@ packages: '@iconify/utils@3.1.0': resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} - '@intlify/core-base@11.4.14': - resolution: {integrity: sha512-Lox+qRGBLnEYfNz4iajqKpw1zuvSAWl4PtzEk654bEM+0NT1GBy9AbOecxzSh7krsyqn9mGKhgmvtuyBsqS6oQ==} + '@intlify/core-base@11.4.16': + resolution: {integrity: sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==} engines: {node: '>= 22'} - '@intlify/devtools-types@11.4.14': - resolution: {integrity: sha512-EQHRYveQg6yHQ51kePfsFyaW2WW+0hk3FdVxSpLDyuKtqFtsRFoyQR2QzASZPmY767ujs9ipuxc2DiGJARkuiQ==} + '@intlify/devtools-types@11.4.16': + resolution: {integrity: sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==} engines: {node: '>= 22'} - '@intlify/message-compiler@11.4.14': - resolution: {integrity: sha512-y79GatHfMhN672Vooxw0v6mgX/vclFHggO1ENL6aH/QubLXiV529WXu/+FhVUbYF+HLTeDDkQpmnpfvSJddBCg==} + '@intlify/message-compiler@11.4.16': + resolution: {integrity: sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==} engines: {node: '>= 22'} - '@intlify/shared@11.4.14': - resolution: {integrity: sha512-f8zSlPs4+Yd9za3CPQfW9OHxAJBOeRQzB/hhOhkbGA7hlqWk0W0bUtSV/LmyrGNwdl8JriBnFtJqZyF140bjGg==} + '@intlify/shared@11.4.16': + resolution: {integrity: sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==} engines: {node: '>= 22'} '@isaacs/cliui@8.0.2': @@ -2289,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: @@ -4415,8 +4416,8 @@ packages: peerDependencies: eslint: '>=6.0.0' - vue-i18n@11.4.14: - resolution: {integrity: sha512-oRFZtQjRDRQtfxu7tFlsZlnCa5gPkg/KX89kJDKdC9bz4GIYgo3cAMaD0XFeUx/WHuKoVbDMRTIo9Hvg67HnPw==} + vue-i18n@11.4.16: + resolution: {integrity: sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==} engines: {node: '>= 22'} peerDependencies: vue: ^3.0.0 @@ -5035,23 +5036,23 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@intlify/core-base@11.4.14': + '@intlify/core-base@11.4.16': dependencies: - '@intlify/devtools-types': 11.4.14 - '@intlify/message-compiler': 11.4.14 - '@intlify/shared': 11.4.14 + '@intlify/devtools-types': 11.4.16 + '@intlify/message-compiler': 11.4.16 + '@intlify/shared': 11.4.16 - '@intlify/devtools-types@11.4.14': + '@intlify/devtools-types@11.4.16': dependencies: - '@intlify/core-base': 11.4.14 - '@intlify/shared': 11.4.14 + '@intlify/core-base': 11.4.16 + '@intlify/shared': 11.4.16 - '@intlify/message-compiler@11.4.14': + '@intlify/message-compiler@11.4.16': dependencies: - '@intlify/shared': 11.4.14 + '@intlify/shared': 11.4.16 source-map-js: 1.2.2 - '@intlify/shared@11.4.14': {} + '@intlify/shared@11.4.16': {} '@isaacs/cliui@8.0.2': dependencies: @@ -6978,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: {} @@ -8714,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 @@ -9618,11 +9619,11 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@11.4.14(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': 11.4.14 - '@intlify/devtools-types': 11.4.14 - '@intlify/shared': 11.4.14 + '@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/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/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 1d20fef8ff7f..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,6 +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: MCPIcon, featureFlag: flagMCP }, ...customMenuItemsForUser.value.map((item): NavItem => ({ path: `/custom/${item.id}`, label: item.label, @@ -845,6 +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 }) + 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/components/payment/__tests__/SubscriptionPlanCard.spec.ts b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts index 13a7ecd8402a..af7eceec4f73 100644 --- a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts +++ b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts @@ -68,6 +68,9 @@ describe("SubscriptionPlanCard", () => { expect(text).toContain("Imagen"); }); + // #4607:管理端保存的单位是复数(months/weeks),此前用户侧只匹配单数 + // '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("/ month"); expect(mountPlanCard("openai", { validity_days: 3, validity_unit: "months" }).text()).toContain("/ 3months"); 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..9abc39fc80b4 --- /dev/null +++ b/frontend/src/i18n/locales/en/mcp.ts @@ -0,0 +1,61 @@ +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.', + 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.', + 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..e8c9e09a4e09 --- /dev/null +++ b/frontend/src/i18n/locales/zh/mcp.ts @@ -0,0 +1,61 @@ +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 密钥和计费权益。请只授权给你信任的应用。', + adminWriteHint: '平台管理的修改权限始终需要你已开启两步验证(TOTP),并在授权时完成验证。', + 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: '
Login
' } })) +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/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 new file mode 100644 index 000000000000..065a739272c7 --- /dev/null +++ b/frontend/src/views/auth/MCPOAuthAuthorizeView.vue @@ -0,0 +1,203 @@ + + + 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..9ef63e66b3c6 --- /dev/null +++ b/frontend/src/views/auth/__tests__/MCPOAuthAuthorizeView.spec.ts @@ -0,0 +1,228 @@ +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('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' }) + 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..3c50ca1d5142 --- /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) + }) +}) 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,