From 251273a2528f28b97f38ecd6472f45494dfdfef0 Mon Sep 17 00:00:00 2001 From: Jm Rohmer <276982731+jmrGrav@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:40:44 +0200 Subject: [PATCH] refactor: extract reusable oauth core primitives --- internal/oauthcore/config.go | 7 +++ internal/oauthcore/errors.go | 38 +++++++++++++++ internal/oauthcore/errors_test.go | 53 +++++++++++++++++++++ internal/oauthcore/mcp_acl.go | 73 +++++++++++++++++++++++++++++ internal/oauthcore/mcp_acl_test.go | 34 ++++++++++++++ internal/oauthcore/metadata.go | 28 +++++++++++ internal/oauthcore/metadata_test.go | 50 ++++++++++++++++++++ internal/oauthcore/models.go | 50 ++++++++++++++++++++ internal/oauthcore/tokens.go | 18 +++++++ internal/oauthcore/tokens_test.go | 11 +++++ internal/oauthproxy/handlers.go | 65 +++++-------------------- internal/oauthproxy/models.go | 35 ++++---------- internal/oauthproxy/proxy.go | 63 ++----------------------- internal/oauthproxy/proxy_test.go | 71 ++++++++++++++++++++++++++++ internal/oauthproxy/service.go | 25 +--------- internal/oauthproxy/service_test.go | 5 +- 16 files changed, 463 insertions(+), 163 deletions(-) create mode 100644 internal/oauthcore/config.go create mode 100644 internal/oauthcore/errors.go create mode 100644 internal/oauthcore/errors_test.go create mode 100644 internal/oauthcore/mcp_acl.go create mode 100644 internal/oauthcore/mcp_acl_test.go create mode 100644 internal/oauthcore/metadata.go create mode 100644 internal/oauthcore/metadata_test.go create mode 100644 internal/oauthcore/models.go create mode 100644 internal/oauthcore/tokens.go create mode 100644 internal/oauthcore/tokens_test.go diff --git a/internal/oauthcore/config.go b/internal/oauthcore/config.go new file mode 100644 index 0000000..602786a --- /dev/null +++ b/internal/oauthcore/config.go @@ -0,0 +1,7 @@ +package oauthcore + +type Config struct { + Issuer string + Resource string + ScopesSupported []string +} diff --git a/internal/oauthcore/errors.go b/internal/oauthcore/errors.go new file mode 100644 index 0000000..d1e4112 --- /dev/null +++ b/internal/oauthcore/errors.go @@ -0,0 +1,38 @@ +package oauthcore + +import ( + "net/http" + "strings" +) + +// MapAuthorizeError maps internal authorization errors to RFC 6749 §4.1.2.1 +// error codes. The HTTP adapter decides whether to redirect or write directly. +func MapAuthorizeError(err error) (code, description string) { + msg := err.Error() + switch { + case strings.HasPrefix(msg, "unsupported_response_type"): + return "unsupported_response_type", "" + case strings.HasPrefix(msg, "invalid_request"): + return "invalid_request", strings.TrimPrefix(msg, "invalid_request: ") + default: + return "invalid_request", msg + } +} + +// MapTokenError maps internal token exchange errors to RFC 6749 §5.2 error +// codes and HTTP status values. +func MapTokenError(err error) (code string, status int) { + msg := err.Error() + switch { + case strings.HasPrefix(msg, "unsupported_grant_type"): + return "unsupported_grant_type", http.StatusBadRequest + case strings.HasPrefix(msg, "invalid_client"): + return "invalid_client", http.StatusUnauthorized + case strings.HasPrefix(msg, "invalid_grant"): + return "invalid_grant", http.StatusBadRequest + case strings.HasPrefix(msg, "server_error"): + return "server_error", http.StatusInternalServerError + default: + return "invalid_request", http.StatusBadRequest + } +} diff --git a/internal/oauthcore/errors_test.go b/internal/oauthcore/errors_test.go new file mode 100644 index 0000000..591dd84 --- /dev/null +++ b/internal/oauthcore/errors_test.go @@ -0,0 +1,53 @@ +package oauthcore + +import ( + "errors" + "net/http" + "testing" +) + +func TestMapAuthorizeError(t *testing.T) { + tests := []struct { + name string + err error + wantCode string + wantDesc string + }{ + {"unsupported response type", errors.New("unsupported_response_type"), "unsupported_response_type", ""}, + {"invalid request keeps detail", errors.New("invalid_request: missing state parameter"), "invalid_request", "missing state parameter"}, + {"unknown error becomes invalid request", errors.New("unexpected"), "invalid_request", "unexpected"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, desc := MapAuthorizeError(tt.err) + if code != tt.wantCode || desc != tt.wantDesc { + t.Fatalf("MapAuthorizeError() = (%q, %q), want (%q, %q)", code, desc, tt.wantCode, tt.wantDesc) + } + }) + } +} + +func TestMapTokenError(t *testing.T) { + tests := []struct { + name string + err error + wantCode string + wantStatus int + }{ + {"unsupported grant", errors.New("unsupported_grant_type"), "unsupported_grant_type", http.StatusBadRequest}, + {"invalid client", errors.New("invalid_client"), "invalid_client", http.StatusUnauthorized}, + {"invalid grant", errors.New("invalid_grant: bad code"), "invalid_grant", http.StatusBadRequest}, + {"server error", errors.New("server_error"), "server_error", http.StatusInternalServerError}, + {"unknown error", errors.New("unexpected"), "invalid_request", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, status := MapTokenError(tt.err) + if code != tt.wantCode || status != tt.wantStatus { + t.Fatalf("MapTokenError() = (%q, %d), want (%q, %d)", code, status, tt.wantCode, tt.wantStatus) + } + }) + } +} diff --git a/internal/oauthcore/mcp_acl.go b/internal/oauthcore/mcp_acl.go new file mode 100644 index 0000000..927fdcd --- /dev/null +++ b/internal/oauthcore/mcp_acl.go @@ -0,0 +1,73 @@ +package oauthcore + +import "encoding/json" + +type AnonymousMCPPolicy struct { + PublicTools []string +} + +type jsonRPCRequest struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type toolCallParams struct { + Name string `json:"name"` +} + +func (p AnonymousMCPPolicy) PayloadAllowed(body []byte) (allowed bool, toolsList bool, reason string) { + var batch []json.RawMessage + if err := json.Unmarshal(body, &batch); err == nil { + if len(batch) == 0 { + return false, false, "empty_batch" + } + containsToolsList := false + for _, raw := range batch { + ok, isToolsList, reason := p.messageAllowed(raw) + if !ok { + return false, false, reason + } + containsToolsList = containsToolsList || isToolsList + } + return true, containsToolsList, "" + } + + return p.messageAllowed(body) +} + +func (p AnonymousMCPPolicy) messageAllowed(raw []byte) (allowed bool, toolsList bool, reason string) { + var msg jsonRPCRequest + if err := json.Unmarshal(raw, &msg); err != nil { + return false, false, "invalid_json" + } + + switch msg.Method { + case "initialize", "notifications/initialized", "ping": + return true, false, "" + case "tools/list": + return true, true, "" + case "tools/call": + var params toolCallParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + return false, false, "invalid_tool_call_params" + } + if params.Name == "" { + return false, false, "missing_tool_name" + } + if p.IsPublicTool(params.Name) { + return true, false, "" + } + return false, false, "tool_not_public" + default: + return false, false, "method_not_public" + } +} + +func (p AnonymousMCPPolicy) IsPublicTool(name string) bool { + for _, tool := range p.PublicTools { + if tool == name { + return true + } + } + return false +} diff --git a/internal/oauthcore/mcp_acl_test.go b/internal/oauthcore/mcp_acl_test.go new file mode 100644 index 0000000..88c0567 --- /dev/null +++ b/internal/oauthcore/mcp_acl_test.go @@ -0,0 +1,34 @@ +package oauthcore + +import "testing" + +func TestAnonymousMCPPayloadAllowed(t *testing.T) { + policy := AnonymousMCPPolicy{PublicTools: []string{"list_pages", "get_page"}} + + tests := []struct { + name string + body string + wantAllowed bool + wantToolsList bool + wantReason string + }{ + {"initialize allowed", `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, true, false, ""}, + {"tools list allowed", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, true, true, ""}, + {"public tool allowed", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_page"}}`, true, false, ""}, + {"private tool rejected", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"publish_post"}}`, false, false, "tool_not_public"}, + {"unknown method rejected", `{"jsonrpc":"2.0","id":1,"method":"resources/read"}`, false, false, "method_not_public"}, + {"batch detects tools list", `[{"jsonrpc":"2.0","id":1,"method":"initialize"},{"jsonrpc":"2.0","id":2,"method":"tools/list"}]`, true, true, ""}, + {"empty batch rejected", `[]`, false, false, "empty_batch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAllowed, gotToolsList, gotReason := policy.PayloadAllowed([]byte(tt.body)) + if gotAllowed != tt.wantAllowed || gotToolsList != tt.wantToolsList || gotReason != tt.wantReason { + t.Fatalf("PayloadAllowed() = (%v, %v, %q), want (%v, %v, %q)", + gotAllowed, gotToolsList, gotReason, + tt.wantAllowed, tt.wantToolsList, tt.wantReason) + } + }) + } +} diff --git a/internal/oauthcore/metadata.go b/internal/oauthcore/metadata.go new file mode 100644 index 0000000..b417ca4 --- /dev/null +++ b/internal/oauthcore/metadata.go @@ -0,0 +1,28 @@ +package oauthcore + +import "fmt" + +func AuthorizationServerMetadata(cfg Config) map[string]interface{} { + return map[string]interface{}{ + "issuer": cfg.Issuer, + "authorization_endpoint": fmt.Sprintf("%s/authorize", cfg.Issuer), + "token_endpoint": fmt.Sprintf("%s/token", cfg.Issuer), + "registration_endpoint": fmt.Sprintf("%s/register", cfg.Issuer), + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none", "client_secret_post"}, + "scopes_supported": cfg.ScopesSupported, + "service_documentation": cfg.Resource, + } +} + +func ProtectedResourceMetadata(cfg Config) map[string]interface{} { + return map[string]interface{}{ + "resource": cfg.Resource, + "authorization_servers": []string{cfg.Issuer}, + "bearer_methods_supported": []string{"header"}, + "scopes_supported": cfg.ScopesSupported, + "resource_documentation": cfg.Resource, + } +} diff --git a/internal/oauthcore/metadata_test.go b/internal/oauthcore/metadata_test.go new file mode 100644 index 0000000..75b23e6 --- /dev/null +++ b/internal/oauthcore/metadata_test.go @@ -0,0 +1,50 @@ +package oauthcore + +import "testing" + +func TestAuthorizationServerMetadata(t *testing.T) { + cfg := Config{ + Issuer: "https://mcp.example.test", + Resource: "https://mcp.example.test/mcp", + ScopesSupported: []string{"mcp"}, + } + + got := AuthorizationServerMetadata(cfg) + + if got["issuer"] != cfg.Issuer { + t.Fatalf("issuer = %v, want %q", got["issuer"], cfg.Issuer) + } + if got["authorization_endpoint"] != "https://mcp.example.test/authorize" { + t.Fatalf("authorization_endpoint = %v", got["authorization_endpoint"]) + } + if got["token_endpoint"] != "https://mcp.example.test/token" { + t.Fatalf("token_endpoint = %v", got["token_endpoint"]) + } + if got["registration_endpoint"] != "https://mcp.example.test/register" { + t.Fatalf("registration_endpoint = %v", got["registration_endpoint"]) + } + if got["service_documentation"] != cfg.Resource { + t.Fatalf("service_documentation = %v, want %q", got["service_documentation"], cfg.Resource) + } +} + +func TestProtectedResourceMetadata(t *testing.T) { + cfg := Config{ + Issuer: "https://mcp.example.test", + Resource: "https://mcp.example.test/mcp", + ScopesSupported: []string{"mcp"}, + } + + got := ProtectedResourceMetadata(cfg) + + if got["resource"] != cfg.Resource { + t.Fatalf("resource = %v, want %q", got["resource"], cfg.Resource) + } + servers, ok := got["authorization_servers"].([]string) + if !ok || len(servers) != 1 || servers[0] != cfg.Issuer { + t.Fatalf("authorization_servers = %#v", got["authorization_servers"]) + } + if got["resource_documentation"] != cfg.Resource { + t.Fatalf("resource_documentation = %v, want %q", got["resource_documentation"], cfg.Resource) + } +} diff --git a/internal/oauthcore/models.go b/internal/oauthcore/models.go new file mode 100644 index 0000000..8e0ced8 --- /dev/null +++ b/internal/oauthcore/models.go @@ -0,0 +1,50 @@ +package oauthcore + +import "time" + +type AuthCode struct { + RedirectURI string + ExpiresAt time.Time + CodeChallenge string + CodeChallengeMethod string +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type RegistrationRequest struct { + RedirectURIs []string `json:"redirect_uris"` +} + +type RegistrationResponse struct { + ClientID string `json:"client_id"` + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + Scope string `json:"scope"` +} + +type AuthorizeRequest struct { + ResponseType string + ClientID string + RedirectURI string + State string + CodeChallenge string + CodeChallengeMethod string +} + +type TokenExchangeRequest struct { + GrantType string + ClientID string + ClientSecret string + RedirectURI string + Code string + CodeVerifier string +} diff --git a/internal/oauthcore/tokens.go b/internal/oauthcore/tokens.go new file mode 100644 index 0000000..afda4d0 --- /dev/null +++ b/internal/oauthcore/tokens.go @@ -0,0 +1,18 @@ +package oauthcore + +import ( + "crypto/sha256" + "encoding/hex" +) + +type TokenStore interface { + Load() (map[string]float64, error) + Save(map[string]float64) error + Close() error +} + +func HashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/oauthcore/tokens_test.go b/internal/oauthcore/tokens_test.go new file mode 100644 index 0000000..d8f5874 --- /dev/null +++ b/internal/oauthcore/tokens_test.go @@ -0,0 +1,11 @@ +package oauthcore + +import "testing" + +func TestHashToken(t *testing.T) { + got := HashToken("token") + want := "3c469e9d6c5875d37a43f353d4f88e61fcf812c66eee3457465a40b0da4153e0" + if got != want { + t.Fatalf("HashToken() = %q, want %q", got, want) + } +} diff --git a/internal/oauthproxy/handlers.go b/internal/oauthproxy/handlers.go index 720a681..6b2ea8d 100644 --- a/internal/oauthproxy/handlers.go +++ b/internal/oauthproxy/handlers.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" mcpctx "mcp-runtime-go/internal/context" + "mcp-runtime-go/internal/oauthcore" "mcp-runtime-go/internal/observability" "mcp-runtime-go/internal/security" "net/http" "net/url" - "strings" ) func (s *Service) auditLog(r *http.Request, event string, fields map[string]interface{}) { @@ -32,18 +32,7 @@ func (s *Service) HandleMetadata(w http.ResponseWriter, r *http.Request) { return } s.auditLog(r, "metadata_served", nil) - data := map[string]interface{}{ - "issuer": s.cfg.OAuthProxy.ProxyBaseURL, - "authorization_endpoint": fmt.Sprintf("%s/authorize", s.cfg.OAuthProxy.ProxyBaseURL), - "token_endpoint": fmt.Sprintf("%s/token", s.cfg.OAuthProxy.ProxyBaseURL), - "registration_endpoint": fmt.Sprintf("%s/register", s.cfg.OAuthProxy.ProxyBaseURL), - "response_types_supported": []string{"code"}, - "grant_types_supported": []string{"authorization_code"}, - "code_challenge_methods_supported": []string{"S256"}, - "token_endpoint_auth_methods_supported": []string{"none", "client_secret_post"}, - "scopes_supported": []string{mcpScope}, - "service_documentation": mcpServiceURL(s.cfg.OAuthProxy.ProxyBaseURL), - } + data := oauthcore.AuthorizationServerMetadata(s.oauthCoreConfig()) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } @@ -55,17 +44,19 @@ func (s *Service) HandleProtectedResourceMetadata(w http.ResponseWriter, r *http return } s.auditLog(r, "resource_metadata_served", nil) - data := map[string]interface{}{ - "resource": mcpServiceURL(s.cfg.OAuthProxy.ProxyBaseURL), - "authorization_servers": []string{s.cfg.OAuthProxy.ProxyBaseURL}, - "bearer_methods_supported": []string{"header"}, - "scopes_supported": []string{mcpScope}, - "resource_documentation": mcpServiceURL(s.cfg.OAuthProxy.ProxyBaseURL), - } + data := oauthcore.ProtectedResourceMetadata(s.oauthCoreConfig()) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } +func (s *Service) oauthCoreConfig() oauthcore.Config { + return oauthcore.Config{ + Issuer: s.cfg.OAuthProxy.ProxyBaseURL, + Resource: mcpServiceURL(s.cfg.OAuthProxy.ProxyBaseURL), + ScopesSupported: []string{mcpScope}, + } +} + func (s *Service) HandleRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") @@ -146,7 +137,7 @@ func (s *Service) HandleAuthorize(w http.ResponseWriter, r *http.Request) { if err != nil { s.auditLog(r, "authorize_rejected", map[string]interface{}{"reason": err.Error()}) // Redirect with RFC-standard error code (redirect_uri and client_id already validated). - rfcErr, rfcDesc := mapAuthorizeError(err) + rfcErr, rfcDesc := oauthcore.MapAuthorizeError(err) params := url.Values{} params.Set("error", rfcErr) if rfcDesc != "" { @@ -197,7 +188,7 @@ func (s *Service) HandleToken(w http.ResponseWriter, r *http.Request) { if err != nil { s.auditLog(r, "token_rejected", map[string]interface{}{"reason": err.Error()}) observability.TokensRejectedTotal.Inc() - rfcErr, status := mapTokenError(err) + rfcErr, status := oauthcore.MapTokenError(err) writeTokenError(w, rfcErr, "", status) return } @@ -232,33 +223,3 @@ func writeTokenError(w http.ResponseWriter, errCode, desc string, status int) { } json.NewEncoder(w).Encode(body) } - -// mapAuthorizeError maps internal IssueAuthCode errors to RFC 6749 §4.1.2.1 error codes. -func mapAuthorizeError(err error) (code, description string) { - msg := err.Error() - switch { - case strings.HasPrefix(msg, "unsupported_response_type"): - return "unsupported_response_type", "" - case strings.HasPrefix(msg, "invalid_request"): - return "invalid_request", strings.TrimPrefix(msg, "invalid_request: ") - default: - return "invalid_request", msg - } -} - -// mapTokenError maps ExchangeToken errors to RFC 6749 §5.2 error codes and HTTP status. -func mapTokenError(err error) (code string, status int) { - msg := err.Error() - switch { - case strings.HasPrefix(msg, "unsupported_grant_type"): - return "unsupported_grant_type", http.StatusBadRequest - case strings.HasPrefix(msg, "invalid_client"): - return "invalid_client", http.StatusUnauthorized - case strings.HasPrefix(msg, "invalid_grant"): - return "invalid_grant", http.StatusBadRequest - case strings.HasPrefix(msg, "server_error"): - return "server_error", http.StatusInternalServerError - default: - return "invalid_request", http.StatusBadRequest - } -} diff --git a/internal/oauthproxy/models.go b/internal/oauthproxy/models.go index eeb2765..4393132 100644 --- a/internal/oauthproxy/models.go +++ b/internal/oauthproxy/models.go @@ -1,32 +1,15 @@ package oauthproxy -import "time" +import "mcp-runtime-go/internal/oauthcore" -type AuthCode struct { - RedirectURI string - ExpiresAt time.Time - CodeChallenge string - CodeChallengeMethod string -} +type AuthCode = oauthcore.AuthCode -type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in,omitempty"` - Scope string `json:"scope,omitempty"` -} +type TokenResponse = oauthcore.TokenResponse -type RegistrationRequest struct { - RedirectURIs []string `json:"redirect_uris"` -} +type RegistrationRequest = oauthcore.RegistrationRequest -type RegistrationResponse struct { - ClientID string `json:"client_id"` - ClientIDIssuedAt int64 `json:"client_id_issued_at"` - RedirectURIs []string `json:"redirect_uris"` - GrantTypes []string `json:"grant_types"` - ResponseTypes []string `json:"response_types"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` - Scope string `json:"scope"` -} +type RegistrationResponse = oauthcore.RegistrationResponse + +type AuthorizeRequest = oauthcore.AuthorizeRequest + +type TokenExchangeRequest = oauthcore.TokenExchangeRequest diff --git a/internal/oauthproxy/proxy.go b/internal/oauthproxy/proxy.go index 23c04f5..b899581 100644 --- a/internal/oauthproxy/proxy.go +++ b/internal/oauthproxy/proxy.go @@ -7,6 +7,7 @@ import ( "fmt" "io" mcpctx "mcp-runtime-go/internal/context" + "mcp-runtime-go/internal/oauthcore" "mcp-runtime-go/internal/observability" "net/http" "net/http/httputil" @@ -166,15 +167,6 @@ func (s *Service) HandleProxy(w http.ResponseWriter, r *http.Request) { }) } -type jsonRPCRequest struct { - Method string `json:"method"` - Params json.RawMessage `json:"params"` -} - -type toolCallParams struct { - Name string `json:"name"` -} - func (s *Service) allowAnonymousMCPRequest(w http.ResponseWriter, r *http.Request) (allowed bool, toolsList bool) { if r.Method != http.MethodPost { s.auditLog(r, "anonymous_proxy_rejected", map[string]interface{}{"reason": "method_not_allowed", "method": r.Method}) @@ -202,60 +194,11 @@ func (s *Service) allowAnonymousMCPRequest(w http.ResponseWriter, r *http.Reques } func (s *Service) anonymousPayloadAllowed(body []byte) (allowed bool, toolsList bool, reason string) { - var batch []json.RawMessage - if err := json.Unmarshal(body, &batch); err == nil { - if len(batch) == 0 { - return false, false, "empty_batch" - } - containsToolsList := false - for _, raw := range batch { - ok, isToolsList, reason := s.anonymousMessageAllowed(raw) - if !ok { - return false, false, reason - } - containsToolsList = containsToolsList || isToolsList - } - return true, containsToolsList, "" - } - - return s.anonymousMessageAllowed(body) -} - -func (s *Service) anonymousMessageAllowed(raw []byte) (allowed bool, toolsList bool, reason string) { - var msg jsonRPCRequest - if err := json.Unmarshal(raw, &msg); err != nil { - return false, false, "invalid_json" - } - - switch msg.Method { - case "initialize", "notifications/initialized", "ping": - return true, false, "" - case "tools/list": - return true, true, "" - case "tools/call": - var params toolCallParams - if err := json.Unmarshal(msg.Params, ¶ms); err != nil { - return false, false, "invalid_tool_call_params" - } - if params.Name == "" { - return false, false, "missing_tool_name" - } - if s.isAnonymousPublicTool(params.Name) { - return true, false, "" - } - return false, false, "tool_not_public" - default: - return false, false, "method_not_public" - } + return oauthcore.AnonymousMCPPolicy{PublicTools: s.cfg.OAuthProxy.AnonymousPublicTools}.PayloadAllowed(body) } func (s *Service) isAnonymousPublicTool(name string) bool { - for _, tool := range s.cfg.OAuthProxy.AnonymousPublicTools { - if tool == name { - return true - } - } - return false + return oauthcore.AnonymousMCPPolicy{PublicTools: s.cfg.OAuthProxy.AnonymousPublicTools}.IsPublicTool(name) } func (s *Service) filterAnonymousToolsListResponse(resp *http.Response) error { diff --git a/internal/oauthproxy/proxy_test.go b/internal/oauthproxy/proxy_test.go index 01b02fd..751851f 100644 --- a/internal/oauthproxy/proxy_test.go +++ b/internal/oauthproxy/proxy_test.go @@ -217,6 +217,77 @@ func TestHandleProxy_AuthFailures(t *testing.T) { }) } +func TestHandleProxy_LegacyDefaultOAuthProxyBehaviorUnchanged(t *testing.T) { + var backendHits int + var gotPath, gotAuth, gotHost string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendHits++ + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotHost = r.Host + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + tmpDir := t.TempDir() + cfg := &config.Config{ + OAuthProxy: config.OAuthProxyConfig{ + HugoMCPURL: backend.URL + "/api/mcp", + HugoToken: "backend-token", + HugoHost: "hugo-backend.internal", + ClientID: "hugo-mcp", + ProxyBaseURL: "https://auth.example.test", + }, + } + audit := observability.NewAuditLogger(filepath.Join(tmpDir, "audit.log")) + store := storage.NewTokenStore(filepath.Join(tmpDir, "tokens.json"), false) + s, _ := NewService(cfg, store, audit, nil) + + t.Run("anonymous remains disabled by default", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))) + rr := httptest.NewRecorder() + + s.HandleProxy(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for anonymous default, got %d", rr.Code) + } + if backendHits != 0 { + t.Fatalf("expected backend not to be reached without bearer, got %d hits", backendHits) + } + if got := rr.Header().Get("WWW-Authenticate"); !strings.Contains(got, `Bearer realm="https://auth.example.test"`) { + t.Fatalf("unexpected WWW-Authenticate header: %q", got) + } + }) + + t.Run("valid bearer still proxies to configured Hugo backend with backend token", func(t *testing.T) { + if err := s.AddAccessToken("valid-token", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("AddAccessToken failed: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/mcp/tools", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rr := httptest.NewRecorder() + + s.HandleProxy(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("expected backend status 204, got %d", rr.Code) + } + if backendHits != 1 { + t.Fatalf("expected one backend hit, got %d", backendHits) + } + if gotPath != "/api/mcp/tools" { + t.Fatalf("backend path = %q, want /api/mcp/tools", gotPath) + } + if gotAuth != "Bearer backend-token" { + t.Fatalf("backend Authorization = %q", gotAuth) + } + if gotHost != "hugo-backend.internal" { + t.Fatalf("backend Host = %q", gotHost) + } + }) +} + func TestHandleProxy_AnonymousReadOnlyTools(t *testing.T) { var backendHits int backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/oauthproxy/service.go b/internal/oauthproxy/service.go index 01f8859..7ed76b1 100644 --- a/internal/oauthproxy/service.go +++ b/internal/oauthproxy/service.go @@ -2,11 +2,10 @@ package oauthproxy import ( "context" - "crypto/sha256" "crypto/subtle" - "encoding/hex" "fmt" "mcp-runtime-go/internal/config" + "mcp-runtime-go/internal/oauthcore" "mcp-runtime-go/internal/observability" "mcp-runtime-go/internal/security" "mcp-runtime-go/internal/storage" @@ -77,9 +76,7 @@ func NewService(cfg *config.Config, store storage.Store, audit *observability.Au } func (s *Service) HashToken(token string) string { - h := sha256.New() - h.Write([]byte(token)) - return hex.EncodeToString(h.Sum(nil)) + return oauthcore.HashToken(token) } func (s *Service) syncTokens() { @@ -222,15 +219,6 @@ func (s *Service) RegisterClient(req RegistrationRequest) (*RegistrationResponse return resp, nil } -type AuthorizeRequest struct { - ResponseType string - ClientID string - RedirectURI string - State string - CodeChallenge string - CodeChallengeMethod string -} - // IssueAuthCode validates the request and issues a new auth code. func (s *Service) IssueAuthCode(req AuthorizeRequest) (string, error) { if req.ResponseType != "code" { @@ -268,15 +256,6 @@ func (s *Service) IssueAuthCode(req AuthorizeRequest) (string, error) { return code, nil } -type TokenExchangeRequest struct { - GrantType string - ClientID string - ClientSecret string - RedirectURI string - Code string - CodeVerifier string -} - // ExchangeToken performs client authentication, code validation, and issues an access token. func (s *Service) ExchangeToken(req TokenExchangeRequest) (*TokenResponse, error) { if req.GrantType != "authorization_code" { diff --git a/internal/oauthproxy/service_test.go b/internal/oauthproxy/service_test.go index 0a689be..f2c357d 100644 --- a/internal/oauthproxy/service_test.go +++ b/internal/oauthproxy/service_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "mcp-runtime-go/internal/config" + "mcp-runtime-go/internal/oauthcore" "mcp-runtime-go/internal/observability" "mcp-runtime-go/internal/storage" "net/http" @@ -484,10 +485,10 @@ func TestExchangeToken_Errors(t *testing.T) { } func TestMapErrorDefaults(t *testing.T) { - if code, desc := mapAuthorizeError(fmt.Errorf("unexpected")); code != "invalid_request" || desc != "unexpected" { + if code, desc := oauthcore.MapAuthorizeError(fmt.Errorf("unexpected")); code != "invalid_request" || desc != "unexpected" { t.Fatalf("unexpected authorize mapping: %s %q", code, desc) } - if code, status := mapTokenError(fmt.Errorf("unexpected")); code != "invalid_request" || status != http.StatusBadRequest { + if code, status := oauthcore.MapTokenError(fmt.Errorf("unexpected")); code != "invalid_request" || status != http.StatusBadRequest { t.Fatalf("unexpected token mapping: %s %d", code, status) } }