From 9ec5c829db2b1662432e587205896f55f978259a Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Thu, 7 Aug 2025 10:49:21 +0300 Subject: [PATCH] feat(auth): .well-known endpoints delegated to auth server (#246) Signed-off-by: Marc Nuri --- pkg/config/config.go | 1 - pkg/http/authorization.go | 8 +- pkg/http/authorization_test.go | 20 ++--- pkg/http/http.go | 3 +- pkg/http/http_test.go | 98 ++++++++++++------------ pkg/http/wellknown.go | 104 +++++++++++--------------- pkg/kubernetes-mcp-server/cmd/root.go | 22 +----- 7 files changed, 109 insertions(+), 147 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 970d875..7f3f11b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` } diff --git a/pkg/http/authorization.go b/pkg/http/authorization.go index 1c256b1..18d2659 100644 --- a/pkg/http/authorization.go +++ b/pkg/http/authorization.go @@ -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 ") { diff --git a/pkg/http/authorization_test.go b/pkg/http/authorization_test.go index bdb9523..2da6541 100644 --- a/pkg/http/authorization_test.go +++ b/pkg/http/authorization_test.go @@ -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) } diff --git a/pkg/http/http.go b/pkg/http/http.go index 6ba625a..113032b 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -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() diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 98cf127..82c62a8 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -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) } diff --git a/pkg/http/wellknown.go b/pkg/http/wellknown.go index e946047..c1e375e 100644 --- a/pkg/http/wellknown.go +++ b/pkg/http/wellknown.go @@ -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) } diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index a96ba76..d22d038 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -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 }