From 3e2c49db5caa802d9dc124def537eac86782276e Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Tue, 13 May 2025 21:09:53 +0530 Subject: [PATCH 01/19] Update the README.md file to reflect latest MCP spec changes --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4694164..c65e3d4 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,33 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that ![Architecture Diagram](https://github.com/user-attachments/assets/41cf6723-c488-4860-8640-8fec45006f92) -## What it Does - -Open MCP Auth Proxy sits between MCP clients and your MCP server to: +## What it Does? - Intercept incoming requests - Validate authorization tokens - Offload authentication and authorization to OAuth-compliant Identity Providers - Support the MCP authorization protocol -## Quick Start + +## πŸš€ Features + +- **Dynamic Authorization** based on MCP Authorization Specification (v1 and v2). +- **JWT Validation** (signature, audience, and scopes). +- **Identity Provider Integration** (OAuth/OIDC via Asgardeo, Auth0, Keycloak). +- **Protocol Version Negotiation** via `MCP-Protocol-Version` header. +- **Comprehensive Authentication Feedback** via RFC-compliant challenges. +- **Flexible Transport Modes**: SSE and stdio. + +## πŸ“Œ MCP Specification Verions + +| Version | Date | Behavior | +| :------ | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **v1** | *before* 2025-03-26 | Only signature check of Bearer JWT on both `/sse` and `/message`
No scope or audience enforcement | +| **v2** | *on/after* 2025-03-26 | Read `MCP-Protocol-Version` from client header
SSE handshake returns `WWW-Authenticate: Bearer resource_metadata="…"`
`/message` enforces:
1. `aud` claim == `ResourceIdentifier`
2. `scope` claim contains per-path `requiredScope`
3. PolicyEngine decision
Rich `WWW-Authenticate` on 401s
Serves `/​.well-known/oauth-protected-resource` JSON | + +> ⚠️ **Note:** MCP v2 support is available **only in SSE mode**. The stdio mode supports only v1. + +## πŸ› οΈ Quick Start ### Prerequisites @@ -73,7 +90,7 @@ Open MCP Auth Proxy sits between MCP clients and your MCP server to: 3. Connect using an MCP client like [MCP Inspector](https://github.com/shashimalcse/inspector)(This is a temporary fork with fixes for authentication [issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/257) in the original implementation) -## Connect an Identity Provider +## πŸ”’ Integrate an Identity Provider ### Asgardeo @@ -94,6 +111,20 @@ asgardeo: org_name: "" # Your Asgardeo org name client_id: "" # Client ID of the M2M app client_secret: "" # Client secret of the M2M app + + # Only required if you are using the latest version of the MCP specification + resource_identifier: "http://localhost:8080" # URL of the MCP proxy server + authorization_servers: + - "https://example.idp.com" # Base URL of the identity provider + jwks_uri: "https://example.idp.com/.well-known/jwks.json" + bearer_methods_supported: + - header + - body + - query + # Protect the MCP endpoints with per-path scopes: + scopes_supported: + "/message": "mcp_proxy:message" + "/resources/list": "mcp_proxy:read" ``` 4. Start the proxy with Asgardeo integration: @@ -107,7 +138,7 @@ asgardeo: - [Auth0](docs/integrations/Auth0.md) - [Keycloak](docs/integrations/keycloak.md) -# Advanced Configuration +# βš™οΈ Advanced Configuration ### Transport Modes @@ -173,7 +204,7 @@ The proxy will: - Handle all authorization requirements - Forward messages between clients and the server -### Complete Configuration Reference +### πŸ“ Complete Configuration Reference ```yaml # Common configuration @@ -220,6 +251,18 @@ asgardeo: org_name: "" client_id: "" client_secret: "" + # Required according to the latest MCP specification + resource_identifier: "http://localhost:8080" + scopes_supported: + "/get-alerts": "mcp_proxy" + "/get-forecast": "mcp_proxy" + authorization_servers: + - "https://dev-3l9-ppfg.us.auth0.com" + jwks_uri: "https://dev-3l9-ppfg.us.auth0.com/.well-known/jwks.json" + bearer_methods_supported: + - header + - body + - query ``` ## Build from Source From d71ee4052cec4228e54019e728638d1222f00751 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Tue, 13 May 2025 23:58:06 +0530 Subject: [PATCH 02/19] Update MCP proxy to adhere to the latest draft of MCP specification --- cmd/proxy/main.go | 15 +++-- internal/authz/default.go | 23 +++++++ internal/authz/policy.go | 19 ++++++ internal/authz/provider.go | 1 + internal/config/config.go | 8 +++ internal/constants/constants.go | 7 ++ internal/proxy/proxy.go | 115 ++++++++++++++++++++++++++++---- 7 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 internal/authz/policy.go diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 6424f18..f24c21d 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -92,12 +92,15 @@ func main() { os.Exit(1) } - // 5. Build the main router - mux := proxy.NewRouter(cfg, provider) + // 5. (Optional) Build the policy engine + engine := &authz.DefaulPolicyEngine{} + + // 6. Build the main router + mux := proxy.NewRouter(cfg, provider, engine) listen_address := fmt.Sprintf(":%d", cfg.ListenPort) - // 6. Start the server + // 7. Start the server srv := &http.Server{ Addr: listen_address, Handler: mux, @@ -111,18 +114,18 @@ func main() { } }() - // 7. Wait for shutdown signal + // 8. Wait for shutdown signal stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop logger.Info("Shutting down...") - // 8. First terminate subprocess if running + // 9. First terminate subprocess if running if procManager != nil && procManager.IsRunning() { procManager.Shutdown() } - // 9. Then shutdown the server + // 10. Then shutdown the server logger.Info("Shutting down HTTP server...") shutdownCtx, cancel := proxy.NewShutdownContext(5 * time.Second) defer cancel() diff --git a/internal/authz/default.go b/internal/authz/default.go index 929f586..f4d640d 100644 --- a/internal/authz/default.go +++ b/internal/authz/default.go @@ -94,3 +94,26 @@ func (p *defaultProvider) WellKnownHandler() http.HandlerFunc { func (p *defaultProvider) RegisterHandler() http.HandlerFunc { return nil } + +func (p *defaultProvider) ProtectedResourceMetadataHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + meta := map[string]interface{}{ + "resource": p.cfg.ResourceIdentifier, + "scopes_supported": p.cfg.ScopesSupported, + "authorization_servers": p.cfg.AuthorizationServers, + } + + if p.cfg.JwksURI != "" { + meta["jwks_uri"] = p.cfg.JwksURI + } + + if len(p.cfg.BearerMethodsSupported) > 0 { + meta["bearer_methods_supported"] = p.cfg.BearerMethodsSupported + } + + if err := json.NewEncoder(w).Encode(meta); err != nil { + http.Error(w, "failed to encode metadata", http.StatusInternalServerError) + } + } +} diff --git a/internal/authz/policy.go b/internal/authz/policy.go new file mode 100644 index 0000000..793e7bc --- /dev/null +++ b/internal/authz/policy.go @@ -0,0 +1,19 @@ +package authz + +import "net/http" + +type Decision int + +const ( + DecisionAllow Decision = iota + DecisionDeny +) + +type PolicyResult struct { + Decision Decision + Message string +} + +type PolicyEngine interface { + Evaluate(r *http.Request, claims *TokenClaims, requiredScope string) PolicyResult +} diff --git a/internal/authz/provider.go b/internal/authz/provider.go index 1629cf4..42a8343 100644 --- a/internal/authz/provider.go +++ b/internal/authz/provider.go @@ -7,4 +7,5 @@ import "net/http" type Provider interface { WellKnownHandler() http.HandlerFunc RegisterHandler() http.HandlerFunc + ProtectedResourceMetadataHandler() http.HandlerFunc } diff --git a/internal/config/config.go b/internal/config/config.go index c51688f..a48963f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -98,11 +98,19 @@ type Config struct { TransportMode TransportMode `yaml:"transport_mode"` Paths PathsConfig `yaml:"paths"` Stdio StdioConfig `yaml:"stdio"` + RequiredScopes map[string]string `yaml:"required_scopes"` // Nested config for Asgardeo Demo DemoConfig `yaml:"demo"` Asgardeo AsgardeoConfig `yaml:"asgardeo"` Default DefaultConfig `yaml:"default"` + + // Protected resource metadata + ResourceIdentifier string `yaml:"resource_identifier"` + ScopesSupported map[string]string `yaml:"scopes_supported"` + AuthorizationServers []string `yaml:"authorization_servers"` + JwksURI string `yaml:"jwks_uri,omitempty"` + BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"` } // Validate checks if the config is valid based on transport mode diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 1e5808e..e7b1bec 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -1,7 +1,14 @@ package constants +import "time" + // Package constant provides constants for the MCP Auth Proxy const ( ASGARDEO_BASE_URL = "https://api.asgardeo.io/t/" ) + +// MCP specification version cutover date +var SpecCutoverDate = time.Date(2025, 3, 26, 0, 0, 0, 0, time.UTC) + +const TimeLayout = "2006-01-02" diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index f4d0dec..6284331 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "fmt" "net/http" "net/http/httputil" "net/url" @@ -10,6 +11,7 @@ import ( "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" + "github.com/wso2/open-mcp-auth-proxy/internal/constants" logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" "github.com/wso2/open-mcp-auth-proxy/internal/util" ) @@ -17,7 +19,7 @@ import ( // NewRouter builds an http.ServeMux that routes // * /authorize, /token, /register, /.well-known to the provider or proxy // * MCP paths to the MCP server, etc. -func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler { +func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.PolicyEngine) http.Handler { mux := http.NewServeMux() modifiers := map[string]RequestModifier{ @@ -55,6 +57,20 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler { defaultPaths = append(defaultPaths, "/.well-known/oauth-authorization-server") } + mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + allowed := getAllowedOrigin(origin, cfg) + if r.Method == http.MethodOptions { + addCORSHeaders(w, cfg, allowed, r.Header.Get("Access-Control-Request-Headers")) + w.WriteHeader(http.StatusNoContent) + return + } + + addCORSHeaders(w, cfg, allowed, "") + provider.ProtectedResourceMetadataHandler()(w, r) + }) + registeredPaths["/.well-known/oauth-protected-resource"] = true + defaultPaths = append(defaultPaths, "/authorize") defaultPaths = append(defaultPaths, "/token") defaultPaths = append(defaultPaths, "/register") @@ -76,7 +92,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler { for _, path := range defaultPaths { if !registeredPaths[path] { - mux.HandleFunc(path, buildProxyHandler(cfg, modifiers)) + mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, policyEngine)) registeredPaths[path] = true } } @@ -84,14 +100,14 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler { // MCP paths mcpPaths := cfg.GetMCPPaths() for _, path := range mcpPaths { - mux.HandleFunc(path, buildProxyHandler(cfg, modifiers)) + mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, policyEngine)) registeredPaths[path] = true } // Register paths from PathMapping that haven't been registered yet for path := range cfg.PathMapping { if !registeredPaths[path] { - mux.HandleFunc(path, buildProxyHandler(cfg, modifiers)) + mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, policyEngine)) registeredPaths[path] = true } } @@ -99,7 +115,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler { return mux } -func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) http.HandlerFunc { +func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, policyEngine authz.PolicyEngine) http.HandlerFunc { // Parse the base URLs up front authBase, err := url.Parse(cfg.AuthServerBaseURL) if err != nil { @@ -141,6 +157,10 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) // Add CORS headers to all responses addCORSHeaders(w, cfg, allowedOrigin, "") + versionRaw := r.Header.Get("MCP-Protocol-Version") + ver, err := time.Parse(constants.TimeLayout, versionRaw) + isLatestSpec := err == nil && !ver.Before(constants.SpecCutoverDate) + // Decide whether the request should go to the auth server or MCP var targetURL *url.URL isSSE := false @@ -148,13 +168,29 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) if isAuthPath(r.URL.Path) { targetURL = authBase } else if isMCPPath(r.URL.Path, cfg) { - // Validate JWT for MCP paths if required - // Placeholder for JWT validation logic - if err := util.ValidateJWT(r.Header.Get("Authorization")); err != nil { - logger.Warn("Unauthorized request to %s: %v", r.URL.Path, err) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return + if ssePaths[r.URL.Path] { + if err := authorizeSSE(w, r, isLatestSpec, cfg.ResourceIdentifier); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + isSSE = true + } else { + claims, err := authorizeMCP(w, r, isLatestSpec, cfg) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + if isLatestSpec { + scope := cfg.ScopesSupported[r.URL.Path] + pr := policyEngine.Evaluate(r, claims, scope) + if pr.Decision == authz.DecisionDeny { + http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden) + return + } + } } + targetURL = mcpBase if ssePaths[r.URL.Path] { isSSE = true @@ -214,7 +250,17 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) }, ModifyResponse: func(resp *http.Response) error { logger.Debug("Response from %s%s: %d", resp.Request.URL.Host, resp.Request.URL.Path, resp.StatusCode) - resp.Header.Del("Access-Control-Allow-Origin") // Avoid upstream conflicts + if resp.StatusCode == http.StatusUnauthorized { + resp.Header.Set( + "WWW-Authenticate", + fmt.Sprintf( + `Bearer resource_metadata="%s"`, + cfg.ResourceIdentifier+"/.well-known/oauth-protected-resource", + )) + resp.Header.Set("Access-Control-Expose-Headers", "WWW-Authenticate") + } + + resp.Header.Del("Access-Control-Allow-Origin") return nil }, ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { @@ -236,7 +282,7 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - + w.Header().Set("Content-Type", "text/event-stream") // Keep SSE connections open HandleSSE(w, r, rp) } else { @@ -248,6 +294,47 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) } } +// Check if the request is for SSE handshake and authorize it +func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, resourceID string) error { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + if isLatestSpec { + realm := resourceID + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, realm)) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + } + + return fmt.Errorf("missing bearer token") + } + + return nil +} + +// Handles both v1 (just signature) and v2 (aud + scope) flows +func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config) (*authz.TokenClaims, error) { + h := r.Header.Get("Authorization") + audience := cfg.ResourceIdentifier + if isLatestSpec { + scope := cfg.ScopesSupported[r.URL.Path] + claims, err := util.ValidateJWT(r.Header.Get("MCP-Protocol-Version"), h, audience, scope) + if err != nil { + realm := audience + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Bearer realm="%s", error="insufficient_scope", scope="%s"`, realm, scope)) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + return nil, err + } + return claims, nil + } + + // v1: only check signature, then continue + if err := util.ValidateJWTOld(h); err != nil { + return nil, err + } + + return &authz.TokenClaims{}, nil +} + func getAllowedOrigin(origin string, cfg *config.Config) string { if origin == "" { return cfg.CORSConfig.AllowedOrigins[0] // Default to first allowed origin @@ -265,6 +352,7 @@ func getAllowedOrigin(origin string, cfg *config.Config) string { func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, requestHeaders string) { w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.CORSConfig.AllowedMethods, ", ")) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") if requestHeaders != "" { w.Header().Set("Access-Control-Allow-Headers", requestHeaders) } else { @@ -283,6 +371,7 @@ func isAuthPath(path string) bool { "/token": true, "/register": true, "/.well-known/oauth-authorization-server": true, + "/.well-known/oauth-protected-resource": true, } if strings.HasPrefix(path, "/u/") { return true From c65f73a6ce14306576781203f5de602eb85a9309 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 15:39:02 +0530 Subject: [PATCH 03/19] Refactor proxy builder --- config.yaml | 13 +++ internal/authz/asgardeo.go | 20 ++++ internal/authz/default_policy_engine.go | 20 ++++ internal/proxy/proxy.go | 34 +++--- internal/util/jwks.go | 142 ++++++++++++++++++++---- 5 files changed, 197 insertions(+), 32 deletions(-) create mode 100644 internal/authz/default_policy_engine.go diff --git a/config.yaml b/config.yaml index 427fc15..77f68ac 100644 --- a/config.yaml +++ b/config.yaml @@ -46,3 +46,16 @@ demo: org_name: "openmcpauthdemo" client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa" client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka" + +# Protected resource metadata +resource_identifier: http://localhost:3000 +scopes_supported: + - get-alerts + - get-forecast +authorization_servers: + - https://idp.example.com +jwks_uri: https://idp.example.com/.well-known/jwks.json +bearer_methods_supported: + - header + - body + - query \ No newline at end of file diff --git a/internal/authz/asgardeo.go b/internal/authz/asgardeo.go index eb26b83..aa458d5 100644 --- a/internal/authz/asgardeo.go +++ b/internal/authz/asgardeo.go @@ -363,3 +363,23 @@ func randomString(n int) string { } return string(b) } + +func (p *asgardeoProvider) ProtectedResourceMetadataHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + meta := map[string]interface{}{ + "resource": p.cfg.ResourceIdentifier, + "scopes_supported": p.cfg.ScopesSupported, + "authorization_servers": p.cfg.AuthorizationServers, + } + if p.cfg.JwksURI != "" { + meta["jwks_uri"] = p.cfg.JwksURI + } + if len(p.cfg.BearerMethodsSupported) > 0 { + meta["bearer_methods_supported"] = p.cfg.BearerMethodsSupported + } + if err := json.NewEncoder(w).Encode(meta); err != nil { + http.Error(w, "failed to encode metadata", http.StatusInternalServerError) + } + } +} diff --git a/internal/authz/default_policy_engine.go b/internal/authz/default_policy_engine.go new file mode 100644 index 0000000..efc23d2 --- /dev/null +++ b/internal/authz/default_policy_engine.go @@ -0,0 +1,20 @@ +package authz + +import ( + "net/http" +) + +type TokenClaims struct { + Scopes []string +} + +type DefaulPolicyEngine struct{} + +func (d *DefaulPolicyEngine) Evaluate(r *http.Request, claims *TokenClaims, requiredScope string) PolicyResult { + for _, scope := range claims.Scopes { + if scope == requiredScope { + return PolicyResult{DecisionAllow, ""} + } + } + return PolicyResult{DecisionDeny, "missing scope '" + requiredScope + "'"} +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 6284331..220f13e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -177,7 +177,7 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, } else { claims, err := authorizeMCP(w, r, isLatestSpec, cfg) if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + http.Error(w, err.Error(), http.StatusForbidden) return } @@ -296,15 +296,14 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, // Check if the request is for SSE handshake and authorize it func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, resourceID string) error { - h := r.Header.Get("Authorization") - if !strings.HasPrefix(h, "Bearer ") { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if isLatestSpec { realm := resourceID + "/.well-known/oauth-protected-resource" w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, realm)) w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") } - - return fmt.Errorf("missing bearer token") + return fmt.Errorf("missing or invalid Authorization header") } return nil @@ -312,23 +311,31 @@ func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, res // Handles both v1 (just signature) and v2 (aud + scope) flows func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config) (*authz.TokenClaims, error) { + logger.Info("authorizeMCP") h := r.Header.Get("Authorization") audience := cfg.ResourceIdentifier if isLatestSpec { - scope := cfg.ScopesSupported[r.URL.Path] - claims, err := util.ValidateJWT(r.Header.Get("MCP-Protocol-Version"), h, audience, scope) + required := cfg.ScopesSupported[r.URL.Path] + claims, err := util.ValidateJWT(r.Header.Get("MCP-Protocol-Version"), h, audience, required) + logger.Info("claims: %v", claims) + logger.Info("err: %v", err) if err != nil { - realm := audience + "/.well-known/oauth-protected-resource" - w.Header().Set("WWW-Authenticate", - fmt.Sprintf(`Bearer realm="%s", error="insufficient_scope", scope="%s"`, realm, scope)) + w.Header().Set( + "WWW-Authenticate", + fmt.Sprintf( + `Bearer realm="%s", error="insufficient_scope", scope="%s"`, + cfg.ResourceIdentifier+"/.well-known/oauth-protected-resource", + required, + ), + ) w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") - return nil, err + return nil, fmt.Errorf("forbidden β€” insufficient scope") } return claims, nil } // v1: only check signature, then continue - if err := util.ValidateJWTOld(h); err != nil { + if err := util.ValidateJWTLegacy(h); err != nil { return nil, err } @@ -352,7 +359,7 @@ func getAllowedOrigin(origin string, cfg *config.Config) string { func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, requestHeaders string) { w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.CORSConfig.AllowedMethods, ", ")) - w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate, MCP-Protocol-Version") if requestHeaders != "" { w.Header().Set("Access-Control-Allow-Headers", requestHeaders) } else { @@ -360,6 +367,7 @@ func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, re } if cfg.CORSConfig.AllowCredentials { w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("MCP-Protocol-Version", ", ") } w.Header().Set("Vary", "Origin") w.Header().Set("X-Accel-Buffering", "no") diff --git a/internal/util/jwks.go b/internal/util/jwks.go index f80d82e..a050427 100644 --- a/internal/util/jwks.go +++ b/internal/util/jwks.go @@ -4,21 +4,29 @@ import ( "crypto/rsa" "encoding/json" "errors" + "fmt" "math/big" "net/http" "strings" + "time" "github.com/golang-jwt/jwt/v4" - "github.com/wso2/open-mcp-auth-proxy/internal/logging" + "github.com/wso2/open-mcp-auth-proxy/internal/authz" + "github.com/wso2/open-mcp-auth-proxy/internal/constants" + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) +type TokenClaims struct { + Scopes []string +} + type JWKS struct { Keys []json.RawMessage `json:"keys"` } var publicKeys map[string]*rsa.PublicKey -// FetchJWKS downloads JWKS and stores in a package-level map +// FetchJWKS downloads JWKS and stores in a package‐level map func FetchJWKS(jwksURL string) error { resp, err := http.Get(jwksURL) if err != nil { @@ -31,23 +39,23 @@ func FetchJWKS(jwksURL string) error { return err } - publicKeys = make(map[string]*rsa.PublicKey) + publicKeys = make(map[string]*rsa.PublicKey, len(jwks.Keys)) for _, keyData := range jwks.Keys { - var parsedKey struct { + var parsed struct { Kid string `json:"kid"` N string `json:"n"` E string `json:"e"` Kty string `json:"kty"` } - if err := json.Unmarshal(keyData, &parsedKey); err != nil { + if err := json.Unmarshal(keyData, &parsed); err != nil { continue } - if parsedKey.Kty != "RSA" { + if parsed.Kty != "RSA" { continue } - pubKey, err := parseRSAPublicKey(parsedKey.N, parsedKey.E) + pk, err := parseRSAPublicKey(parsed.N, parsed.E) if err == nil { - publicKeys[parsedKey.Kid] = pubKey + publicKeys[parsed.Kid] = pk } } logger.Info("Loaded %d public keys.", len(publicKeys)) @@ -73,25 +81,121 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { return &rsa.PublicKey{N: n, E: e}, nil } -// ValidateJWT checks the Authorization: Bearer token using stored JWKS -func ValidateJWT(authHeader string) error { - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - return errors.New("missing or invalid Authorization header") - } +// ValidateJWT checks the Bearer token according to the Mcp-Protocol-Version. +// - versionHeader: the raw value of the "Mcp-Protocol-Version" header +// - authHeader: the full "Authorization" header +// - audience: the resource identifier to check "aud" against +// - requiredScope: the single scope required (empty β‡’ skip scope check) +func ValidateJWT( + versionHeader, authHeader, audience, requiredScope string, +) (*authz.TokenClaims, error) { tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if tokenStr == "" { + return nil, errors.New("empty bearer token") + } + + // 2) parse & verify signature token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { kid, _ := token.Header["kid"].(string) - pubKey, ok := publicKeys[kid] + pk, ok := publicKeys[kid] if !ok { - return nil, errors.New("unknown or missing kid in token header") + return nil, fmt.Errorf("unknown kid %q", kid) } - return pubKey, nil + return pk, nil }) + + logger.Info("token: %v", token) + logger.Info("err: %v", err) + if err != nil { - return errors.New("invalid token: " + err.Error()) + return nil, fmt.Errorf("invalid token: %w", err) } if !token.Valid { - return errors.New("invalid token: token not valid") + return nil, errors.New("token not valid") } - return nil + + // always extract claims + claimsMap, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("unexpected claim type") + } + + // parse version date + verDate, err := time.Parse("2006-01-02", versionHeader) + if err != nil { + // if unparsable or missing, assume _old_ spec + verDate = time.Time{} // zero time β‡’ before cutover + } + + // if older than cutover, skip audience+scope + if verDate.Before(constants.SpecCutoverDate) { + return &authz.TokenClaims{Scopes: nil}, nil + } + + // --- new spec flow: enforce audience --- + audRaw, exists := claimsMap["aud"] + if !exists { + return nil, errors.New("aud claim missing") + } + switch v := audRaw.(type) { + case string: + if v != audience { + return nil, fmt.Errorf("aud %q does not match %q", v, audience) + } + case []interface{}: + var found bool + for _, a := range v { + if s, ok := a.(string); ok && s == audience { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("audience %v does not include %q", v, audience) + } + default: + return nil, errors.New("aud claim has unexpected type") + } + + // if no scope required, we're done + if requiredScope == "" { + return &authz.TokenClaims{Scopes: nil}, nil + } + + // enforce scope + rawScope, exists := claimsMap["scope"] + if !exists { + return nil, errors.New("scope claim missing") + } + scopeStr, ok := rawScope.(string) + if !ok { + return nil, errors.New("scope claim not a string") + } + scopes := strings.Fields(scopeStr) + for _, s := range scopes { + if s == requiredScope { + return &authz.TokenClaims{Scopes: scopes}, nil + } + } + return nil, fmt.Errorf("insufficient scope: %q not in %v", requiredScope, scopes) +} + +// Performs basic JWT validation +func ValidateJWTLegacy(authHeader string) error { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + _, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, errors.New("kid header not found") + } + key, ok := publicKeys[kid] + if !ok { + return nil, fmt.Errorf("key not found for kid: %s", kid) + } + return key, nil + }) + return err } From ba19c7363aee970bcae2a61566ed114663d92172 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 21:47:15 +0530 Subject: [PATCH 04/19] Fix audience validation issues --- cmd/proxy/main.go | 2 +- internal/authz/default.go | 1 + internal/authz/default_policy_engine.go | 39 +++++++++++-- internal/config/config.go | 1 + internal/proxy/proxy.go | 78 +++++++++++++++---------- internal/util/jwks.go | 54 +++++------------ internal/util/rpc.go | 39 +++++++++++++ internal/util/version.go | 25 ++++++++ 8 files changed, 163 insertions(+), 76 deletions(-) create mode 100644 internal/util/rpc.go create mode 100644 internal/util/version.go diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index f24c21d..562e7aa 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -93,7 +93,7 @@ func main() { } // 5. (Optional) Build the policy engine - engine := &authz.DefaulPolicyEngine{} + engine := &authz.DefaultPolicyEngine{} // 6. Build the main router mux := proxy.NewRouter(cfg, provider, engine) diff --git a/internal/authz/default.go b/internal/authz/default.go index f4d640d..dc8900d 100644 --- a/internal/authz/default.go +++ b/internal/authz/default.go @@ -99,6 +99,7 @@ func (p *defaultProvider) ProtectedResourceMetadataHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") meta := map[string]interface{}{ + "audience": p.cfg.Audience, "resource": p.cfg.ResourceIdentifier, "scopes_supported": p.cfg.ScopesSupported, "authorization_servers": p.cfg.AuthorizationServers, diff --git a/internal/authz/default_policy_engine.go b/internal/authz/default_policy_engine.go index efc23d2..d9efe12 100644 --- a/internal/authz/default_policy_engine.go +++ b/internal/authz/default_policy_engine.go @@ -1,20 +1,49 @@ package authz import ( + "fmt" "net/http" + "strings" ) type TokenClaims struct { Scopes []string } -type DefaulPolicyEngine struct{} +type DefaultPolicyEngine struct{} -func (d *DefaulPolicyEngine) Evaluate(r *http.Request, claims *TokenClaims, requiredScope string) PolicyResult { - for _, scope := range claims.Scopes { - if scope == requiredScope { +// Evaluate and checks the token claims against one or more required scopes. +func (d *DefaultPolicyEngine) Evaluate( + _ *http.Request, + claims *TokenClaims, + requiredScope string, +) PolicyResult { + if strings.TrimSpace(requiredScope) == "" { + return PolicyResult{DecisionAllow, ""} + } + + raw := strings.FieldsFunc(requiredScope, func(r rune) bool { + return r == ' ' || r == ',' + }) + want := make(map[string]struct{}, len(raw)) + for _, s := range raw { + if s = strings.TrimSpace(s); s != "" { + want[s] = struct{}{} + } + } + + for _, have := range claims.Scopes { + if _, ok := want[have]; ok { return PolicyResult{DecisionAllow, ""} } } - return PolicyResult{DecisionDeny, "missing scope '" + requiredScope + "'"} + + var list []string + for s := range want { + list = append(list, s) + } + return PolicyResult{ + DecisionDeny, + fmt.Sprintf("missing required scope(s): %s", strings.Join(list, ", ")), + } } diff --git a/internal/config/config.go b/internal/config/config.go index a48963f..7f08755 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,6 +106,7 @@ type Config struct { Default DefaultConfig `yaml:"default"` // Protected resource metadata + Audience string `yaml:"audience"` ResourceIdentifier string `yaml:"resource_identifier"` ScopesSupported map[string]string `yaml:"scopes_supported"` AuthorizationServers []string `yaml:"authorization_servers"` diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 220f13e..9eb8729 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,7 +11,6 @@ import ( "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" - "github.com/wso2/open-mcp-auth-proxy/internal/constants" logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" "github.com/wso2/open-mcp-auth-proxy/internal/util" ) @@ -157,9 +156,10 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, // Add CORS headers to all responses addCORSHeaders(w, cfg, allowedOrigin, "") - versionRaw := r.Header.Get("MCP-Protocol-Version") - ver, err := time.Parse(constants.TimeLayout, versionRaw) - isLatestSpec := err == nil && !ver.Before(constants.SpecCutoverDate) + // Check if the request is for the latest spec + specVersion := util.GetVersionWithDefault(r.Header.Get("MCP-Protocol-Version")) + ver, err := util.ParseVersionDate(specVersion) + isLatestSpec := util.IsLatestSpec(ver, err) // Decide whether the request should go to the auth server or MCP var targetURL *url.URL @@ -311,35 +311,53 @@ func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, res // Handles both v1 (just signature) and v2 (aud + scope) flows func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config) (*authz.TokenClaims, error) { - logger.Info("authorizeMCP") - h := r.Header.Get("Authorization") - audience := cfg.ResourceIdentifier - if isLatestSpec { - required := cfg.ScopesSupported[r.URL.Path] - claims, err := util.ValidateJWT(r.Header.Get("MCP-Protocol-Version"), h, audience, required) - logger.Info("claims: %v", claims) - logger.Info("err: %v", err) - if err != nil { - w.Header().Set( - "WWW-Authenticate", - fmt.Sprintf( - `Bearer realm="%s", error="insufficient_scope", scope="%s"`, - cfg.ResourceIdentifier+"/.well-known/oauth-protected-resource", - required, - ), - ) - w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") - return nil, fmt.Errorf("forbidden β€” insufficient scope") - } - return claims, nil - } - - // v1: only check signature, then continue - if err := util.ValidateJWTLegacy(h); err != nil { + // Parse JSON-RPC request if present + if env, err := util.ParseRPCRequest(r); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) return nil, err + } else if env != nil { + logger.Info("JSON-RPC method = %q", env.Method) } - return &authz.TokenClaims{}, nil + authzHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authzHeader, "Bearer ") { + if isLatestSpec { + realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf( + `Bearer resource_metadata=%q`, realm, + )) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + } + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, fmt.Errorf("missing or invalid Authorization header") + } + + requiredScope := "" + if isLatestSpec { + requiredScope = cfg.ScopesSupported[r.URL.Path] + } + claims, err := util.ValidateJWT( + isLatestSpec, + authzHeader, + cfg.Audience, + requiredScope, + ) + if err != nil { + if isLatestSpec { + realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf( + `Bearer realm=%q, error="insufficient_scope", scope=%q`, + realm, requiredScope, + )) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + http.Error(w, "Forbidden", http.StatusForbidden) + } else { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } + return nil, err + } + + return claims, nil } func getAllowedOrigin(origin string, cfg *config.Config) string { diff --git a/internal/util/jwks.go b/internal/util/jwks.go index a050427..40d72bf 100644 --- a/internal/util/jwks.go +++ b/internal/util/jwks.go @@ -8,12 +8,10 @@ import ( "math/big" "net/http" "strings" - "time" "github.com/golang-jwt/jwt/v4" "github.com/wso2/open-mcp-auth-proxy/internal/authz" - "github.com/wso2/open-mcp-auth-proxy/internal/constants" - logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" + "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type TokenClaims struct { @@ -87,7 +85,7 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { // - audience: the resource identifier to check "aud" against // - requiredScope: the single scope required (empty β‡’ skip scope check) func ValidateJWT( - versionHeader, authHeader, audience, requiredScope string, + isLatestSpec bool, authHeader, audience, requiredScope string, ) (*authz.TokenClaims, error) { tokenStr := strings.TrimPrefix(authHeader, "Bearer ") if tokenStr == "" { @@ -96,17 +94,20 @@ func ValidateJWT( // 2) parse & verify signature token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { - kid, _ := token.Header["kid"].(string) - pk, ok := publicKeys[kid] - if !ok { - return nil, fmt.Errorf("unknown kid %q", kid) + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return pk, nil + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, errors.New("kid header not found") + } + key, ok := publicKeys[kid] + if !ok { + return nil, fmt.Errorf("key not found for kid: %s", kid) + } + return key, nil }) - logger.Info("token: %v", token) - logger.Info("err: %v", err) - if err != nil { return nil, fmt.Errorf("invalid token: %w", err) } @@ -120,15 +121,8 @@ func ValidateJWT( return nil, errors.New("unexpected claim type") } - // parse version date - verDate, err := time.Parse("2006-01-02", versionHeader) - if err != nil { - // if unparsable or missing, assume _old_ spec - verDate = time.Time{} // zero time β‡’ before cutover - } - // if older than cutover, skip audience+scope - if verDate.Before(constants.SpecCutoverDate) { + if !isLatestSpec { return &authz.TokenClaims{Scopes: nil}, nil } @@ -179,23 +173,3 @@ func ValidateJWT( } return nil, fmt.Errorf("insufficient scope: %q not in %v", requiredScope, scopes) } - -// Performs basic JWT validation -func ValidateJWTLegacy(authHeader string) error { - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - _, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - kid, ok := token.Header["kid"].(string) - if !ok { - return nil, errors.New("kid header not found") - } - key, ok := publicKeys[kid] - if !ok { - return nil, fmt.Errorf("key not found for kid: %s", kid) - } - return key, nil - }) - return err -} diff --git a/internal/util/rpc.go b/internal/util/rpc.go new file mode 100644 index 0000000..896e9b2 --- /dev/null +++ b/internal/util/rpc.go @@ -0,0 +1,39 @@ +package util + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" +) + +type RPCEnvelope struct { + Method string `json:"method"` + Params any `json:"params"` + ID any `json:"id"` +} + +// This function parses a JSON-RPC request from an HTTP request body +func ParseRPCRequest(r *http.Request) (*RPCEnvelope, error) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if len(bodyBytes) == 0 { + return nil, nil + } + + var env RPCEnvelope + dec := json.NewDecoder(bytes.NewReader(bodyBytes)) + if err := dec.Decode(&env); err != nil && err != io.EOF { + logger.Warn("Error parsing JSON-RPC envelope: %v", err) + return nil, err + } + + logger.Info("JSON-RPC method = %q", env.Method) + return &env, nil +} diff --git a/internal/util/version.go b/internal/util/version.go new file mode 100644 index 0000000..acf381a --- /dev/null +++ b/internal/util/version.go @@ -0,0 +1,25 @@ +package util + +import ( + "time" + + "github.com/wso2/open-mcp-auth-proxy/internal/constants" +) + +// This function checks if the given version date is after the spec cutover date +func IsLatestSpec(versionDate time.Time, err error) bool { + return err == nil && !versionDate.Before(constants.SpecCutoverDate) +} + +// This function parses a version string into a time.Time +func ParseVersionDate(version string) (time.Time, error) { + return time.Parse("2006-01-02", version) +} + +// This function returns the version string, using the cutover date if empty +func GetVersionWithDefault(version string) string { + if version == "" { + return constants.SpecCutoverDate.Format("2006-01-02") + } + return version +} From c5cfecf243c9ef278cbda3eaafa8ee41ab8750e9 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 22:18:41 +0530 Subject: [PATCH 05/19] Fix minor issue --- internal/util/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/util/version.go b/internal/util/version.go index acf381a..04ed2ce 100644 --- a/internal/util/version.go +++ b/internal/util/version.go @@ -8,7 +8,7 @@ import ( // This function checks if the given version date is after the spec cutover date func IsLatestSpec(versionDate time.Time, err error) bool { - return err == nil && !versionDate.Before(constants.SpecCutoverDate) + return err == nil && !versionDate.After(constants.SpecCutoverDate) } // This function parses a version string into a time.Time From 5601c7836c5d9bf211ed73b667ec552ac3fbcfbe Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 22:36:46 +0530 Subject: [PATCH 06/19] Update config.yaml file --- config.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/config.yaml b/config.yaml index 77f68ac..f6674af 100644 --- a/config.yaml +++ b/config.yaml @@ -49,12 +49,14 @@ demo: # Protected resource metadata resource_identifier: http://localhost:3000 +audience: mcp_proxy scopes_supported: - - get-alerts - - get-forecast + "read:tools" + "read:resources" + "read:prompts" authorization_servers: - - https://idp.example.com -jwks_uri: https://idp.example.com/.well-known/jwks.json + - https://api.asgardeo.io/t/acme/ +jwks_uri: https://api.asgardeo.io/t/acme/oauth2/jwks bearer_methods_supported: - header - body From 21805b4f0baaeded7a35244c2c2b3b0c483c7354 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 01:20:29 +0530 Subject: [PATCH 07/19] Refactor scope validation --- config.yaml | 6 +- internal/authz/default_policy_engine.go | 32 +++++--- internal/authz/policy.go | 10 +-- internal/config/config.go | 12 +-- internal/proxy/proxy.go | 100 +++++++++++------------- internal/util/jwks.go | 66 +++++++++------- internal/util/rpc.go | 1 - 7 files changed, 120 insertions(+), 107 deletions(-) diff --git a/config.yaml b/config.yaml index f6674af..9d57905 100644 --- a/config.yaml +++ b/config.yaml @@ -51,9 +51,9 @@ demo: resource_identifier: http://localhost:3000 audience: mcp_proxy scopes_supported: - "read:tools" - "read:resources" - "read:prompts" + - "tools":"read:tools" + - "resources":"read:resources" + - "prompts":"read:prompts" authorization_servers: - https://api.asgardeo.io/t/acme/ jwks_uri: https://api.asgardeo.io/t/acme/oauth2/jwks diff --git a/internal/authz/default_policy_engine.go b/internal/authz/default_policy_engine.go index d9efe12..6f002e6 100644 --- a/internal/authz/default_policy_engine.go +++ b/internal/authz/default_policy_engine.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "strings" + + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type TokenClaims struct { @@ -16,30 +18,42 @@ type DefaultPolicyEngine struct{} func (d *DefaultPolicyEngine) Evaluate( _ *http.Request, claims *TokenClaims, - requiredScope string, + requiredScopes any, ) PolicyResult { - if strings.TrimSpace(requiredScope) == "" { + + logger.Info("Required scopes: %v", requiredScopes) + + var scopeStr string + switch v := requiredScopes.(type) { + case string: + scopeStr = v + case []string: + scopeStr = strings.Join(v, " ") + } + + if strings.TrimSpace(scopeStr) == "" { return PolicyResult{DecisionAllow, ""} } - raw := strings.FieldsFunc(requiredScope, func(r rune) bool { + scopes := strings.FieldsFunc(scopeStr, func(r rune) bool { return r == ' ' || r == ',' }) - want := make(map[string]struct{}, len(raw)) - for _, s := range raw { + required := make(map[string]struct{}, len(scopes)) + for _, s := range scopes { if s = strings.TrimSpace(s); s != "" { - want[s] = struct{}{} + required[s] = struct{}{} } } - for _, have := range claims.Scopes { - if _, ok := want[have]; ok { + logger.Info("Token scopes: %v", claims.Scopes) + for _, tokenScope := range claims.Scopes { + if _, ok := required[tokenScope]; ok { return PolicyResult{DecisionAllow, ""} } } var list []string - for s := range want { + for s := range required { list = append(list, s) } return PolicyResult{ diff --git a/internal/authz/policy.go b/internal/authz/policy.go index 793e7bc..5995250 100644 --- a/internal/authz/policy.go +++ b/internal/authz/policy.go @@ -5,15 +5,15 @@ import "net/http" type Decision int const ( - DecisionAllow Decision = iota - DecisionDeny + DecisionAllow Decision = iota + DecisionDeny ) type PolicyResult struct { - Decision Decision - Message string + Decision Decision + Message string } type PolicyEngine interface { - Evaluate(r *http.Request, claims *TokenClaims, requiredScope string) PolicyResult + Evaluate(r *http.Request, claims *TokenClaims, requiredScopes any) PolicyResult } diff --git a/internal/config/config.go b/internal/config/config.go index 7f08755..dea7a79 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,12 +106,12 @@ type Config struct { Default DefaultConfig `yaml:"default"` // Protected resource metadata - Audience string `yaml:"audience"` - ResourceIdentifier string `yaml:"resource_identifier"` - ScopesSupported map[string]string `yaml:"scopes_supported"` - AuthorizationServers []string `yaml:"authorization_servers"` - JwksURI string `yaml:"jwks_uri,omitempty"` - BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"` + Audience string `yaml:"audience"` + ResourceIdentifier string `yaml:"resource_identifier"` + ScopesSupported any `yaml:"scopes_supported"` + AuthorizationServers []string `yaml:"authorization_servers"` + JwksURI string `yaml:"jwks_uri,omitempty"` + BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"` } // Validate checks if the config is valid based on transport mode diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 9eb8729..377f164 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -175,20 +175,10 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, } isSSE = true } else { - claims, err := authorizeMCP(w, r, isLatestSpec, cfg) - if err != nil { + if err := authorizeMCP(w, r, isLatestSpec, cfg, policyEngine); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } - - if isLatestSpec { - scope := cfg.ScopesSupported[r.URL.Path] - pr := policyEngine.Evaluate(r, claims, scope) - if pr.Decision == authz.DecisionDeny { - http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden) - return - } - } } targetURL = mcpBase @@ -310,54 +300,54 @@ func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, res } // Handles both v1 (just signature) and v2 (aud + scope) flows -func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config) (*authz.TokenClaims, error) { - // Parse JSON-RPC request if present - if env, err := util.ParseRPCRequest(r); err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) - return nil, err - } else if env != nil { - logger.Info("JSON-RPC method = %q", env.Method) +func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config, policyEngine authz.PolicyEngine) error { + authzHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authzHeader, "Bearer ") { + if isLatestSpec { + realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf( + `Bearer resource_metadata=%q`, realm, + )) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + } + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return fmt.Errorf("missing or invalid Authorization header") } - authzHeader := r.Header.Get("Authorization") - if !strings.HasPrefix(authzHeader, "Bearer ") { - if isLatestSpec { - realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" - w.Header().Set("WWW-Authenticate", fmt.Sprintf( - `Bearer resource_metadata=%q`, realm, - )) - w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") - } - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return nil, fmt.Errorf("missing or invalid Authorization header") - } + claims, err := util.ValidateJWT(isLatestSpec, authzHeader, cfg.Audience) + if err != nil { + if isLatestSpec { + realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf(err.Error(), + `Bearer realm=%q`, + realm, + )) + w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") + http.Error(w, "Forbidden", http.StatusForbidden) + } else { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } + return err + } - requiredScope := "" - if isLatestSpec { - requiredScope = cfg.ScopesSupported[r.URL.Path] - } - claims, err := util.ValidateJWT( - isLatestSpec, - authzHeader, - cfg.Audience, - requiredScope, - ) - if err != nil { - if isLatestSpec { - realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" - w.Header().Set("WWW-Authenticate", fmt.Sprintf( - `Bearer realm=%q, error="insufficient_scope", scope=%q`, - realm, requiredScope, - )) - w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") - http.Error(w, "Forbidden", http.StatusForbidden) - } else { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - } - return nil, err - } + if isLatestSpec { + env, err := util.ParseRPCRequest(r) + if err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return err + } + requiredScopes := util.GetRequiredScopes(cfg, env.Method) + if len(requiredScopes) == 0 { + return nil + } + pr := policyEngine.Evaluate(r, claims, requiredScopes) + if pr.Decision == authz.DecisionDeny { + http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden) + return fmt.Errorf("forbidden β€” %s", pr.Message) + } + } - return claims, nil + return nil } func getAllowedOrigin(origin string, cfg *config.Config) string { diff --git a/internal/util/jwks.go b/internal/util/jwks.go index 40d72bf..0692057 100644 --- a/internal/util/jwks.go +++ b/internal/util/jwks.go @@ -11,7 +11,8 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/wso2/open-mcp-auth-proxy/internal/authz" - "github.com/wso2/open-mcp-auth-proxy/internal/logging" + "github.com/wso2/open-mcp-auth-proxy/internal/config" + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type TokenClaims struct { @@ -80,19 +81,20 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { } // ValidateJWT checks the Bearer token according to the Mcp-Protocol-Version. -// - versionHeader: the raw value of the "Mcp-Protocol-Version" header +// - isLatestSpec: whether to use the latest spec validation // - authHeader: the full "Authorization" header // - audience: the resource identifier to check "aud" against -// - requiredScope: the single scope required (empty β‡’ skip scope check) +// - requiredScopes: the scopes required (empty β‡’ skip scope check) func ValidateJWT( - isLatestSpec bool, authHeader, audience, requiredScope string, + isLatestSpec bool, + authHeader, audience string, ) (*authz.TokenClaims, error) { tokenStr := strings.TrimPrefix(authHeader, "Bearer ") if tokenStr == "" { return nil, errors.New("empty bearer token") } - // 2) parse & verify signature + // --- parse & verify signature --- token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -107,7 +109,6 @@ func ValidateJWT( } return key, nil }) - if err != nil { return nil, fmt.Errorf("invalid token: %w", err) } @@ -115,18 +116,19 @@ func ValidateJWT( return nil, errors.New("token not valid") } - // always extract claims + // --- extract raw claims --- claimsMap, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("unexpected claim type") } - // if older than cutover, skip audience+scope + // --- v1: skip audience check entirely --- if !isLatestSpec { + // we still want to return an empty set of scopes for policy to see return &authz.TokenClaims{Scopes: nil}, nil } - // --- new spec flow: enforce audience --- + // --- v2: enforce audience --- audRaw, exists := claimsMap["aud"] if !exists { return nil, errors.New("aud claim missing") @@ -151,25 +153,33 @@ func ValidateJWT( return nil, errors.New("aud claim has unexpected type") } - // if no scope required, we're done - if requiredScope == "" { - return &authz.TokenClaims{Scopes: nil}, nil + // --- collect all scopes from the token, if any --- + rawScope := claimsMap["scope"] + scopeList := []string{} + if s, ok := rawScope.(string); ok { + scopeList = strings.Fields(s) } - // enforce scope - rawScope, exists := claimsMap["scope"] - if !exists { - return nil, errors.New("scope claim missing") - } - scopeStr, ok := rawScope.(string) - if !ok { - return nil, errors.New("scope claim not a string") - } - scopes := strings.Fields(scopeStr) - for _, s := range scopes { - if s == requiredScope { - return &authz.TokenClaims{Scopes: scopes}, nil - } - } - return nil, fmt.Errorf("insufficient scope: %q not in %v", requiredScope, scopes) + return &authz.TokenClaims{Scopes: scopeList}, nil +} + +// Process the required scopes +func GetRequiredScopes(cfg *config.Config, method string) []string { + if scopes, ok := cfg.ScopesSupported.(map[string]string); ok && len(scopes) > 0 { + if scope, ok := scopes[method]; ok { + return []string{scope} + } + if parts := strings.SplitN(method, "/", 2); len(parts) > 0 { + if scope, ok := scopes[parts[0]]; ok { + return []string{scope} + } + } + return nil + } + + if scopes, ok := cfg.ScopesSupported.([]string); ok && len(scopes) > 0 { + return scopes + } + + return []string{} } diff --git a/internal/util/rpc.go b/internal/util/rpc.go index 896e9b2..5338437 100644 --- a/internal/util/rpc.go +++ b/internal/util/rpc.go @@ -34,6 +34,5 @@ func ParseRPCRequest(r *http.Request) (*RPCEnvelope, error) { return nil, err } - logger.Info("JSON-RPC method = %q", env.Method) return &env, nil } From 7120886e9b233a71ca543065ae8698950b7237f2 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 01:32:27 +0530 Subject: [PATCH 08/19] Update the README.md file --- README.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c65e3d4..3fa7115 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that ## πŸš€ Features -- **Dynamic Authorization** based on MCP Authorization Specification (v1 and v2). +- **Dynamic Authorization** based on MCP Authorization Specification. - **JWT Validation** (signature, audience, and scopes). - **Identity Provider Integration** (OAuth/OIDC via Asgardeo, Auth0, Keycloak). - **Protocol Version Negotiation** via `MCP-Protocol-Version` header. @@ -29,10 +29,10 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that ## πŸ“Œ MCP Specification Verions -| Version | Date | Behavior | -| :------ | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **v1** | *before* 2025-03-26 | Only signature check of Bearer JWT on both `/sse` and `/message`
No scope or audience enforcement | -| **v2** | *on/after* 2025-03-26 | Read `MCP-Protocol-Version` from client header
SSE handshake returns `WWW-Authenticate: Bearer resource_metadata="…"`
`/message` enforces:
1. `aud` claim == `ResourceIdentifier`
2. `scope` claim contains per-path `requiredScope`
3. PolicyEngine decision
Rich `WWW-Authenticate` on 401s
Serves `/​.well-known/oauth-protected-resource` JSON | +| Version | Behavior | +| :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2025-03-26 | Only signature check of Bearer JWT on both `/sse` and `/message`
No scope or audience enforcement | +| Latest(draft) | Read `MCP-Protocol-Version` from client header
SSE handshake returns `WWW-Authenticate: Bearer resource_metadata="…"`
`/message` enforces:
1. `aud` claim == `ResourceIdentifier`
2. `scope` claim contains per-path `requiredScope`
3. PolicyEngine decision
Rich `WWW-Authenticate` on 401s
Serves `/​.well-known/oauth-protected-resource` JSON | > ⚠️ **Note:** MCP v2 support is available **only in SSE mode**. The stdio mode supports only v1. @@ -112,7 +112,6 @@ asgardeo: client_id: "" # Client ID of the M2M app client_secret: "" # Client secret of the M2M app - # Only required if you are using the latest version of the MCP specification resource_identifier: "http://localhost:8080" # URL of the MCP proxy server authorization_servers: - "https://example.idp.com" # Base URL of the identity provider @@ -251,14 +250,14 @@ asgardeo: org_name: "" client_id: "" client_secret: "" - # Required according to the latest MCP specification resource_identifier: "http://localhost:8080" - scopes_supported: - "/get-alerts": "mcp_proxy" - "/get-forecast": "mcp_proxy" + scopes_supported: # Define the required scopes for the MCP server + "tools": "read:tools" + "resources": "read:resources" + audience: "" authorization_servers: - - "https://dev-3l9-ppfg.us.auth0.com" - jwks_uri: "https://dev-3l9-ppfg.us.auth0.com/.well-known/jwks.json" + - "https://api.asgardeo.io/t/acme" + jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" bearer_methods_supported: - header - body From 1ab421dc81152f95a37adec06f908e3d5becf1cc Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 11:14:34 +0530 Subject: [PATCH 09/19] Fix minor issue --- internal/util/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/util/version.go b/internal/util/version.go index 04ed2ce..f330016 100644 --- a/internal/util/version.go +++ b/internal/util/version.go @@ -8,7 +8,7 @@ import ( // This function checks if the given version date is after the spec cutover date func IsLatestSpec(versionDate time.Time, err error) bool { - return err == nil && !versionDate.After(constants.SpecCutoverDate) + return err == nil && versionDate.After(constants.SpecCutoverDate) } // This function parses a version string into a time.Time From 885b32ee808cfb09f284c4c31a059677c372a07f Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 11:22:10 +0530 Subject: [PATCH 10/19] Fix minor formatting issues --- config.yaml | 3 ++- internal/proxy/proxy.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index 9d57905..a22338c 100644 --- a/config.yaml +++ b/config.yaml @@ -60,4 +60,5 @@ jwks_uri: https://api.asgardeo.io/t/acme/oauth2/jwks bearer_methods_supported: - header - body - - query \ No newline at end of file + - query + \ No newline at end of file diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 377f164..9ec8211 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,7 +11,7 @@ import ( "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" - logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" + "github.com/wso2/open-mcp-auth-proxy/internal/logging" "github.com/wso2/open-mcp-auth-proxy/internal/util" ) From 2c175869a9cc2b34d6abdffa30480f5f69454e2b Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Sun, 18 May 2025 13:07:08 +0530 Subject: [PATCH 11/19] Update scope validator --- cmd/proxy/main.go | 6 +-- internal/authz/access_control.go | 19 ++++++++ internal/authz/policy.go | 19 -------- ...lt_policy_engine.go => scope_validator.go} | 12 ++--- internal/proxy/proxy.go | 44 +++++++++---------- internal/util/version.go | 3 +- 6 files changed, 52 insertions(+), 51 deletions(-) create mode 100644 internal/authz/access_control.go delete mode 100644 internal/authz/policy.go rename internal/authz/{default_policy_engine.go => scope_validator.go} (83%) diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 562e7aa..2583b75 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -92,11 +92,11 @@ func main() { os.Exit(1) } - // 5. (Optional) Build the policy engine - engine := &authz.DefaultPolicyEngine{} + // 5. (Optional) Build the access controler + accessController := &authz.ScopeValidator{} // 6. Build the main router - mux := proxy.NewRouter(cfg, provider, engine) + mux := proxy.NewRouter(cfg, provider, accessController) listen_address := fmt.Sprintf(":%d", cfg.ListenPort) diff --git a/internal/authz/access_control.go b/internal/authz/access_control.go new file mode 100644 index 0000000..2e321c3 --- /dev/null +++ b/internal/authz/access_control.go @@ -0,0 +1,19 @@ +package authz + +import "net/http" + +type Decision int + +const ( + DecisionAllow Decision = iota + DecisionDeny +) + +type AccessControlResult struct { + Decision Decision + Message string +} + +type AccessControl interface { + ValidateAccess(r *http.Request, claims *TokenClaims, requiredScopes any) AccessControlResult +} diff --git a/internal/authz/policy.go b/internal/authz/policy.go deleted file mode 100644 index 5995250..0000000 --- a/internal/authz/policy.go +++ /dev/null @@ -1,19 +0,0 @@ -package authz - -import "net/http" - -type Decision int - -const ( - DecisionAllow Decision = iota - DecisionDeny -) - -type PolicyResult struct { - Decision Decision - Message string -} - -type PolicyEngine interface { - Evaluate(r *http.Request, claims *TokenClaims, requiredScopes any) PolicyResult -} diff --git a/internal/authz/default_policy_engine.go b/internal/authz/scope_validator.go similarity index 83% rename from internal/authz/default_policy_engine.go rename to internal/authz/scope_validator.go index 6f002e6..248cf8a 100644 --- a/internal/authz/default_policy_engine.go +++ b/internal/authz/scope_validator.go @@ -12,14 +12,14 @@ type TokenClaims struct { Scopes []string } -type DefaultPolicyEngine struct{} +type ScopeValidator struct{} // Evaluate and checks the token claims against one or more required scopes. -func (d *DefaultPolicyEngine) Evaluate( +func (d *ScopeValidator) ValidateAccess( _ *http.Request, claims *TokenClaims, requiredScopes any, -) PolicyResult { +) AccessControlResult { logger.Info("Required scopes: %v", requiredScopes) @@ -32,7 +32,7 @@ func (d *DefaultPolicyEngine) Evaluate( } if strings.TrimSpace(scopeStr) == "" { - return PolicyResult{DecisionAllow, ""} + return AccessControlResult{DecisionAllow, ""} } scopes := strings.FieldsFunc(scopeStr, func(r rune) bool { @@ -48,7 +48,7 @@ func (d *DefaultPolicyEngine) Evaluate( logger.Info("Token scopes: %v", claims.Scopes) for _, tokenScope := range claims.Scopes { if _, ok := required[tokenScope]; ok { - return PolicyResult{DecisionAllow, ""} + return AccessControlResult{DecisionAllow, ""} } } @@ -56,7 +56,7 @@ func (d *DefaultPolicyEngine) Evaluate( for s := range required { list = append(list, s) } - return PolicyResult{ + return AccessControlResult{ DecisionDeny, fmt.Sprintf("missing required scope(s): %s", strings.Join(list, ", ")), } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 9ec8211..83aeb6e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -18,7 +18,7 @@ import ( // NewRouter builds an http.ServeMux that routes // * /authorize, /token, /register, /.well-known to the provider or proxy // * MCP paths to the MCP server, etc. -func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.PolicyEngine) http.Handler { +func NewRouter(cfg *config.Config, provider authz.Provider, accessController authz.AccessControl) http.Handler { mux := http.NewServeMux() modifiers := map[string]RequestModifier{ @@ -56,20 +56,6 @@ func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.P defaultPaths = append(defaultPaths, "/.well-known/oauth-authorization-server") } - mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - allowed := getAllowedOrigin(origin, cfg) - if r.Method == http.MethodOptions { - addCORSHeaders(w, cfg, allowed, r.Header.Get("Access-Control-Request-Headers")) - w.WriteHeader(http.StatusNoContent) - return - } - - addCORSHeaders(w, cfg, allowed, "") - provider.ProtectedResourceMetadataHandler()(w, r) - }) - registeredPaths["/.well-known/oauth-protected-resource"] = true - defaultPaths = append(defaultPaths, "/authorize") defaultPaths = append(defaultPaths, "/token") defaultPaths = append(defaultPaths, "/register") @@ -78,6 +64,20 @@ func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.P } } + mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + allowed := getAllowedOrigin(origin, cfg) + if r.Method == http.MethodOptions { + addCORSHeaders(w, cfg, allowed, r.Header.Get("Access-Control-Request-Headers")) + w.WriteHeader(http.StatusNoContent) + return + } + + addCORSHeaders(w, cfg, allowed, "") + provider.ProtectedResourceMetadataHandler()(w, r) + }) + registeredPaths["/.well-known/oauth-protected-resource"] = true + // Remove duplicates from defaultPaths uniquePaths := make(map[string]bool) cleanPaths := []string{} @@ -91,7 +91,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.P for _, path := range defaultPaths { if !registeredPaths[path] { - mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, policyEngine)) + mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, accessController)) registeredPaths[path] = true } } @@ -99,14 +99,14 @@ func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.P // MCP paths mcpPaths := cfg.GetMCPPaths() for _, path := range mcpPaths { - mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, policyEngine)) + mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, accessController)) registeredPaths[path] = true } // Register paths from PathMapping that haven't been registered yet for path := range cfg.PathMapping { if !registeredPaths[path] { - mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, policyEngine)) + mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, accessController)) registeredPaths[path] = true } } @@ -114,7 +114,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider, policyEngine authz.P return mux } -func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, policyEngine authz.PolicyEngine) http.HandlerFunc { +func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, accessController authz.AccessControl) http.HandlerFunc { // Parse the base URLs up front authBase, err := url.Parse(cfg.AuthServerBaseURL) if err != nil { @@ -175,7 +175,7 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, } isSSE = true } else { - if err := authorizeMCP(w, r, isLatestSpec, cfg, policyEngine); err != nil { + if err := authorizeMCP(w, r, isLatestSpec, cfg, accessController); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } @@ -300,7 +300,7 @@ func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, res } // Handles both v1 (just signature) and v2 (aud + scope) flows -func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config, policyEngine authz.PolicyEngine) error { +func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config, accessController authz.AccessControl) error { authzHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authzHeader, "Bearer ") { if isLatestSpec { @@ -340,7 +340,7 @@ func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg if len(requiredScopes) == 0 { return nil } - pr := policyEngine.Evaluate(r, claims, requiredScopes) + pr := accessController.ValidateAccess(r, claims, requiredScopes) if pr.Decision == authz.DecisionDeny { http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden) return fmt.Errorf("forbidden β€” %s", pr.Message) diff --git a/internal/util/version.go b/internal/util/version.go index f330016..230ef1d 100644 --- a/internal/util/version.go +++ b/internal/util/version.go @@ -19,7 +19,8 @@ func ParseVersionDate(version string) (time.Time, error) { // This function returns the version string, using the cutover date if empty func GetVersionWithDefault(version string) string { if version == "" { - return constants.SpecCutoverDate.Format("2006-01-02") + defaultTime, _ := time.Parse(constants.TimeLayout, "2025-05-15") + return defaultTime.Format(constants.TimeLayout) } return version } From 5b13c7167f267dd3a67013335fa679ecbcf546d6 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Sun, 18 May 2025 13:23:15 +0530 Subject: [PATCH 12/19] Fix minor formatting issues --- config.yaml | 1 - internal/authz/scope_validator.go | 4 ---- internal/util/jwks.go | 17 ++++------------- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/config.yaml b/config.yaml index a22338c..88c6117 100644 --- a/config.yaml +++ b/config.yaml @@ -61,4 +61,3 @@ bearer_methods_supported: - header - body - query - \ No newline at end of file diff --git a/internal/authz/scope_validator.go b/internal/authz/scope_validator.go index 248cf8a..779a044 100644 --- a/internal/authz/scope_validator.go +++ b/internal/authz/scope_validator.go @@ -20,9 +20,6 @@ func (d *ScopeValidator) ValidateAccess( claims *TokenClaims, requiredScopes any, ) AccessControlResult { - - logger.Info("Required scopes: %v", requiredScopes) - var scopeStr string switch v := requiredScopes.(type) { case string: @@ -45,7 +42,6 @@ func (d *ScopeValidator) ValidateAccess( } } - logger.Info("Token scopes: %v", claims.Scopes) for _, tokenScope := range claims.Scopes { if _, ok := required[tokenScope]; ok { return AccessControlResult{DecisionAllow, ""} diff --git a/internal/util/jwks.go b/internal/util/jwks.go index 0692057..54ca735 100644 --- a/internal/util/jwks.go +++ b/internal/util/jwks.go @@ -12,7 +12,7 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" - logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" + "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type TokenClaims struct { @@ -52,9 +52,9 @@ func FetchJWKS(jwksURL string) error { if parsed.Kty != "RSA" { continue } - pk, err := parseRSAPublicKey(parsed.N, parsed.E) + pubKey, err := parseRSAPublicKey(parsed.N, parsed.E) if err == nil { - publicKeys[parsed.Kid] = pk + publicKeys[parsed.Kid] = pubKey } } logger.Info("Loaded %d public keys.", len(publicKeys)) @@ -81,10 +81,6 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { } // ValidateJWT checks the Bearer token according to the Mcp-Protocol-Version. -// - isLatestSpec: whether to use the latest spec validation -// - authHeader: the full "Authorization" header -// - audience: the resource identifier to check "aud" against -// - requiredScopes: the scopes required (empty β‡’ skip scope check) func ValidateJWT( isLatestSpec bool, authHeader, audience string, @@ -94,7 +90,7 @@ func ValidateJWT( return nil, errors.New("empty bearer token") } - // --- parse & verify signature --- + // Parse & verify the signature token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -116,19 +112,15 @@ func ValidateJWT( return nil, errors.New("token not valid") } - // --- extract raw claims --- claimsMap, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("unexpected claim type") } - // --- v1: skip audience check entirely --- if !isLatestSpec { - // we still want to return an empty set of scopes for policy to see return &authz.TokenClaims{Scopes: nil}, nil } - // --- v2: enforce audience --- audRaw, exists := claimsMap["aud"] if !exists { return nil, errors.New("aud claim missing") @@ -153,7 +145,6 @@ func ValidateJWT( return nil, errors.New("aud claim has unexpected type") } - // --- collect all scopes from the token, if any --- rawScope := claimsMap["scope"] scopeList := []string{} if s, ok := rawScope.(string); ok { From 31fce13ca4cddce8682e9d94abba0b428e6f248c Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Sun, 18 May 2025 20:33:39 +0530 Subject: [PATCH 13/19] Update the README file --- README.md | 22 +++++++++++----------- internal/authz/scope_validator.go | 2 -- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3fa7115..7e0b35e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that ![Architecture Diagram](https://github.com/user-attachments/assets/41cf6723-c488-4860-8640-8fec45006f92) -## What it Does? +## πŸ›‘οΈ What it Does? - Intercept incoming requests - Validate authorization tokens @@ -112,18 +112,18 @@ asgardeo: client_id: "" # Client ID of the M2M app client_secret: "" # Client secret of the M2M app - resource_identifier: "http://localhost:8080" # URL of the MCP proxy server + resource_identifier: "http://localhost:8080" + scopes_supported: + - "read:tools" + - "read:resources" + audience: "" authorization_servers: - - "https://example.idp.com" # Base URL of the identity provider - jwks_uri: "https://example.idp.com/.well-known/jwks.json" + - "https://api.asgardeo.io/t/acme" + jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" bearer_methods_supported: - header - body - query - # Protect the MCP endpoints with per-path scopes: - scopes_supported: - "/message": "mcp_proxy:message" - "/resources/list": "mcp_proxy:read" ``` 4. Start the proxy with Asgardeo integration: @@ -251,9 +251,9 @@ asgardeo: client_id: "" client_secret: "" resource_identifier: "http://localhost:8080" - scopes_supported: # Define the required scopes for the MCP server - "tools": "read:tools" - "resources": "read:resources" + scopes_supported: + - "read:tools" + - "read:resources" audience: "" authorization_servers: - "https://api.asgardeo.io/t/acme" diff --git a/internal/authz/scope_validator.go b/internal/authz/scope_validator.go index 779a044..03ef3bf 100644 --- a/internal/authz/scope_validator.go +++ b/internal/authz/scope_validator.go @@ -4,8 +4,6 @@ import ( "fmt" "net/http" "strings" - - logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type TokenClaims struct { From a2d775a902492668fab65739ec6d6c951ca969d5 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 21 May 2025 10:00:01 +0530 Subject: [PATCH 14/19] Update scope validation implementation --- README.md | 57 ++++++---------- cmd/proxy/main.go | 19 +----- cmd/proxy/provider.go | 45 +++++++++++++ internal/authz/access_control.go | 9 ++- internal/authz/scope_validator.go | 86 ++++++++++++++---------- internal/proxy/proxy.go | 16 +++-- internal/util/jwks.go | 108 +++++++++++++++++++----------- 7 files changed, 202 insertions(+), 138 deletions(-) create mode 100644 cmd/proxy/provider.go diff --git a/README.md b/README.md index 7e0b35e..f15aca8 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,7 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that | Version | Behavior | | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2025-03-26 | Only signature check of Bearer JWT on both `/sse` and `/message`
No scope or audience enforcement | -| Latest(draft) | Read `MCP-Protocol-Version` from client header
SSE handshake returns `WWW-Authenticate: Bearer resource_metadata="…"`
`/message` enforces:
1. `aud` claim == `ResourceIdentifier`
2. `scope` claim contains per-path `requiredScope`
3. PolicyEngine decision
Rich `WWW-Authenticate` on 401s
Serves `/​.well-known/oauth-protected-resource` JSON | - -> ⚠️ **Note:** MCP v2 support is available **only in SSE mode**. The stdio mode supports only v1. +| Latest(draft) | Read `MCP-Protocol-Version` from client header
SSE handshake returns `WWW-Authenticate: Bearer resource_metadata="…"`
`/message` enforces:
`aud` claim == `ResourceIdentifier`
`scope` claim contains `requiredScope`
Scope based access control
Rich `WWW-Authenticate` on 401s
Serves `/​.well-known/oauth-protected-resource` JSON | ## πŸ› οΈ Quick Start @@ -104,26 +102,17 @@ To enable authorization through your Asgardeo organization: 3. Update `config.yaml` with the following parameters. ```yaml -base_url: "http://localhost:8000" # URL of your MCP server -listen_port: 8080 # Address where the proxy will listen +base_url: "http://localhost:8000" # URL of your MCP server +listen_port: 8080 # Address where the proxy will listen -asgardeo: - org_name: "" # Your Asgardeo org name - client_id: "" # Client ID of the M2M app - client_secret: "" # Client secret of the M2M app - - resource_identifier: "http://localhost:8080" - scopes_supported: - - "read:tools" - - "read:resources" - audience: "" - authorization_servers: - - "https://api.asgardeo.io/t/acme" - jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" - bearer_methods_supported: - - header - - body - - query +resource_identifier: "http://localhost:8080" # Proxy server URL +scopes_supported: # Scopes required to access the MCP server +- "read:tools" +- "read:resources" +audience: "" # Access token audience +authorization_servers: # Authorization server URL +- "https://api.asgardeo.io/t/acme" +jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" # JWKS URL of the Authorization server ``` 4. Start the proxy with Asgardeo integration: @@ -246,22 +235,14 @@ demo: client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka" # Asgardeo configuration (used with --asgardeo flag) -asgardeo: - org_name: "" - client_id: "" - client_secret: "" - resource_identifier: "http://localhost:8080" - scopes_supported: - - "read:tools" - - "read:resources" - audience: "" - authorization_servers: - - "https://api.asgardeo.io/t/acme" - jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" - bearer_methods_supported: - - header - - body - - query +resource_identifier: "http://localhost:8080" +scopes_supported: +- "read:tools" +- "read:resources" +audience: "" +authorization_servers: +- "https://api.asgardeo.io/t/acme" +jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" ``` ## Build from Source diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 2583b75..0208ead 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -11,7 +11,6 @@ import ( "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" - "github.com/wso2/open-mcp-auth-proxy/internal/constants" "github.com/wso2/open-mcp-auth-proxy/internal/logging" "github.com/wso2/open-mcp-auth-proxy/internal/proxy" "github.com/wso2/open-mcp-auth-proxy/internal/subprocess" @@ -68,23 +67,7 @@ func main() { } // 3. Create the chosen provider - var provider authz.Provider - if *demoMode { - cfg.Mode = "demo" - cfg.AuthServerBaseURL = constants.ASGARDEO_BASE_URL + cfg.Demo.OrgName + "/oauth2" - cfg.JWKSURL = constants.ASGARDEO_BASE_URL + cfg.Demo.OrgName + "/oauth2/jwks" - provider = authz.NewAsgardeoProvider(cfg) - } else if *asgardeoMode { - cfg.Mode = "asgardeo" - cfg.AuthServerBaseURL = constants.ASGARDEO_BASE_URL + cfg.Asgardeo.OrgName + "/oauth2" - cfg.JWKSURL = constants.ASGARDEO_BASE_URL + cfg.Asgardeo.OrgName + "/oauth2/jwks" - provider = authz.NewAsgardeoProvider(cfg) - } else { - cfg.Mode = "default" - cfg.JWKSURL = cfg.Default.JWKSURL - cfg.AuthServerBaseURL = cfg.Default.BaseURL - provider = authz.NewDefaultProvider(cfg) - } + var provider authz.Provider = MakeProvider(cfg, *demoMode, *asgardeoMode) // 4. (Optional) Fetch JWKS if you want local JWT validation if err := util.FetchJWKS(cfg.JWKSURL); err != nil { diff --git a/cmd/proxy/provider.go b/cmd/proxy/provider.go new file mode 100644 index 0000000..be4ee21 --- /dev/null +++ b/cmd/proxy/provider.go @@ -0,0 +1,45 @@ +package main + +import ( + "github.com/wso2/open-mcp-auth-proxy/internal/authz" + "github.com/wso2/open-mcp-auth-proxy/internal/config" + "github.com/wso2/open-mcp-auth-proxy/internal/constants" +) + +func MakeProvider(cfg *config.Config, demoMode, asgardeoMode bool) authz.Provider { + var mode, orgName string + switch { + case demoMode: + mode = "demo" + orgName = cfg.Demo.OrgName + case asgardeoMode: + mode = "asgardeo" + orgName = cfg.Asgardeo.OrgName + default: + mode = "default" + } + cfg.Mode = mode + + switch mode { + case "demo", "asgardeo": + if len(cfg.AuthorizationServers) == 0 && cfg.JwksURI == "" { + base := constants.ASGARDEO_BASE_URL + orgName + "/oauth2" + cfg.AuthServerBaseURL = base + cfg.JWKSURL = base + "/jwks" + } else { + cfg.AuthServerBaseURL = cfg.AuthorizationServers[0] + cfg.JWKSURL = cfg.JwksURI + } + return authz.NewAsgardeoProvider(cfg) + + default: + if cfg.Default.BaseURL != "" && cfg.Default.JWKSURL != "" { + cfg.AuthServerBaseURL = cfg.Default.BaseURL + cfg.JWKSURL = cfg.Default.JWKSURL + } else if len(cfg.AuthorizationServers) > 0 { + cfg.AuthServerBaseURL = cfg.AuthorizationServers[0] + cfg.JWKSURL = cfg.JwksURI + } + return authz.NewDefaultProvider(cfg) + } +} \ No newline at end of file diff --git a/internal/authz/access_control.go b/internal/authz/access_control.go index 2e321c3..1f7ce7b 100644 --- a/internal/authz/access_control.go +++ b/internal/authz/access_control.go @@ -1,6 +1,11 @@ package authz -import "net/http" +import ( + "net/http" + + "github.com/golang-jwt/jwt/v4" + "github.com/wso2/open-mcp-auth-proxy/internal/config" +) type Decision int @@ -15,5 +20,5 @@ type AccessControlResult struct { } type AccessControl interface { - ValidateAccess(r *http.Request, claims *TokenClaims, requiredScopes any) AccessControlResult + ValidateAccess(r *http.Request, claims *jwt.MapClaims, config *config.Config) AccessControlResult } diff --git a/internal/authz/scope_validator.go b/internal/authz/scope_validator.go index 03ef3bf..004fd80 100644 --- a/internal/authz/scope_validator.go +++ b/internal/authz/scope_validator.go @@ -4,54 +4,68 @@ import ( "fmt" "net/http" "strings" -) -type TokenClaims struct { - Scopes []string -} + "github.com/golang-jwt/jwt/v4" + "github.com/wso2/open-mcp-auth-proxy/internal/config" + "github.com/wso2/open-mcp-auth-proxy/internal/util" +) type ScopeValidator struct{} // Evaluate and checks the token claims against one or more required scopes. func (d *ScopeValidator) ValidateAccess( - _ *http.Request, - claims *TokenClaims, - requiredScopes any, + r *http.Request, + claims *jwt.MapClaims, + config *config.Config, ) AccessControlResult { - var scopeStr string - switch v := requiredScopes.(type) { - case string: - scopeStr = v - case []string: - scopeStr = strings.Join(v, " ") + env, err := util.ParseRPCRequest(r) + if err != nil { + return AccessControlResult{DecisionDeny, "bad JSON-RPC request"} + } + requiredScopes := util.GetRequiredScopes(config, env.Method) + if len(requiredScopes) == 0 { + return AccessControlResult{DecisionAllow, ""} + } + + required := make(map[string]struct{}, len(requiredScopes)) + for _, s := range requiredScopes { + s = strings.TrimSpace(s) + if s != "" { + required[s] = struct{}{} + } + } + + var tokenScopes []string + if claims, ok := (*claims)["scope"]; ok { + switch v := claims.(type) { + case string: + tokenScopes = strings.Fields(v) + case []interface{}: + for _, x := range v { + if s, ok := x.(string); ok && s != "" { + tokenScopes = append(tokenScopes, s) + } + } + } + } + + tokenScopeSet := make(map[string]struct{}, len(tokenScopes)) + for _, s := range tokenScopes { + tokenScopeSet[s] = struct{}{} } - if strings.TrimSpace(scopeStr) == "" { - return AccessControlResult{DecisionAllow, ""} - } - - scopes := strings.FieldsFunc(scopeStr, func(r rune) bool { - return r == ' ' || r == ',' - }) - required := make(map[string]struct{}, len(scopes)) - for _, s := range scopes { - if s = strings.TrimSpace(s); s != "" { - required[s] = struct{}{} - } - } - - for _, tokenScope := range claims.Scopes { - if _, ok := required[tokenScope]; ok { - return AccessControlResult{DecisionAllow, ""} - } - } - - var list []string + var missing []string for s := range required { - list = append(list, s) + if _, ok := tokenScopeSet[s]; !ok { + missing = append(missing, s) + } + } + + if len(missing) == 0 { + return AccessControlResult{DecisionAllow, ""} } return AccessControlResult{ DecisionDeny, - fmt.Sprintf("missing required scope(s): %s", strings.Join(list, ", ")), + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 83aeb6e..b880f99 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -302,6 +302,7 @@ func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, res // Handles both v1 (just signature) and v2 (aud + scope) flows func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config, accessController authz.AccessControl) error { authzHeader := r.Header.Get("Authorization") + accessToken, _ := util.ExtractAccessToken(authzHeader) if !strings.HasPrefix(authzHeader, "Bearer ") { if isLatestSpec { realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" @@ -314,7 +315,7 @@ func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg return fmt.Errorf("missing or invalid Authorization header") } - claims, err := util.ValidateJWT(isLatestSpec, authzHeader, cfg.Audience) + err := util.ValidateJWT(isLatestSpec, accessToken, cfg.Audience) if err != nil { if isLatestSpec { realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" @@ -331,16 +332,19 @@ func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg } if isLatestSpec { - env, err := util.ParseRPCRequest(r) + _, err := util.ParseRPCRequest(r) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return err } - requiredScopes := util.GetRequiredScopes(cfg, env.Method) - if len(requiredScopes) == 0 { - return nil + + claimsMap, err := util.ParseJWT(accessToken) + if err != nil { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return fmt.Errorf("invalid token claims") } - pr := accessController.ValidateAccess(r, claims, requiredScopes) + + pr := accessController.ValidateAccess(r, &claimsMap, cfg) if pr.Decision == authz.DecisionDeny { http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden) return fmt.Errorf("forbidden β€” %s", pr.Message) diff --git a/internal/util/jwks.go b/internal/util/jwks.go index 54ca735..b1afb6f 100644 --- a/internal/util/jwks.go +++ b/internal/util/jwks.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/golang-jwt/jwt/v4" - "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) @@ -83,15 +82,12 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { // ValidateJWT checks the Bearer token according to the Mcp-Protocol-Version. func ValidateJWT( isLatestSpec bool, - authHeader, audience string, -) (*authz.TokenClaims, error) { - tokenStr := strings.TrimPrefix(authHeader, "Bearer ") - if tokenStr == "" { - return nil, errors.New("empty bearer token") - } - + accessToken string, + audience string, +) error { + logger.Warn("isLatestSpec: %s", isLatestSpec) // Parse & verify the signature - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } @@ -106,29 +102,31 @@ func ValidateJWT( return key, nil }) if err != nil { - return nil, fmt.Errorf("invalid token: %w", err) + logger.Warn("Error detected, returning early") + return fmt.Errorf("invalid token: %w", err) } if !token.Valid { - return nil, errors.New("token not valid") + logger.Warn("Token invalid, returning early") + return errors.New("token not valid") } claimsMap, ok := token.Claims.(jwt.MapClaims) if !ok { - return nil, errors.New("unexpected claim type") + return errors.New("unexpected claim type") } if !isLatestSpec { - return &authz.TokenClaims{Scopes: nil}, nil + return nil } audRaw, exists := claimsMap["aud"] if !exists { - return nil, errors.New("aud claim missing") + return errors.New("aud claim missing") } switch v := audRaw.(type) { case string: if v != audience { - return nil, fmt.Errorf("aud %q does not match %q", v, audience) + return fmt.Errorf("aud %q does not match %q", v, audience) } case []interface{}: var found bool @@ -139,38 +137,72 @@ func ValidateJWT( } } if !found { - return nil, fmt.Errorf("audience %v does not include %q", v, audience) + return fmt.Errorf("audience %v does not include %q", v, audience) } default: - return nil, errors.New("aud claim has unexpected type") + return errors.New("aud claim has unexpected type") } - rawScope := claimsMap["scope"] - scopeList := []string{} - if s, ok := rawScope.(string); ok { - scopeList = strings.Fields(s) - } + return nil +} - return &authz.TokenClaims{Scopes: scopeList}, nil +// Parses the JWT token and returns the claims +func ParseJWT(tokenStr string) (jwt.MapClaims, error) { + if tokenStr == "" { + return nil, fmt.Errorf("empty JWT") + } + + var claims jwt.MapClaims + _, _, err := jwt.NewParser().ParseUnverified(tokenStr, &claims) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT: %w", err) + } + return claims, nil } // Process the required scopes func GetRequiredScopes(cfg *config.Config, method string) []string { - if scopes, ok := cfg.ScopesSupported.(map[string]string); ok && len(scopes) > 0 { - if scope, ok := scopes[method]; ok { - return []string{scope} - } - if parts := strings.SplitN(method, "/", 2); len(parts) > 0 { - if scope, ok := scopes[parts[0]]; ok { - return []string{scope} - } - } - return nil - } + switch raw := cfg.ScopesSupported.(type) { + case map[string]string: + if scope, ok := raw[method]; ok { + return []string{scope} + } + parts := strings.SplitN(method, "/", 2) + if len(parts) > 0 { + if scope, ok := raw[parts[0]]; ok { + return []string{scope} + } + } + return nil + case []interface{}: + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + return out - if scopes, ok := cfg.ScopesSupported.([]string); ok && len(scopes) > 0 { - return scopes - } + case []string: + return raw + } - return []string{} + return nil +} + +// Extracts the Bearer token from the Authorization header +func ExtractAccessToken(authHeader string) (string, error) { + if authHeader == "" { + return "", errors.New("empty authorization header") + } + if !strings.HasPrefix(authHeader, "Bearer ") { + return "", fmt.Errorf("invalid authorization header format: %s", authHeader) + } + + tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if tokenStr == "" { + return "", errors.New("empty bearer token") + } + + return tokenStr, nil } From 2cad797aeef96bda38861922fa4872b92e776b2a Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 21 May 2025 13:42:13 +0530 Subject: [PATCH 15/19] Update the README file --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f15aca8..7b707ff 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,13 @@ base_url: "http://localhost:8000" # URL of your MCP listen_port: 8080 # Address where the proxy will listen resource_identifier: "http://localhost:8080" # Proxy server URL -scopes_supported: # Scopes required to access the MCP server +scopes_supported: # Scopes required to defined for the MCP server - "read:tools" - "read:resources" audience: "" # Access token audience -authorization_servers: # Authorization server URL +authorization_servers: # Authorization server issuer identifier(s) - "https://api.asgardeo.io/t/acme" -jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" # JWKS URL of the Authorization server +jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" # JWKS URL ``` 4. Start the proxy with Asgardeo integration: From b30aa6273c2ffada950ed333e47b66782c62f26c Mon Sep 17 00:00:00 2001 From: Pavindu Lakshan Date: Wed, 6 Aug 2025 14:50:20 +0530 Subject: [PATCH 16/19] Improve readme --- CONTRIBUTING.md | 62 +++++++++++ README.md | 261 +++++--------------------------------------- resources/README.md | 21 ++++ 3 files changed, 108 insertions(+), 236 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 resources/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3375ec0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +## Build from Source + +> Prerequisites +> +> * Go 1.20 or higher +> * Git +> * Make (optional, for simplified builds) + +1. **Clone the repository:** + ```bash + git clone https://github.com/wso2/open-mcp-auth-proxy + cd open-mcp-auth-proxy + ``` + +2. **Install dependencies:** + ```bash + go get -v -t -d ./... + ``` + +3. **Build the application:** + + **Option A: Using Make** + + ```bash + # Build for all platforms + make all + + # Or build for specific platforms + make build-linux # For Linux (x86_64) + make build-linux-arm # For ARM-based Linux + make build-darwin # For macOS + make build-windows # For Windows + ``` + + **Option B: Manual build (works on all platforms)** + + ```bash + # Build for your current platform + go build -o openmcpauthproxy ./cmd/proxy + + # Cross-compile for other platforms + GOOS=linux GOARCH=amd64 go build -o openmcpauthproxy-linux ./cmd/proxy + GOOS=windows GOARCH=amd64 go build -o openmcpauthproxy.exe ./cmd/proxy + GOOS=darwin GOARCH=amd64 go build -o openmcpauthproxy-macos ./cmd/proxy + ``` + +After building, you'll find the executables in the `build` directory (when using Make) or in your project root (when building manually). + +### Additional Make Targets + +If you're using Make, these additional targets are available: + +```bash +make test # Run tests +make coverage # Run tests with coverage report +make fmt # Format code with gofmt +make vet # Run go vet +make clean # Clean build artifacts +make help # Show all available targets +``` \ No newline at end of file diff --git a/README.md b/README.md index 7b707ff..bd7abac 100644 --- a/README.md +++ b/README.md @@ -10,83 +10,38 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that ![Architecture Diagram](https://github.com/user-attachments/assets/41cf6723-c488-4860-8640-8fec45006f92) -## πŸ›‘οΈ What it Does? - -- Intercept incoming requests -- Validate authorization tokens -- Offload authentication and authorization to OAuth-compliant Identity Providers -- Support the MCP authorization protocol - - ## πŸš€ Features -- **Dynamic Authorization** based on MCP Authorization Specification. -- **JWT Validation** (signature, audience, and scopes). -- **Identity Provider Integration** (OAuth/OIDC via Asgardeo, Auth0, Keycloak). -- **Protocol Version Negotiation** via `MCP-Protocol-Version` header. -- **Comprehensive Authentication Feedback** via RFC-compliant challenges. -- **Flexible Transport Modes**: SSE and stdio. - -## πŸ“Œ MCP Specification Verions - -| Version | Behavior | -| :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2025-03-26 | Only signature check of Bearer JWT on both `/sse` and `/message`
No scope or audience enforcement | -| Latest(draft) | Read `MCP-Protocol-Version` from client header
SSE handshake returns `WWW-Authenticate: Bearer resource_metadata="…"`
`/message` enforces:
`aud` claim == `ResourceIdentifier`
`scope` claim contains `requiredScope`
Scope based access control
Rich `WWW-Authenticate` on 401s
Serves `/​.well-known/oauth-protected-resource` JSON | +- **Dynamic Authorization**: based on MCP Authorization Specification. +- **JWT Validation**: Validates the token’s signature, checks the `audience` claim, and enforces scope requirements. +- **Identity Provider Integration**: Supports integrating any OAuth/OIDC provider such as Asgardeo, Auth0, Keycloak, etc. +- **Protocol Version Negotiation**: via `MCP-Protocol-Version` header. +- **Flexible Transport Modes**: Supports STDIO, SSE and streamable HTTP transport options. ## πŸ› οΈ Quick Start -### Prerequisites - -* Go 1.20 or higher -* A running MCP server - -> If you don't have an MCP server, you can use the included example: -> -> 1. Navigate to the `resources` directory -> 2. Set up a Python environment: +> **Prerequisites** > -> ```bash -> python3 -m venv .venv -> source .venv/bin/activate -> pip3 install -r requirements.txt -> ``` -> -> 3. Start the example server: -> -> ```bash -> python3 echo_server.py -> ``` - -* An MCP client that supports MCP authorization - -### Basic Usage +> * A running MCP server (Use the [example MCP server](resources/README.md) if you don't have an MCP server already) +> * An MCP client that supports MCP authorization specification 1. Download the latest release from [Github releases](https://github.com/wso2/open-mcp-auth-proxy/releases/latest). 2. Start the proxy in demo mode (uses pre-configured authentication with Asgardeo sandbox): -#### Linux/macOS: +- Linux/macOS: + ```bash ./openmcpauthproxy --demo ``` -#### Windows: +- Windows: + ```powershell .\openmcpauthproxy.exe --demo ``` -> The repository comes with a default `config.yaml` file that contains the basic configuration: -> -> ```yaml -> listen_port: 8080 -> base_url: "http://localhost:8000" # Your MCP server URL -> paths: -> sse: "/sse" -> messages: "/messages/" -> ``` - -3. Connect using an MCP client like [MCP Inspector](https://github.com/shashimalcse/inspector)(This is a temporary fork with fixes for authentication [issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/257) in the original implementation) +3. Connect using an MCP client like [MCP Inspector](https://github.com/modelcontextprotocol/inspector). ## πŸ”’ Integrate an Identity Provider @@ -96,10 +51,10 @@ To enable authorization through your Asgardeo organization: 1. [Register](https://asgardeo.io/signup) and create an organization in Asgardeo 2. Create an [M2M application](https://wso2.com/asgardeo/docs/guides/applications/register-machine-to-machine-app/) - 1. [Authorize this application](https://wso2.com/asgardeo/docs/guides/applications/register-machine-to-machine-app/#authorize-the-api-resources-for-the-app) to invoke "Application Management API" with the `internal_application_mgt_create` scope +3. [Authorize this application](https://wso2.com/asgardeo/docs/guides/applications/register-machine-to-machine-app/#authorize-the-api-resources-for-the-app) to invoke "Application Management API" with the `internal_application_mgt_create` scope ![image](https://github.com/user-attachments/assets/0bd57cac-1904-48cc-b7aa-0530224bc41a) -3. Update `config.yaml` with the following parameters. +4. Update `config.yaml` with the following parameters. ```yaml base_url: "http://localhost:8000" # URL of your MCP server @@ -115,7 +70,7 @@ authorization_servers: # Authorization ser jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" # JWKS URL ``` -4. Start the proxy with Asgardeo integration: +5. Start the proxy with Asgardeo integration: ```bash ./openmcpauthproxy --asgardeo @@ -126,59 +81,24 @@ jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" # JWKS URL - [Auth0](docs/integrations/Auth0.md) - [Keycloak](docs/integrations/keycloak.md) -# βš™οΈ Advanced Configuration +## Transport Modes -### Transport Modes - -The proxy supports two transport modes: - -- **SSE Mode (Default)**: For Server-Sent Events transport -- **stdio Mode**: For MCP servers that use stdio transport +### **STDIO Mode** When using stdio mode, the proxy: - Starts an MCP server as a subprocess using the command specified in the configuration - Communicates with the subprocess through standard input/output (stdio) -- **Note**: Any commands specified (like `npx` in the example below) must be installed on your system first -To use stdio mode: - -```bash -./openmcpauthproxy --demo --stdio -``` - -#### Example: Running an MCP Server as a Subprocess +> **Note**: Any commands specified (like `npx` in the example below) must be installed on your system first 1. Configure stdio mode in your `config.yaml`: ```yaml -listen_port: 8080 -base_url: "http://localhost:8000" - stdio: enabled: true user_command: "npx -y @modelcontextprotocol/server-github" # Example using a GitHub MCP server env: # Environment variables (optional) - - "GITHUB_PERSONAL_ACCESS_TOKEN=gitPAT" - -# CORS configuration -cors: - allowed_origins: - - "http://localhost:5173" # Origin of your client application - allowed_methods: - - "GET" - - "POST" - - "PUT" - - "DELETE" - allowed_headers: - - "Authorization" - - "Content-Type" - allow_credentials: true - -# Demo configuration for Asgardeo -demo: - org_name: "openmcpauthdemo" - client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa" - client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka" + - "GITHUB_PERSONAL_ACCESS_TOKEN=gitPAT" ``` 2. Run the proxy with stdio mode: @@ -187,132 +107,10 @@ demo: ./openmcpauthproxy --demo ``` -The proxy will: -- Start the MCP server as a subprocess using the specified command -- Handle all authorization requirements -- Forward messages between clients and the server +- **SSE Mode (Default)**: For Server-Sent Events transport +- **Streamable HTTP Mode**: For Streamable HTTP transport -### πŸ“ Complete Configuration Reference - -```yaml -# Common configuration -listen_port: 8080 -base_url: "http://localhost:8000" -port: 8000 - -# Path configuration -paths: - sse: "/sse" - messages: "/messages/" - -# Transport mode -transport_mode: "sse" # Options: "sse" or "stdio" - -# stdio-specific configuration (used only in stdio mode) -stdio: - enabled: true - user_command: "npx -y @modelcontextprotocol/server-github" # Command to start the MCP server (requires npx to be installed) - work_dir: "" # Optional working directory for the subprocess - -# CORS configuration -cors: - allowed_origins: - - "http://localhost:5173" - allowed_methods: - - "GET" - - "POST" - - "PUT" - - "DELETE" - allowed_headers: - - "Authorization" - - "Content-Type" - allow_credentials: true - -# Demo configuration for Asgardeo -demo: - org_name: "openmcpauthdemo" - client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa" - client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka" - -# Asgardeo configuration (used with --asgardeo flag) -resource_identifier: "http://localhost:8080" -scopes_supported: -- "read:tools" -- "read:resources" -audience: "" -authorization_servers: -- "https://api.asgardeo.io/t/acme" -jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" -``` -## Build from Source - -### Prerequisites - -* Go 1.20 or higher -* Git -* Make (optional, for simplified builds) - -### Clone and Build - -1. **Clone the repository:** - ```bash - git clone https://github.com/wso2/open-mcp-auth-proxy - cd open-mcp-auth-proxy - ``` - -2. **Install dependencies:** - ```bash - go get -v -t -d ./... - ``` - -3. **Build the application:** - - **Option A: Using Make** - ```bash - # Build for all platforms - make all - - # Or build for specific platforms - make build-linux # For Linux (x86_64) - make build-linux-arm # For ARM-based Linux - make build-darwin # For macOS - make build-windows # For Windows - ``` - - **Option B: Manual build (works on all platforms)** - ```bash - # Build for your current platform - go build -o openmcpauthproxy ./cmd/proxy - - # Cross-compile for other platforms - GOOS=linux GOARCH=amd64 go build -o openmcpauthproxy-linux ./cmd/proxy - GOOS=windows GOARCH=amd64 go build -o openmcpauthproxy.exe ./cmd/proxy - GOOS=darwin GOARCH=amd64 go build -o openmcpauthproxy-macos ./cmd/proxy - ``` - -### Run the Built Application - -After building, you'll find the executables in the `build` directory (when using Make) or in your project root (when building manually). - -**Linux/macOS:** -```bash -# If built with Make -./build/linux/openmcpauthproxy --demo - -# If built manually -./openmcpauthproxy --demo -``` - -**Windows:** -```powershell -# If built with Make -.\build\windows\openmcpauthproxy.exe --demo - -# If built manually -.\openmcpauthproxy.exe --demo -``` - -### Available Command Line Options +## Available Command Line Options ```bash # Start in demo mode (using Asgardeo sandbox) @@ -331,15 +129,6 @@ After building, you'll find the executables in the `build` directory (when using ./openmcpauthproxy --help ``` -### Additional Make Targets +## Contributing -If you're using Make, these additional targets are available: - -```bash -make test # Run tests -make coverage # Run tests with coverage report -make fmt # Format code with gofmt -make vet # Run go vet -make clean # Clean build artifacts -make help # Show all available targets -``` +We appreciate your contributions, whether it is improving documentation, adding new features, or fixing bugs. To get started, please refer to our [contributing guide](CONTRIBUTING.md). diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 0000000..047c220 --- /dev/null +++ b/resources/README.md @@ -0,0 +1,21 @@ +# Example MCP server + +Use this example MCP server, if you don't already have an MCP server to test the open-mcp-auth-proxy. + +## Setting Up + +1. Navigate to the `resources` directory + +2. Set up a Python environment: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip3 install -r requirements.txt +``` + +3. Start the example server: + +```bash +python3 echo_server.py +``` From 8589035d64c8cbb0f84c14112ca31eca8f5aa087 Mon Sep 17 00:00:00 2001 From: Pavindu Lakshan Date: Mon, 11 Aug 2025 16:39:35 +0530 Subject: [PATCH 17/19] Misc improvements --- cmd/proxy/provider.go | 68 +++++++++++------------ config.yaml | 36 ++++++------ internal/authz/asgardeo.go | 41 +++++++++++--- internal/authz/default.go | 17 +++--- internal/authz/scope_validator.go | 57 +++++++++---------- internal/config/config.go | 23 +++++--- internal/proxy/proxy.go | 44 ++++++++++----- internal/util/jwks.go | 92 +++++++++++++++++++------------ 8 files changed, 222 insertions(+), 156 deletions(-) diff --git a/cmd/proxy/provider.go b/cmd/proxy/provider.go index be4ee21..90ef369 100644 --- a/cmd/proxy/provider.go +++ b/cmd/proxy/provider.go @@ -7,39 +7,39 @@ import ( ) func MakeProvider(cfg *config.Config, demoMode, asgardeoMode bool) authz.Provider { - var mode, orgName string - switch { - case demoMode: - mode = "demo" - orgName = cfg.Demo.OrgName - case asgardeoMode: - mode = "asgardeo" - orgName = cfg.Asgardeo.OrgName - default: - mode = "default" - } - cfg.Mode = mode + var mode, orgName string + switch { + case demoMode: + mode = "demo" + orgName = cfg.Demo.OrgName + case asgardeoMode: + mode = "asgardeo" + orgName = cfg.Asgardeo.OrgName + default: + mode = "default" + } + cfg.Mode = mode - switch mode { - case "demo", "asgardeo": - if len(cfg.AuthorizationServers) == 0 && cfg.JwksURI == "" { - base := constants.ASGARDEO_BASE_URL + orgName + "/oauth2" - cfg.AuthServerBaseURL = base - cfg.JWKSURL = base + "/jwks" - } else { - cfg.AuthServerBaseURL = cfg.AuthorizationServers[0] - cfg.JWKSURL = cfg.JwksURI - } - return authz.NewAsgardeoProvider(cfg) + switch mode { + case "demo", "asgardeo": + if len(cfg.ProtectedResourceMetadata.AuthorizationServers) == 0 && cfg.ProtectedResourceMetadata.JwksURI == "" { + base := constants.ASGARDEO_BASE_URL + orgName + "/oauth2" + cfg.AuthServerBaseURL = base + cfg.JWKSURL = base + "/jwks" + } else { + cfg.AuthServerBaseURL = cfg.ProtectedResourceMetadata.AuthorizationServers[0] + cfg.JWKSURL = cfg.ProtectedResourceMetadata.JwksURI + } + return authz.NewAsgardeoProvider(cfg) - default: - if cfg.Default.BaseURL != "" && cfg.Default.JWKSURL != "" { - cfg.AuthServerBaseURL = cfg.Default.BaseURL - cfg.JWKSURL = cfg.Default.JWKSURL - } else if len(cfg.AuthorizationServers) > 0 { - cfg.AuthServerBaseURL = cfg.AuthorizationServers[0] - cfg.JWKSURL = cfg.JwksURI - } - return authz.NewDefaultProvider(cfg) - } -} \ No newline at end of file + default: + if cfg.Default.BaseURL != "" && cfg.Default.JWKSURL != "" { + cfg.AuthServerBaseURL = cfg.Default.BaseURL + cfg.JWKSURL = cfg.Default.JWKSURL + } else if len(cfg.ProtectedResourceMetadata.AuthorizationServers) > 0 { + cfg.AuthServerBaseURL = cfg.ProtectedResourceMetadata.AuthorizationServers[0] + cfg.JWKSURL = cfg.ProtectedResourceMetadata.JwksURI + } + return authz.NewDefaultProvider(cfg) + } +} diff --git a/config.yaml b/config.yaml index 88c6117..47eb8eb 100644 --- a/config.yaml +++ b/config.yaml @@ -1,9 +1,10 @@ # config.yaml # Common configuration for all transport modes +proxy_base_url: http://localhost:8080 listen_port: 8080 -base_url: "http://localhost:3001" # Base URL for the MCP server -port: 3001 # Port for the MCP server +base_url: "http://localhost:8000" # Base URL for the MCP server +port: 8000 # Port for the MCP server timeout_seconds: 10 # Path configuration @@ -17,7 +18,7 @@ transport_mode: "sse" # Options: "sse" or "stdio" # stdio-specific configuration (used only when transport_mode is "stdio") stdio: - enabled: true + enabled: false user_command: "npx -y @modelcontextprotocol/server-github" work_dir: "" # Working directory (optional) # env: # Environment variables (optional) @@ -30,6 +31,7 @@ path_mapping: cors: allowed_origins: - "http://127.0.0.1:6274" + - "http://localhost:6274" allowed_methods: - "GET" - "POST" @@ -47,17 +49,17 @@ demo: client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa" client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka" -# Protected resource metadata -resource_identifier: http://localhost:3000 -audience: mcp_proxy -scopes_supported: - - "tools":"read:tools" - - "resources":"read:resources" - - "prompts":"read:prompts" -authorization_servers: - - https://api.asgardeo.io/t/acme/ -jwks_uri: https://api.asgardeo.io/t/acme/oauth2/jwks -bearer_methods_supported: - - header - - body - - query +protected_resource_metadata: + resource_identifier: http://localhost:8080/sse + audience: 2xGW_poFYoObUE_vUQxvGdPSUPwa + scopes_supported: + - initialize: "mcp_init" + - tools/call: + - echo_tool: "mcp_echo_tool" + authorization_servers: + - https://api.asgardeo.io/t/openmcpauthdemo/oauth2/token + jwks_uri: https://api.asgardeo.io/t/openmcpauthdemo/oauth2/jwks + bearer_methods_supported: + - header + - body + - query diff --git a/internal/authz/asgardeo.go b/internal/authz/asgardeo.go index aa458d5..598d1ca 100644 --- a/internal/authz/asgardeo.go +++ b/internal/authz/asgardeo.go @@ -194,7 +194,7 @@ func (p *asgardeoProvider) createAsgardeoApplication(regReq RegisterRequest) err if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("Asgardeo creation error (%d): %s", resp.StatusCode, string(respBody)) + return fmt.Errorf("asgardeo creation error (%d): %s", resp.StatusCode, string(respBody)) } logger.Info("Created Asgardeo application for clientID=%s", regReq.ClientID) @@ -367,16 +367,41 @@ func randomString(n int) string { func (p *asgardeoProvider) ProtectedResourceMetadataHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + + // Extract only the values into a []string + var supportedScopes []string + var extractStrings func(interface{}) + extractStrings = func(val interface{}) { + switch v := val.(type) { + case string: + supportedScopes = append(supportedScopes, v) + case []any: + for _, item := range v { + extractStrings(item) + } + case map[string]any: + for _, item := range v { + extractStrings(item) + } + } + } + for _, m := range p.cfg.ProtectedResourceMetadata.ScopesSupported { + for _, v := range m { + extractStrings(v) + } + } + meta := map[string]interface{}{ - "resource": p.cfg.ResourceIdentifier, - "scopes_supported": p.cfg.ScopesSupported, - "authorization_servers": p.cfg.AuthorizationServers, + "resource": p.cfg.ProtectedResourceMetadata.ResourceIdentifier, + "scopes_supported": supportedScopes, + "authorization_servers": p.cfg.ProtectedResourceMetadata.AuthorizationServers, } - if p.cfg.JwksURI != "" { - meta["jwks_uri"] = p.cfg.JwksURI + + if p.cfg.ProtectedResourceMetadata.JwksURI != "" { + meta["jwks_uri"] = p.cfg.ProtectedResourceMetadata.JwksURI } - if len(p.cfg.BearerMethodsSupported) > 0 { - meta["bearer_methods_supported"] = p.cfg.BearerMethodsSupported + if len(p.cfg.ProtectedResourceMetadata.BearerMethodsSupported) > 0 { + meta["bearer_methods_supported"] = p.cfg.ProtectedResourceMetadata.BearerMethodsSupported } if err := json.NewEncoder(w).Encode(meta); err != nil { http.Error(w, "failed to encode metadata", http.StatusInternalServerError) diff --git a/internal/authz/default.go b/internal/authz/default.go index dc8900d..8b58fa0 100644 --- a/internal/authz/default.go +++ b/internal/authz/default.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/wso2/open-mcp-auth-proxy/internal/config" - "github.com/wso2/open-mcp-auth-proxy/internal/logging" + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type defaultProvider struct { @@ -99,18 +99,17 @@ func (p *defaultProvider) ProtectedResourceMetadataHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") meta := map[string]interface{}{ - "audience": p.cfg.Audience, - "resource": p.cfg.ResourceIdentifier, - "scopes_supported": p.cfg.ScopesSupported, - "authorization_servers": p.cfg.AuthorizationServers, + "audience": p.cfg.ProtectedResourceMetadata.Audience, + "scopes_supported": p.cfg.ProtectedResourceMetadata.ScopesSupported, + "authorization_servers": p.cfg.ProtectedResourceMetadata.AuthorizationServers, } - if p.cfg.JwksURI != "" { - meta["jwks_uri"] = p.cfg.JwksURI + if p.cfg.ProtectedResourceMetadata.JwksURI != "" { + meta["jwks_uri"] = p.cfg.ProtectedResourceMetadata.JwksURI } - if len(p.cfg.BearerMethodsSupported) > 0 { - meta["bearer_methods_supported"] = p.cfg.BearerMethodsSupported + if len(p.cfg.ProtectedResourceMetadata.BearerMethodsSupported) > 0 { + meta["bearer_methods_supported"] = p.cfg.ProtectedResourceMetadata.BearerMethodsSupported } if err := json.NewEncoder(w).Encode(meta); err != nil { diff --git a/internal/authz/scope_validator.go b/internal/authz/scope_validator.go index 004fd80..bf18a07 100644 --- a/internal/authz/scope_validator.go +++ b/internal/authz/scope_validator.go @@ -18,36 +18,37 @@ func (d *ScopeValidator) ValidateAccess( claims *jwt.MapClaims, config *config.Config, ) AccessControlResult { - env, err := util.ParseRPCRequest(r) - if err != nil { - return AccessControlResult{DecisionDeny, "bad JSON-RPC request"} - } - requiredScopes := util.GetRequiredScopes(config, env.Method) - if len(requiredScopes) == 0 { - return AccessControlResult{DecisionAllow, ""} - } + env, err := util.ParseRPCRequest(r) + if err != nil { + return AccessControlResult{DecisionDeny, "bad JSON-RPC request"} + } + requiredScopes := util.GetRequiredScopes(config, env) - required := make(map[string]struct{}, len(requiredScopes)) - for _, s := range requiredScopes { - s = strings.TrimSpace(s) - if s != "" { - required[s] = struct{}{} - } - } + if len(requiredScopes) == 0 { + return AccessControlResult{DecisionAllow, ""} + } - var tokenScopes []string - if claims, ok := (*claims)["scope"]; ok { - switch v := claims.(type) { - case string: - tokenScopes = strings.Fields(v) - case []interface{}: - for _, x := range v { - if s, ok := x.(string); ok && s != "" { - tokenScopes = append(tokenScopes, s) - } - } - } - } + required := make(map[string]struct{}, len(requiredScopes)) + for _, s := range requiredScopes { + s = strings.TrimSpace(s) + if s != "" { + required[s] = struct{}{} + } + } + + var tokenScopes []string + if claims, ok := (*claims)["scope"]; ok { + switch v := claims.(type) { + case string: + tokenScopes = strings.Fields(v) + case []interface{}: + for _, x := range v { + if s, ok := x.(string); ok && s != "" { + tokenScopes = append(tokenScopes, s) + } + } + } + } tokenScopeSet := make(map[string]struct{}, len(tokenScopes)) for _, s := range tokenScopes { diff --git a/internal/config/config.go b/internal/config/config.go index dea7a79..2a7958a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,8 +13,9 @@ import ( type TransportMode string const ( - SSETransport TransportMode = "sse" - StdioTransport TransportMode = "stdio" + SSETransport TransportMode = "sse" + StdioTransport TransportMode = "stdio" + StreamableHTTPTransport TransportMode = "streamable_http" ) // Common path configuration for all transport modes @@ -68,6 +69,15 @@ type ResponseConfig struct { CodeChallengeMethodsSupported []string `yaml:"code_challenge_methods_supported,omitempty"` } +type ProtectedResourceMetadata struct { + ResourceIdentifier string `yaml:"resource_identifier"` + Audience string `yaml:"audience"` + ScopesSupported []map[string]interface{} `yaml:"scopes_supported"` + AuthorizationServers []string `yaml:"authorization_servers"` + JwksURI string `yaml:"jwks_uri,omitempty"` + BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"` +} + type PathConfig struct { // For well-known endpoint Response *ResponseConfig `yaml:"response,omitempty"` @@ -86,6 +96,7 @@ type DefaultConfig struct { } type Config struct { + ProxyBaseURL string `yaml:"proxy_base_url"` AuthServerBaseURL string ListenPort int `yaml:"listen_port"` BaseURL string `yaml:"base_url"` @@ -98,7 +109,6 @@ type Config struct { TransportMode TransportMode `yaml:"transport_mode"` Paths PathsConfig `yaml:"paths"` Stdio StdioConfig `yaml:"stdio"` - RequiredScopes map[string]string `yaml:"required_scopes"` // Nested config for Asgardeo Demo DemoConfig `yaml:"demo"` @@ -106,12 +116,7 @@ type Config struct { Default DefaultConfig `yaml:"default"` // Protected resource metadata - Audience string `yaml:"audience"` - ResourceIdentifier string `yaml:"resource_identifier"` - ScopesSupported any `yaml:"scopes_supported"` - AuthorizationServers []string `yaml:"authorization_servers"` - JwksURI string `yaml:"jwks_uri,omitempty"` - BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"` + ProtectedResourceMetadata ProtectedResourceMetadata `yaml:"protected_resource_metadata"` } // Validate checks if the config is valid based on transport mode diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index b880f99..fa72d58 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,7 +11,7 @@ import ( "github.com/wso2/open-mcp-auth-proxy/internal/authz" "github.com/wso2/open-mcp-auth-proxy/internal/config" - "github.com/wso2/open-mcp-auth-proxy/internal/logging" + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" "github.com/wso2/open-mcp-auth-proxy/internal/util" ) @@ -64,7 +64,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider, accessController aut } } - mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(getProtectedResourceMetadataEndpointPath(cfg), func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") allowed := getAllowedOrigin(origin, cfg) if r.Method == http.MethodOptions { @@ -76,7 +76,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider, accessController aut addCORSHeaders(w, cfg, allowed, "") provider.ProtectedResourceMetadataHandler()(w, r) }) - registeredPaths["/.well-known/oauth-protected-resource"] = true + registeredPaths[getProtectedResourceMetadataEndpointPath(cfg)] = true // Remove duplicates from defaultPaths uniquePaths := make(map[string]bool) @@ -165,11 +165,11 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, var targetURL *url.URL isSSE := false - if isAuthPath(r.URL.Path) { + if isAuthPath(r.URL.Path, cfg) { targetURL = authBase } else if isMCPPath(r.URL.Path, cfg) { if ssePaths[r.URL.Path] { - if err := authorizeSSE(w, r, isLatestSpec, cfg.ResourceIdentifier); err != nil { + if err := authorizeSSE(w, r, isLatestSpec, cfg); err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } @@ -245,7 +245,7 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, "WWW-Authenticate", fmt.Sprintf( `Bearer resource_metadata="%s"`, - cfg.ResourceIdentifier+"/.well-known/oauth-protected-resource", + cfg.ProxyBaseURL+getProtectedResourceMetadataEndpointPath(cfg), )) resp.Header.Set("Access-Control-Expose-Headers", "WWW-Authenticate") } @@ -285,11 +285,11 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, } // Check if the request is for SSE handshake and authorize it -func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, resourceID string) error { +func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config) error { authHeader := r.Header.Get("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if isLatestSpec { - realm := resourceID + "/.well-known/oauth-protected-resource" + realm := cfg.BaseURL + getProtectedResourceMetadataEndpointPath(cfg) w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, realm)) w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate") } @@ -305,7 +305,7 @@ func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg accessToken, _ := util.ExtractAccessToken(authzHeader) if !strings.HasPrefix(authzHeader, "Bearer ") { if isLatestSpec { - realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" + realm := cfg.ProxyBaseURL + getProtectedResourceMetadataEndpointPath(cfg) w.Header().Set("WWW-Authenticate", fmt.Sprintf( `Bearer resource_metadata=%q`, realm, )) @@ -315,10 +315,10 @@ func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg return fmt.Errorf("missing or invalid Authorization header") } - err := util.ValidateJWT(isLatestSpec, accessToken, cfg.Audience) + err := util.ValidateJWT(isLatestSpec, accessToken, cfg.ProtectedResourceMetadata.Audience) if err != nil { if isLatestSpec { - realm := cfg.ResourceIdentifier + "/.well-known/oauth-protected-resource" + realm := cfg.ProxyBaseURL + getProtectedResourceMetadataEndpointPath(cfg) w.Header().Set("WWW-Authenticate", fmt.Sprintf(err.Error(), `Bearer realm=%q`, realm, @@ -343,7 +343,7 @@ func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg http.Error(w, "Invalid token claims", http.StatusUnauthorized) return fmt.Errorf("invalid token claims") } - + pr := accessController.ValidateAccess(r, &claimsMap, cfg) if pr.Decision == authz.DecisionDeny { http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden) @@ -385,13 +385,13 @@ func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, re w.Header().Set("X-Accel-Buffering", "no") } -func isAuthPath(path string) bool { +func isAuthPath(path string, cfg *config.Config) bool { authPaths := map[string]bool{ "/authorize": true, "/token": true, "/register": true, - "/.well-known/oauth-authorization-server": true, - "/.well-known/oauth-protected-resource": true, + "/.well-known/oauth-authorization-server": true, + getProtectedResourceMetadataEndpointPath(cfg): true, } if strings.HasPrefix(path, "/u/") { return true @@ -417,3 +417,17 @@ func skipHeader(h string) bool { } return false } + +func getProtectedResourceMetadataEndpointPath(cfg *config.Config) string { + + protectedResourceMetadataPath := "/.well-known/oauth-protected-resource" + + switch cfg.TransportMode { + case config.SSETransport: + protectedResourceMetadataPath += cfg.Paths.SSE + case config.StreamableHTTPTransport: + protectedResourceMetadataPath += cfg.Paths.StreamableHTTP + } + + return protectedResourceMetadataPath +} diff --git a/internal/util/jwks.go b/internal/util/jwks.go index b1afb6f..1a00d6e 100644 --- a/internal/util/jwks.go +++ b/internal/util/jwks.go @@ -11,7 +11,7 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/wso2/open-mcp-auth-proxy/internal/config" - "github.com/wso2/open-mcp-auth-proxy/internal/logging" + logger "github.com/wso2/open-mcp-auth-proxy/internal/logging" ) type TokenClaims struct { @@ -82,7 +82,7 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { // ValidateJWT checks the Bearer token according to the Mcp-Protocol-Version. func ValidateJWT( isLatestSpec bool, - accessToken string, + accessToken string, audience string, ) error { logger.Warn("isLatestSpec: %s", isLatestSpec) @@ -148,46 +148,66 @@ func ValidateJWT( // Parses the JWT token and returns the claims func ParseJWT(tokenStr string) (jwt.MapClaims, error) { - if tokenStr == "" { - return nil, fmt.Errorf("empty JWT") - } + if tokenStr == "" { + return nil, fmt.Errorf("empty JWT") + } - var claims jwt.MapClaims - _, _, err := jwt.NewParser().ParseUnverified(tokenStr, &claims) - if err != nil { - return nil, fmt.Errorf("failed to parse JWT: %w", err) - } - return claims, nil + var claims jwt.MapClaims + _, _, err := jwt.NewParser().ParseUnverified(tokenStr, &claims) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT: %w", err) + } + return claims, nil } // Process the required scopes -func GetRequiredScopes(cfg *config.Config, method string) []string { - switch raw := cfg.ScopesSupported.(type) { - case map[string]string: - if scope, ok := raw[method]; ok { - return []string{scope} - } - parts := strings.SplitN(method, "/", 2) - if len(parts) > 0 { - if scope, ok := raw[parts[0]]; ok { - return []string{scope} - } - } - return nil - case []interface{}: - out := make([]string, 0, len(raw)) - for _, v := range raw { - if s, ok := v.(string); ok && s != "" { - out = append(out, s) - } - } - return out +func GetRequiredScopes(cfg *config.Config, requestBody *RPCEnvelope) []string { - case []string: - return raw - } + var scopeObj interface{} + found := false + for _, m := range cfg.ProtectedResourceMetadata.ScopesSupported { + if val, ok := m[requestBody.Method]; ok { + scopeObj = val + found = true + break + } + } + if !found { + return nil + } - return nil + switch v := scopeObj.(type) { + case string: + return []string{v} + case []any: + if requestBody.Params != nil { + if paramsMap, ok := requestBody.Params.(map[string]any); ok { + name, ok := paramsMap["name"].(string) + if ok { + for _, item := range v { + if scopeMap, ok := item.(map[interface{}]interface{}); ok { + if scopeVal, exists := scopeMap[name]; exists { + if scopeStr, ok := scopeVal.(string); ok { + return []string{scopeStr} + } + if scopeArr, ok := scopeVal.([]any); ok { + var scopes []string + for _, s := range scopeArr { + if str, ok := s.(string); ok { + scopes = append(scopes, str) + } + } + return scopes + } + } + } + } + } + } + } + } + + return nil } // Extracts the Bearer token from the Authorization header From c38e27b09736c8903ba41b100f4cf63611c1fb3f Mon Sep 17 00:00:00 2001 From: Pavindu Lakshan Date: Mon, 11 Aug 2025 16:39:46 +0530 Subject: [PATCH 18/19] Improve example MCP server --- resources/README.md | 6 ++---- resources/echo_server.py | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/resources/README.md b/resources/README.md index 047c220..a10285d 100644 --- a/resources/README.md +++ b/resources/README.md @@ -4,9 +4,7 @@ Use this example MCP server, if you don't already have an MCP server to test the ## Setting Up -1. Navigate to the `resources` directory - -2. Set up a Python environment: +1. Set up a Python virtual environment. ```bash python3 -m venv .venv @@ -14,7 +12,7 @@ source .venv/bin/activate pip3 install -r requirements.txt ``` -3. Start the example server: +2. Start the example server. ```bash python3 echo_server.py diff --git a/resources/echo_server.py b/resources/echo_server.py index 889bcc7..f9339c5 100644 --- a/resources/echo_server.py +++ b/resources/echo_server.py @@ -2,7 +2,6 @@ from mcp.server.fastmcp import FastMCP mcp = FastMCP("Echo") - @mcp.resource("echo://{message}") def echo_resource(message: str) -> str: """Echo a message as a resource""" From eb72be0aabe65eed2f27d28540844d9d062be501 Mon Sep 17 00:00:00 2001 From: Pavindu Lakshan Date: Tue, 12 Aug 2025 07:58:34 +0530 Subject: [PATCH 19/19] Fix tests --- internal/util/jwks_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/util/jwks_test.go b/internal/util/jwks_test.go index 3b00c68..19c506e 100644 --- a/internal/util/jwks_test.go +++ b/internal/util/jwks_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -47,7 +48,14 @@ func TestValidateJWT(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := ValidateJWT(tc.authHeader) + var accessToken string + parts := strings.Split(tc.authHeader, "Bearer ") + if len(parts) == 2 { + accessToken = parts[1] + } else { + accessToken = "" + } + err := ValidateJWT(true, accessToken, "test-audience") if tc.expectError && err == nil { t.Errorf("Expected error but got none") } @@ -128,6 +136,7 @@ func createValidJWT(t *testing.T) string { token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "sub": "1234567890", "name": "Test User", + "aud": "test-audience", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), })