diff --git a/actions/go.mod b/actions/go.mod index 8552d2c316..a82f64f7f6 100644 --- a/actions/go.mod +++ b/actions/go.mod @@ -247,3 +247,5 @@ require ( sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace github.com/rancher/shepherd => github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51 diff --git a/actions/go.sum b/actions/go.sum index a8a6710c52..f29b23d8c9 100644 --- a/actions/go.sum +++ b/actions/go.sum @@ -88,6 +88,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creasty/defaults v1.5.2 h1:/VfB6uxpyp6h0fr7SPp7n8WJBoV8jfxQXPCnkVSjyls= github.com/creasty/defaults v1.5.2/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY= +github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51 h1:98cF+CtVrwlsz0EHnjGd5jhnLQIGVqJaFwAKpMdW0nc= +github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51/go.mod h1:SJtW8Jqv0rphZzsGnvB965YdyR2FqFtB+TbbzVLt8F4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -298,8 +300,6 @@ github.com/rancher/rancher/pkg/apis v0.0.0-20260105201356-c4811cb9f2af h1:ZXIKdK github.com/rancher/rancher/pkg/apis v0.0.0-20260105201356-c4811cb9f2af/go.mod h1:NwWL+lOkxPRibQ6j+9uFSo6t1CJ18z1oY4OYJMOQ/R0= github.com/rancher/rke v1.8.0 h1:87jeoOccnnNCq27YgWgMh4o0GVrrVKbw+zfo+cHMZlo= github.com/rancher/rke v1.8.0/go.mod h1:x9N1abruzDFMwTpqq2cnaDYpKCptlNoW8VraNWB6Pc4= -github.com/rancher/shepherd v0.0.0-20260417171403-fce40497b62e h1:Ebfatf9cHp8JMEslofnM65b3GaEg5xEo6lVZFt/ScjA= -github.com/rancher/shepherd v0.0.0-20260417171403-fce40497b62e/go.mod h1:SJtW8Jqv0rphZzsGnvB965YdyR2FqFtB+TbbzVLt8F4= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20250930163923-f2c9e60b1078 h1:1MJSgYkgXhr/Zc5idJkKa10SiBQd0HVtbxVOBoghlzY= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20250930163923-f2c9e60b1078/go.mod h1:CV2Soy/Skw8/SA9dDJVgpeHxoEdtjYkNpNy6xvvC5kA= github.com/rancher/tfp-automation v0.0.0-20260421175526-4dfd0e02e9ac h1:Dt7R+sOpwuI2C+T7Q8W+840TtSAQwTMiRGZZo7h1AsE= diff --git a/go.mod b/go.mod index 2cfa1cd449..32e0eb83d8 100644 --- a/go.mod +++ b/go.mod @@ -288,3 +288,5 @@ require ( sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect ) + +replace github.com/rancher/shepherd => github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51 diff --git a/go.sum b/go.sum index 864ad9623b..62c53560fc 100644 --- a/go.sum +++ b/go.sum @@ -1518,6 +1518,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creasty/defaults v1.5.2 h1:/VfB6uxpyp6h0fr7SPp7n8WJBoV8jfxQXPCnkVSjyls= github.com/creasty/defaults v1.5.2/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY= +github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51 h1:98cF+CtVrwlsz0EHnjGd5jhnLQIGVqJaFwAKpMdW0nc= +github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51/go.mod h1:SJtW8Jqv0rphZzsGnvB965YdyR2FqFtB+TbbzVLt8F4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -2192,8 +2194,6 @@ github.com/rancher/rancher/pkg/apis v0.0.0-20260105201356-c4811cb9f2af h1:ZXIKdK github.com/rancher/rancher/pkg/apis v0.0.0-20260105201356-c4811cb9f2af/go.mod h1:NwWL+lOkxPRibQ6j+9uFSo6t1CJ18z1oY4OYJMOQ/R0= github.com/rancher/rke v1.8.0 h1:87jeoOccnnNCq27YgWgMh4o0GVrrVKbw+zfo+cHMZlo= github.com/rancher/rke v1.8.0/go.mod h1:x9N1abruzDFMwTpqq2cnaDYpKCptlNoW8VraNWB6Pc4= -github.com/rancher/shepherd v0.0.0-20260417171403-fce40497b62e h1:Ebfatf9cHp8JMEslofnM65b3GaEg5xEo6lVZFt/ScjA= -github.com/rancher/shepherd v0.0.0-20260417171403-fce40497b62e/go.mod h1:SJtW8Jqv0rphZzsGnvB965YdyR2FqFtB+TbbzVLt8F4= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20250930163923-f2c9e60b1078 h1:1MJSgYkgXhr/Zc5idJkKa10SiBQd0HVtbxVOBoghlzY= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20250930163923-f2c9e60b1078/go.mod h1:CV2Soy/Skw8/SA9dDJVgpeHxoEdtjYkNpNy6xvvC5kA= github.com/rancher/tfp-automation v0.0.0-20260421175526-4dfd0e02e9ac h1:Dt7R+sOpwuI2C+T7Q8W+840TtSAQwTMiRGZZo7h1AsE= diff --git a/interoperability/go.mod b/interoperability/go.mod index 197ad00d9e..b4944751b3 100644 --- a/interoperability/go.mod +++ b/interoperability/go.mod @@ -209,3 +209,5 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace github.com/rancher/shepherd => github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51 diff --git a/interoperability/go.sum b/interoperability/go.sum index a95e708838..7c0378a339 100644 --- a/interoperability/go.sum +++ b/interoperability/go.sum @@ -1459,6 +1459,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creasty/defaults v1.5.2 h1:/VfB6uxpyp6h0fr7SPp7n8WJBoV8jfxQXPCnkVSjyls= github.com/creasty/defaults v1.5.2/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY= +github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51 h1:98cF+CtVrwlsz0EHnjGd5jhnLQIGVqJaFwAKpMdW0nc= +github.com/dasarinaidu/shepherd v0.0.0-20260430221619-8bc337a95f51/go.mod h1:SJtW8Jqv0rphZzsGnvB965YdyR2FqFtB+TbbzVLt8F4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -2083,8 +2085,6 @@ github.com/rancher/rancher/pkg/apis v0.0.0-20260105201356-c4811cb9f2af h1:ZXIKdK github.com/rancher/rancher/pkg/apis v0.0.0-20260105201356-c4811cb9f2af/go.mod h1:NwWL+lOkxPRibQ6j+9uFSo6t1CJ18z1oY4OYJMOQ/R0= github.com/rancher/rke v1.8.0 h1:87jeoOccnnNCq27YgWgMh4o0GVrrVKbw+zfo+cHMZlo= github.com/rancher/rke v1.8.0/go.mod h1:x9N1abruzDFMwTpqq2cnaDYpKCptlNoW8VraNWB6Pc4= -github.com/rancher/shepherd v0.0.0-20260417171403-fce40497b62e h1:Ebfatf9cHp8JMEslofnM65b3GaEg5xEo6lVZFt/ScjA= -github.com/rancher/shepherd v0.0.0-20260417171403-fce40497b62e/go.mod h1:SJtW8Jqv0rphZzsGnvB965YdyR2FqFtB+TbbzVLt8F4= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20250930163923-f2c9e60b1078 h1:1MJSgYkgXhr/Zc5idJkKa10SiBQd0HVtbxVOBoghlzY= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20250930163923-f2c9e60b1078/go.mod h1:CV2Soy/Skw8/SA9dDJVgpeHxoEdtjYkNpNy6xvvC5kA= github.com/rancher/tfp-automation v0.0.0-20260421175526-4dfd0e02e9ac h1:Dt7R+sOpwuI2C+T7Q8W+840TtSAQwTMiRGZZo7h1AsE= diff --git a/validation/auth/oidc/README.md b/validation/auth/oidc/README.md new file mode 100644 index 0000000000..d8bbd707a2 --- /dev/null +++ b/validation/auth/oidc/README.md @@ -0,0 +1,36 @@ +# OIDC Provider / OAuth2 Access Token Tests + +This package contains tests for Rancher's built-in OIDC Provider and OAuth2 access token authentication introduced in v2.14.0. + +## Prerequisites + +- Rancher v2.14.0 or later +- An existing cluster that the user has access to + +## Configuration + +In your `cattle-config.yaml`, add the following alongside the existing `rancher:` block: + +```yaml +rancher: + host: "rancher_server_address" + adminToken: "rancher_admin_token" + insecure: true + +oidc: + clientName: "automation-oidc-client" + redirectURI: "http://127.0.0.1:5556/auth/rancher/callback" + adminUsername: "admin" + adminPassword: "rancher_admin_password" +``` + +## Running the Tests + +Your GO suite should be set to `-run ^TestOIDCProviderSuite$` + +```bash +gotestsum --format standard-verbose \ + --packages=github.com/rancher/tests/validation/auth/oidc \ + --junitfile results.xml \ + -- -timeout=30m -tags=validation -v -run ^TestOIDCProviderSuite$ +``` \ No newline at end of file diff --git a/validation/auth/oidc/oidc_test.go b/validation/auth/oidc/oidc_test.go new file mode 100644 index 0000000000..5c2d3c634f --- /dev/null +++ b/validation/auth/oidc/oidc_test.go @@ -0,0 +1,575 @@ +//go:build (validation || infra.any || cluster.any || extended) && !sanity && !stress && !2.8 && !2.9 && !2.10 && !2.11 && !2.12 && !2.13 + +package oidc + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/rancher/shepherd/clients/rancher" + oidcauth "github.com/rancher/shepherd/clients/rancher/auth/oidc" + oidcext "github.com/rancher/shepherd/extensions/auth/oidc" + "github.com/rancher/shepherd/extensions/kubeapi/auth/oidcclient" + "github.com/rancher/shepherd/extensions/defaults" + features "github.com/rancher/shepherd/extensions/kubeapi/features" + "github.com/rancher/shepherd/extensions/kubeapi/workloads/deployments" + "github.com/rancher/shepherd/pkg/clientbase" + "github.com/rancher/shepherd/pkg/config" + namegen "github.com/rancher/shepherd/pkg/namegenerator" + "github.com/rancher/shepherd/pkg/session" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + kwait "k8s.io/apimachinery/pkg/util/wait" +) + +type OIDCTestSuite struct { + suite.Suite + session *session.Session + client *rancher.Client + oidcConfig *oidcauth.Config + oidcAPI *oidcauth.APIClient + clientID string + clientSecret string + tokenSet *oidcext.TokenSet +} + +func (s *OIDCTestSuite) SetupSuite() { + s.session = session.NewSession() + + client, err := rancher.NewClient("", s.session) + require.NoError(s.T(), err, "Failed to create Rancher client") + s.client = client + + logrus.Info("Loading OIDC configuration from config file") + s.oidcConfig = new(oidcauth.Config) + config.LoadConfig(oidcauth.ConfigurationFileKey, s.oidcConfig) + require.NotEmpty(s.T(), s.oidcConfig.ClientName, "oidc.clientName must be set in cattle-config.yaml") + require.NotEmpty(s.T(), s.oidcConfig.RedirectURI, "oidc.redirectURI must be set in cattle-config.yaml") + require.NotEmpty(s.T(), s.oidcConfig.AdminUsername, "oidc.adminUsername must be set in cattle-config.yaml") + require.NotEmpty(s.T(), s.oidcConfig.AdminPassword, "oidc.adminPassword must be set in cattle-config.yaml") + + if len(s.oidcConfig.Scopes) == 0 { + s.oidcConfig.Scopes = oidcauth.DefaultAutomationScopes + } + if s.oidcConfig.TokenExpirationSeconds == 0 { + s.oidcConfig.TokenExpirationSeconds = oidcauth.DefaultTokenExpirationSeconds + } + if s.oidcConfig.RefreshTokenExpirationSeconds == 0 { + s.oidcConfig.RefreshTokenExpirationSeconds = oidcauth.DefaultRefreshTokenExpirationSeconds + } + + s.oidcAPI = oidcauth.NewAPIClient(client.RancherConfig.Host, s.session) + + logrus.Info("Enabling oidc-provider feature flag") + err = features.EnableFeatureFlag(client, oidcauth.OIDCProviderFeatureFlag) + require.NoError(s.T(), err, "Failed to enable oidc-provider feature flag") + + err = deployments.WaitForDeploymentActive(client, "local", deployments.RancherDeploymentNamespace, deployments.RancherDeploymentName) + require.NoError(s.T(), err, "Rancher did not become ready after enabling oidc-provider") + + logrus.Infof("Creating OIDCClient %q", s.oidcConfig.ClientName) + spec := oidcclient.ClientSpec{ + RedirectURIs: []string{s.oidcConfig.RedirectURI}, + Scopes: s.oidcConfig.Scopes, + TokenExpirationSeconds: s.oidcConfig.TokenExpirationSeconds, + RefreshTokenExpirationSeconds: s.oidcConfig.RefreshTokenExpirationSeconds, + } + err = oidcclient.CreateOIDCClient(client, s.oidcConfig.ClientName, spec) + require.NoError(s.T(), err, "Failed to create OIDCClient CRD") + + clientID, secretKeyName, err := oidcclient.WaitForOIDCClientReady(client, s.oidcConfig.ClientName) + require.NoError(s.T(), err, "Failed to wait for OIDCClient status.clientID") + require.NotEmpty(s.T(), clientID, "OIDCClient status.clientID is empty after ready wait") + s.clientID = clientID + + clientSecret, err := oidcclient.FetchOIDCClientSecret(client, clientID, secretKeyName) + require.NoError(s.T(), err, "Failed to fetch OIDCClient secret from cattle-oidc-client-secrets") + require.NotEmpty(s.T(), clientSecret, "OIDCClient secret value is empty") + s.clientSecret = clientSecret + + logrus.Info("Completing headless PKCE auth-code flow to obtain access token") + scopeStr := strings.Join(s.oidcConfig.Scopes, " ") + tokenSet, err := s.oidcAPI.CompleteAuthCodeFlow( + s.clientID, s.clientSecret, + s.oidcConfig.RedirectURI, scopeStr, + s.oidcConfig.AdminUsername, s.oidcConfig.AdminPassword, + ) + require.NoError(s.T(), err, "Failed to complete PKCE auth flow in SetupSuite") + require.NotEmpty(s.T(), tokenSet.AccessToken, "Access token is empty after PKCE flow") + s.tokenSet = tokenSet + logrus.Infof("Access token obtained (length=%d)", len(tokenSet.AccessToken)) +} + +func (s *OIDCTestSuite) TearDownSuite() { + s.session.Cleanup() +} + + +// craftJWTWithIntKid returns a malformed JWT whose header `kid` field is an integer. +func craftJWTWithIntKid() (string, error) { + header := map[string]interface{}{"alg": "RS256", "typ": "JWT", "kid": 12345} + hBytes, err := json.Marshal(header) + if err != nil { + return "", err + } + payload := map[string]interface{}{"sub": "test-user", "iss": "https://test.example.com", "exp": 9999999999} + pBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(hBytes) + "." + + base64.RawURLEncoding.EncodeToString(pBytes) + ".fakesignature", nil +} + +func (s *OIDCTestSuite) TestFeatureFlagEnabledAllowsAccessTokenAuth() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying OIDC access token authenticates when oidc-provider flag is ON") + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+s.tokenSet.AccessToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, resp.StatusCode, + "Expected 200 with OIDC access token when flag is ON, body: %s", resp.Body) +} + +func (s *OIDCTestSuite) TestDiscoveryWellKnownEndpointReturns200() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying GET /.well-known/openid-configuration returns 200") + + resp, discoveryDoc, err := s.oidcAPI.GetDiscovery() + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, resp.StatusCode, + "Discovery endpoint must return 200") + require.NotNil(s.T(), discoveryDoc) +} + +func (s *OIDCTestSuite) TestDiscoveryContainsRequiredRFC8414Fields() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying discovery document contains required RFC 8414 fields") + + _, discoveryDoc, err := s.oidcAPI.GetDiscovery() + require.NoError(s.T(), err) + + for _, field := range []string{ + "issuer", "authorization_endpoint", "token_endpoint", "jwks_uri", + "response_types_supported", "subject_types_supported", + "id_token_signing_alg_values_supported", + } { + require.Contains(s.T(), discoveryDoc, field, + "Discovery document missing required field %q", field) + } +} + +func (s *OIDCTestSuite) TestDiscoveryContainsMCPRequiredFields() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying discovery document contains MCP required fields") + + _, discoveryDoc, err := s.oidcAPI.GetDiscovery() + require.NoError(s.T(), err) + + for _, field := range []string{"grant_types_supported", "code_challenge_methods_supported"} { + require.Contains(s.T(), discoveryDoc, field, "Discovery document missing field %q", field) + } + + if _, ok := discoveryDoc["registration_endpoint"]; !ok { + logrus.Warn("registration_endpoint not present in discovery document — Dynamic Client Registration not yet implemented") + } + + grantTypes, ok := discoveryDoc["grant_types_supported"].([]interface{}) + require.True(s.T(), ok) + var hasAuthCode bool + for _, grantType := range grantTypes { + if grantType == "authorization_code" { + hasAuthCode = true + } + } + require.True(s.T(), hasAuthCode, "grant_types_supported must include 'authorization_code'") + + challengeMethods, ok := discoveryDoc["code_challenge_methods_supported"].([]interface{}) + require.True(s.T(), ok) + var hasS256 bool + for _, method := range challengeMethods { + if method == "S256" { + hasS256 = true + } + } + require.True(s.T(), hasS256, "code_challenge_methods_supported must include 'S256'") +} + +func (s *OIDCTestSuite) TestOIDCClientUnregisteredScopeIsRejected() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying auth flow rejects scopes not registered in OIDCClient spec") + + _, err := s.oidcAPI.CompleteAuthCodeFlow( + s.clientID, s.clientSecret, + s.oidcConfig.RedirectURI, + "openid rancher:users admin:write", + s.oidcConfig.AdminUsername, s.oidcConfig.AdminPassword, + ) + require.Error(s.T(), err, + "Expected error when requesting scope not in spec.scopes") + logrus.Infof("Unregistered scope correctly rejected: %v", err) +} + +func (s *OIDCTestSuite) TestOIDCClientScopeListLimitsIDToken() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying id_token is absent when openid scope is omitted") + + require.NotEmpty(s.T(), s.tokenSet.IDToken, + "id_token must be present when openid scope is requested") + + tokenSet, err := s.oidcAPI.CompleteAuthCodeFlow( + s.clientID, s.clientSecret, + s.oidcConfig.RedirectURI, + "profile rancher:users", + s.oidcConfig.AdminUsername, s.oidcConfig.AdminPassword, + ) + require.NoError(s.T(), err) + require.Empty(s.T(), tokenSet.IDToken, + "id_token must NOT be present when openid scope is omitted") + require.NotEmpty(s.T(), tokenSet.AccessToken) +} + +func (s *OIDCTestSuite) TestOIDCClientRegisteredInNormanAPI() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying OIDCClient schema is registered in Norman API") + + resp, err := s.oidcAPI.RawRequest("GET", "/v3/schemas/oidcclient", + "Bearer "+s.client.RancherConfig.AdminToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, resp.StatusCode, + "OIDCClient schema must be registered in Norman: status=%d body=%s", resp.StatusCode, resp.Body) +} + +func (s *OIDCTestSuite) TestOIDCClientCreatableViaNormanAPI() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying POST /v3/oidcclients creates an OIDCClient via Norman API") + + normanClientName := namegen.AppendRandomString("norman-oidc") + + rancherURL := strings.TrimRight(s.client.RancherConfig.Host, "/") + if !strings.HasPrefix(rancherURL, "https://") && !strings.HasPrefix(rancherURL, "http://") { + rancherURL = "https://" + rancherURL + } + + httpClient := &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec + } + + body := map[string]interface{}{ + "type": "oidcclient", + "name": normanClientName, + "redirectURIs": []string{s.oidcConfig.RedirectURI}, + "scopes": s.oidcConfig.Scopes, + "tokenExpirationSeconds": s.oidcConfig.TokenExpirationSeconds, + "refreshTokenExpirationSeconds": s.oidcConfig.RefreshTokenExpirationSeconds, + } + + resp, err := clientbase.Do(httpClient, "POST", rancherURL+"/v3/oidcclients", body, map[string]string{ + "Authorization": "Bearer " + s.client.RancherConfig.AdminToken, + "Content-Type": "application/json", + }) + require.NoError(s.T(), err) + + if resp.StatusCode == http.StatusCreated { + subSession.RegisterCleanupFunc(func() error { + return oidcclient.DeleteOIDCClient(s.client, normanClientName) + }) + } + + require.Equal(s.T(), http.StatusCreated, resp.StatusCode, + "POST /v3/oidcclients must return 201 Created when Norman is wired: status=%d body=%s", resp.StatusCode, resp.Body) +} + +func (s *OIDCTestSuite) TestAccessTokenAuthenticatesV3UsersAPI() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying OIDC access token authenticates GET /v3/users") + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+s.tokenSet.AccessToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, resp.StatusCode, + "Expected 200 with OIDC access token, body: %s", resp.Body) + + var body map[string]interface{} + err = json.Unmarshal(resp.Body, &body) + require.NoError(s.T(), err) + require.Equal(s.T(), "collection", body["type"]) + _, hasData := body["data"] + require.True(s.T(), hasData) +} + +func (s *OIDCTestSuite) TestAccessTokenJWTContainsRequiredClaims() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying OIDC access token JWT contains required claims") + + claims, err := oidcext.DecodeJWTPayload(s.tokenSet.AccessToken) + require.NoError(s.T(), err, "access_token must be a valid 3-part JWT") + + for _, claim := range []string{"sub", "iss", "exp", "iat"} { + require.Contains(s.T(), claims, claim, "JWT missing required claim %q", claim) + } + subject, _ := claims["sub"].(string) + require.NotEmpty(s.T(), subject) + logrus.Infof("JWT sub=%s iss=%s", subject, claims["iss"]) +} + +func (s *OIDCTestSuite) TestAccessTokenTamperedTokenReturns401() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying tampered JWT signature returns 401") + + tamperedToken, err := oidcext.TamperJWTSignature(s.tokenSet.AccessToken) + require.NoError(s.T(), err) + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+tamperedToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, + "Tampered JWT must return 401, got %d body: %s", resp.StatusCode, resp.Body) + require.NotEqual(s.T(), http.StatusInternalServerError, resp.StatusCode) +} + +func (s *OIDCTestSuite) TestAccessTokenV3ClustersAccessible() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying OIDC access token authenticates GET /v3/clusters") + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.ClustersPath, "Bearer "+s.tokenSet.AccessToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, resp.StatusCode, + "Expected 200 on /v3/clusters with OIDC access token, body: %s", resp.Body) +} + +func (s *OIDCTestSuite) TestAccessTokenAdminTokenUnaffectedByFlag() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying admin token continues to work while oidc-provider flag is ON") + + users, err := s.client.Management.User.List(nil) + require.NoError(s.T(), err, "Admin token must still work with flag ON") + require.NotEmpty(s.T(), users.Data) +} + +func (s *OIDCTestSuite) TestTokenEndpointPKCEFlowProducesValidTokens() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying full PKCE auth-code flow returns access, id, and refresh tokens") + + scopeStr := strings.Join(s.oidcConfig.Scopes, " ") + tokenSet, err := s.oidcAPI.CompleteAuthCodeFlow( + s.clientID, s.clientSecret, + s.oidcConfig.RedirectURI, scopeStr, + s.oidcConfig.AdminUsername, s.oidcConfig.AdminPassword, + ) + require.NoError(s.T(), err, "PKCE auth flow must succeed") + require.NotEmpty(s.T(), tokenSet.AccessToken) + require.NotEmpty(s.T(), tokenSet.IDToken, "id_token must be present when openid scope is requested") + require.NotEmpty(s.T(), tokenSet.RefreshToken, "refresh_token must be present when offline_access scope is requested") + require.Equal(s.T(), "Bearer", tokenSet.TokenType) +} + +func (s *OIDCTestSuite) TestTokenEndpointRefreshTokenExchangeWorks() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying refresh_token exchange produces a valid access token") + + require.NotEmpty(s.T(), s.tokenSet.RefreshToken, + "refresh_token must have been obtained in SetupSuite") + + refreshedTokenSet, err := s.oidcAPI.RefreshAccessToken(s.tokenSet.RefreshToken, s.clientID, s.clientSecret) + require.NoError(s.T(), err, "Refresh token exchange must succeed") + require.NotEmpty(s.T(), refreshedTokenSet.AccessToken) + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+refreshedTokenSet.AccessToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, resp.StatusCode, + "Refreshed access_token must authenticate /v3/users") +} + +func (s *OIDCTestSuite) TestTokenEndpointWrongClientSecretReturns4xx() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying wrong client_secret on refresh_token grant is rejected") + + _, err := s.oidcAPI.RefreshAccessToken( + s.tokenSet.RefreshToken, s.clientID, "wrong-secret-xyz-99999") + + if err == nil { + s.T().Skip("client_secret not yet enforced on refresh_token grant — fix in progress rancher/rancher#54401") + return + } + require.Error(s.T(), err, "wrong client_secret must result in an error") + require.NotContains(s.T(), err.Error(), "500") +} + +func (s *OIDCTestSuite) TestSecurityMissingAuthHeaderReturns401() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying request with no Authorization header returns 401") + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "") + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, + "No auth header must return 401, got %d body: %s", resp.StatusCode, resp.Body) + require.NotEqual(s.T(), http.StatusInternalServerError, resp.StatusCode) +} + +func (s *OIDCTestSuite) TestSecurityMalformedBearerTokenReturns401() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying malformed bearer token formats return 401") + + malformedTokenCases := []struct{ name, header string }{ + {"random-string", "Bearer not-a-jwt-at-all"}, + {"empty-bearer", "Bearer "}, + {"two-part-jwt", "Bearer a.b"}, + {"one-dot", "Bearer a."}, + {"bearer-only", "Bearer"}, + {"spaces-in-token", "Bearer eye . abc . def"}, + } + for _, testCase := range malformedTokenCases { + s.T().Run(testCase.name, func(t *testing.T) { + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, testCase.header) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "(%s): got %d body: %s", testCase.name, resp.StatusCode, resp.Body) + assert.NotEqual(t, http.StatusInternalServerError, resp.StatusCode) + }) + } +} + +func (s *OIDCTestSuite) TestSecurityNonStringKidDoesNotPanic() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying JWT with integer kid field returns 401 without panic") + + craftedJWT, err := craftJWTWithIntKid() + require.NoError(s.T(), err) + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+craftedJWT) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, + "Integer kid must return 401, got %d body: %s", resp.StatusCode, resp.Body) + require.NotEqual(s.T(), http.StatusInternalServerError, resp.StatusCode) + + bodyStr := strings.ToLower(string(resp.Body)) + require.NotContains(s.T(), bodyStr, "panic") + require.NotContains(s.T(), bodyStr, "runtime error") +} + +func (s *OIDCTestSuite) TestSecurityTamperedSignatureReturns401() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying JWT with tampered signature returns 401") + + tamperedToken, err := oidcext.TamperJWTSignature(s.tokenSet.AccessToken) + require.NoError(s.T(), err) + + resp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+tamperedToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, + "Tampered signature must return 401") + require.NotEqual(s.T(), http.StatusInternalServerError, resp.StatusCode) +} + +func (s *OIDCTestSuite) TestRegressionBothOIDCAndAdminTokenWork() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying OIDC access token and admin token both authenticate successfully") + + oidcResp, err := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer "+s.tokenSet.AccessToken) + require.NoError(s.T(), err) + require.Equal(s.T(), http.StatusOK, oidcResp.StatusCode, + "OIDC token must return 200") + + users, err := s.client.Management.User.List(nil) + require.NoError(s.T(), err, "Admin token must return users") + require.NotEmpty(s.T(), users.Data) +} + +func (s *OIDCTestSuite) TestRegressionDiscoveryDocumentIssuerMatchesRancherURL() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying discovery document issuer matches Rancher host URL") + + _, discoveryDoc, err := s.oidcAPI.GetDiscovery() + require.NoError(s.T(), err) + + issuer, _ := discoveryDoc["issuer"].(string) + require.NotEmpty(s.T(), issuer, "issuer must be in discovery doc") + + rancherHost := strings.TrimPrefix(strings.TrimRight(s.client.RancherConfig.Host, "/"), "https://") + require.True(s.T(), strings.Contains(issuer, rancherHost), + "issuer %q must reference Rancher host %q", issuer, rancherHost) +} + +func (s *OIDCTestSuite) TestTokenWhenFeatureFlagDisabled() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying oidc-provider flag can be disabled and re-enabled and endpoint recovers") + + err := features.DisableFeatureFlag(s.client, oidcauth.OIDCProviderFeatureFlag) + require.NoError(s.T(), err, "Should be able to disable oidc-provider feature flag") + + logrus.Info("Re-enabling oidc-provider flag after test") + err = features.EnableFeatureFlag(s.client, oidcauth.OIDCProviderFeatureFlag) + require.NoError(s.T(), err, "Should be able to re-enable oidc-provider feature flag") + err = deployments.WaitForDeploymentActive(s.client, "local", deployments.RancherDeploymentNamespace, deployments.RancherDeploymentName) + require.NoError(s.T(), err, "Rancher did not become ready after re-enabling oidc-provider") + + logrus.Info("Waiting for OIDC endpoint to become available") + pollErr := kwait.PollUntilContextTimeout( + context.Background(), defaults.FiveSecondTimeout, defaults.FiveMinuteTimeout, false, + func(ctx context.Context) (bool, error) { + pollResp, pollErr := s.oidcAPI.RawRequest("GET", oidcext.UsersPath, "Bearer invalid-probe-token") + if pollErr != nil { + return false, nil + } + return pollResp.StatusCode == http.StatusUnauthorized, nil + }, + ) + require.NoError(s.T(), pollErr, "OIDC endpoint should be available after re-enable") +} + +func TestOIDCProviderSuite(t *testing.T) { + suite.Run(t, new(OIDCTestSuite)) +} +