mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(auth): .well-known endpoints delegated to auth server (#246)
Signed-off-by: Marc Nuri <marc@marcnuri.com>
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 ") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user