From cfae89f63cad8ed8b4c4fe9ca3543e3a804e3729 Mon Sep 17 00:00:00 2001 From: Sourav Sarangi Date: Tue, 7 May 2024 02:24:57 +0530 Subject: [PATCH] ccl,sql,util: Fix jwt auth and add sensitive error logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are running into issues with jwt authentication and currently unable to provide support as we are not logging the error from the http client used in the authenticator. The PR looks to propagate this obtained error from `ValidateJWTLogin` http client. We are also introducing `JWTClientCustomCA` cluster setting such that this could be configured directly for the http client used in authenticator. The http client now also respects the system http proxy if set. Validated the error details when presenting an expired token ``` ERROR: JWT authentication: invalid token SQLSTATE: 28000 DETAIL: unable to parse token: exp not satisfied Failed running "sql" ``` Validated error on setting wrong proxy params ``` ERROR: JWT authentication: unable to validate token SQLSTATE: 28000 Failed running "sql" ``` and logged error: ``` I240510 08:31:28.604141 1473 4@util/log/event_log.go:32 ⋮ [T1,Vsystem,n1,client=127.0.0.1:56289,hostssl,user=‹sourav.sarangi›] 3 ={"Timestamp":1715329888604122000,"EventType":"client_authentication_failed","InstanceID":1,"Network":"tcp","RemoteAddress":"‹127.0.0.1:56289›","SessionID":"17ce136f2a8ecd480000000000000001","Transport":"hostssl","User":"‹sourav.sarangi›","SystemIdentity":"‹sourav.sarangi›","Reason":"CREDENTIALS_INVALID","Detail":"JWT authentication: unable to validate token\nunable to fetch jwks: Get \"https://accounts.google.com/.well-known/openid-configuration\": proxyconnect tcp: dial tcp [::1]:3129: connect: connection refused","Method":"jwt_token"} ``` Verified access logs after setting up squid proxy and passing env HTTP_PROXY and HTTPS_PROXY params ``` 1715103871.761 144 ::1 TCP_TUNNEL/200 5708 CONNECT accounts.google.com:443 - HIER_DIRECT/74.125.200.84 - 1715103871.836 73 ::1 TCP_TUNNEL/200 5964 CONNECT www.googleapis.com:443 - HIER_DIRECT/142.250.182.10 - ``` fixes https://github.com/cockroachdb/cockroach/issues/123575, Epic CRDB-38386, CRDB-38408 Release note(security update): We are adding a cluster settings `server.jwt_authentication.client.custom_ca` which can tune the jwt auth behaviour when we need custom ca http client calls. --- .../settings/settings-for-tenants.txt | 1 + docs/generated/settings/settings.html | 1 + pkg/ccl/jwtauthccl/authentication_jwt.go | 70 +++++-- pkg/ccl/jwtauthccl/authentication_jwt_test.go | 191 ++++++++++++++---- pkg/ccl/jwtauthccl/settings.go | 22 +- pkg/ccl/jwtauthccl/testdata/ca.crt | 19 ++ pkg/ccl/jwtauthccl/testdata/node.crt | 20 ++ pkg/ccl/jwtauthccl/testdata/node.key | 27 +++ pkg/ccl/testccl/authccl/testdata/jwt | 2 + pkg/sql/pgwire/auth_methods.go | 13 +- pkg/util/httputil/client.go | 27 +++ 11 files changed, 317 insertions(+), 76 deletions(-) create mode 100644 pkg/ccl/jwtauthccl/testdata/ca.crt create mode 100644 pkg/ccl/jwtauthccl/testdata/node.crt create mode 100644 pkg/ccl/jwtauthccl/testdata/node.key diff --git a/docs/generated/settings/settings-for-tenants.txt b/docs/generated/settings/settings-for-tenants.txt index b1e4a760c5a1..c50d5e10f37f 100644 --- a/docs/generated/settings/settings-for-tenants.txt +++ b/docs/generated/settings/settings-for-tenants.txt @@ -88,6 +88,7 @@ server.hot_ranges_request.node.timeout duration 5m0s the duration allowed for a server.hsts.enabled boolean false if true, HSTS headers will be sent along with all HTTP requests. The headers will contain a max-age setting of one year. Browsers honoring the header will always use HTTPS to access the DB Console. Ensure that TLS is correctly configured prior to enabling. application server.http.base_path string / path to redirect the user to upon succcessful login application server.identity_map.configuration string system-identity to database-username mappings application +server.jwt_authentication.client.custom_ca string sets the custom root CA for verifying certificates when interacting with jwt internal HTTPS client application server.log_gc.max_deletions_per_cycle integer 1000 the maximum number of entries to delete on each purge of log-like system tables application server.log_gc.period duration 1h0m0s the period at which log-like system tables are checked for old entries application server.max_connections_per_gateway integer -1 the maximum number of SQL connections per gateway allowed at a given time (note: this will only limit future connection attempts and will not affect already established connections). Negative values result in unlimited number of connections. Superusers are not affected by this limit. application diff --git a/docs/generated/settings/settings.html b/docs/generated/settings/settings.html index 7aad458c1e17..3ffcbc0822b1 100644 --- a/docs/generated/settings/settings.html +++ b/docs/generated/settings/settings.html @@ -116,6 +116,7 @@
server.hsts.enabled
booleanfalseif true, HSTS headers will be sent along with all HTTP requests. The headers will contain a max-age setting of one year. Browsers honoring the header will always use HTTPS to access the DB Console. Ensure that TLS is correctly configured prior to enabling.Serverless/Dedicated/Self-Hosted
server.http.base_path
string/path to redirect the user to upon succcessful loginServerless/Dedicated/Self-Hosted
server.identity_map.configuration
stringsystem-identity to database-username mappingsServerless/Dedicated/Self-Hosted +
server.jwt_authentication.client.custom_ca
stringsets the custom root CA for verifying certificates when interacting with jwt internal HTTPS clientServerless/Dedicated/Self-Hosted
server.log_gc.max_deletions_per_cycle
integer1000the maximum number of entries to delete on each purge of log-like system tablesServerless/Dedicated/Self-Hosted
server.log_gc.period
duration1h0m0sthe period at which log-like system tables are checked for old entriesServerless/Dedicated/Self-Hosted
server.max_connections_per_gateway
integer-1the maximum number of SQL connections per gateway allowed at a given time (note: this will only limit future connection attempts and will not affect already established connections). Negative values result in unlimited number of connections. Superusers are not affected by this limit.Serverless/Dedicated/Self-Hosted diff --git a/pkg/ccl/jwtauthccl/authentication_jwt.go b/pkg/ccl/jwtauthccl/authentication_jwt.go index 8a106b46742e..664fbaef1666 100644 --- a/pkg/ccl/jwtauthccl/authentication_jwt.go +++ b/pkg/ccl/jwtauthccl/authentication_jwt.go @@ -71,6 +71,7 @@ type jwtAuthenticatorConf struct { jwks jwk.Set claim string jwksAutoFetchEnabled bool + customCA string } // reloadConfig locks mutex and then refreshes the values in conf from the cluster settings. @@ -91,6 +92,7 @@ func (authenticator *jwtAuthenticator) reloadConfigLocked( jwks: mustParseJWKS(JWTAuthJWKS.Get(&st.SV)), claim: JWTAuthClaim.Get(&st.SV), jwksAutoFetchEnabled: JWKSAutoFetchEnabled.Get(&st.SV), + customCA: JWTClientCustomCA.Get(&st.SV), } if !authenticator.mu.conf.enabled && conf.enabled { @@ -126,18 +128,23 @@ func (authenticator *jwtAuthenticator) mapUsername( // * the audience field matches the audience cluster setting. // * the issuer field is one of the values in the issuer cluster setting. // * the cluster has an enterprise license. +// It returns authError (which is the error sql clients will see in case of +// failures) and detailedError (which is the internal error from http clients +// that might contain sensitive information we do not want to send to sql +// clients but still want to log it). We do not want to send any information +// back to client which was not provided by the client. func (authenticator *jwtAuthenticator) ValidateJWTLogin( ctx context.Context, st *cluster.Settings, user username.SQLUsername, tokenBytes []byte, identMap *identmap.Conf, -) error { +) (detailedErrors string, authError error) { authenticator.mu.Lock() defer authenticator.mu.Unlock() if !authenticator.mu.enabled { - return errors.Newf("JWT authentication: not enabled") + return "", errors.Newf("JWT authentication: not enabled") } telemetry.Inc(beginAuthUseCounter) @@ -146,7 +153,9 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( // The token will be parsed again later to actually verify the signature. unverifiedToken, err := jwt.Parse(tokenBytes) if err != nil { - return errors.Newf("JWT authentication: invalid token") + return "", errors.WithDetailf( + errors.Newf("JWT authentication: invalid token"), + "token parsing failed: %v", err) } // Check for issuer match against configured issuers. @@ -160,7 +169,7 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( } } if !issuerMatch { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: invalid issuer"), "token issued by %s", unverifiedToken.Issuer()) } @@ -168,9 +177,10 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( var jwkSet jwk.Set // If auto-fetch is enabled, fetch the JWKS remotely from the issuer's well known jwks url. if authenticator.mu.conf.jwksAutoFetchEnabled { - jwkSet, err = remoteFetchJWKS(ctx, issuerUrl) + jwkSet, err = authenticator.remoteFetchJWKS(ctx, issuerUrl) if err != nil { - return errors.Newf("JWT authentication: unable to validate token") + return fmt.Sprintf("unable to fetch jwks: %v", err), + errors.Newf("JWT authentication: unable to validate token") } } else { jwkSet = authenticator.mu.conf.jwks @@ -179,7 +189,9 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( // Now that both the issuer and key-id are matched, parse the token again to validate the signature. parsedToken, err := jwt.Parse(tokenBytes, jwt.WithKeySet(jwkSet), jwt.WithValidate(true), jwt.InferAlgorithmFromKey(true)) if err != nil { - return errors.Newf("JWT authentication: invalid token") + return "", errors.WithDetailf( + errors.Newf("JWT authentication: invalid token"), + "unable to parse token: %v", err) } // Extract all requested principals from the token. By default, we take it from the subject unless they specify @@ -190,7 +202,7 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( } else { claimValue, ok := parsedToken.Get(authenticator.mu.conf.claim) if !ok { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: missing claim"), "token does not contain a claim for %s", authenticator.mu.conf.claim) } @@ -217,14 +229,14 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( for _, tokenPrincipal := range tokenPrincipals { mappedUsernames, err := authenticator.mapUsername(tokenPrincipal, parsedToken.Issuer(), identMap) if err != nil { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: invalid claim value"), "the value %s for the issuer %s is invalid", tokenPrincipal, parsedToken.Issuer()) } acceptedUsernames = append(acceptedUsernames, mappedUsernames...) } if len(acceptedUsernames) == 0 { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: invalid principal"), "the value %s for the issuer %s is invalid", tokenPrincipals, parsedToken.Issuer()) } @@ -236,12 +248,12 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( } } if !principalMatch { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: invalid principal"), "token issued for %s and login was for %s", tokenPrincipals, user.Normalized()) } if user.IsRootUser() || user.IsReserved() { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: invalid identity"), "cannot use JWT auth to login to a reserved user %s", user.Normalized()) } @@ -255,26 +267,29 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( } } if !audienceMatch { - return errors.WithDetailf( + return "", errors.WithDetailf( errors.Newf("JWT authentication: invalid audience"), "token issued with an audience of %s", parsedToken.Audience()) } if err = utilccl.CheckEnterpriseEnabled(st, "JWT authentication"); err != nil { - return err + return "", err } telemetry.Inc(loginSuccessUseCounter) - return nil + return "", nil } // remoteFetchJWKS fetches the JWKS from the provided URI. -func remoteFetchJWKS(ctx context.Context, issuerUrl string) (jwk.Set, error) { - jwksUrl, err := getJWKSUrl(ctx, issuerUrl) +func (authenticator *jwtAuthenticator) remoteFetchJWKS( + ctx context.Context, issuerUrl string, +) (jwk.Set, error) { + jwksUrl, err := authenticator.getJWKSUrl(ctx, issuerUrl) if err != nil { return nil, err } - body, err := getHttpResponse(ctx, jwksUrl) + + body, err := getHttpResponse(ctx, jwksUrl, authenticator) if err != nil { return nil, err } @@ -286,12 +301,14 @@ func remoteFetchJWKS(ctx context.Context, issuerUrl string) (jwk.Set, error) { } // getJWKSUrl returns the JWKS URI from the OpenID configuration endpoint. -func getJWKSUrl(ctx context.Context, issuerUrl string) (string, error) { +func (authenticator *jwtAuthenticator) getJWKSUrl( + ctx context.Context, issuerUrl string, +) (string, error) { type OIDCConfigResponse struct { JWKSUri string `json:"jwks_uri"` } openIdConfigEndpoint := getOpenIdConfigEndpoint(issuerUrl) - body, err := getHttpResponse(ctx, openIdConfigEndpoint) + body, err := getHttpResponse(ctx, openIdConfigEndpoint, authenticator) if err != nil { return "", err } @@ -311,8 +328,14 @@ func getOpenIdConfigEndpoint(issuerUrl string) string { return openIdConfigEndpoint } -var getHttpResponse = func(ctx context.Context, url string) ([]byte, error) { - resp, err := httputil.Get(ctx, url) +var getHttpResponse = func(ctx context.Context, url string, authenticator *jwtAuthenticator) ([]byte, error) { + defaultTimeout := httputil.StandardHTTPTimeout + // TODO(souravcrl): cache the http client in a callback attached to customCA + // and other http client cluster settings as re parsing the custom CA every + // time is expensive + httpClient := httputil.NewClientWithTimeoutsCustomCA( + defaultTimeout, defaultTimeout, authenticator.mu.conf.customCA) + resp, err := httpClient.Get(context.Background(), url) if err != nil { return nil, err } @@ -354,6 +377,9 @@ var ConfigureJWTAuth = func( JWKSAutoFetchEnabled.SetOnChange(&st.SV, func(ctx context.Context) { authenticator.reloadConfig(ambientCtx.AnnotateCtx(ctx), st) }) + JWTClientCustomCA.SetOnChange(&st.SV, func(ctx context.Context) { + authenticator.reloadConfig(ambientCtx.AnnotateCtx(ctx), st) + }) return &authenticator } diff --git a/pkg/ccl/jwtauthccl/authentication_jwt_test.go b/pkg/ccl/jwtauthccl/authentication_jwt_test.go index ce0d97713003..0e12753d3730 100644 --- a/pkg/ccl/jwtauthccl/authentication_jwt_test.go +++ b/pkg/ccl/jwtauthccl/authentication_jwt_test.go @@ -14,7 +14,12 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/tls" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" "os" "strings" "testing" @@ -137,15 +142,16 @@ func TestJWTEnabledCheck(t *testing.T) { key := createRSAKey(t, keyID1) token := createJWT(t, username1, audience1, issuer1, timeutil.Now().Add(time.Hour), key, jwa.RS256, "", "") // JWT auth is not enabled. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: not enabled") // Enable JWT auth. JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) // Now the validate call gets past the enabled check and fails on the next check (issuer check). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid issuer") + require.EqualValues(t, "token issued by issuer1", errors.GetAllDetails(err)[0]) } func TestJWTSingleKey(t *testing.T) { @@ -172,16 +178,18 @@ func TestJWTSingleKey(t *testing.T) { // When JWKSAutoFetchEnabled JWKS fetch should be attempted and fail for configured issuer. JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, true) - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + detailedError, err := verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: unable to validate token") + require.EqualValues(t, "unable to fetch jwks: Get \"issuer1/.well-known/openid-configuration\": unsupported protocol scheme \"\"", detailedError) // Set the JWKS cluster setting. JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, false) JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, jwkPublicKey) // Now the validate call gets past the token validity check and fails on the next check (subject matching user). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) } func TestJWTSingleKeyWithoutKeyAlgorithm(t *testing.T) { @@ -210,7 +218,7 @@ func TestJWTSingleKeyWithoutKeyAlgorithm(t *testing.T) { // When JWKSAutoFetchEnabled, JWKS fetch should be attempted and fail for configured issuer. JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, true) - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: unable to validate token") // Set the JWKS cluster setting. @@ -218,8 +226,9 @@ func TestJWTSingleKeyWithoutKeyAlgorithm(t *testing.T) { JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, jwkPublicKey) // Now the validate call gets past the token validity check and fails on the next check (subject matching user). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) } func TestJWTMultiKey(t *testing.T) { @@ -249,7 +258,7 @@ func TestJWTMultiKey(t *testing.T) { // When JWKSAutoFetchEnabled the jwks fetch should be attempted and fail. JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, true) - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: unable to validate token") // Set both jwk1 and jwk2 to be valid signing keys. @@ -257,8 +266,9 @@ func TestJWTMultiKey(t *testing.T) { JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySet)) // Now jwk2 token passes the validity check and fails on the next check (subject matching user). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) } func TestExpiredToken(t *testing.T) { @@ -281,8 +291,9 @@ func TestExpiredToken(t *testing.T) { verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) // Validation fails with an invalid token error for tokens with an expiration date in the past. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid token") + require.EqualValues(t, "unable to parse token: exp not satisfied", errors.GetAllDetails(err)[0]) } func TestKeyIdMismatch(t *testing.T) { @@ -307,7 +318,7 @@ func TestKeyIdMismatch(t *testing.T) { JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer1) // When JWKSAutoFetchEnabled the jwks fetch should be attempted and fail. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid token") // Reset the key id and regenerate the token. @@ -315,7 +326,7 @@ func TestKeyIdMismatch(t *testing.T) { token = createJWT(t, username1, audience1, issuer1, timeutil.Now().Add(time.Hour), key, jwa.RS256, "", "") // Now jwk1 token passes the validity check and fails on the next check (subject matching user).. JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience1) - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) } @@ -341,29 +352,33 @@ func TestIssuerCheck(t *testing.T) { verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) // Validation fails with no issuer are configured. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) require.ErrorContains(t, err, "JWT authentication: invalid issuer") + require.EqualValues(t, "token issued by issuer1", errors.GetAllDetails(err)[0]) JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer2) // Validation fails with an issuer error when the issuer in the token is not in cluster's accepted issuers. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) require.ErrorContains(t, err, "JWT authentication: invalid issuer") + require.EqualValues(t, "token issued by issuer1", errors.GetAllDetails(err)[0]) // Validation succeeds when the issuer in the token is equal to the cluster's accepted issuers. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token2, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token2, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) // Set the cluster setting to accept issuer values of either "issuer" or "issuer2". JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, "[\""+issuer1+"\", \""+issuer2+"\"]") // Validation succeeds when the issuer in the token is an element of the cluster's accepted issuers. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) // Validation succeeds when the issuer in the token is an element of the cluster's accepted issuers. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token2, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token2, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") - + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) } func TestSubjectCheck(t *testing.T) { @@ -387,13 +402,15 @@ func TestSubjectCheck(t *testing.T) { // Validation fails with a subject error when a user tries to log in with a user named // "invalid" but the token is for the user "test2". - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for invalid_user", errors.GetAllDetails(err)[0]) // Validation passes the subject check when the username matches the subject and then fails on the next // check (audience field not matching). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) } func TestClaimMissing(t *testing.T) { @@ -417,8 +434,9 @@ func TestClaimMissing(t *testing.T) { JWTAuthClaim.Override(ctx, &s.ClusterSettings().SV, customClaimName) // Validation fails with missing claim - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), missingClaimToken, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), missingClaimToken, identMap) require.ErrorContains(t, err, "JWT authentication: missing claim") + require.EqualValues(t, "token does not contain a claim for groups", errors.GetAllDetails(err)[0]) } func TestIntegerClaimValue(t *testing.T) { @@ -443,8 +461,9 @@ func TestIntegerClaimValue(t *testing.T) { JWTAuthClaim.Override(ctx, &s.ClusterSettings().SV, customClaimName) // the integer claim is implicitly cast to a string - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), intClaimToken, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), intClaimToken, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) } func TestSingleClaim(t *testing.T) { @@ -469,13 +488,14 @@ func TestSingleClaim(t *testing.T) { // Validation fails with a subject error when a user tries to log in with a user named // "invalid" but the token is for the user "test2". - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") // Validation passes the subject check when the username matches the subject and then fails on the next // check (audience field not matching). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) } func TestMultipleClaim(t *testing.T) { @@ -500,15 +520,17 @@ func TestMultipleClaim(t *testing.T) { // Validation fails with a subject error when a user tries to log in with a user named // "invalid" but the token is for the user "test2". - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") // Validation passes the subject check when the username matches the subject and then fails on the next // check (audience field not matching). - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap) + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) } func TestSubjectMappingCheck(t *testing.T) { @@ -534,16 +556,19 @@ func TestSubjectMappingCheck(t *testing.T) { // Validation fails with a subject error when a user tries to log in when their user is mapped to username2 // but they try to log in with username1. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "token issued for [test_user1] and login was for test_user1", errors.GetAllDetails(err)[0]) // Validation fails if there is a map for the issuer but no mapping rule matches. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token2, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token2, identMap) require.ErrorContains(t, err, "JWT authentication: invalid principal") + require.EqualValues(t, "the value [test_user2] for the issuer issuer2 is invalid", errors.GetAllDetails(err)[0]) // Validation passes the subject check when the username matches the mapped subject. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) } func TestSubjectReservedUser(t *testing.T) { @@ -568,12 +593,14 @@ func TestSubjectReservedUser(t *testing.T) { JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, "[\""+issuer1+"\", \""+issuer2+"\"]") // You cannot log in as root or other reserved users using token based auth when mapped to root. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString("root"), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString("root"), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid identity") + require.EqualValues(t, "cannot use JWT auth to login to a reserved user root", errors.GetAllDetails(err)[0]) // You cannot log in as root or other reserved users using token based auth when no map is involved. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString("root"), token2, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString("root"), token2, identMap) require.ErrorContains(t, err, "JWT authentication: invalid identity") + require.EqualValues(t, "cannot use JWT auth to login to a reserved user root", errors.GetAllDetails(err)[0]) } func TestAudienceCheck(t *testing.T) { @@ -599,26 +626,27 @@ func TestAudienceCheck(t *testing.T) { verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) // Validation fails with an audience error when the audience in the token doesn't match the cluster's audience. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) // Update the audience field to "test_cluster". JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience1) // Validation passes the audience check now that they match. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) // Set audience field to both audience1 and audience2. JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, "[\""+audience2+"\",\""+audience1+"\"]") // Validation passes the audience check now that both audiences are accepted. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) } // mockGetHttpResponseWithLocalFileContent is a mock function for getHttpResponse. This is used to intercept the call to // getHttpResponse and return the content of a local file instead of making a http call. -var mockGetHttpResponseWithLocalFileContent = func(ctx context.Context, url string) ([]byte, error) { +var mockGetHttpResponseWithLocalFileContent = func(ctx context.Context, url string, authenticator *jwtAuthenticator) ([]byte, error) { // remove https:// and replace / with _ in the url to get the testdata file name fileName := "testdata/" + strings.ReplaceAll(strings.ReplaceAll(url, "https://", ""), "/", "_") // read content of the file as a byte array @@ -681,20 +709,21 @@ func Test_JWKSFetchWorksWhenEnabled(t *testing.T) { verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) // Validation fails with an audience error when the audience in the token doesn't match the cluster's audience. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) // Update the audience field to "test_cluster". JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience1) // Validation passes the audience check now that they match. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) // Set audience field to both audience1 and audience2. JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, "[\""+audience2+"\",\""+audience1+"\"]") // Validation passes the audience check now that both audiences are accepted. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) } @@ -736,19 +765,99 @@ func Test_JWKSFetchWorksWhenEnabledIgnoresTheStaticJWKS(t *testing.T) { verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) // Validation fails with an audience error when the audience in the token doesn't match the cluster's audience. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.ErrorContains(t, err, "JWT authentication: invalid audience") + require.EqualValues(t, "token issued with an audience of [test_cluster]", errors.GetAllDetails(err)[0]) // Update the audience field to "test_cluster". JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience1) // Validation passes the audience check now that they match. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) // Set audience field to both audience1 and audience2. JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, "[\""+audience2+"\",\""+audience1+"\"]") // Validation passes the audience check now that both audiences are accepted. - err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + _, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + require.NoError(t, err) +} + +func TestJWTAuthCanUseHTTPProxy(t *testing.T) { + defer leaktest.AfterTest(t)() + ctx := context.Background() + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(fmt.Sprintf("proxied-%s", r.URL))) + })) + defer proxy.Close() + + // Normally, we would set proxy via HTTP_PROXY environment variable. + // However, if we run multiple tests in this package, and earlier tests + // happen to create an http client, then the DefaultTransport will have + // been been initialized with an empty Proxy. So, set proxy directly. + defer testutils.TestingHook( + &http.DefaultTransport.(*http.Transport).Proxy, + func(_ *http.Request) (*url.URL, error) { + return url.Parse(proxy.URL) + })() + + authenticator := jwtAuthenticator{} + res, err := getHttpResponse(ctx, "http://my-server/.well-known/openid-configuration", &authenticator) + require.NoError(t, err) + require.EqualValues(t, "proxied-http://my-server/.well-known/openid-configuration", string(res)) +} + +func NewLocalHTTPSTestServerCustomCA( + handler http.Handler, customCACert tls.Certificate, +) *httptest.Server { + ts := httptest.NewUnstartedServer(handler) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{customCACert}} + ts.StartTLS() + return ts +} + +func TestJWTAuthCanUseCustomCACert(t *testing.T) { + defer leaktest.AfterTest(t)() + ctx := context.Background() + + tlsCert, err := tls.LoadX509KeyPair("testdata/node.crt", "testdata/node.key") + require.NoError(t, err) + + // start proxy server with tls using customCA cert + proxy := NewLocalHTTPSTestServerCustomCA(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(fmt.Sprintf("proxied-%s", r.URL))) + }), tlsCert) + defer proxy.Close() + + // Normally, we would set proxy via HTTP_PROXY environment variable. + // However, if we run multiple tests in this package, and earlier tests + // happen to create an http client, then the DefaultTransport will have + // been been initialized with an empty Proxy. So, set proxy directly. + defer testutils.TestingHook( + &http.DefaultTransport.(*http.Transport).Proxy, + func(_ *http.Request) (*url.URL, error) { + return url.Parse(proxy.URL) + })() + + authenticator := jwtAuthenticator{} + _, err = getHttpResponse(ctx, "http://my-server/.well-known/openid-configuration", &authenticator) + require.ErrorContains(t, err, "proxyconnect tcp: tls: failed to verify "+ + "certificate: x509: ") + + // http library throws 2 different error failures locally and on teamcity. + // This is to handle both cases. + errString := err.Error() + failureReason1 := "certificate signed by unknown authority" + failureReason2 := "“node” certificate is not standards compliant" + if !(strings.Contains(errString, failureReason1) || + strings.Contains(errString, failureReason2)) { + t.Fatalf("expected error %q to contain %q or %q", err, failureReason1, failureReason2) + } + + publicKeyPem, err := os.ReadFile("testdata/ca.crt") + require.NoError(t, err) + authenticator.mu.conf.customCA = string(publicKeyPem) + res, err := getHttpResponse(ctx, "http://my-server/.well-known/openid-configuration", &authenticator) require.NoError(t, err) + require.EqualValues(t, "proxied-http://my-server/.well-known/openid-configuration", string(res)) } diff --git a/pkg/ccl/jwtauthccl/settings.go b/pkg/ccl/jwtauthccl/settings.go index 995415fcf663..a74bdb15f36e 100644 --- a/pkg/ccl/jwtauthccl/settings.go +++ b/pkg/ccl/jwtauthccl/settings.go @@ -19,13 +19,14 @@ import ( // All cluster settings necessary for the JWT authentication feature. const ( - baseJWTAuthSettingName = "server.jwt_authentication." - JWTAuthAudienceSettingName = baseJWTAuthSettingName + "audience" - JWTAuthEnabledSettingName = baseJWTAuthSettingName + "enabled" - JWTAuthIssuersSettingName = baseJWTAuthSettingName + "issuers" - JWTAuthJWKSSettingName = baseJWTAuthSettingName + "jwks" - JWTAuthClaimSettingName = baseJWTAuthSettingName + "claim" - JWKSAutoFetchEnabledSettingName = baseJWTAuthSettingName + "jwks_auto_fetch.enabled" + baseJWTAuthSettingName = "server.jwt_authentication." + JWTAuthAudienceSettingName = baseJWTAuthSettingName + "audience" + JWTAuthEnabledSettingName = baseJWTAuthSettingName + "enabled" + JWTAuthIssuersSettingName = baseJWTAuthSettingName + "issuers" + JWTAuthJWKSSettingName = baseJWTAuthSettingName + "jwks" + JWTAuthClaimSettingName = baseJWTAuthSettingName + "claim" + JWKSAutoFetchEnabledSettingName = baseJWTAuthSettingName + "jwks_auto_fetch.enabled" + JWTAuthClientCustomCASettingName = baseJWTAuthSettingName + "client.custom_ca" ) // JWTAuthClaim sets the JWT claim that is parsed to get the username. @@ -84,6 +85,13 @@ var JWKSAutoFetchEnabled = settings.RegisterBoolSetting( settings.WithReportable(true), ) +var JWTClientCustomCA = settings.RegisterStringSetting( + settings.ApplicationLevel, + JWTAuthClientCustomCASettingName, + "sets the custom root CA for verifying certificates when interacting with jwt internal HTTPS client", + "", + settings.WithPublic) + func validateJWTAuthIssuers(values *settings.Values, s string) error { var issuers []string diff --git a/pkg/ccl/jwtauthccl/testdata/ca.crt b/pkg/ccl/jwtauthccl/testdata/ca.crt new file mode 100644 index 000000000000..ed6ae6d3903b --- /dev/null +++ b/pkg/ccl/jwtauthccl/testdata/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIQCEE9wJ6n+33qOpq9I0L+rTANBgkqhkiG9w0BAQsFADAr +MRIwEAYDVQQKEwlDb2Nrcm9hY2gxFTATBgNVBAMTDENvY2tyb2FjaCBDQTAeFw0y +NDA1MDQxMjQ3NDFaFw0zNDA1MTMxMjQ3NDFaMCsxEjAQBgNVBAoTCUNvY2tyb2Fj +aDEVMBMGA1UEAxMMQ29ja3JvYWNoIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqjRN1At979UwgdXw6jO1oCHGYRwjnDBb7QMTM0ifql82o07Db1td +stPrl6nr6cuBE+GxviKYYq/jHrsWnSvWx1IX6C7FJcYei1EpwZbN+6pnMe6FeUup +I+W8yBum5gGm527ZGaCvj97Dwk6MtMF2O+L9EFS2eK0SzxgCZYxi4qH0vWSG/kTo +W9K5pl+Ybk9B7+N6UkOP4jznv2nxrtt8s/+nYugPj7ny4yVIS5KFTGLU5VzmVvP8 +S6ap3nzWojK349O9L4lunbBG8n2STYIWminJtla1QwzYsq1DjfNaRTrNRFCgTsmV +XWSgPM8l2F9lnyWIyRCokwjNAtoHMfCSewIDAQABo0UwQzAOBgNVHQ8BAf8EBAMC +AuQwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUWggEuxn5oBwFQcbQ+NZw +tyLXEuswDQYJKoZIhvcNAQELBQADggEBACHyUBrzZnzUjCMGhJvwDL7KKAq/lin6 +WPUgh+hr9NOMWIpfz9TKZ5IqT7o3R9Hpm7wTCMm90ONlfRLNoBRT6sb8e19HUsgt +g1f+2TPa0pKLkKPe9O98GS4ilzb7yD2Nb5h2+pHufmXlq7zQMP0+iHGZ/lxW6voB +iKsW9NReXf716lY7Iam/e7eoYUaoW0gIFNjlleVAlOI/LD0uZp0TQ4idXHZ0K9BY +c25GjLSMI9hw5Dm4z79rbDmAX1h6TKkKshk3oCZbVQ9UrnqtY5+BSpdqSZJZAj21 +wWVQ+EOge85C8/b2VuRHLSmb+DzicUufDUSvhHDMdUXQg2zT7wiPm48= +-----END CERTIFICATE----- diff --git a/pkg/ccl/jwtauthccl/testdata/node.crt b/pkg/ccl/jwtauthccl/testdata/node.crt new file mode 100644 index 000000000000..ca2e34293c7e --- /dev/null +++ b/pkg/ccl/jwtauthccl/testdata/node.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUjCCAjqgAwIBAgIRAPpCjCa+EMLri7SrAEjCw/IwDQYJKoZIhvcNAQELBQAw +KzESMBAGA1UEChMJQ29ja3JvYWNoMRUwEwYDVQQDEwxDb2Nrcm9hY2ggQ0EwHhcN +MjQwNTA4MDg1NzI3WhcNMjkwNTEzMDg1NzI3WjAjMRIwEAYDVQQKEwlDb2Nrcm9h +Y2gxDTALBgNVBAMTBG5vZGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDJxBdgKSquDuu574i8R70vu4D+sNdNi7SYakgf+bu+GSTdC6mWGcZSMI2ajHCG +0RF7pndNzp8nxvhgHreuAW0YaXH0geVwu/ExHXSWPIR5PLU4wA9gldrj+J5RMtuX +Hh1bmOgJkkw31gQKyZs7J0xzrx409GOLkLK/bxsexE/3u8Vg1QSfFWidyX1One1F +Yxnd8p1jL5Z0W5xn2YL8yxHLCNF5E1dOFMWlsIkmLjfkNYASVLCwO3WUtTokLfHl +z5yfqfqVQCpwJbHVEEOlyN/LzttlzHBAPBfpTuqRfAde1hWiXM/r/33mT9BqQk11 +3bdGVPTXn5KR4X8nY5md2lrxAgMBAAGjeTB3MA4GA1UdDwEB/wQEAwIFoDAdBgNV +HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHwYDVR0jBBgwFoAUWggEuxn5oBwF +QcbQ+NZwtyLXEuswJQYDVR0RBB4wHIIJbG9jYWxob3N0gglteS1zZXJ2ZXKHBH8A +AAEwDQYJKoZIhvcNAQELBQADggEBADFPX72szlOoM4Zc+eoe1xW2FqJq5dSAAUTz ++tBbga0/ttYQogIca3ZBTHgTDCPJoZU4/WP1l2LVF+kpR398b2KEX+fC6rQgqqqT +J4PnFVT9bpWW534Vwlpkvex8EeiiJv4rnu/y/e8tC9fPJupY3B+8S7Xiq+TrXLKM +phb9AdOg32q6XFI6PYsh3vGJQctvgyxV4pmkqA8GEkN8bsTFft2d1b2KJ2Oig2Am +CEuy44VZTGmkH2YlC6X6S47X6H33ygB3P2QBJcSFs6Mcb9dnDrjoHwV6DTW57Fxg +r9oQeDbT6jkSSER/oKal3vudSMFzurDXTwUZcRb/8C2Tc+v8Xlg= +-----END CERTIFICATE----- diff --git a/pkg/ccl/jwtauthccl/testdata/node.key b/pkg/ccl/jwtauthccl/testdata/node.key new file mode 100644 index 000000000000..bd84df03f590 --- /dev/null +++ b/pkg/ccl/jwtauthccl/testdata/node.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAycQXYCkqrg7rue+IvEe9L7uA/rDXTYu0mGpIH/m7vhkk3Qup +lhnGUjCNmoxwhtERe6Z3Tc6fJ8b4YB63rgFtGGlx9IHlcLvxMR10ljyEeTy1OMAP +YJXa4/ieUTLblx4dW5joCZJMN9YECsmbOydMc68eNPRji5Cyv28bHsRP97vFYNUE +nxVoncl9Tp3tRWMZ3fKdYy+WdFucZ9mC/MsRywjReRNXThTFpbCJJi435DWAElSw +sDt1lLU6JC3x5c+cn6n6lUAqcCWx1RBDpcjfy87bZcxwQDwX6U7qkXwHXtYVolzP +6/995k/QakJNdd23RlT015+SkeF/J2OZndpa8QIDAQABAoIBAFLI/HZeLb7hLGNM +HyRxovRU0OavlIeizOTEpvdHtnqXQoLX6CtIRBExDsjc2sbWPYAom0b3gmIFx59Y +uzDQMP0VNsaGDagl1R674TxLdGdyO0EfvK96jSeAuSTD40mr94u/v2U9+7vlOh9a +/TBWPY4xygkcRUPciRr28X+8OLuCOuZfX9qh3MWvePbxXEfDoqKGizBTVar6YZI6 +NvB4KAsp5szYhKaunGqkTNYyze7/hOE0mmShgdKvPYi67CS+kmmyLAwlH9Kru4Os +k+umX8mKqhxL1NP1EHMNHile0Z2SBK2uIVN01QNn/3lIjiCcw1BK5869uDZefRrL +QgNfqWECgYEA1ToDWCgboeF5u1PJx8Q4vLY35irQDGVwjJeQQAOt5OZY8Fy6KdtZ +vepVkOlmVykJZnH+ZR1orAt94fkJF6za3jkFrhRSmROh8NnvlfzQDZjLtbfF45O0 +hPHWQAP3NJRPd5R65Ht5SdEjTmzrk/4DAqhfde/GkZhR7FA6kXG2HJ0CgYEA8j2I +IxF0Se3fKjQoGobIgsI7adTJRX2GMuZHsG49rDRt53txemEpx05Iynpv/EjgjGVQ +/JnGLZeZyCpNRGVLbJ6KC1zpQ9SM6Rri6isv98RdCnYzBHqgGCxU/eN6PxvomP4I +d5nSV5Juke7xn/Y3xsCq83mlQvfvivvKcyyaBWUCgYBzSsb8J9OpGC2mKIv2RBcZ +kr1u6r9LtJ3Cp0EMG/Euz45PiRPwBfbkRYy2qjz36+x1RDX1no02WZBbWzibYI+H +kPWNRtfShVcO0AhH5Ubp0zBytUd+4iK9r7XVL0+/UmIVmmdIm6rdSwP4J9TJVJN0 +IG5daqTHnMDPMiI4+m8p0QKBgQCCRxFmOsbd065HllFTItXHbQpi+mtckguQaER5 +4HAOowvQiJUZChRb+gAv1PVRKyiImRybHWB1uKid/BIeDJQp0IHRZqCVOESoQSt0 +YNGlYNfg/nyllgVvurfi62odcrb/QO/w0qtLKZeJqIYOT27j106ZLvzwLUOI7a2N +j5kt+QKBgAkfrZyN1GP+x5Q8Ud/xIJ598hCa6IaCOharwuvJxLd1by/uicSb2ZRE +Nexo2S96CuvkrBHW/+or4FGV5Uvt3O4Bc7VH9eplIimEg53S1/03mZnkpkGqJ6fW +Whwip+5s004EpRAstRgRVujTSVCO1fqYLUXSoKUqNfvi+xhRN6TK +-----END RSA PRIVATE KEY----- diff --git a/pkg/ccl/testccl/authccl/testdata/jwt b/pkg/ccl/testccl/authccl/testdata/jwt index 37a3ddd7ec99..9bf3dd5d0c00 100644 --- a/pkg/ccl/testccl/authccl/testdata/jwt +++ b/pkg/ccl/testccl/authccl/testdata/jwt @@ -67,6 +67,7 @@ subtest expired_token connect user=jwt_user options=--crdb:jwt_auth_enabled=true password=eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QyIn0.eyJhdWQiOiJ0ZXN0X2NsdXN0ZXIiLCJleHAiOjE2NjEyNjQzOTgsImlhdCI6MTY2MTI2NDM5OCwiaXNzIjoiaXNzdWVyMiIsInN1YiI6InRlc3QyIn0.1nWuqpwj4uPDk0pyyqEJhpIgyridv699B7OjEBGSyQ8iyrqryeG1yr7oP1qnKlrcqtbVmuB5ELJoXNUerd8BL0GQBMCkkxjG1cuLvLNOWo5yzifcfYHiiaCL25EblWG46eBrxAeHmqGigQiIpSUPjQTlZT_lRLrEI9h_xQhwNp5AnsY2S1f8N4oaMqjUjgREGdLhZT9sOyNmrf5uowTFcR3aWBkpIB5Ac5rvI8-U7-D1rY5KJ3Wez4G2L3Miyof_lOlK1g8XwAasCPKlhHea5qZNjqHLqgOb5EIQ_yd_KICT7pFLSgMXw_IJ9c68z-H1N7wEivnnLydgQUR3WVEytA ---- ERROR: JWT authentication: invalid token (SQLSTATE 28000) +DETAIL: unable to parse token: exp not satisfied subtest end @@ -80,6 +81,7 @@ jwt_cluster_setting jwks={"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":" connect user=jwt_user options=--crdb:jwt_auth_enabled=true password=eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QyIn0.eyJhdWQiOiJ0ZXN0X2NsdXN0ZXIiLCJleHAiOjI2NjEyNjQyNjksImlhdCI6MTY2MTI2NDI2OSwiaXNzIjoiaXNzdWVyMiIsInN1YiI6InRlc3QyIn0.Tot41E-wSz24wo1wj3b8CwEr-O_dqWZoHZkAh2x4nfK2hT4yhfiOcajmKQJVVZX2_897c8uDOqfLzl77JEe-AX4mlEBZXWUNqwwQIdIFZxpL6FEV_YjvTF0bQuu9oeD7kYW-6i3-QQpB6QpCVb-wLW8bBbJ4zCap88nYk14HZH-ZYSzPAP7YEVppHQNhWrxQ66nQU__RuYeQdL6J5Edes9qCHUgqnZCnMPzDZ4l_3Pc5tTSNVcOUl5MMHsvrYsb0VtSFTNCOjJIADXbc2KzVbfqLt-ArUDxs36__u_g84TfGFXoT0VTDbDjYwD7wpyLuT3oLcJuA4m_tto6Rrn7Rww ---- ERROR: JWT authentication: invalid token (SQLSTATE 28000) +DETAIL: unable to parse token: failed to find key with key ID "test2" in key set jwt_cluster_setting jwks={"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test","n":"sJCwOk5gVjZZu3oaODecZaT_-Lee7J-q3rQIvCilg-7B8fFNJ2XHZCsF74JX2d7ePyjz7u9d2r5CvstufiH0qGPHBBm0aKrxGRILRGUTfqBs8Dnrnv9ymTEFsRUQjgy9ACUfwcgLVQIwv1NozySLb4Z5N8X91b0TmcJun6yKjBrnr1ynUsI_XXjzLnDpJ2Ng_shuj-z7DKSEeiFUg9eSFuTeg_wuHtnnhw4Y9pwT47c-XBYnqtGYMADSVEzKLQbUini0p4-tfYboF6INluKQsO5b1AZaaXgmStPIqteS7r2eR3LFL-XB7rnZOR4cAla773Cq5DD-8RnYamnmmLu_gQ","e":"AQAB"},{"kty":"RSA","use":"sig","alg":"RS256","kid":"test2","n":"3gOrVdePypBAs6bTwD-6dZhMuwOSq8QllMihBfcsiRmo3c14_wfa_DRDy3kSsacwdih5-CaeF8ou-Dan6WqXzjDyJNekmGltPLfO2XB5FkHQoZ-X9lnXktsAgNLj3WsKjr-xUxrh8p8FFz62HJYN8QGaNttWBJZb3CgdzF7i8bPqVet4P1ekzs7mPBH2arEDy1f1q4o7fpmw0t9wuCrmtkj_g_eS6Hi2Rxm3m7HJUFVVbQeuZlT_W84FUzpSQCkNi2QDvoNVVCE2DSYZxDrzRxSZSv_fIh5XeJhwYY-f8iEfI4qx91ONGzGMvPn2GagrBnLBQRx-6RsORh4YmOOeeQ","e":"AQAB"}]} ---- diff --git a/pkg/sql/pgwire/auth_methods.go b/pkg/sql/pgwire/auth_methods.go index b2a298b8ea88..7a2043aad896 100644 --- a/pkg/sql/pgwire/auth_methods.go +++ b/pkg/sql/pgwire/auth_methods.go @@ -705,7 +705,7 @@ type JWTVerifier interface { _ username.SQLUsername, _ []byte, _ *identmap.Conf, - ) error + ) (detailedErrors string, authError error) } var jwtVerifier JWTVerifier @@ -714,8 +714,8 @@ type noJWTConfigured struct{} func (c *noJWTConfigured) ValidateJWTLogin( _ context.Context, _ *cluster.Settings, _ username.SQLUsername, _ []byte, _ *identmap.Conf, -) error { - return errors.New("JWT token authentication requires CCL features") +) (detailedErrors string, authError error) { + return "", errors.New("JWT token authentication requires CCL features") } // ConfigureJWTAuth is a hook for the `jwtauthccl` library to add JWT login support. It's called to @@ -780,9 +780,10 @@ func authJwtToken( if len(token) == 0 { return security.NewErrPasswordUserAuthFailed(user) } - if err = jwtVerifier.ValidateJWTLogin(ctx, execCfg.Settings, user, []byte(token), identMap); err != nil { - c.LogAuthFailed(ctx, eventpb.AuthFailReason_CREDENTIALS_INVALID, err) - return err + if detailedErrors, authError := jwtVerifier.ValidateJWTLogin(ctx, execCfg.Settings, user, []byte(token), identMap); authError != nil { + c.LogAuthFailed(ctx, eventpb.AuthFailReason_CREDENTIALS_INVALID, + errors.Join(authError, errors.New(detailedErrors))) + return authError } c.LogAuthOK(ctx) return nil diff --git a/pkg/util/httputil/client.go b/pkg/util/httputil/client.go index 7c2537593528..a611af943b3f 100644 --- a/pkg/util/httputil/client.go +++ b/pkg/util/httputil/client.go @@ -12,6 +12,8 @@ package httputil import ( "context" + "crypto/tls" + "crypto/x509" "io" "net" "net/http" @@ -38,6 +40,22 @@ func NewClientWithTimeout(timeout time.Duration) *Client { // NewClientWithTimeouts defines a http.Client with the given dialer and client timeouts. func NewClientWithTimeouts(dialerTimeout, clientTimeout time.Duration) *Client { + return NewClientWithTimeoutsCustomCA(dialerTimeout, clientTimeout, "") +} + +// NewClientWithTimeoutsCustomCA defines a http.Client with the given dialer and client timeouts and custom CA pem. +func NewClientWithTimeoutsCustomCA( + dialerTimeout, clientTimeout time.Duration, customCAPem string, +) *Client { + var tlsConf *tls.Config + if customCAPem != "" { + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(customCAPem)) { + return nil + } + tlsConf = &tls.Config{RootCAs: roots} + } + t := http.DefaultTransport.(*http.Transport) return &Client{&http.Client{ Timeout: clientTimeout, Transport: &http.Transport{ @@ -45,6 +63,15 @@ func NewClientWithTimeouts(dialerTimeout, clientTimeout time.Duration) *Client { // much higher than on linux). DialContext: (&net.Dialer{Timeout: dialerTimeout}).DialContext, DisableKeepAlives: true, + + Proxy: t.Proxy, + MaxIdleConns: t.MaxIdleConns, + IdleConnTimeout: t.IdleConnTimeout, + TLSHandshakeTimeout: t.TLSHandshakeTimeout, + ExpectContinueTimeout: t.ExpectContinueTimeout, + + // Add our custom CA. + TLSClientConfig: tlsConf, }, }} }