From ba03bb732b42a5b31de1979a04895ced90be4f39 Mon Sep 17 00:00:00 2001 From: Denis Mishin Date: Tue, 22 Apr 2025 17:51:27 -0400 Subject: [PATCH] mcp: add oauth metadata endpoint --- config/envoyconfig/routes.go | 13 +++ config/envoyconfig/routes_test.go | 127 +++++++++++++++++++++++++++++ internal/controlplane/http.go | 6 ++ internal/mcp/handler.go | 11 +++ internal/mcp/handler_metadata.go | 129 ++++++++++++++++++++++++++++++ 5 files changed, 286 insertions(+) create mode 100644 internal/mcp/handler.go create mode 100644 internal/mcp/handler_metadata.go diff --git a/config/envoyconfig/routes.go b/config/envoyconfig/routes.go index 4bbb10263..7f86f6499 100644 --- a/config/envoyconfig/routes.go +++ b/config/envoyconfig/routes.go @@ -60,6 +60,7 @@ func (b *Builder) buildPomeriumHTTPRoutes( return nil, err } if !isFrontingAuthenticate { + // Add common routes routes = append(routes, b.buildControlPlanePathRoute(options, "/ping"), b.buildControlPlanePathRoute(options, "/healthz"), @@ -68,6 +69,18 @@ func (b *Builder) buildPomeriumHTTPRoutes( b.buildControlPlanePathRoute(options, "/.well-known/pomerium"), b.buildControlPlanePrefixRoute(options, "/.well-known/pomerium/"), ) + + // Only add oauth-authorization-server route if there's an MCP policy + hasMCPPolicy := false + for _, policy := range options.GetAllPoliciesIndexed() { + if policy.IsMCP() { + hasMCPPolicy = true + break + } + } + if hasMCPPolicy { + routes = append(routes, b.buildControlPlanePathRoute(options, "/.well-known/oauth-authorization-server")) + } } authRoutes, err := b.buildPomeriumAuthenticateHTTPRoutes(options, host) diff --git a/config/envoyconfig/routes_test.go b/config/envoyconfig/routes_test.go index c20c060f9..b97a3d304 100644 --- a/config/envoyconfig/routes_test.go +++ b/config/envoyconfig/routes_test.go @@ -2244,3 +2244,130 @@ func mustParseURL(t *testing.T, str string) *url.URL { func ptr[T any](v T) *T { return &v } + +func Test_buildPomeriumHTTPRoutesWithMCP(t *testing.T) { + routeString := func(typ, name string) string { + str := `{ + "name": "pomerium-` + typ + `-` + name + `", + "decorator": { + "operation": "internal: ${method} ${host}${path}" + }, + "match": { + "` + typ + `": "` + name + `" + }, + "responseHeadersToAdd": [ + { + "appendAction": "OVERWRITE_IF_EXISTS_OR_ADD", + "header": { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + } + }, + { + "appendAction": "OVERWRITE_IF_EXISTS_OR_ADD", + "header": { + "key": "X-XSS-Protection", + "value": "1; mode=block" + } + } + ], + "route": { + "cluster": "pomerium-control-plane-http" + }, + "typedPerFilterConfig": { + "envoy.filters.http.ext_authz": { + "@type": "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute", + "checkSettings": { + "contextExtensions": { + "internal": "true", + "route_id": "0" + } + } + } + } + }` + return str + } + + t.Run("without MCP policy", func(t *testing.T) { + b := &Builder{filemgr: filemgr.NewManager()} + options := &config.Options{ + Services: "all", + AuthenticateURLString: "https://authenticate.example.com", + Policies: []config.Policy{ + { + From: "https://example.com", + To: mustParseWeightedURLs(t, "https://to.example.com"), + }, + }, + } + + routes, err := b.buildPomeriumHTTPRoutes(options, "example.com") + require.NoError(t, err) + + // Check routes for well-known endpoints + hasOAuthServer := false + hasPomerium := false + for _, route := range routes { + if route.GetMatch().GetPath() == "/.well-known/oauth-authorization-server" { + hasOAuthServer = true + } + if route.GetMatch().GetPath() == "/.well-known/pomerium" { + hasPomerium = true + } + } + + // Verify oauth-authorization-server route is NOT present + assert.True(t, hasPomerium, "/.well-known/pomerium route should be present") + assert.False(t, hasOAuthServer, "/.well-known/oauth-authorization-server route should NOT be present") + }) + + t.Run("with MCP policy", func(t *testing.T) { + b := &Builder{filemgr: filemgr.NewManager()} + options := &config.Options{ + Services: "all", + AuthenticateURLString: "https://authenticate.example.com", + Policies: []config.Policy{ + { + From: "https://example.com", + To: mustParseWeightedURLs(t, "https://to.example.com"), + }, + { + From: "https://mcp.example.com", + To: mustParseWeightedURLs(t, "https://mcp-backend.example.com"), + MCP: &config.MCP{}, // This marks the policy as an MCP policy + }, + }, + } + + routes, err := b.buildPomeriumHTTPRoutes(options, "example.com") + require.NoError(t, err) + + // Check routes for well-known endpoints + hasOAuthServer := false + hasPomerium := false + for _, route := range routes { + if route.GetMatch().GetPath() == "/.well-known/oauth-authorization-server" { + hasOAuthServer = true + } + if route.GetMatch().GetPath() == "/.well-known/pomerium" { + hasPomerium = true + } + } + + // Verify oauth-authorization-server route IS present + assert.True(t, hasPomerium, "/.well-known/pomerium route should be present") + assert.True(t, hasOAuthServer, "/.well-known/oauth-authorization-server route should be present") + + // Verify the expected route structures + testutil.AssertProtoJSONEqual(t, `[ + `+routeString("path", "/ping")+`, + `+routeString("path", "/healthz")+`, + `+routeString("path", "/.pomerium")+`, + `+routeString("prefix", "/.pomerium/")+`, + `+routeString("path", "/.well-known/pomerium")+`, + `+routeString("prefix", "/.well-known/pomerium/")+`, + `+routeString("path", "/.well-known/oauth-authorization-server")+` + ]`, routes) + }) +} diff --git a/internal/controlplane/http.go b/internal/controlplane/http.go index e6091cf97..ab8c9e77d 100644 --- a/internal/controlplane/http.go +++ b/internal/controlplane/http.go @@ -15,6 +15,7 @@ import ( "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/handlers" "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/mcp" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/telemetry" "github.com/pomerium/pomerium/internal/urlutil" @@ -79,5 +80,10 @@ func (srv *Server) mountCommonEndpoints(root *mux.Router, cfg *config.Config) er root.Handle("/.well-known/pomerium/", traceHandler(handlers.WellKnownPomerium(authenticateURL))) root.Path("/.well-known/pomerium/jwks.json").Methods(http.MethodGet).Handler(traceHandler(handlers.JWKSHandler(signingKey))) root.Path(urlutil.HPKEPublicKeyPath).Methods(http.MethodGet).Handler(traceHandler(hpke_handlers.HPKEPublicKeyHandler(hpkePublicKey))) + + root.Path("/.well-known/oauth-authorization-server"). + Methods(http.MethodGet, http.MethodOptions). + Handler(mcp.AuthorizationServerMetadataHandler(mcp.DefaultPrefix)) + return nil } diff --git a/internal/mcp/handler.go b/internal/mcp/handler.go new file mode 100644 index 000000000..47e284d21 --- /dev/null +++ b/internal/mcp/handler.go @@ -0,0 +1,11 @@ +package mcp + +const ( + DefaultPrefix = "/.pomerium/mcp" + + authorizationEndpoint = "/authorize" + oauthCallbackEndpoint = "/oauth/callback" + registerEndpoint = "/register" + revocationEndpoint = "/revoke" + tokenEndpoint = "/token" +) diff --git a/internal/mcp/handler_metadata.go b/internal/mcp/handler_metadata.go new file mode 100644 index 000000000..338782d7a --- /dev/null +++ b/internal/mcp/handler_metadata.go @@ -0,0 +1,129 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/url" + "path" + + "github.com/gorilla/mux" + "github.com/rs/cors" +) + +// AuthorizationServerMetadata represents the OAuth 2.0 Authorization Server Metadata (RFC 8414). +// https://datatracker.ietf.org/doc/html/rfc8414#section-2 +type AuthorizationServerMetadata struct { + // Issuer is REQUIRED. The authorization server's issuer identifier, a URL using the "https" scheme with no query or fragment. + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the URL of the authorization server's authorization endpoint. REQUIRED unless no grant types use the authorization endpoint. + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + + // TokenEndpoint is the URL of the authorization server's token endpoint. REQUIRED unless only the implicit grant type is supported. + TokenEndpoint string `json:"token_endpoint,omitempty"` + + // JwksURI is OPTIONAL. URL of the authorization server's JWK Set document. + JwksURI string `json:"jwks_uri,omitempty"` + + // RegistrationEndpoint is OPTIONAL. URL of the authorization server's OAuth 2.0 Dynamic Client Registration endpoint. + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // ScopesSupported is RECOMMENDED. JSON array of supported OAuth 2.0 "scope" values. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // ResponseTypesSupported is REQUIRED. JSON array of supported OAuth 2.0 "response_type" values. + ResponseTypesSupported []string `json:"response_types_supported"` + + // ResponseModesSupported is OPTIONAL. JSON array of supported OAuth 2.0 "response_mode" values. Default: ["query", "fragment"]. + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + // GrantTypesSupported is OPTIONAL. JSON array of supported OAuth 2.0 grant type values. Default: ["authorization_code", "implicit"]. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // TokenEndpointAuthMethodsSupported is OPTIONAL. JSON array of client authentication methods supported by the token endpoint. Default: "client_secret_basic". + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // TokenEndpointAuthSigningAlgValuesSupported is OPTIONAL. JSON array of JWS signing algorithms supported by the token endpoint for JWT client authentication. + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + // ServiceDocumentation is OPTIONAL. URL of a page with human-readable information for developers. + ServiceDocumentation string `json:"service_documentation,omitempty"` + + // UILocalesSupported is OPTIONAL. JSON array of supported languages and scripts for the UI, as BCP 47 language tags. + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + + // OpPolicyURI is OPTIONAL. URL for the authorization server's policy on client data usage. + OpPolicyURI string `json:"op_policy_uri,omitempty"` + + // OpTosURI is OPTIONAL. URL for the authorization server's terms of service. + OpTosURI string `json:"op_tos_uri,omitempty"` + + // RevocationEndpoint is OPTIONAL. URL of the authorization server's OAuth 2.0 revocation endpoint. + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + + // RevocationEndpointAuthMethodsSupported is OPTIONAL. JSON array of client authentication methods supported by the revocation endpoint. Default: "client_secret_basic". + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + + // RevocationEndpointAuthSigningAlgValuesSupported is OPTIONAL. JSON array of JWS signing algorithms supported by the revocation endpoint for JWT client authentication. + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + + // IntrospectionEndpoint is OPTIONAL. URL of the authorization server's OAuth 2.0 introspection endpoint. + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // IntrospectionEndpointAuthMethodsSupported is OPTIONAL. JSON array of client authentication methods supported by the introspection endpoint. + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + + // IntrospectionEndpointAuthSigningAlgValuesSupported is OPTIONAL. JSON array of JWS signing algorithms supported by the introspection endpoint for JWT client authentication. + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + + // CodeChallengeMethodsSupported is OPTIONAL. JSON array of PKCE code challenge methods supported by this authorization server. + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +func AuthorizationServerMetadataHandler(prefix string) http.HandlerFunc { + c := cors.New(cors.Options{ + AllowedMethods: []string{http.MethodGet, http.MethodOptions}, + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"mcp-protocol-version"}, + }) + r := mux.NewRouter() + r.Use(c.Handler) + r.Methods(http.MethodGet).HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + meta := getAuthorizationServerMetadata(r.Host, prefix) + _ = json.NewEncoder(w).Encode(meta) + }) + r.Methods(http.MethodOptions).HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + return http.HandlerFunc(r.ServeHTTP) +} + +func getAuthorizationServerMetadata(host, prefix string) AuthorizationServerMetadata { + baseURL := url.URL{ + Scheme: "https", + Host: host, + } + P := func(path string) string { + u := baseURL + u.Path = path + return u.String() + } + + return AuthorizationServerMetadata{ + Issuer: P("/"), + ServiceDocumentation: "https://pomerium.com/docs", + AuthorizationEndpoint: P(path.Join(prefix, authorizationEndpoint)), + ResponseTypesSupported: []string{"code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpoint: P(path.Join(prefix, tokenEndpoint)), + TokenEndpointAuthMethodsSupported: []string{"none"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + RevocationEndpoint: P(path.Join(prefix, revocationEndpoint)), + RevocationEndpointAuthMethodsSupported: []string{"client_secret_post"}, + RegistrationEndpoint: P(path.Join(prefix, registerEndpoint)), + ScopesSupported: []string{"openid", "offline"}, + } +}