diff --git a/CHANGELOG.md b/CHANGELOG.md index 529311e..96746e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [0.12.0] - 2024-05-05 + +### Changed +- Reworked JWT authentication to use standard configuration options (CNJR-4190) + ## [0.11.4] - 2024-05-09 ### Security diff --git a/Dockerfile b/Dockerfile index 3c00e8d..9e30458 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,17 @@ ARG FROM_IMAGE="golang:1.22" FROM ${FROM_IMAGE} -MAINTAINER Conjur Inc. +LABEL maintainer="CyberArk Software Ltd." CMD /bin/bash EXPOSE 8080 -RUN apt update -y && \ - apt install -y bash \ - gcc \ - git \ - jq \ - less \ - libc-dev +RUN apt-get update -y && \ + apt-get install -y bash \ + gcc \ + git \ + jq \ + less \ + libc-dev RUN go install github.com/jstemmer/go-junit-report@latest && \ go install github.com/axw/gocov/gocov@latest && \ diff --git a/README.md b/README.md index b5d5762..01b02bc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ questions, please contact us on [Discourse](https://discuss.cyberarkcommons.org/ The `conjur-api-go` has been tested against the following Go versions: + - 1.20 - 1.21 - 1.22 @@ -191,6 +192,6 @@ guide][contrib]. ## License -Copyright (c) 2022 CyberArk Software Ltd. All rights reserved. +Copyright (c) 2022-2024 CyberArk Software Ltd. All rights reserved. This repository is licensed under Apache License 2.0 - see [`LICENSE`](LICENSE) for more details. diff --git a/conjurapi/authn.go b/conjurapi/authn.go index 6a69a48..e730843 100644 --- a/conjurapi/authn.go +++ b/conjurapi/authn.go @@ -251,6 +251,26 @@ func (c *Client) OidcAuthenticate(code, nonce, code_verifier string) ([]byte, er return resp, err } +func (c *Client) JWTAuthenticate(jwt, hostID string) ([]byte, error) { + req, err := c.JWTAuthenticateRequest(jwt, hostID) + if err != nil { + return nil, err + } + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + + resp, err := response.DataResponse(res) + + if err == nil && c.storage != nil { + c.storage.StoreAuthnToken(resp) + } + + return resp, err +} + func (c *Client) ListOidcProviders() ([]OidcProvider, error) { req, err := c.ListOidcProvidersRequest() if err != nil { diff --git a/conjurapi/authn/jwt_authenticator.go b/conjurapi/authn/jwt_authenticator.go new file mode 100644 index 0000000..85d712d --- /dev/null +++ b/conjurapi/authn/jwt_authenticator.go @@ -0,0 +1,63 @@ +package authn + +import ( + "fmt" + "os" + + "github.com/cyberark/conjur-api-go/conjurapi/logging" +) + +type JWTAuthenticator struct { + JWT string + JWTFilePath string + HostID string + Authenticate func(jwt, hostId string) ([]byte, error) +} + +const k8sJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +func (a *JWTAuthenticator) RefreshToken() ([]byte, error) { + err := a.RefreshJWT() + if err != nil { + return nil, fmt.Errorf("Failed to refresh JWT: %v", err) + } + return a.Authenticate(a.JWT, a.HostID) +} + +func (a *JWTAuthenticator) NeedsTokenRefresh() bool { + return false +} + +func (a *JWTAuthenticator) RefreshJWT() error { + // If a JWT token is already set or retrieved, do nothing. + if a.JWT != "" { + logging.ApiLog.Debugf("Using stored JWT") + return nil + } + + // If a token file path is provided, read the JWT token from the file. + // Otherwise, read the token from the default Kubernetes service account path. + var jwtFilePath string + if a.JWTFilePath != "" { + logging.ApiLog.Debugf("Reading JWT from %s", a.JWTFilePath) + jwtFilePath = a.JWTFilePath + } else { + jwtFilePath = k8sJWTPath + logging.ApiLog.Debugf("No JWT file path set. Attempting to ready JWT from %s", jwtFilePath) + } + + token, err := readJWTFromFile(jwtFilePath) + if err != nil { + return err + } + a.JWT = token + return nil +} + +func readJWTFromFile(filePath string) (string, error) { + bytes, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return string(bytes), nil +} diff --git a/conjurapi/authn/jwt_authenticator_test.go b/conjurapi/authn/jwt_authenticator_test.go new file mode 100644 index 0000000..cd9dbaf --- /dev/null +++ b/conjurapi/authn/jwt_authenticator_test.go @@ -0,0 +1,105 @@ +package authn + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJWTAuthenticator_RefreshToken(t *testing.T) { + // Test that the RefreshToken method calls the Authenticate method + t.Run("Calls Authenticate with stored JWT", func(t *testing.T) { + authenticator := JWTAuthenticator{ + Authenticate: func(jwt, hostid string) ([]byte, error) { + assert.Equal(t, "jwt", jwt) + assert.Equal(t, "", hostid) + return []byte("token"), nil + }, + JWT: "jwt", + } + + token, err := authenticator.RefreshToken() + + assert.NoError(t, err) + assert.Equal(t, []byte("token"), token) + }) + + t.Run("Calls Authenticate with JWT from file", func(t *testing.T) { + tempDir := t.TempDir() + err := os.WriteFile(filepath.Join(tempDir, "jwt"), []byte("jwt-content"), 0600) + assert.NoError(t, err) + + authenticator := JWTAuthenticator{ + Authenticate: func(jwt, hostid string) ([]byte, error) { + assert.Equal(t, "jwt-content", jwt) + assert.Equal(t, "host-id", hostid) + return []byte("token"), nil + }, + JWTFilePath: filepath.Join(tempDir, "jwt"), + HostID: "host-id", + } + + token, err := authenticator.RefreshToken() + assert.NoError(t, err) + assert.Equal(t, []byte("token"), token) + }) + + t.Run("Defaults to Kubernetes service account path", func(t *testing.T) { + authenticator := JWTAuthenticator{ + Authenticate: func(jwt, hostid string) ([]byte, error) { + assert.Equal(t, "k8s-jwt-content", jwt) + assert.Equal(t, "", hostid) + return []byte("token"), nil + }, + } + + // Note: this may fail when not running in a container + err := os.MkdirAll(filepath.Dir(k8sJWTPath), 0755) + assert.NoError(t, err) + err = os.WriteFile(k8sJWTPath, []byte("k8s-jwt-content"), 0600) + assert.NoError(t, err) + + token, err := authenticator.RefreshToken() + assert.NoError(t, err) + assert.Equal(t, []byte("token"), token) + + t.Cleanup(func() { + os.Remove(k8sJWTPath) + }) + }) + + t.Run("Returns error when Authenticate fails", func(t *testing.T) { + authenticator := JWTAuthenticator{ + Authenticate: func(jwt, hostid string) ([]byte, error) { + return nil, assert.AnError + }, + } + + token, err := authenticator.RefreshToken() + assert.Error(t, err) + assert.Nil(t, token) + }) + + t.Run("Returns error when no JWT provided", func(t *testing.T) { + authenticator := JWTAuthenticator{ + Authenticate: func(jwt, hostid string) ([]byte, error) { + return nil, nil + }, + } + + token, err := authenticator.RefreshToken() + assert.ErrorContains(t, err, "Failed to refresh JWT") + assert.Nil(t, token) + }) +} + +func TestJWTAuthenticator_NeedsTokenRefresh(t *testing.T) { + t.Run("Returns false", func(t *testing.T) { + // Test that the NeedsTokenRefresh method always returns false + authenticator := JWTAuthenticator{} + + assert.False(t, authenticator.NeedsTokenRefresh()) + }) +} diff --git a/conjurapi/authn_test.go b/conjurapi/authn_test.go index a7d6a1a..6534af7 100644 --- a/conjurapi/authn_test.go +++ b/conjurapi/authn_test.go @@ -329,6 +329,18 @@ func TestClient_Login(t *testing.T) { assert.Contains(t, string(contents), client.GetConfig().ApplianceURL+"/authn-oidc/test-service-id") assert.Contains(t, string(contents), "test-token-oidc") }) + + t.Run("JWT authentication", func(t *testing.T) { + ts, client := setupTestClient(t) + defer ts.Close() + + client.config.AuthnType = "jwt" + client.config.ServiceID = "test-service-id" + + token, err := client.JWTAuthenticate("jwt", "") + assert.NoError(t, err) + assert.Equal(t, "test-token-jwt", string(token)) + }) } func TestClient_AuthenticateReader(t *testing.T) { @@ -632,6 +644,9 @@ func setupTestClient(t *testing.T) (*httptest.Server, *Client) { } else if strings.HasSuffix(r.URL.Path, "/authn-oidc/test-service-id/cucumber/authenticate") { w.WriteHeader(http.StatusOK) w.Write([]byte("test-token-oidc")) + } else if strings.HasSuffix(r.URL.Path, "/authn-jwt/test-service-id/cucumber/authenticate") { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test-token-jwt")) } else if strings.HasSuffix(r.URL.Path, "/authn-oidc/cucumber/providers") { w.WriteHeader(http.StatusOK) w.Write([]byte(`[{"service_id": "test-service-id"}]`)) diff --git a/conjurapi/client.go b/conjurapi/client.go index 6f8dbb2..ebe347e 100644 --- a/conjurapi/client.go +++ b/conjurapi/client.go @@ -14,7 +14,6 @@ import ( "github.com/cyberark/conjur-api-go/conjurapi/authn" "github.com/cyberark/conjur-api-go/conjurapi/logging" - "github.com/cyberark/conjur-api-go/conjurapi/response" ) type Authenticator interface { @@ -114,9 +113,8 @@ func NewClientFromEnvironment(config Config) (*Client, error) { return NewClientFromToken(config, authnToken) } - authnJwtServiceID := os.Getenv("CONJUR_AUTHN_JWT_SERVICE_ID") - if authnJwtServiceID != "" { - return NewClientFromJwt(config, authnJwtServiceID) + if config.JWTFilePath != "" || os.Getenv("CONJUR_AUTHN_JWT_SERVICE_ID") != "" { + return NewClientFromJwt(config) } loginPair, err := LoginPairFromEnv() @@ -127,63 +125,20 @@ func NewClientFromEnvironment(config Config) (*Client, error) { return newClientFromStoredCredentials(config) } -func NewClientFromJwt(config Config, authnJwtServiceID string) (*Client, error) { - var jwtTokenString string - jwtToken := os.Getenv("CONJUR_AUTHN_JWT_TOKEN") - jwtTokenString = fmt.Sprintf("jwt=%s", jwtToken) - if jwtToken == "" { - jwtTokenPath := os.Getenv("JWT_TOKEN_PATH") - if jwtTokenPath == "" { - jwtTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" - } - - jwtToken, err := os.ReadFile(jwtTokenPath) - if err != nil { - return nil, err - } - jwtTokenString = fmt.Sprintf("jwt=%s", string(jwtToken)) - } - - var httpClient *http.Client - if config.IsHttps() { - cert, err := config.ReadSSLCert() - if err != nil { - return nil, err - } - httpClient, err = newHTTPSClient(cert, config) - if err != nil { - return nil, err - } - - } else { - httpClient = &http.Client{Timeout: time.Second * time.Duration(config.GetHttpTimeout())} - } - - authnJwtHostID := os.Getenv("CONJUR_AUTHN_JWT_HOST_ID") - var authnJwtUrl string - if authnJwtHostID != "" { - authnJwtUrl = makeRouterURL(config.ApplianceURL, "authn-jwt", authnJwtServiceID, config.Account, url.PathEscape(authnJwtHostID), "authenticate").String() - } else { - authnJwtUrl = makeRouterURL(config.ApplianceURL, "authn-jwt", authnJwtServiceID, config.Account, "authenticate").String() +func NewClientFromJwt(config Config) (*Client, error) { + authenticator := &authn.JWTAuthenticator{ + JWT: config.JWTContent, + JWTFilePath: config.JWTFilePath, + HostID: config.JWTHostID, } - - req, err := http.NewRequest("POST", authnJwtUrl, strings.NewReader(jwtTokenString)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - - token, err := response.DataResponse(resp) - if err != nil { - return nil, err + client, err := newClientWithAuthenticator( + config, + authenticator, + ) + if err == nil { + authenticator.Authenticate = client.JWTAuthenticate } - - return NewClientFromToken(config, string(token)) + return client, err } func newClientFromStoredCredentials(config Config) (*Client, error) { @@ -285,6 +240,24 @@ func (c *Client) AuthenticateRequest(loginPair authn.LoginPair) (*http.Request, return req, nil } +func (c *Client) JWTAuthenticateRequest(token, hostID string) (*http.Request, error) { + var authenticateURL string + if hostID != "" { + authenticateURL = makeRouterURL(c.authnURL(), url.PathEscape(hostID), "authenticate").String() + } else { + authenticateURL = makeRouterURL(c.authnURL(), "authenticate").String() + } + + token = fmt.Sprintf("jwt=%s", token) + req, err := http.NewRequest("POST", authenticateURL, strings.NewReader(token)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return req, nil +} + func (c *Client) ListOidcProvidersRequest() (*http.Request, error) { return http.NewRequest("GET", c.oidcProvidersUrl(), nil) } @@ -513,15 +486,15 @@ func (c *Client) LoadPolicyRequest(mode PolicyMode, policyID string, policy io.R } routerUrl := makeRouterURL( - c.policiesURL(account), - kind, - url.QueryEscape(id), + c.policiesURL(account), + kind, + url.QueryEscape(id), ) - + if validate { - routerUrl = routerUrl.withQuery("validate=true") + routerUrl = routerUrl.withQuery("validate=true") } - + policyURL := routerUrl.String() var method string diff --git a/conjurapi/client_test.go b/conjurapi/client_test.go index 0cc5ac2..3f45c1d 100644 --- a/conjurapi/client_test.go +++ b/conjurapi/client_test.go @@ -95,10 +95,8 @@ func TestNewClientFromEnvironment(t *testing.T) { config := Config{Account: "account", ApplianceURL: "appliance-url"} t.Setenv("CONJUR_AUTHN_JWT_SERVICE_ID", "jwt-service") client, err := NewClientFromEnvironment(config) - - // Expect it to fail without a mocked JWT server - assert.Error(t, err) - assert.Nil(t, client) + assert.NoError(t, err) + assert.IsType(t, &authn.JWTAuthenticator{}, client.authenticator) }) t.Run("Calls NewClientFromKey with when LoginPair is retrieved from env variables", func(t *testing.T) { config := Config{Account: "account", ApplianceURL: "appliance-url"} @@ -138,14 +136,26 @@ func TestNewClientFromEnvironment(t *testing.T) { func TestNewClientFromJwt(t *testing.T) { t.Run("Fetches config but fails due to unreachable host", func(t *testing.T) { - config := Config{Account: "account", ApplianceURL: "https://appliance-url", SSLCert: sample_cert} - t.Setenv("CONJUR_AUTHN_JWT_TOKEN", "jwt-token") + config := Config{ + Account: "account", + ApplianceURL: "https://appliance-url", + SSLCert: sample_cert, + AuthnType: "jwt", + ServiceID: "jwt-service", + JWTContent: "jwt-token", + } - client, err := NewClientFromJwt(config, "jwt-service") + client, err := NewClientFromJwt(config) + assert.NoError(t, err) + assert.NotNil(t, client) + + // Verify that the client authenticator is of type TokenAuthenticator + assert.IsType(t, &authn.JWTAuthenticator{}, client.authenticator) // Expect it to fail without a mocked JWT server + token, err := client.authenticator.(*authn.JWTAuthenticator).RefreshToken() assert.Error(t, err) - assert.Nil(t, client) + assert.Equal(t, "", string(token)) }) t.Run("Fetches config and succeeds", func(t *testing.T) { @@ -153,17 +163,25 @@ func TestNewClientFromJwt(t *testing.T) { mockConjurServer := mockConjurServerWithJWT() defer mockConjurServer.Close() - config := Config{Account: "myaccount", ApplianceURL: mockConjurServer.URL} - t.Setenv("CONJUR_AUTHN_JWT_TOKEN", "jwt-token") + config := Config{ + Account: "myaccount", + ApplianceURL: mockConjurServer.URL, + AuthnType: "jwt", + ServiceID: "jwt-service", + JWTContent: "jwt-token", + } - client, err := NewClientFromJwt(config, "jwt-service") + client, err := NewClientFromJwt(config) assert.NoError(t, err) assert.NotNil(t, client) // Verify that the client authenticator is of type TokenAuthenticator - assert.IsType(t, &authn.TokenAuthenticator{}, client.authenticator) - // Verify that the auth token is set to the expected value - assert.Equal(t, "test-api-key", client.authenticator.(*authn.TokenAuthenticator).Token) + assert.IsType(t, &authn.JWTAuthenticator{}, client.authenticator) + + // Verify that the JWT authenticator succeeds + token, err := client.authenticator.(*authn.JWTAuthenticator).RefreshToken() + assert.NoError(t, err) + assert.Equal(t, "test-access-token", string(token)) }) t.Run("Fetches JWT from file", func(t *testing.T) { @@ -175,17 +193,23 @@ func TestNewClientFromJwt(t *testing.T) { err := os.WriteFile(tempDir+"/jwt-token", []byte("jwt-token"), 0644) assert.NoError(t, err) - config := Config{Account: "myaccount", ApplianceURL: mockConjurServer.URL} - t.Setenv("JWT_TOKEN_PATH", tempDir+"/jwt-token") + config := Config{ + Account: "myaccount", + ApplianceURL: mockConjurServer.URL, + AuthnType: "jwt", + ServiceID: "jwt-service", + JWTFilePath: tempDir + "/jwt-token", + } - client, err := NewClientFromJwt(config, "jwt-service") + client, err := NewClientFromJwt(config) assert.NoError(t, err) assert.NotNil(t, client) // Verify that the client authenticator is of type TokenAuthenticator - assert.IsType(t, &authn.TokenAuthenticator{}, client.authenticator) - // Verify that the auth token is set to the expected value - assert.Equal(t, "test-api-key", client.authenticator.(*authn.TokenAuthenticator).Token) + assert.IsType(t, &authn.JWTAuthenticator{}, client.authenticator) + // Verify that the JWT token is read correctly + client.authenticator.(*authn.JWTAuthenticator).RefreshJWT() + assert.Equal(t, "jwt-token", client.authenticator.(*authn.JWTAuthenticator).JWT) }) t.Run("Fetches config and fails with incorrect JWT", func(t *testing.T) { @@ -193,13 +217,21 @@ func TestNewClientFromJwt(t *testing.T) { mockConjurServer := mockConjurServerWithJWT() defer mockConjurServer.Close() - config := Config{Account: "myaccount", ApplianceURL: mockConjurServer.URL} - t.Setenv("CONJUR_AUTHN_JWT_TOKEN", "incorrect-jwt-token") + config := Config{ + Account: "myaccount", + ApplianceURL: mockConjurServer.URL, + AuthnType: "jwt", + ServiceID: "jwt-service", + JWTContent: "incorrect-jwt-token", + } - client, err := NewClientFromJwt(config, "jwt-service") - assert.Error(t, err) + client, err := NewClientFromJwt(config) + assert.NoError(t, err) + + // Expect it to fail without a mocked JWT server + token, err := client.authenticator.RefreshToken() assert.ErrorContains(t, err, "401 Unauthorized") - assert.Nil(t, client) + assert.Equal(t, "", string(token)) }) t.Run("Appends JWT Host ID to authn URL", func(t *testing.T) { @@ -207,25 +239,36 @@ func TestNewClientFromJwt(t *testing.T) { mockConjurServer := mockConjurServerWithJWT() defer mockConjurServer.Close() - config := Config{Account: "myaccount", ApplianceURL: mockConjurServer.URL} - t.Setenv("CONJUR_AUTHN_JWT_TOKEN", "jwt-token") - t.Setenv("CONJUR_AUTHN_JWT_HOST_ID", "my-host") // This should be added to the authn URL + config := Config{ + Account: "myaccount", + ApplianceURL: mockConjurServer.URL, + AuthnType: "jwt", + ServiceID: "jwt-service", + JWTContent: "jwt-token", + JWTHostID: "my-host", // This should be added to the authn URL + } - client, err := NewClientFromJwt(config, "jwt-service") + client, err := NewClientFromJwt(config) assert.NoError(t, err) assert.NotNil(t, client) - // Verify that the client authenticator is of type TokenAuthenticator - assert.IsType(t, &authn.TokenAuthenticator{}, client.authenticator) - // Verify that the auth token is set to the expected value - assert.Equal(t, "test-api-key-from-host", client.authenticator.(*authn.TokenAuthenticator).Token) + // Verify that the JWT authenticator succeeds + token, err := client.authenticator.RefreshToken() + assert.NoError(t, err) + assert.Equal(t, "test-access-token-with-host-id", string(token)) }) t.Run("Returns error when using nonexistent SSLCertPath", func(t *testing.T) { - config := Config{Account: "account", ApplianceURL: "https://appliance-url", SSLCertPath: "fake-path"} - t.Setenv("CONJUR_AUTHN_JWT_TOKEN", "jwt-token") + config := Config{ + Account: "account", + ApplianceURL: "https://appliance-url", + SSLCertPath: "fake-path", + AuthnType: "jwt", + ServiceID: "jwt-service", + JWTContent: "jwt-token", + } - client, err := NewClientFromJwt(config, "jwt-service") + client, err := NewClientFromJwt(config) assert.EqualError(t, err, "open fake-path: no such file or directory") assert.Nil(t, client) @@ -430,7 +473,7 @@ func TestClient_HttpClientTimeoutValue(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, client) - assert.Equal(t, time.Second * time.Duration(HttpTimeoutDefaultValue), client.Timeout) + assert.Equal(t, time.Second*time.Duration(HttpTimeoutDefaultValue), client.Timeout) }) t.Run("Create HTTP client with no timeout", func(t *testing.T) { config := Config{Account: "account", ApplianceURL: "http://appliance-url", HttpTimeout: -1} @@ -438,7 +481,7 @@ func TestClient_HttpClientTimeoutValue(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, client) - assert.Equal(t, time.Second * time.Duration(0), client.Timeout) + assert.Equal(t, time.Second*time.Duration(0), client.Timeout) }) t.Run("Create HTTP client with specific timeout", func(t *testing.T) { config := Config{Account: "account", ApplianceURL: "http://appliance-url", HttpTimeout: 5} @@ -446,7 +489,7 @@ func TestClient_HttpClientTimeoutValue(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, client) - assert.Equal(t, time.Second * time.Duration(5), client.Timeout) + assert.Equal(t, time.Second*time.Duration(5), client.Timeout) }) } @@ -459,14 +502,14 @@ func mockConjurServerWithJWT() *httptest.Server { if string(body) == "jwt=jwt-token" { w.WriteHeader(http.StatusOK) - w.Write([]byte("test-api-key")) + w.Write([]byte("test-access-token")) } else { w.WriteHeader(http.StatusUnauthorized) } } else if strings.HasSuffix(r.URL.Path, "/authn-jwt/jwt-service/myaccount/my-host/authenticate") { - // When a host is specified, return a different API key + // When a host is specified, return a different access token w.WriteHeader(http.StatusOK) - w.Write([]byte("test-api-key-from-host")) + w.Write([]byte("test-access-token-with-host-id")) } else { w.WriteHeader(http.StatusNotFound) } diff --git a/conjurapi/config.go b/conjurapi/config.go index 6653e12..17d1658 100644 --- a/conjurapi/config.go +++ b/conjurapi/config.go @@ -17,7 +17,7 @@ const ( HttpTimeoutDefaultValue = 10 ) -var supportedAuthnTypes = []string{"authn", "ldap", "oidc"} +var supportedAuthnTypes = []string{"authn", "ldap", "oidc", "jwt"} type Config struct { Account string `yaml:"account,omitempty"` @@ -28,6 +28,9 @@ type Config struct { AuthnType string `yaml:"authn_type,omitempty"` ServiceID string `yaml:"service_id,omitempty"` CredentialStorage string `yaml:"credential_storage,omitempty"` + JWTHostID string `yaml:"jwt_host_id,omitempty"` + JWTContent string `yaml:"-"` + JWTFilePath string `yaml:"jwt_file,omitempty"` HttpTimeout int `yaml:"-"` } @@ -50,10 +53,14 @@ func (c *Config) Validate() error { errors = append(errors, fmt.Sprintf("AuthnType must be one of %v", supportedAuthnTypes)) } - if (c.AuthnType == "ldap" || c.AuthnType == "oidc") && c.ServiceID == "" { + if (c.AuthnType == "ldap" || c.AuthnType == "oidc" || c.AuthnType == "jwt") && c.ServiceID == "" { errors = append(errors, fmt.Sprintf("Must specify a ServiceID when using %s", c.AuthnType)) } + if c.AuthnType == "jwt" && (c.JWTContent == "" && c.JWTFilePath == "") { + errors = append(errors, "Must specify a JWT token when using JWT authentication") + } + if len(errors) == 0 { return nil } else if logging.ApiLog.Level == logrus.DebugLevel { @@ -81,8 +88,8 @@ func (c *Config) BaseURL() string { return prefix + c.ApplianceURL } -// The GetHttpTimeout function retrieves the Timeout value from the config struc. -// If config.HttpTimeout is +// The GetHttpTimeout function retrieves the Timeout value from the config struc. +// If config.HttpTimeout is // - less than 0, GetHttpTimeout returns 0 (no timeout) // - equal to 0, GetHttpTimeout returns the default value (constant HttpTimeoutDefaultValue) // Otherwise, GetHttpTimeout returns the value of config.HttpTimeout @@ -113,6 +120,9 @@ func (c *Config) merge(o *Config) { c.CredentialStorage = mergeValue(c.CredentialStorage, o.CredentialStorage) c.AuthnType = mergeValue(c.AuthnType, o.AuthnType) c.ServiceID = mergeValue(c.ServiceID, o.ServiceID) + c.JWTHostID = mergeValue(c.JWTHostID, o.JWTHostID) + c.JWTContent = mergeValue(c.JWTContent, o.JWTContent) + c.JWTFilePath = mergeValue(c.JWTFilePath, o.JWTFilePath) } func (c *Config) mergeYAML(filename string) error { @@ -171,6 +181,16 @@ func (c *Config) mergeEnv() { CredentialStorage: os.Getenv("CONJUR_CREDENTIAL_STORAGE"), AuthnType: os.Getenv("CONJUR_AUTHN_TYPE"), ServiceID: os.Getenv("CONJUR_SERVICE_ID"), + JWTContent: os.Getenv("CONJUR_AUTHN_JWT_TOKEN"), + JWTFilePath: os.Getenv("JWT_TOKEN_PATH"), + JWTHostID: os.Getenv("CONJUR_AUTHN_JWT_HOST_ID"), + } + + if os.Getenv("CONJUR_AUTHN_JWT_SERVICE_ID") != "" { + // If the CONJUR_AUTHN_JWT_SERVICE_ID env var is set, we are implicitly using authn-jwt + env.AuthnType = "jwt" + // If using authn-jwt, CONJUR_AUTHN_JWT_SERVICE_ID overrides CONJUR_SERVICE_ID + env.ServiceID = mergeValue(env.ServiceID, os.Getenv("CONJUR_AUTHN_JWT_SERVICE_ID")) } logging.ApiLog.Debugf("Config from environment: %+v\n", env) diff --git a/conjurapi/config_test.go b/conjurapi/config_test.go index d8d1f64..69bda25 100644 --- a/conjurapi/config_test.go +++ b/conjurapi/config_test.go @@ -93,6 +93,21 @@ func TestConfig_IsValid(t *testing.T) { assert.Contains(t, errString, "AuthnType must be one of ") }) + t.Run("Return error for invalid configuration missing JWT", func(t *testing.T) { + config := Config{ + Account: "account", + ApplianceURL: "appliance-url", + AuthnType: "jwt", + ServiceID: "service-id", + } + + err := config.Validate() + assert.Error(t, err) + + errString := err.Error() + assert.Contains(t, errString, "Must specify a JWT token when using JWT authentication") + }) + t.Run("Includes config when debug logging is enabled", func(t *testing.T) { config := Config{ Account: "account", diff --git a/docker-compose.yml b/docker-compose.yml index 47f1523..52cabf0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '2.1' services: postgres: image: postgres:15