diff --git a/dp-terraform/helm/rhacs-terraform/templates/emailsender.yaml b/dp-terraform/helm/rhacs-terraform/templates/emailsender.yaml index 98d70082e9..9017c9c021 100644 --- a/dp-terraform/helm/rhacs-terraform/templates/emailsender.yaml +++ b/dp-terraform/helm/rhacs-terraform/templates/emailsender.yaml @@ -32,6 +32,10 @@ spec: value: {{ .Values.emailsender.clusterName }} - name: ENVIRONMENT value: {{ .Values.emailsender.environment }} + {{- if .Values.emailsender.authConfigFromKubernetes }} + - name: AUTH_CONFIG_FROM_KUBERNETES + value: "true" + {{- end }} ports: - name: monitoring containerPort: 9090 diff --git a/dp-terraform/helm/rhacs-terraform/values.yaml b/dp-terraform/helm/rhacs-terraform/values.yaml index 1149086244..f5360faf7b 100644 --- a/dp-terraform/helm/rhacs-terraform/values.yaml +++ b/dp-terraform/helm/rhacs-terraform/values.yaml @@ -76,6 +76,7 @@ emailsender: clusterId: "" clusterName: "" environment: "" + authConfigFromKubernetes: true resources: requests: cpu: "100m" diff --git a/emailsender/config/config.go b/emailsender/config/config.go index fab5074103..6b769da707 100644 --- a/emailsender/config/config.go +++ b/emailsender/config/config.go @@ -2,7 +2,13 @@ package config import ( + "encoding/json" "fmt" + "io" + "net/http" + "os" + "path" + "strings" "github.com/caarlos0/env/v6" "gopkg.in/yaml.v2" @@ -10,18 +16,27 @@ import ( "github.com/pkg/errors" "github.com/stackrox/acs-fleet-manager/pkg/shared" "github.com/stackrox/rox/pkg/errorhelpers" + "github.com/stackrox/rox/pkg/utils" +) + +const ( + defaultSATokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + defaultKubernetesCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + k8sAPISvc = "https://kubernetes.default.svc" + wellKnownPath = ".well-known/openid-configuration" ) // Config contains this application's runtime configuration. type Config struct { - ClusterID string `env:"CLUSTER_ID"` - ServerAddress string `env:"SERVER_ADDRESS" envDefault:":8080"` - EnableHTTPS bool `env:"ENABLE_HTTPS" envDefault:"false"` - HTTPSCertFile string `env:"HTTPS_CERT_FILE" envDefault:""` - HTTPSKeyFile string `env:"HTTPS_KEY_FILE" envDefault:""` - MetricsAddress string `env:"METRICS_ADDRESS" envDefault:":9090"` - AuthConfigFile string `env:"AUTH_CONFIG_FILE" envDefault:"config/emailsender-authz.yaml"` - AuthConfig AuthConfig + ClusterID string `env:"CLUSTER_ID"` + ServerAddress string `env:"SERVER_ADDRESS" envDefault:":8080"` + EnableHTTPS bool `env:"ENABLE_HTTPS" envDefault:"false"` + HTTPSCertFile string `env:"HTTPS_CERT_FILE" envDefault:""` + HTTPSKeyFile string `env:"HTTPS_KEY_FILE" envDefault:""` + MetricsAddress string `env:"METRICS_ADDRESS" envDefault:":9090"` + AuthConfigFile string `env:"AUTH_CONFIG_FILE" envDefault:"config/emailsender-authz.yaml"` + AuthConfigFromKubernetes bool `env:"AUTH_CONFIG_FROM_KUBERNETES" envDefault:"false"` + AuthConfig AuthConfig } // GetConfig retrieves the current runtime configuration from the environment and returns it. @@ -43,9 +58,28 @@ func GetConfig() (*Config, error) { } } - auth := &AuthConfig{file: c.AuthConfigFile} - if err := auth.ReadFile(); err != nil { - configErrors.AddError(err) + auth := &AuthConfig{ + configFile: c.AuthConfigFile, + saTokenFile: defaultSATokenFile, + k8sSvcURL: k8sAPISvc, + jwksDir: os.TempDir(), + } + + var authError error + if c.AuthConfigFromKubernetes { + client, err := k8sSvcClient() + if err != nil { + authError = err + } else { + auth.httpClient = client + authError = auth.readFromKubernetes() + } + } else { + authError = auth.readFile() + } + + if authError != nil { + configErrors.AddError(authError) } c.AuthConfig = *auth @@ -56,18 +90,28 @@ func GetConfig() (*Config, error) { return &c, nil } +type oidcConfig struct { + JwksURI string `json:"jwks_uri"` + Issuer string `json:"issuer"` +} + // AuthConfig is the configuration for authn/authz for the emailsender type AuthConfig struct { - file string + configFile string + saTokenFile string + k8sSvcURL string + httpClient *http.Client + jwksDir string JwksURLs []string `yaml:"jwks_urls"` + JwksFiles []string `yaml:"jwks_files"` AllowedIssuer []string `yaml:"allowed_issuers"` AllowedOrgIDs []string `yaml:"allowed_org_ids"` AllowedAudiences []string `yaml:"allowed_audiences"` } -// ReadFile reads the config -func (c *AuthConfig) ReadFile() error { - fileContents, err := shared.ReadFile(c.file) +// readFile reads the config +func (c *AuthConfig) readFile() error { + fileContents, err := shared.ReadFile(c.configFile) if err != nil { return fmt.Errorf("failed to read emailsender authz config: %w", err) } @@ -79,3 +123,118 @@ func (c *AuthConfig) ReadFile() error { return nil } + +// readFromKubernetes uses the service account token and the Kubernetes api +// to derive an AuthConfig from the Kubernetes openid-configuration +func (c *AuthConfig) readFromKubernetes() error { + // we need the own SA token to be able to authenticate to the jwks key endpoint + // since we're not allowed to call it with an anonymous user + tokenBytes, err := shared.ReadFile(c.saTokenFile) + if err != nil { + return fmt.Errorf("failed to read service account token from file %w", err) + } + + token := string(tokenBytes) + oidcCfg, err := c.getOIDCConfig(token) + if err != nil { + return err + } + + jwksBytes, err := c.getJWKS(oidcCfg, token) + if err != nil { + return err + } + + jwksFilePath := path.Join(c.jwksDir, "jwks.json") + // We store this in a file because the OCM SDK middleware we use for auth + // isn't able to call a jwks URL that requires authentication. + // As a workaround we can pre-load the jwks to a file and use the JwksFile option of that SDK. + if err := os.WriteFile(jwksFilePath, jwksBytes, 0644); err != nil { + return fmt.Errorf("failed to store jwks file in temp dir: %w", err) + } + + // for default svc account token issuer == audience + c.AllowedAudiences = []string{oidcCfg.Issuer} + c.AllowedIssuer = []string{oidcCfg.Issuer} + c.JwksFiles = []string{jwksFilePath} + + return nil +} + +func (c *AuthConfig) getOIDCConfig(token string) (oidcConfig, error) { + var oidcCfg oidcConfig + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", c.k8sSvcURL, wellKnownPath), nil) + if err != nil { + return oidcCfg, fmt.Errorf("failed to create HTTP request for openid configuration: %w", err) + } + addAuthHeader(req, token) + + oidcCfgRes, err := c.httpClient.Do(req) + if err != nil { + return oidcCfg, fmt.Errorf("failed to send HTTP requests for openid configuration: %w", err) + } + defer utils.IgnoreError(oidcCfgRes.Body.Close) + + if oidcCfgRes.StatusCode != 200 { + return oidcCfg, fmt.Errorf("HTTP request for openid configuration failed with status: %d", oidcCfgRes.StatusCode) + } + + if err := json.NewDecoder(oidcCfgRes.Body).Decode(&oidcCfg); err != nil { + return oidcCfg, fmt.Errorf("failed to decoded openid configuration response body: %w", err) + } + + return oidcCfg, nil +} + +func (c *AuthConfig) getJWKS(oidcCfg oidcConfig, token string) ([]byte, error) { + // replacing the potentially public facing JWKS url with the cluster internal k8sSvcURL + // since we don't want to call the endpoint via ingress but within the cluster + jwksPath := jwksPathFromURL(oidcCfg.JwksURI) + jwksRequest, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", c.k8sSvcURL, jwksPath), nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request for jwks: %w", err) + } + addAuthHeader(jwksRequest, token) + + jwksRes, err := c.httpClient.Do(jwksRequest) + if err != nil { + return nil, fmt.Errorf("failed to send HTTP request for jwks: %w", err) + } + defer utils.IgnoreError(jwksRes.Body.Close) + + if jwksRes.StatusCode != 200 { + return nil, fmt.Errorf("jwks key request failed with status code: %d", jwksRes.StatusCode) + } + + jwksBytes, err := io.ReadAll(jwksRes.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body for jwks: %w", err) + } + + return jwksBytes, nil +} + +func jwksPathFromURL(url string) string { + jwksPath, _ := strings.CutPrefix(url, "https://") + jwksPath, _ = strings.CutPrefix(jwksPath, "http://") + _, jwksPath, _ = strings.Cut(jwksPath, "/") + return jwksPath +} + +func addAuthHeader(req *http.Request, token string) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) +} + +func k8sSvcClient() (*http.Client, error) { + tlsConf, err := shared.TLSWithAdditionalCAs(defaultKubernetesCAFile) + if err != nil { + return nil, fmt.Errorf("failed to create tls conf: %w", err) + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConf, + }, + }, nil +} diff --git a/emailsender/config/config_test.go b/emailsender/config/config_test.go index c55af2c080..22469219a9 100644 --- a/emailsender/config/config_test.go +++ b/emailsender/config/config_test.go @@ -1,6 +1,12 @@ package config import ( + "fmt" + "io" + "net/http" + "os" + "path" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -64,3 +70,96 @@ func TestGetConfigFailureEnabledHTTPSOnly(t *testing.T) { assert.Error(t, err) assert.Nil(t, cfg) } + +func TestJWKSPathFromURL(t *testing.T) { + + tests := map[string]struct { + url string + expected string + }{ + "crc url": { + url: "https://api-int.crc.testing:6443/openid/v1/jwks", + expected: "openid/v1/jwks", + }, + "dataplane internal url": { + url: "https://10.0.145.59:6443/openid/v1/jwks", + expected: "openid/v1/jwks", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := jwksPathFromURL(tc.url) + require.Equal(t, tc.expected, actual) + }) + } +} + +// copied from a CRC openid-configuration response +const exampleOidcCfgContent = `{"issuer":"https://kubernetes.default.svc","jwks_uri":"https://api-int.crc.testing:6443/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"]}` + +// copied from a CRC jwks response +const exampleJwksContent = `{"keys":[{"use":"sig","kty":"RSA","kid":"fopPsQkHnyVQN7buPdX_dZprJGWLS9yUB3snAklSwrU","alg":"RS256","n":"0xQ7zns3GOmClc5MLs4auWGrxndZnZ_UbzUC7gfhG2aIoUoJ7E8M5OVwl403nHo4mL8-7Q-U7xj59SFgLOfCCSbppW1VlaIec848RknnACSB-BArOKpoNliiSV5825P1ASgb2m5OJPdDTB6fe-7dSEXk_YjOVzuQUDB12b7oV6gjpKDspCAuK7jPiGyW_HrdavCPJu8zmHFmJUK8nhAE2eJy54BK4u7Iy6B8-al6Ah2ljxKrp_u6YQDyV_uXg4DjGM0iyZNOONmUdBrVKbnlUxtUYD-FIZgQxJad7qNX19dPt4yJE3DLZt4uA8A4GP6W-ZeI87AuvAVc0JXF_UJQiQ","e":"AQAB"},{"use":"sig","kty":"RSA","kid":"_xRpmmptK7pO0biiahy7FfW9msL0bOWSiUYHGfMBSjo","alg":"RS256","n":"wYWZZSARuDz1XCgaJ_MEG9znRz_9261tbZqsYpML2rioc41_8oRZE1QYKRUmHQnF51xkM6TuJr8lr10bP8mi17L_Y5UutQtCWuTKhwDwBfy3Bb-_dXwo9DCuK8gMryVcwViMWlOhFJ_573dpSfoQ3eyP3JkKMSFcn_aO5VVhJPvphDOj8cD8eJOilWCjjObpfNqHlcQUZ-rT15B6KzsVD_62SiecO6aEFU8jOYJGZOdCc4mp4ava7EW3jxbOAr3izTK781VS-PcuAQ1CxQA_H_Iwx1FMos70o0dxtFhZv0CNXQ9afATYha9vybksUaTkCStRI0hxnQaRoFzhsodiT0WH_JxsWBLrn38YWphAzTqMC8PtZYoaJnTzyme5Pq30xfr6Z_T-zk0TEB2PZ4jlw-2S2s3rxOPykaBf7tUOcrKzA0YZfn5LC_1DIt7B_IxGxjw5JMhz4-V15D-zOr0Mb0HWFnfhA1pNqNSGt9MdQMAVFGFP7PceKnthz0AqNI2u_J1f4KtuF_NCkmtyqlidevZD__QcKmobEpC81Zq05jNOLdDODpwQ9jdEMQKmlRZbVYdK8j0HcVhPZSMmWFMop94mwh9zuLkTr1xbv1Acnt9uUBaD6YYtC4TOSPAbKQUQfvZlwaeMp2RDclriafrEcbIq5P10oJFCs5mmjJH3KuM","e":"AQAB"},{"use":"sig","kty":"RSA","kid":"IXZasI0jKhRyIDobBVzn9WK28OFK0nf3csqvVacUYRw","alg":"RS256","n":"qZoqs8fW9RGNms1cTbTCRp9K1FNJDRPA16YcyyEBxMyA52g3lEtD7Qt59enBO4ecTj6E4_2qMQIvOSiq1scG5aROhgdG1ikXzFJP1oZiYBYUZ11tWtvH340mYNmucGQBjDOFtFZw8g-5JTir7PL2zdt1JtM2fyT9PIwXfsWtS9pedcMAJ0qFcv63JdTef3yxIbbpKPGjnOGZALSSP_GRpcXyUPGzByRZOcNcjYzcdU2bBed9x7pLz0ryv_E75mXnDN5FXi3oUI_WfMb_7s4ctV-RAe_KcFQVQ1O5CwUj7u6diRXZSZF_XiN09JPgOh1x8B_roviWr_ZrxA4uz9OcmxpzjTCJvO5V3L6S_WBwM4KhAdo2Cln1_oFdf_0FVx42iu9WPplNoBYDrHLxIXJXgykPQKsCBTiD9x6jHnDE60MENiVAg7MqaXvZ2JqtA7QDO8tUqhsPQTwNfJoowfofugKUhUpKl3KH8U5B-UY8Any3yvjtSv9uLxEUHMX-px-pPQGbtzcTgpyp2NNoxI_36HfFDX54HsEJRCUk4-E7S5XPu_e27iC4CFlvOIn7J6OZsnDIsLQFS2ff_uUx3ONIPiE3rDtFRo4ayE1iswCbJRHGIvYCBI3St5PbF9KCtKkBUsqoMEqqtgE06D5IsRCMZs6cgmAp9Reh93B9flTrh-8","e":"AQAB"}]}` // pragma: allowlist secret + +func TestAuthConfigFromKubernetes(t *testing.T) { + t.Setenv("AUTH_CONFIG_FROM_KUBERNETES", "true") + testDir := t.TempDir() + tokenFile := path.Join(testDir, "token") + expectedToken := "faketoken" + + err := os.WriteFile(tokenFile, []byte(expectedToken), 0644) + require.NoError(t, err, "failed to write test token to file") + + rt := fakeK8sRoundTripper{ + expectedToken: expectedToken, + returnOidcCfg: exampleOidcCfgContent, + returnJwks: exampleJwksContent, + t: t, + } + fakeK8sClient := &http.Client{Transport: rt} + + authCfg := &AuthConfig{ + saTokenFile: tokenFile, + httpClient: fakeK8sClient, + k8sSvcURL: "localhost.testservice", + jwksDir: testDir, + } + + err = authCfg.readFromKubernetes() + require.NoError(t, err) + require.Equal(t, []string{"https://kubernetes.default.svc"}, authCfg.AllowedIssuer, "issuers do not match") + require.Equal(t, []string{"https://kubernetes.default.svc"}, authCfg.AllowedAudiences, "audiences do not match") + require.Equal(t, []string{path.Join(testDir, "jwks.json")}, authCfg.JwksFiles, "jwks files do not match") +} + +type fakeK8sRoundTripper struct { + expectedToken string + returnOidcCfg string + returnJwks string + t *testing.T +} + +func (f fakeK8sRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + token := req.Header.Get("Authorization") + require.Equal(f.t, fmt.Sprintf("Bearer %s", f.expectedToken), token, "request token did not match expected token") + + res := &http.Response{} + + switch url := req.URL.String(); { + case strings.Contains(url, wellKnownPath): + res.StatusCode = 200 + res.Body = readCloserFromString(exampleOidcCfgContent) + case strings.Contains(url, "jwks"): + res.StatusCode = 200 + res.Body = readCloserFromString(exampleJwksContent) + default: + res.StatusCode = 404 + res.Body = readCloserFromString("") + } + + return res, nil +} + +func readCloserFromString(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +} diff --git a/emailsender/pkg/api/routes.go b/emailsender/pkg/api/routes.go index e5ffff95f2..0e3ebb99f9 100644 --- a/emailsender/pkg/api/routes.go +++ b/emailsender/pkg/api/routes.go @@ -77,6 +77,10 @@ func buildAuthnHandler(router http.Handler, cfg config.AuthConfig) (http.Handler authnHandlerBuilder.KeysURL(keyURL) } + for _, keyFile := range cfg.JwksFiles { + authnHandlerBuilder.KeysFile(keyFile) + } + authHandler, err := authnHandlerBuilder.Build() if err != nil { return nil, errors.Wrap(err, "failed to create authentication handler") diff --git a/pkg/shared/tls.go b/pkg/shared/tls.go new file mode 100644 index 0000000000..f925f8f410 --- /dev/null +++ b/pkg/shared/tls.go @@ -0,0 +1,29 @@ +package shared + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" +) + +// TLSWithAdditionalCAs returns a tls config with addiotional trusted ca certificates. +// It uses the systems default certificates and appends the CA certificates in the given files. +func TLSWithAdditionalCAs(caFiles ...string) (*tls.Config, error) { + rootCAs, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("failed to load system cert pool: %w", err) + } + + for _, caFile := range caFiles { + ca, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("failed to read ca file '%s': %w", caFile, err) + } + rootCAs.AppendCertsFromPEM(ca) + } + + return &tls.Config{ + RootCAs: rootCAs, + }, nil +} diff --git a/pkg/shared/tls_test.go b/pkg/shared/tls_test.go new file mode 100644 index 0000000000..ae9fb6c918 --- /dev/null +++ b/pkg/shared/tls_test.go @@ -0,0 +1,103 @@ +package shared + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path" + "testing" + "time" + + "github.com/stackrox/rox/pkg/certgen" + "github.com/stackrox/rox/pkg/mtls" + "github.com/stackrox/rox/pkg/retry" + "github.com/stretchr/testify/require" +) + +func TestTLSWithAdditonalCA(t *testing.T) { + ca, err := certgen.GenerateCA() + require.NoError(t, err, "failed to generate test CA") + + caDir := t.TempDir() + filePath := path.Join(caDir, "cert.pem") + err = os.WriteFile(filePath, ca.CertPEM(), 0644) + require.NoError(t, err, "failed to write test CA to file") + + testServerCalled := false + tlsServ := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testServerCalled = true + w.WriteHeader(200) + })) + + tlsServ.TLS = &tls.Config{ + Certificates: []tls.Certificate{generateTestServerCert(t, ca)}, + } + + tlsServ.StartTLS() + defer tlsServ.Close() + + tlsConf, err := TLSWithAdditionalCAs(filePath) + require.NoError(t, err, "failed to create tls config") + + httpClient := http.Client{Transport: &http.Transport{ + TLSClientConfig: tlsConf, + }} + + err = retry.WithRetry( + // there's a chance the first call fails on tests depending on + // server startup timing + func() error { + _, err := httpClient.Get(tlsServ.URL) + return err + }, + retry.Tries(3), + retry.BetweenAttempts(func(_ int) { + time.Sleep(1 * time.Second) + }), + ) + + require.NoError(t, err, "expected HTTP call to test server to succeed") + require.True(t, testServerCalled, "expected test server to be called succesfully") +} + +func generateTestServerCert(t *testing.T, ca mtls.CA) tls.Certificate { + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + BasicConstraintsValid: true, + IsCA: false, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + certKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "failed to generate test server TLS key") + + certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Certificate(), certKey.Public(), ca.PrivateKey()) + require.NoError(t, err, "failed to generate test server TLS cert") + + certPem := &bytes.Buffer{} + err = pem.Encode(certPem, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + require.NoError(t, err, "failed to encode test server TLS cert to pem") + + keyDER, err := x509.MarshalPKCS8PrivateKey(certKey) + require.NoError(t, err, "failed to marshal test server TLS key") + keyPem := &bytes.Buffer{} + err = pem.Encode(keyPem, &pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + require.NoError(t, err, "failed to encode test server TLS key to pem") + + cert, err := tls.X509KeyPair(certPem.Bytes(), keyPem.Bytes()) + require.NoError(t, err, "failed to create test server TLS key pair") + return cert +}