feat(auth): .well-known endpoints delegated to auth server (#246)

Signed-off-by: Marc Nuri <marc@marcnuri.com>
This commit is contained in:
Marc Nuri
2025-08-07 10:49:21 +03:00
committed by GitHub
parent 40326c1c6a
commit 9ec5c829db
7 changed files with 109 additions and 147 deletions

View File

@@ -24,7 +24,6 @@ type StaticConfig struct {
DisabledTools []string `toml:"disabled_tools,omitempty"`
RequireOAuth bool `toml:"require_oauth,omitempty"`
AuthorizationURL string `toml:"authorization_url,omitempty"`
JwksURL string `toml:"jwks_url,omitempty"`
CertificateAuthority string `toml:"certificate_authority,omitempty"`
ServerURL string `toml:"server_url,omitempty"`
}

View File

@@ -10,19 +10,20 @@ import (
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"k8s.io/klog/v2"
"k8s.io/utils/strings/slices"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
)
const (
Audience = "kubernetes-mcp-server"
Audience = "mcp-server"
)
// AuthorizationMiddleware validates the OAuth flow using Kubernetes TokenReview API
func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *oidc.Provider, mcpServer *mcp.Server) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == healthEndpoint || r.URL.Path == oauthProtectedResourceEndpoint || r.URL.Path == oauthAuthorizationServerEndpoint {
if r.URL.Path == healthEndpoint || slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) {
next.ServeHTTP(w, r)
return
}
@@ -32,9 +33,6 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
}
audience := Audience
if serverURL != "" {
audience = serverURL
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {

View File

@@ -8,12 +8,12 @@ import (
)
const (
// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.0363P6xGmWpU-O9TAVkcOd95lPXxhI-_k5NKbHGNQeL--B8XMAz2vC8hpKnyC6rKOGifRTSR2XNHx_5fjd7lEA // notsecret
tokenBasicNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.0363P6xGmWpU-O9TAVkcOd95lPXxhI-_k5NKbHGNQeL--B8XMAz2vC8hpKnyC6rKOGifRTSR2XNHx_5fjd7lEA" // notsecret
// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoxLCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.USsuGLsB_7MwG9i0__cFkVVZa0djtmQpc8Vwi56GrapAgVAcyTfmae3s83XMDP5AwcFnxhYxLCfiZWRJri6GTA // notsecret
tokenBasicExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoxLCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.USsuGLsB_7MwG9i0__cFkVVZa0djtmQpc8Vwi56GrapAgVAcyTfmae3s83XMDP5AwcFnxhYxLCfiZWRJri6GTA" // notsecret
// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0Iiwic2NvcGUiOiJyZWFkIHdyaXRlIn0.vl5se9BuxoVDhvR7M5wGfkLoyMSYUiORMZVxl0CQ7jw3x53mZfGEkU_kkIVIl9Ui371qCCVVxdvuZPcAgbM6pQ // notsecret
tokenMultipleAudienceNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0Iiwic2NvcGUiOiJyZWFkIHdyaXRlIn0.vl5se9BuxoVDhvR7M5wGfkLoyMSYUiORMZVxl0CQ7jw3x53mZfGEkU_kkIVIl9Ui371qCCVVxdvuZPcAgbM6pQ" // notsecret
// https://jwt.io/#token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q // notsecret
tokenBasicNotExpired = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q" // notsecret
// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ // notsecret
tokenBasicExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ" // notsecret
// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg // notsecret
tokenMultipleAudienceNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg" // notsecret
)
func TestParseJWTClaimsPayloadValid(t *testing.T) {
@@ -32,7 +32,7 @@ func TestParseJWTClaimsPayloadValid(t *testing.T) {
}
})
t.Run("Parses audience", func(t *testing.T) {
expectedAudiences := []string{"https://kubernetes.default.svc.cluster.local", "kubernetes-mcp-server"}
expectedAudiences := []string{"https://kubernetes.default.svc.cluster.local", "mcp-server"}
for _, expected := range expectedAudiences {
if !basicClaims.Audience.Contains(expected) {
t.Errorf("expected audience to contain %s", expected)
@@ -91,7 +91,7 @@ func TestParseJWTClaimsPayloadInvalid(t *testing.T) {
}
})
t.Run("invalid base64 payload", func(t *testing.T) {
invalidPayload := "invalid_base64" + tokenBasicNotExpired
invalidPayload := strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid")
_, err := ParseJWTClaims(invalidPayload)
if err == nil {
@@ -111,7 +111,7 @@ func TestJWTTokenValidate(t *testing.T) {
t.Fatalf("expected no error for expired token parsing, got %v", err)
}
err = claims.Validate(t.Context(), "kubernetes-mcp-server", nil)
err = claims.Validate(t.Context(), "mcp-server", nil)
if err == nil {
t.Fatalf("expected error for expired token, got nil")
}
@@ -130,7 +130,7 @@ func TestJWTTokenValidate(t *testing.T) {
t.Fatalf("expected claims to be returned, got nil")
}
err = claims.Validate(t.Context(), "kubernetes-mcp-server", nil)
err = claims.Validate(t.Context(), "mcp-server", nil)
if err != nil {
t.Fatalf("expected no error for valid audience, got %v", err)
}

View File

@@ -44,8 +44,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat
mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc(oauthAuthorizationServerEndpoint, OAuthAuthorizationServerHandler(staticConfig))
mux.HandleFunc(oauthProtectedResourceEndpoint, OAuthProtectedResourceHandler(mcpServer, staticConfig))
mux.Handle("/.well-known/", WellKnownHandler(staticConfig))
ctx, cancel := context.WithCancel(ctx)
defer cancel()

View File

@@ -286,53 +286,55 @@ func TestHealthCheck(t *testing.T) {
})
}
func TestWellKnownOAuthAuthorizationServer(t *testing.T) {
// Simple http server to mock the authorization server
func TestWellKnownReverseProxy(t *testing.T) {
cases := []string{
".well-known/oauth-authorization-server",
".well-known/oauth-protected-resource",
".well-known/openid-configuration",
}
// With No Authorization URL configured
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) {
for _, path := range cases {
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
t.Cleanup(func() { _ = resp.Body.Close() })
t.Run("Protected resource '"+path+"' without Authorization URL returns 404 - Not Found", func(t *testing.T) {
if err != nil {
t.Fatalf("Failed to get %s endpoint: %v", path, err)
}
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected HTTP 404 Not Found, got %d", resp.StatusCode)
}
})
}
})
// With Authorization URL configured
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/oauth-authorization-server" {
if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"issuer": "https://example.com"}`))
_, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`))
}))
t.Cleanup(testServer.Close)
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true}}, func(ctx *httpContext) {
resp, err := http.Get(fmt.Sprintf("http://%s/.well-known/oauth-authorization-server", ctx.HttpAddress))
t.Cleanup(func() { _ = resp.Body.Close() })
t.Run("Exposes .well-known/oauth-authorization-server endpoint", func(t *testing.T) {
if err != nil {
t.Fatalf("Failed to get .well-known/oauth-authorization-server endpoint: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
}
})
t.Run(".well-known/oauth-authorization-server returns application/json content type", func(t *testing.T) {
if resp.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type"))
}
})
})
}
func TestWellKnownOAuthProtectedResource(t *testing.T) {
testCase(t, func(ctx *httpContext) {
resp, err := http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress))
t.Cleanup(func() { _ = resp.Body.Close() })
t.Run("Exposes .well-known/oauth-protected-resource endpoint", func(t *testing.T) {
if err != nil {
t.Fatalf("Failed to get .well-known/oauth-protected-resource endpoint: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
}
})
t.Run(".well-known/oauth-protected-resource returns application/json content type", func(t *testing.T) {
if resp.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type"))
}
})
for _, path := range cases {
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
t.Cleanup(func() { _ = resp.Body.Close() })
t.Run("Exposes "+path+" endpoint", func(t *testing.T) {
if err != nil {
t.Fatalf("Failed to get %s endpoint: %v", path, err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
}
})
t.Run(path+" returns application/json content type", func(t *testing.T) {
if resp.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type"))
}
})
}
})
}
@@ -340,12 +342,12 @@ func TestMiddlewareLogging(t *testing.T) {
testCase(t, func(ctx *httpContext) {
_, _ = http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress))
t.Run("Logs HTTP requests and responses", func(t *testing.T) {
if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 200") {
if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 404") {
t.Errorf("Expected log entry for GET /.well-known/oauth-protected-resource, got: %s", ctx.LogBuffer.String())
}
})
t.Run("Logs HTTP request duration", func(t *testing.T) {
expected := `"GET /.well-known/oauth-protected-resource 200 (.+)"`
expected := `"GET /.well-known/oauth-protected-resource 404 (.+)"`
m := regexp.MustCompile(expected).FindStringSubmatch(ctx.LogBuffer.String())
if len(m) != 2 {
t.Fatalf("Expected log entry to contain duration, got %s", ctx.LogBuffer.String())
@@ -376,7 +378,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
})
t.Run("Protected resource with MISSING Authorization header returns WWW-Authenticate header", func(t *testing.T) {
authHeader := resp.Header.Get("WWW-Authenticate")
expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="missing_token"`
expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="missing_token"`
if authHeader != expected {
t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
}
@@ -401,7 +403,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
t.Cleanup(func() { _ = resp.Body.Close })
t.Run("Protected resource with INCOMPATIBLE Authorization header returns WWW-Authenticate header", func(t *testing.T) {
authHeader := resp.Header.Get("WWW-Authenticate")
expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="missing_token"`
expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="missing_token"`
if authHeader != expected {
t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
}
@@ -431,7 +433,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
})
t.Run("Protected resource with INVALID Authorization header returns WWW-Authenticate header", func(t *testing.T) {
authHeader := resp.Header.Get("WWW-Authenticate")
expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"`
expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
if authHeader != expected {
t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
}
@@ -462,7 +464,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
})
t.Run("Protected resource with EXPIRED Authorization header returns WWW-Authenticate header", func(t *testing.T) {
authHeader := resp.Header.Get("WWW-Authenticate")
expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"`
expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
if authHeader != expected {
t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
}
@@ -495,7 +497,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
})
t.Run("Protected resource with INVALID OIDC Authorization header returns WWW-Authenticate header", func(t *testing.T) {
authHeader := resp.Header.Get("WWW-Authenticate")
expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"`
expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
if authHeader != expected {
t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
}
@@ -511,7 +513,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
rawClaims := `{
"iss": "` + httpServer.URL + `",
"exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
"aud": "kubernetes-mcp-server"
"aud": "mcp-server"
}`
validOidcToken := oidctest.SignIDToken(key, "test-oidc-key-id", oidc.RS256, rawClaims)
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}, OidcProvider: oidcProvider}, func(ctx *httpContext) {
@@ -532,7 +534,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
})
t.Run("Protected resource with INVALID KUBERNETES Authorization header returns WWW-Authenticate header", func(t *testing.T) {
authHeader := resp.Header.Get("WWW-Authenticate")
expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"`
expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
if authHeader != expected {
t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
}

View File

@@ -1,83 +1,65 @@
package http
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
)
const (
oauthAuthorizationServerEndpoint = "/.well-known/oauth-authorization-server"
oauthProtectedResourceEndpoint = "/.well-known/oauth-protected-resource"
openIDConfigurationEndpoint = "/.well-known/openid-configuration"
)
func OAuthAuthorizationServerHandler(staticConfig *config.StaticConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if staticConfig.AuthorizationURL == "" {
http.Error(w, "Authorization URL is not configured", http.StatusNotFound)
return
}
req, err := http.NewRequest(r.Method, staticConfig.AuthorizationURL+oauthAuthorizationServerEndpoint, nil)
if err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req.WithContext(r.Context()))
if err != nil {
http.Error(w, "Failed to perform request: "+err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Failed to read response body: "+err.Error(), http.StatusInternalServerError)
return
}
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
}
var WellKnownEndpoints = []string{
oauthAuthorizationServerEndpoint,
oauthProtectedResourceEndpoint,
openIDConfigurationEndpoint,
}
func OAuthProtectedResourceHandler(mcpServer *mcp.Server, staticConfig *config.StaticConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
type WellKnown struct {
authorizationUrl string
}
var authServers []string
if staticConfig.AuthorizationURL != "" {
authServers = []string{staticConfig.AuthorizationURL}
} else {
// Fallback to Kubernetes API server host if authorization_server is not configured
if apiServerHost := mcpServer.GetKubernetesAPIServerHost(); apiServerHost != "" {
authServers = []string{apiServerHost}
}
}
var _ http.Handler = &WellKnown{}
response := map[string]interface{}{
"authorization_servers": authServers,
"authorization_server": authServers[0],
"scopes_supported": mcpServer.GetEnabledTools(),
"bearer_methods_supported": []string{"header"},
}
func WellKnownHandler(staticConfig *config.StaticConfig) http.Handler {
authorizationUrl := staticConfig.AuthorizationURL
if authorizationUrl != "" && strings.HasSuffix("authorizationUrl", "/") {
authorizationUrl = strings.TrimSuffix(authorizationUrl, "/")
}
return &WellKnown{authorizationUrl}
}
if staticConfig.ServerURL != "" {
response["resource"] = staticConfig.ServerURL
}
if staticConfig.JwksURL != "" {
response["jwks_uri"] = staticConfig.JwksURL
}
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
func (w WellKnown) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if w.authorizationUrl == "" {
http.Error(writer, "Authorization URL is not configured", http.StatusNotFound)
return
}
req, err := http.NewRequest(request.Method, w.authorizationUrl+request.URL.EscapedPath(), nil)
if err != nil {
http.Error(writer, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req.WithContext(request.Context()))
if err != nil {
http.Error(writer, "Failed to perform request: "+err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(writer, "Failed to read response body: "+err.Error(), http.StatusInternalServerError)
return
}
for key, values := range resp.Header {
for _, value := range values {
writer.Header().Add(key, value)
}
}
writer.WriteHeader(resp.StatusCode)
_, _ = writer.Write(body)
}

View File

@@ -63,7 +63,6 @@ type MCPServerOptions struct {
DisableDestructive bool
RequireOAuth bool
AuthorizationURL string
JwksURL string
CertificateAuthority string
ServerURL string
@@ -122,8 +121,6 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
_ = cmd.Flags().MarkHidden("require-oauth")
cmd.Flags().StringVar(&o.AuthorizationURL, "authorization-url", o.AuthorizationURL, "OAuth authorization server URL for protected resource endpoint. If not provided, the Kubernetes API server host will be used. Only valid if require-oauth is enabled.")
_ = cmd.Flags().MarkHidden("authorization-url")
cmd.Flags().StringVar(&o.JwksURL, "jwks-url", o.JwksURL, "OAuth JWKS server URL for protected resource endpoint. Only valid if require-oauth is enabled.")
_ = cmd.Flags().MarkHidden("jwks-url")
cmd.Flags().StringVar(&o.ServerURL, "server-url", o.ServerURL, "Server URL of this application. Optional. If set, this url will be served in protected resource metadata endpoint and tokens will be validated with this audience. If not set, expected audience is kubernetes-mcp-server. Only valid if require-oauth is enabled.")
_ = cmd.Flags().MarkHidden("server-url")
cmd.Flags().StringVar(&o.CertificateAuthority, "certificate-authority", o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.")
@@ -185,9 +182,6 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag("authorization-url").Changed {
m.StaticConfig.AuthorizationURL = m.AuthorizationURL
}
if cmd.Flag("jwks-url").Changed {
m.StaticConfig.JwksURL = m.JwksURL
}
if cmd.Flag("server-url").Changed {
m.StaticConfig.ServerURL = m.ServerURL
}
@@ -212,8 +206,8 @@ func (m *MCPServerOptions) Validate() error {
if m.Port != "" && (m.SSEPort > 0 || m.HttpPort > 0) {
return fmt.Errorf("--port is mutually exclusive with deprecated --http-port and --sse-port flags")
}
if !m.StaticConfig.RequireOAuth && (m.StaticConfig.AuthorizationURL != "" || m.StaticConfig.ServerURL != "" || m.StaticConfig.JwksURL != "" || m.StaticConfig.CertificateAuthority != "") {
return fmt.Errorf("authorization-url, server-url, certificate-authority and jwks-url are only valid if require-oauth is enabled. Missing --port may implicitly set require-oauth to false")
if !m.StaticConfig.RequireOAuth && (m.StaticConfig.AuthorizationURL != "" || m.StaticConfig.ServerURL != "" || m.StaticConfig.CertificateAuthority != "") {
return fmt.Errorf("authorization-url, server-url and certificate-authority are only valid if require-oauth is enabled. Missing --port may implicitly set require-oauth to false")
}
if m.StaticConfig.AuthorizationURL != "" {
u, err := url.Parse(m.StaticConfig.AuthorizationURL)
@@ -227,18 +221,6 @@ func (m *MCPServerOptions) Validate() error {
klog.Warningf("authorization-url is using http://, this is not recommended production use")
}
}
if m.StaticConfig.JwksURL != "" {
u, err := url.Parse(m.StaticConfig.JwksURL)
if err != nil {
return err
}
if u.Scheme != "https" && u.Scheme != "http" {
return fmt.Errorf("--jwks-url must be a valid URL")
}
if u.Scheme == "http" {
klog.Warningf("jwks-url is using http://, this is not recommended production use")
}
}
return nil
}