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