From e799a60dd3fde7851d7a672bb7e0259780eb6e42 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. 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, CRDB-38386, CRDB-38408 Epic None Release note: None --- pkg/ccl/jwtauthccl/authentication_jwt.go | 63 +++++--- pkg/ccl/jwtauthccl/authentication_jwt_test.go | 136 ++++++++++++------ pkg/ccl/testccl/authccl/testdata/jwt | 2 + pkg/sql/pgwire/auth_methods.go | 13 +- pkg/util/httputil/client.go | 12 ++ 5 files changed, 157 insertions(+), 69 deletions(-) diff --git a/pkg/ccl/jwtauthccl/authentication_jwt.go b/pkg/ccl/jwtauthccl/authentication_jwt.go index 8a106b46742e..eaf92d7f158d 100644 --- a/pkg/ccl/jwtauthccl/authentication_jwt.go +++ b/pkg/ccl/jwtauthccl/authentication_jwt.go @@ -126,18 +126,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 { +) (detailedErrorMsg 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 +151,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 +167,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 +175,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 +187,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 +200,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 +227,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 +246,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 +265,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 +299,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 +326,12 @@ 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) { + // 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.NewClientWithTimeout(httputil.StandardHTTPTimeout) + resp, err := httpClient.Get(context.Background(), url) if err != nil { return nil, err } diff --git a/pkg/ccl/jwtauthccl/authentication_jwt_test.go b/pkg/ccl/jwtauthccl/authentication_jwt_test.go index ce0d97713003..78da1ec0f8b8 100644 --- a/pkg/ccl/jwtauthccl/authentication_jwt_test.go +++ b/pkg/ccl/jwtauthccl/authentication_jwt_test.go @@ -15,6 +15,10 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" "os" "strings" "testing" @@ -137,15 +141,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 +177,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) + detailedErrorMsg, 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 \"\"", detailedErrorMsg) // 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 +217,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 +225,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 +257,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 +265,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) { @@ -271,6 +280,7 @@ func TestExpiredToken(t *testing.T) { identMapString := "" identMap, err := identmap.From(strings.NewReader(identMapString)) require.NoError(t, err) + // Make sure jwt auth is enabled and accepts valid signing keys. JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) // Configure issuer as it gets checked even before the token validity check. @@ -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,44 @@ 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)) +} 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..bb14ef2bc594 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 + ) (detailedErrorMsg 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") +) (detailedErrorMsg 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.Newf("%s", detailedErrors))) + return authError } c.LogAuthOK(ctx) return nil diff --git a/pkg/util/httputil/client.go b/pkg/util/httputil/client.go index 7c2537593528..9df531bdcf51 100644 --- a/pkg/util/httputil/client.go +++ b/pkg/util/httputil/client.go @@ -12,6 +12,7 @@ package httputil import ( "context" + "crypto/tls" "io" "net" "net/http" @@ -38,6 +39,8 @@ 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 { + var tlsConf *tls.Config + t := http.DefaultTransport.(*http.Transport) return &Client{&http.Client{ Timeout: clientTimeout, Transport: &http.Transport{ @@ -45,6 +48,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, }, }} }