mcp: add oauth metadata endpoint

This commit is contained in:
Denis Mishin 2025-04-22 17:51:27 -04:00
parent e1d84a1dde
commit ba03bb732b
5 changed files with 286 additions and 0 deletions

View file

@ -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)

View file

@ -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)
})
}

View file

@ -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
}

11
internal/mcp/handler.go Normal file
View file

@ -0,0 +1,11 @@
package mcp
const (
DefaultPrefix = "/.pomerium/mcp"
authorizationEndpoint = "/authorize"
oauthCallbackEndpoint = "/oauth/callback"
registerEndpoint = "/register"
revocationEndpoint = "/revoke"
tokenEndpoint = "/token"
)

View file

@ -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"},
}
}