Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ROX-23255: emailsender read auth cfg from kubernetes #1860

Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dp-terraform/helm/rhacs-terraform/templates/emailsender.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dp-terraform/helm/rhacs-terraform/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ emailsender:
clusterId: ""
clusterName: ""
environment: ""
authConfigFromKubernetes: true
resources:
requests:
cpu: "100m"
Expand Down
186 changes: 171 additions & 15 deletions emailsender/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,41 @@
package config

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"

"github.com/caarlos0/env/v6"
"gopkg.in/yaml.v2"

"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.
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -79,3 +123,115 @@ 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")
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", 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
}
98 changes: 98 additions & 0 deletions emailsender/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package config

import (
"io"
"net/http"
"os"
"path"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -64,3 +69,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, 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))
}
4 changes: 4 additions & 0 deletions emailsender/pkg/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
29 changes: 29 additions & 0 deletions pkg/shared/tls.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kurlov I copied a lot of this logic from the PR you already review in the core repo. The function itself is still somewhat different, as it errors on failed certificates loads and does not return a full http.Transport like here.

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
}
Loading
Loading