From 9c2d37e2df5cb5dc836fe1a0f7b3d60e4e7d467c Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Tue, 13 May 2025 21:09:53 +0530 Subject: [PATCH 01/15] Update the README.md file to reflect latest MCP spec changes --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6be3ece..e736e90 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 @@ -67,7 +84,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 @@ -88,6 +105,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: @@ -101,7 +132,7 @@ asgardeo: - [Auth0](docs/integrations/Auth0.md) - [Keycloak](docs/integrations/keycloak.md) -# Advanced Configuration +# βš™οΈ Advanced Configuration ### Transport Modes @@ -167,7 +198,7 @@ The proxy will: - Handle all authorization requirements - Forward messages between clients and the server -### Complete Configuration Reference +### πŸ“ Complete Configuration Reference ```yaml # Common configuration @@ -214,9 +245,21 @@ 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 +### πŸ–₯️ Build from source ```bash git clone https://github.com/wso2/open-mcp-auth-proxy From 85e5fe1c1dbc763eea464f877f228ab85a208771 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Tue, 13 May 2025 23:58:06 +0530 Subject: [PATCH 02/15] 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 | 48 +++++++------ internal/constants/constants.go | 7 ++ internal/proxy/proxy.go | 119 ++++++++++++++++++++++++++++---- 7 files changed, 191 insertions(+), 41 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 fc6743c..47778d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,15 +17,15 @@ const ( // Common path configuration for all transport modes type PathsConfig struct { - SSE string `yaml:"sse"` - Messages string `yaml:"messages"` + SSE string `yaml:"sse"` + Messages string `yaml:"messages"` } // StdioConfig contains stdio-specific configuration type StdioConfig struct { Enabled bool `yaml:"enabled"` - UserCommand string `yaml:"user_command"` // The command provided by the user - WorkDir string `yaml:"work_dir"` // Working directory (optional) + UserCommand string `yaml:"user_command"` // The command provided by the user + WorkDir string `yaml:"work_dir"` // Working directory (optional) Args []string `yaml:"args,omitempty"` // Additional arguments Env []string `yaml:"env,omitempty"` // Environment variables } @@ -83,23 +83,31 @@ type DefaultConfig struct { } type Config struct { - AuthServerBaseURL string - ListenPort int `yaml:"listen_port"` - BaseURL string `yaml:"base_url"` - Port int `yaml:"port"` - JWKSURL string - TimeoutSeconds int `yaml:"timeout_seconds"` - PathMapping map[string]string `yaml:"path_mapping"` - Mode string `yaml:"mode"` - CORSConfig CORSConfig `yaml:"cors"` - TransportMode TransportMode `yaml:"transport_mode"` - Paths PathsConfig `yaml:"paths"` - Stdio StdioConfig `yaml:"stdio"` + AuthServerBaseURL string + ListenPort int `yaml:"listen_port"` + BaseURL string `yaml:"base_url"` + Port int `yaml:"port"` + JWKSURL string + TimeoutSeconds int `yaml:"timeout_seconds"` + PathMapping map[string]string `yaml:"path_mapping"` + Mode string `yaml:"mode"` + CORSConfig CORSConfig `yaml:"cors"` + 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 @@ -165,12 +173,12 @@ func LoadConfig(path string) (*Config, error) { if err := decoder.Decode(&cfg); err != nil { return nil, err } - + // Set default values if cfg.TimeoutSeconds == 0 { cfg.TimeoutSeconds = 15 // default } - + // Set default transport mode if not specified if cfg.TransportMode == "" { cfg.TransportMode = SSETransport // Default to SSE @@ -180,11 +188,11 @@ func LoadConfig(path string) (*Config, error) { if cfg.Port == 0 { cfg.Port = 8000 // default } - + // Validate the configuration if err := cfg.Validate(); err != nil { return nil, err } - + return &cfg, nil } 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 33a9ea3..682867e 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,14 +11,15 @@ 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" + "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" ) // 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,14 +115,14 @@ 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 { logger.Error("Invalid auth server URL: %v", err) panic(err) // Fatal error that prevents startup } - + mcpBase, err := url.Parse(cfg.BaseURL) if err != nil { logger.Error("Invalid MCP server URL: %v", err) @@ -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 331cc281c6a70f4f7c07a5c6517181c3fc087ab8 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 15:39:02 +0530 Subject: [PATCH 03/15] Refactor proxy builder --- config.yaml | 13 +++ internal/authz/asgardeo.go | 20 ++++ internal/authz/default_policy_engine.go | 20 ++++ internal/proxy/proxy.go | 40 ++++--- internal/util/jwks.go | 142 ++++++++++++++++++++---- 5 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 internal/authz/default_policy_engine.go diff --git a/config.yaml b/config.yaml index 5621195..7d7520d 100644 --- a/config.yaml +++ b/config.yaml @@ -45,3 +45,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 a3c812c..dc433b4 100644 --- a/internal/authz/asgardeo.go +++ b/internal/authz/asgardeo.go @@ -336,3 +336,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 682867e..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 } @@ -227,13 +227,13 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, req.Host = targetURL.Host cleanHeaders := http.Header{} - + // Set proper origin header to match the target if isSSE { // For SSE, ensure origin matches the target req.Header.Set("Origin", targetURL.Scheme+"://"+targetURL.Host) } - + for k, v := range r.Header { // Skip hop-by-hop headers if skipHeader(k) { @@ -277,7 +277,7 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, proxyHost: r.Host, targetHost: targetURL.Host, } - + // Set SSE-specific headers w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Cache-Control", "no-cache") @@ -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 312a5557f084a36c4f6b5e9acc453cfa86f70c45 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 21:47:15 +0530 Subject: [PATCH 04/15] 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 47778d0..aba479e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,6 +103,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 3bc9bd1ecb602b2569adff17b812bc100d1da6fb Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 22:18:41 +0530 Subject: [PATCH 05/15] 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 ed525dc7b5db69f658b572e635380b0d98df8e8e Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 14 May 2025 22:36:46 +0530 Subject: [PATCH 06/15] Update config.yaml file --- config.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/config.yaml b/config.yaml index 7d7520d..294b9e8 100644 --- a/config.yaml +++ b/config.yaml @@ -48,12 +48,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 7d64cc4093f9e4fea549df36fbb23f292f3741d0 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 01:20:29 +0530 Subject: [PATCH 07/15] Refactor scope validation --- config.yaml | 6 +- internal/authz/default_policy_engine.go | 32 +++++--- internal/authz/policy.go | 10 +-- internal/config/config.go | 2 +- internal/proxy/proxy.go | 100 +++++++++++------------- internal/util/jwks.go | 66 +++++++++------- internal/util/rpc.go | 1 - 7 files changed, 115 insertions(+), 102 deletions(-) diff --git a/config.yaml b/config.yaml index 294b9e8..4d1e2aa 100644 --- a/config.yaml +++ b/config.yaml @@ -50,9 +50,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 aba479e..8c47d8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,7 +105,7 @@ type Config struct { // Protected resource metadata Audience string `yaml:"audience"` ResourceIdentifier string `yaml:"resource_identifier"` - ScopesSupported map[string]string `yaml:"scopes_supported"` + ScopesSupported any `yaml:"scopes_supported"` AuthorizationServers []string `yaml:"authorization_servers"` JwksURI string `yaml:"jwks_uri,omitempty"` BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"` 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 d3909a98dedd732577ec00ed1b822d7e9c9a0d8b Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 01:32:27 +0530 Subject: [PATCH 08/15] Update the README.md file --- README.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e736e90..1b3793e 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. @@ -106,7 +106,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 @@ -245,14 +244,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 11a14d768fe69516a916c620a9c6b02029fb1904 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 11:14:34 +0530 Subject: [PATCH 09/15] 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 33671e6dd1f1cb0bcbf571433fc099da29f2e7b6 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Thu, 15 May 2025 11:22:10 +0530 Subject: [PATCH 10/15] 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 4d1e2aa..f73c5b2 100644 --- a/config.yaml +++ b/config.yaml @@ -59,4 +59,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 5b1daaefc37f6fc8926b9c03f21a5f4ab1ec3ba8 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Sun, 18 May 2025 13:07:08 +0530 Subject: [PATCH 11/15] 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 6a1c9c588384158200b730d22a72845aedbad38a Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Sun, 18 May 2025 13:23:15 +0530 Subject: [PATCH 12/15] 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 f73c5b2..d97c8ca 100644 --- a/config.yaml +++ b/config.yaml @@ -60,4 +60,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 5c22f36ddcd1e5cfcca2ebc90b243584f1ffc93a Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Sun, 18 May 2025 20:33:39 +0530 Subject: [PATCH 13/15] 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 1b3793e..a6a1e26 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 @@ -106,18 +106,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: @@ -245,9 +245,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 64caaa0f7ca287eecd151f92c36ca4263e219161 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 21 May 2025 10:00:01 +0530 Subject: [PATCH 14/15] 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 a6a1e26..d5d54ab 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 @@ -98,26 +96,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: @@ -240,22 +229,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 1964829dcd48f29024f0341f61735cfdcac13969 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Wed, 21 May 2025 13:42:13 +0530 Subject: [PATCH 15/15] Update the README file --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d5d54ab..49a471f 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,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: