diff --git a/pkg/ccl/jwtauthccl/BUILD.bazel b/pkg/ccl/jwtauthccl/BUILD.bazel index dd4947054908..4fe32eacebda 100644 --- a/pkg/ccl/jwtauthccl/BUILD.bazel +++ b/pkg/ccl/jwtauthccl/BUILD.bazel @@ -16,6 +16,7 @@ go_library( "//pkg/settings/cluster", "//pkg/sql/pgwire", "//pkg/sql/pgwire/identmap", + "//pkg/util/httputil", "//pkg/util/log", "//pkg/util/syncutil", "//pkg/util/uuid", @@ -34,6 +35,7 @@ go_test( "settings_test.go", ], args = ["-test.timeout=295s"], + data = glob(["testdata/**"]), embed = [":jwtauthccl"], deps = [ "//pkg/base", @@ -43,12 +45,15 @@ go_test( "//pkg/security/username", "//pkg/server", "//pkg/sql/pgwire/identmap", + "//pkg/testutils", "//pkg/testutils/serverutils", "//pkg/testutils/testcluster", "//pkg/util/leaktest", "//pkg/util/log", "//pkg/util/randutil", "//pkg/util/timeutil", + "@com_github_cockroachdb_errors//:errors", + "@com_github_cockroachdb_errors//oserror", "@com_github_lestrrat_go_jwx//jwa", "@com_github_lestrrat_go_jwx//jwk", "@com_github_lestrrat_go_jwx//jwt", diff --git a/pkg/ccl/jwtauthccl/authentication_jwt.go b/pkg/ccl/jwtauthccl/authentication_jwt.go index 15bf60769a77..627b772f46fd 100644 --- a/pkg/ccl/jwtauthccl/authentication_jwt.go +++ b/pkg/ccl/jwtauthccl/authentication_jwt.go @@ -10,7 +10,10 @@ package jwtauthccl import ( "context" + "encoding/json" "fmt" + "io" + "strings" "github.com/cockroachdb/cockroach/pkg/ccl/utilccl" "github.com/cockroachdb/cockroach/pkg/security/username" @@ -18,6 +21,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/settings/cluster" "github.com/cockroachdb/cockroach/pkg/sql/pgwire" "github.com/cockroachdb/cockroach/pkg/sql/pgwire/identmap" + "github.com/cockroachdb/cockroach/pkg/util/httputil" "github.com/cockroachdb/cockroach/pkg/util/log" "github.com/cockroachdb/cockroach/pkg/util/syncutil" "github.com/cockroachdb/cockroach/pkg/util/uuid" @@ -61,11 +65,12 @@ type jwtAuthenticator struct { // jwtAuthenticatorConf contains all the values to configure JWT authentication. These values are copied from // the matching cluster settings. type jwtAuthenticatorConf struct { - audience []string - enabled bool - issuers []string - jwks jwk.Set - claim string + audience []string + enabled bool + issuers []string + jwks jwk.Set + claim string + jwksAutoFetchEnabled bool } // reloadConfig locks mutex and then refreshes the values in conf from the cluster settings. @@ -80,11 +85,12 @@ func (authenticator *jwtAuthenticator) reloadConfigLocked( ctx context.Context, st *cluster.Settings, ) { conf := jwtAuthenticatorConf{ - audience: mustParseValueOrArray(JWTAuthAudience.Get(&st.SV)), - enabled: JWTAuthEnabled.Get(&st.SV), - issuers: mustParseValueOrArray(JWTAuthIssuers.Get(&st.SV)), - jwks: mustParseJWKS(JWTAuthJWKS.Get(&st.SV)), - claim: JWTAuthClaim.Get(&st.SV), + audience: mustParseValueOrArray(JWTAuthAudience.Get(&st.SV)), + enabled: JWTAuthEnabled.Get(&st.SV), + issuers: mustParseValueOrArray(JWTAuthIssuers.Get(&st.SV)), + jwks: mustParseJWKS(JWTAuthJWKS.Get(&st.SV)), + claim: JWTAuthClaim.Get(&st.SV), + jwksAutoFetchEnabled: JWKSAutoFetchEnabled.Get(&st.SV), } if !authenticator.mu.conf.enabled && conf.enabled { @@ -121,7 +127,11 @@ func (authenticator *jwtAuthenticator) mapUsername( // * the issuer field is one of the values in the issuer cluster setting. // * the cluster has an enterprise license. func (authenticator *jwtAuthenticator) ValidateJWTLogin( - st *cluster.Settings, user username.SQLUsername, tokenBytes []byte, identMap *identmap.Conf, + ctx context.Context, + st *cluster.Settings, + user username.SQLUsername, + tokenBytes []byte, + identMap *identmap.Conf, ) error { authenticator.mu.Lock() defer authenticator.mu.Unlock() @@ -132,22 +142,44 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( telemetry.Inc(beginAuthUseCounter) - parsedToken, err := jwt.Parse(tokenBytes, jwt.WithKeySet(authenticator.mu.conf.jwks), jwt.WithValidate(true), jwt.InferAlgorithmFromKey(true)) + // Just parse the token to check the format is valid and issuer is present. + // 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") } + // Check for issuer match against configured issuers. + issuerUrl := "" issuerMatch := false for _, issuer := range authenticator.mu.conf.issuers { - if issuer == parsedToken.Issuer() { + if issuer == unverifiedToken.Issuer() { issuerMatch = true + issuerUrl = issuer break } } if !issuerMatch { return errors.WithDetailf( errors.Newf("JWT authentication: invalid issuer"), - "token issued by %s", parsedToken.Issuer()) + "token issued by %s", unverifiedToken.Issuer()) + } + + 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) + if err != nil { + return errors.Newf("JWT authentication: unable to validate token") + } + } else { + jwkSet = authenticator.mu.conf.jwks + } + + // 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") } // Extract all requested principals from the token. By default, we take it from the subject unless they specify @@ -236,6 +268,63 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin( 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) + if err != nil { + return nil, err + } + body, err := getHttpResponse(ctx, jwksUrl) + if err != nil { + return nil, err + } + jwkSet, err := jwk.Parse(body) + if err != nil { + return nil, err + } + return jwkSet, nil +} + +// getJWKSUrl returns the JWKS URI from the OpenID configuration endpoint. +func getJWKSUrl(ctx context.Context, issuerUrl string) (string, error) { + type OIDCConfigResponse struct { + JWKSUri string `json:"jwks_uri"` + } + openIdConfigEndpoint := getOpenIdConfigEndpoint(issuerUrl) + body, err := getHttpResponse(ctx, openIdConfigEndpoint) + if err != nil { + return "", err + } + var config OIDCConfigResponse + if err = json.Unmarshal(body, &config); err != nil { + return "", err + } + if config.JWKSUri == "" { + return "", errors.Newf("no JWKS URI found in OpenID configuration") + } + return config.JWKSUri, nil +} + +// getOpenIdConfigEndpoint returns the OpenID configuration endpoint by appending standard open-id url. +func getOpenIdConfigEndpoint(issuerUrl string) string { + openIdConfigEndpoint := strings.TrimSuffix(issuerUrl, "/") + "/.well-known/openid-configuration" + return openIdConfigEndpoint +} + +var getHttpResponse = func(ctx context.Context, url string) ([]byte, error) { + resp, err := httputil.Get(ctx, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + // ConfigureJWTAuth initializes and returns a jwtAuthenticator. It also sets up listeners so // that the jwtAuthenticator's config is updated when the cluster settings values change. var ConfigureJWTAuth = func( @@ -262,6 +351,9 @@ var ConfigureJWTAuth = func( JWTAuthClaim.SetOnChange(&st.SV, func(ctx context.Context) { authenticator.reloadConfig(ambientCtx.AnnotateCtx(ctx), st) }) + JWKSAutoFetchEnabled.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 c257e6c3241b..216c48be5e88 100644 --- a/pkg/ccl/jwtauthccl/authentication_jwt_test.go +++ b/pkg/ccl/jwtauthccl/authentication_jwt_test.go @@ -15,6 +15,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "os" "strings" "testing" "time" @@ -22,12 +23,15 @@ import ( "github.com/cockroachdb/cockroach/pkg/base" "github.com/cockroachdb/cockroach/pkg/security/username" "github.com/cockroachdb/cockroach/pkg/sql/pgwire/identmap" + "github.com/cockroachdb/cockroach/pkg/testutils" "github.com/cockroachdb/cockroach/pkg/testutils/serverutils" "github.com/cockroachdb/cockroach/pkg/util/leaktest" "github.com/cockroachdb/cockroach/pkg/util/log" "github.com/cockroachdb/cockroach/pkg/util/timeutil" + "github.com/cockroachdb/errors" + "github.com/cockroachdb/errors/oserror" "github.com/lestrrat-go/jwx/jwa" - jwk "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jwt" "github.com/stretchr/testify/require" ) @@ -133,15 +137,15 @@ 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(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 (token validity). - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid token") + // 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) + require.ErrorContains(t, err, "JWT authentication: invalid issuer") } func TestJWTSingleKey(t *testing.T) { @@ -163,16 +167,21 @@ func TestJWTSingleKey(t *testing.T) { require.NoError(t, err) jwkPublicKey := serializePublicKey(t, publicKey) - // When no JWKS is specified the token will be invalid. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid token") + // Configure issuer as it gets checked even before the token validity check. + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer1) + + // 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) + require.ErrorContains(t, err, "JWT authentication: unable to validate token") // 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(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid issuer") + err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + require.ErrorContains(t, err, "JWT authentication: invalid principal") } func TestJWTSingleKeyWithoutKeyAlgorithm(t *testing.T) { @@ -196,16 +205,21 @@ func TestJWTSingleKeyWithoutKeyAlgorithm(t *testing.T) { require.NoError(t, err) jwkPublicKey := serializePublicKey(t, publicKey) - // When no JWKS is specified the token will be invalid. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid token") + // Configure issuer as it gets checked even before the token validity check. + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer1) + + // 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) + require.ErrorContains(t, err, "JWT authentication: unable to validate token") // 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(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid issuer") + err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + require.ErrorContains(t, err, "JWT authentication: invalid principal") } func TestJWTMultiKey(t *testing.T) { @@ -220,6 +234,8 @@ func TestJWTMultiKey(t *testing.T) { require.NoError(t, err) // Make sure jwt auth is enabled. JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) + // Configure issuer as it gets checked even before the token validity check. + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer1) keySet, key, key2 := createJWKS(t) token := createJWT(t, username1, audience1, issuer1, timeutil.Now().Add(time.Hour), key2, jwa.ES384, "", "") publicKey, err := key.PublicKey() @@ -231,16 +247,18 @@ func TestJWTMultiKey(t *testing.T) { verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) - // Validation fails with an invalid token error for tokens signed with a different key. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid token") + // 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) + require.ErrorContains(t, err, "JWT authentication: unable to validate token") // Set both jwk1 and jwk2 to be valid signing keys. + JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, false) 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(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid issuer") + err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + require.ErrorContains(t, err, "JWT authentication: invalid principal") } func TestExpiredToken(t *testing.T) { @@ -255,13 +273,15 @@ func TestExpiredToken(t *testing.T) { 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. + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer1) keySet, key, _ := createJWKS(t) token := createJWT(t, username1, audience1, issuer1, timeutil.Now().Add(-1*time.Second), key, jwa.RS256, "", "") JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySet)) 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(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") } @@ -283,17 +303,20 @@ func TestKeyIdMismatch(t *testing.T) { JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySet)) verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) + // Configure issuer as it gets checked even before the token validity check. + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer1) - // Validation fails with an invalid token error for tokens with a kid not equal to that in JWKS. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) + // When JWKSAutoFetchEnabled the jwks fetch should be attempted and fail. + 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. require.NoError(t, key.Set(jwk.KeyIDKey, keyID1)) 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. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token, identMap) - require.ErrorContains(t, err, "JWT authentication: invalid issuer") + // 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) + require.NoError(t, err) } func TestIssuerCheck(t *testing.T) { @@ -314,27 +337,31 @@ func TestIssuerCheck(t *testing.T) { // and the issuer of "issuer2". JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySet)) - JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, issuer2) verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID()) - // Validation fails with an audience error when the issuer in the token is equal to the cluster's accepted issuers. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) + // Validation fails with no issuer are configured. + err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(invalidUsername), token1, identMap) + require.ErrorContains(t, err, "JWT authentication: invalid issuer") + + 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) require.ErrorContains(t, err, "JWT authentication: invalid issuer") // Validation succeeds when the issuer in the token is equal to the cluster's accepted issuers. - err = verifier.ValidateJWTLogin(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") // 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(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") // Validation succeeds when the issuer in the token is an element of the cluster's accepted issuers. - err = verifier.ValidateJWTLogin(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") } @@ -360,12 +387,12 @@ 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(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(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") } @@ -390,7 +417,7 @@ func TestClaimMissing(t *testing.T) { JWTAuthClaim.Override(ctx, &s.ClusterSettings().SV, customClaimName) // Validation fails with missing claim - err = verifier.ValidateJWTLogin(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") } @@ -416,7 +443,7 @@ func TestIntegerClaimValue(t *testing.T) { JWTAuthClaim.Override(ctx, &s.ClusterSettings().SV, customClaimName) // the integer claim is implicitly cast to a string - err = verifier.ValidateJWTLogin(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") } @@ -442,12 +469,12 @@ 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(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(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") } @@ -473,14 +500,14 @@ 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(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(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(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") } @@ -507,15 +534,15 @@ 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(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") // Validation fails if there is a map for the issuer but no mapping rule matches. - err = verifier.ValidateJWTLogin(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") // Validation passes the subject check when the username matches the mapped subject. - err = verifier.ValidateJWTLogin(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") } @@ -541,11 +568,11 @@ 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(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") // You cannot log in as root or other reserved users using token based auth when no map is involved. - err = verifier.ValidateJWTLogin(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") } @@ -573,19 +600,156 @@ 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(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") + + // 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) + 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) + 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) { + // 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 + byteValue, err := os.ReadFile(fileName) + if err != nil { + if oserror.IsNotExist(err) { + // return http status 404 if the file does not exist + return nil, errors.New("404 Not Found") + } + return nil, err + } + return byteValue, nil +} + +// createJWKSFromFile creates a jwk set from a local file. The file used by this function is expected to contain both +// private and public keys. +func createJWKSFromFile(t *testing.T, fileName string) jwk.Set { + byteValue, err := os.ReadFile(fileName) + require.NoError(t, err) + jwkSet, err := jwk.Parse(byteValue) + if err != nil { + return nil + } + return jwkSet +} + +// test that jwks url is used when JWKSAutoFetchEnabled is true. +func Test_JWKSFetchWorksWhenEnabled(t *testing.T) { + defer leaktest.AfterTest(t)() + // Intercept the call to getHttpResponse and return the mockGetHttpResponse + restoreHook := testutils.TestingHook(&getHttpResponse, mockGetHttpResponseWithLocalFileContent) + defer func() { + restoreHook() + }() + defer leaktest.AfterTest(t)() + defer log.Scope(t).Close(t) + ctx := context.Background() + s, _, _ := serverutils.StartServer(t, base.TestServerArgs{}) + defer s.Stopper().Stop(ctx) + identMapString := "" + identMap, err := identmap.From(strings.NewReader(identMapString)) + require.NoError(t, err) + + // Create key from a file. This key will be used to sign the token. + // Matching public key available in jwks url is used to verify token. + keySet := createJWKSFromFile(t, "testdata/www.idp1apis.com_oauth2_v3_certs_private") + key, _ := keySet.Get(0) + validIssuer := "https://accounts.idp1.com" + token := createJWT(t, username1, audience1, validIssuer, timeutil.Now().Add(time.Hour), key, jwa.RS256, "", "") + + // Make sure jwt auth is enabled and accepts jwk1 or jwk2 as valid signing keys. + JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) + //JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySet)) + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, validIssuer) + + // Set audience field to audience2. + JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience2) + + JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, true) + 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) + require.ErrorContains(t, err, "JWT authentication: invalid audience") + + // 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) + 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) + require.NoError(t, err) +} + +// test jwks url is used when JWKSAutoFetchEnabled and static jwks ignored. +func Test_JWKSFetchWorksWhenEnabledIgnoresTheStaticJWKS(t *testing.T) { + defer leaktest.AfterTest(t)() + // Intercept the call to getHttpResponse and return the mockGetHttpResponse + restoreHook := testutils.TestingHook(&getHttpResponse, mockGetHttpResponseWithLocalFileContent) + defer func() { + restoreHook() + }() + defer leaktest.AfterTest(t)() + defer log.Scope(t).Close(t) + ctx := context.Background() + s, _, _ := serverutils.StartServer(t, base.TestServerArgs{}) + defer s.Stopper().Stop(ctx) + identMapString := "" + identMap, err := identmap.From(strings.NewReader(identMapString)) + require.NoError(t, err) + + // Create key from a file. This key will be used to sign the token. + // Matching public key available in jwks url is used to verify token. + keySetUsedForSigning := createJWKSFromFile(t, "testdata/www.idp1apis.com_oauth2_v3_certs_private") + key, _ := keySetUsedForSigning.Get(0) + validIssuer := "https://accounts.idp1.com" + token := createJWT(t, username1, audience1, validIssuer, timeutil.Now().Add(time.Hour), key, jwa.RS256, "", "") + + // Make sure jwt auth is enabled and accepts jwk1 or jwk2 as valid signing keys. + JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true) + JWKSAutoFetchEnabled.Override(ctx, &s.ClusterSettings().SV, true) + // Configure cluster setting with a key that is not used for signing. + keySetNotUsedForSigning, _, _ := createJWKS(t) + JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySetNotUsedForSigning)) + JWTAuthIssuers.Override(ctx, &s.ClusterSettings().SV, validIssuer) + + // Set audience field to audience2. + JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience2) + + 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) require.ErrorContains(t, err, "JWT authentication: invalid audience") // Update the audience field to "test_cluster". JWTAuthAudience.Override(ctx, &s.ClusterSettings().SV, audience1) - // Validation passess the audience check now that they match. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + // Validation passes the audience check now that they match. + 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 passess the audience check now that both audiences are accepted. - err = verifier.ValidateJWTLogin(s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) + // Validation passes the audience check now that both audiences are accepted. + err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap) require.NoError(t, err) } diff --git a/pkg/ccl/jwtauthccl/settings.go b/pkg/ccl/jwtauthccl/settings.go index 633dbfdafb0c..f4e58009b47a 100644 --- a/pkg/ccl/jwtauthccl/settings.go +++ b/pkg/ccl/jwtauthccl/settings.go @@ -19,12 +19,13 @@ 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" + 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" ) // JWTAuthClaim sets the JWT claim that is parsed to get the username. @@ -56,7 +57,7 @@ var JWTAuthEnabled = func() *settings.BoolSetting { s := settings.RegisterBoolSetting( settings.TenantWritable, JWTAuthEnabledSettingName, - "enables or disabled JWT login for the SQL interface", + "enables or disables JWT login for the SQL interface", false, ) s.SetReportable(true) @@ -88,6 +89,19 @@ var JWTAuthIssuers = func() *settings.StringSetting { return s }() +// JWKSAutoFetchEnabled enables or disables automatic fetching of JWKs from the issuer's well-known endpoint. +var JWKSAutoFetchEnabled = func() *settings.BoolSetting { + s := settings.RegisterBoolSetting( + settings.TenantWritable, + JWKSAutoFetchEnabledSettingName, + "enables or disables automatic fetching of JWKs from the issuer's well-known endpoint. "+ + "If this is enabled, the server.jwt_authentication.jwks will be ignored.", + false, + ) + s.SetReportable(true) + return s +}() + func validateJWTAuthIssuers(values *settings.Values, s string) error { var issuers []string diff --git a/pkg/ccl/jwtauthccl/testdata/accounts.idp1.com_.well-known_openid-configuration b/pkg/ccl/jwtauthccl/testdata/accounts.idp1.com_.well-known_openid-configuration new file mode 100644 index 000000000000..ac3695b90766 --- /dev/null +++ b/pkg/ccl/jwtauthccl/testdata/accounts.idp1.com_.well-known_openid-configuration @@ -0,0 +1,58 @@ +{ + "issuer": "https://accounts.idp1.com", + "authorization_endpoint": "https://accounts.idp1.com/o/oauth2/v2/auth", + "device_authorization_endpoint": "https://oauth2.idp1apis.com/device/code", + "token_endpoint": "https://oauth2.idp1apis.com/token", + "userinfo_endpoint": "https://openidconnect.idp1apis.com/v1/userinfo", + "revocation_endpoint": "https://oauth2.idp1apis.com/revoke", + "jwks_uri": "https://www.idp1apis.com/oauth2/v3/certs", + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "locale", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ] +} diff --git a/pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs b/pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs new file mode 100644 index 000000000000..e0edebbdb3fd --- /dev/null +++ b/pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs @@ -0,0 +1,37 @@ +{ + "keys": [ + { + "e": "AQAB", + "kid": "9b0285c31bfd8b040e03157b19c4e960bdc10c6f", + "use": "sig", + "n": "uCYe4j3rIDaC9U8jCloiD5UP5cQCndcKr570LSxEznqNB0qpmtqDJBU-RuSJbMEYZ853AlezSWca8uqDBAgdIWPod-scaQTOTg049m9hFwQuP7FzXsAjtxiOHub0nrD60Dy7vI1dPoiyiFdox25JUdW6OSPyq2OlFxCPIQy4SpKvebXduA2ZeIY5TWE2wt0mVPo__s9NACn4Ni9GwsPCcgG6yn8oAJ-JW6xCLnz5_CycNlg178Sxj8LWVEisPbdEK9LhSwQ7V3YU7pfLpEAtGWHYrIcH3-Tfz6IkS9-UmAzbdjaGk2W-AXkZW8jiIbfNER7e4ZKLntC4Am4InHkJzw", + "alg": "RS256", + "kty": "RSA" + }, + { + "e": "AQAB", + "alg": "RS256", + "n": "1yFBscIm7d2VYYx8dSK4R4b5EOLKoFXPdr-B9RVYaFS_XHso47Mdc5_oj8DwYGeeJgvJN6kKrDqRd3W3JmEkA-woKe6e0Vd56sMWvc2s94utfI8AiXBNwXAYnCQWGHnu9faF903JaRDJTeaRTSmbrSMibpshpK2PcOtOk0Fb9CyZm9E8jSMblMa3jhW8vlTnln3r4qgr1nwddbOj0WEmAjwA7G32EdlF5Oz30_HeTiEKpMtLumf0GbmCP23dyc8Ibrl8ahhEdGtBBb8tDCIroB2C_O_QBdYVE8GZW2ZUBSEx7-riMZ5h--2bweM94I6dMSBke9IZ2582Sn8j3lFEWw", + "kty": "RSA", + "use": "sig", + "kid": "456b52c81e36fead259231a6947e040e03ea1262" + }, + { + "kty": "RSA", + "alg": "RS256", + "n": "sm72oBH-R2Rqt4hkjp66tz5qCtq42TMnVgZg2Pdm_zs7_-EoFyNs9sD1MKsZAFaBPXBHDiWywyaHhLgwETLN9hlJIZPzGCEtV3mXJFSYG-8L6t3kyKi9X1lUTZzbmNpE0tf-eMW-3gs3VQSBJQOcQnuiANxbSXwS3PFmi173C_5fDSuC1RoYGT6X3JqLc3DWUmBGucuQjPaUF0w6LMqEIy0W_WYbW7HImwANT6dT52T72md0JWZuAKsRRnRr_bvaUX8_e3K8Pb1K_t3dD6WSLvtmEfUnGQgLynVl3aV5sRYC0Hy_IkRgoxl2fd8AaZT1X_rdPexYpx152Pl_CHJ79Q", + "use": "sig", + "kid": "0ad1fec78504f447bae65bcf5afaedb65eec9e81", + "e": "AQAB" + }, + { + "alg": "RS256", + "e": "AQAB", + "kid": "valid-kid-testing", + "kty": "RSA", + "n": "6x265Wl0SZLY6qYr7Vx6Zp8METTH9LMmHJ_hmRHU_kpnVJ_qxcWCcgwsJeGw37i5-rsUDVIhiRAhUcyUNW3DYxlTEvTTDAzSjSB94gSo4RoEAs8CIDA7tH_s9gKnVQ5KCkxgRYCIrckZpvDsLA7RcLcOcH4SHzxT0zMnybFz8p9hMbu04eB_Jpv1c6bhYYuAiwDnTKcBdgV7BdE-48mHx-IP5Tp3CDlhIlGmdmis31nm0SlOZtqzEPsT8M-0x_nOYCa63HSfzbP2YxKekPB5Sb_9yhNbRFgQq2Azo-v_6fxm93rqjZlfGiTUOTMTA1zM2veWDZL-5se2YRt8rQMtCQ", + "use": "sig" + } + + ] +} diff --git a/pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs_private b/pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs_private new file mode 100644 index 000000000000..59a67f78ccd8 --- /dev/null +++ b/pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs_private @@ -0,0 +1,17 @@ +{ + "keys": [ + { + "alg": "RS256", + "d": "hr2U_CBBKmDjuyXcCr1y0BjZ24p6BTwd3U2rBgP4InsVWKQE8a5NIXrkWhlLOgstWgmYZkHpQhliXvR1A2GSFdrPhw-TW1aF26cBPWQaPFaicdGckEHUFY8yh5Hhv5bey6QVj_8nVSDoeImdb2pWkNf3iHRXglsaVvD8HlR59FJVNt0A3KbSbiVe05HZuxxNer3b4_f9o2sLEuJxSojgY0qRUbJLdylbSp3rmAcScygcaLdK4sXdwyGzWp-hRGwxvSDuVp900JUU1rCfuuYFjL28bNbUelavvyDFhFN76yy-FEkWx01Ox2nt9eI3VwLs-KwgPf-U_1M5FL-QsW-W2Q", + "dp": "BLkTBppN71qxl8rxptU-CCK9nyZhRLJMJVmOu-8r3ZxBlXdcOJgz1xgH3ZNxYpL6-XA2Gvdctb_rGoo1dlBeupwbpBWVokYBsWLvKTdp5YyabvuoiEgA3rG_ZLdEERgQfoJdzqccOPCSuvZVkD0X7eSBwFjCHXH9yl0-kIqfOI0", + "dq": "8vWZuxsek1HhCnRv-WRvI7aD0St_fkeNV2JZpsR9XoDthpxWxjLOPaalUjfQQuhrZcgtcIH_9wUnOTeU17GPWiD-h-pkTHuloHrhAf9a8TWkYZwMIlQrVixo683MKNWkVAKKE4hEWt6l7JpJTjS1gvmjFNDtimdLqxy1alF7q-U", + "e": "AQAB", + "kid": "valid-kid-testing", + "kty": "RSA", + "n": "6x265Wl0SZLY6qYr7Vx6Zp8METTH9LMmHJ_hmRHU_kpnVJ_qxcWCcgwsJeGw37i5-rsUDVIhiRAhUcyUNW3DYxlTEvTTDAzSjSB94gSo4RoEAs8CIDA7tH_s9gKnVQ5KCkxgRYCIrckZpvDsLA7RcLcOcH4SHzxT0zMnybFz8p9hMbu04eB_Jpv1c6bhYYuAiwDnTKcBdgV7BdE-48mHx-IP5Tp3CDlhIlGmdmis31nm0SlOZtqzEPsT8M-0x_nOYCa63HSfzbP2YxKekPB5Sb_9yhNbRFgQq2Azo-v_6fxm93rqjZlfGiTUOTMTA1zM2veWDZL-5se2YRt8rQMtCQ", + "p": "9tUT8MGkEHXIKcFq7Waof4uWOstqYETD6KqvDdzYjhxqggexmTJPELaz-iSIyCNt6po95Ike2VckBfvq4B5tNuHWZLC7yTcc3WxPPVyAZpVukCV2zRKbs8Zy0zjpnglOgmvapSNKulgfwIYHDtUp2OSSV-Ma_Snl8Rq6i-Dr0yM", + "q": "89lAqtOqdusC2Psz-CUbB0Zq_yiIWIlbUdXFxv0jefmyKy4CKItF63XKexuWIEqzwVFMzhcJ5DdJpNYND2q8RroZrMeNvKVT0AvsktDbHETdnUinSecCbpqSzLEEiNkG7hg4mrIphb1W0fdp7PhSlHUq_GqtiKnkBGR17KjrB-M", + "qi": "ooUkn5a8TvSav3qLop4HpcxUWh5rF4tasrsWDTHBxsTv9kvgfQ7X3nsZJXABF7GieX9E9U-Lfi9ZcdMlIJe70z6eayRGkTvAbQO155ZiVGKRHkBi5k-WzvmrNBYrhXF8al2ABSk37IzjtPAQBBAfr8qAnf8xYbjar7yabqmpZ0M" + } + ] +} diff --git a/pkg/ccl/testccl/authccl/auth_test.go b/pkg/ccl/testccl/authccl/auth_test.go index fe0ddefb63a9..73adfe4a2aa6 100644 --- a/pkg/ccl/testccl/authccl/auth_test.go +++ b/pkg/ccl/testccl/authccl/auth_test.go @@ -241,6 +241,15 @@ func jwtRunTest(t *testing.T, insecure bool) { t.Fatalf("wrong number of comma separated argumenets to jwt_cluster_setting ident_map: %d", len(a.Vals)) } pgwire.ConnIdentityMapConf.Override(context.Background(), sv, strings.Join(args, " ")) + case "jwks_auto_fetch.enabled": + if len(a.Vals) != 1 { + t.Fatalf("wrong number of argumenets to jwt_cluster_setting jwks_auto.fetch_enabled: %d", len(a.Vals)) + } + v, err := strconv.ParseBool(a.Vals[0]) + if err != nil { + t.Fatalf("unknown value for jwt_cluster_setting jwks_auto_fetch.enabled: %s", a.Vals[0]) + } + jwtauthccl.JWKSAutoFetchEnabled.Override(context.Background(), sv, v) default: t.Fatalf("unknown jwt_cluster_setting: %s", a.Key) } diff --git a/pkg/ccl/testccl/authccl/testdata/jwt b/pkg/ccl/testccl/authccl/testdata/jwt index dddde1669744..37a3ddd7ec99 100644 --- a/pkg/ccl/testccl/authccl/testdata/jwt +++ b/pkg/ccl/testccl/authccl/testdata/jwt @@ -20,11 +20,10 @@ ERROR: JWT authentication: not enabled (SQLSTATE 28000) jwt_cluster_setting enabled=true ---- - # see authentication_jwt_test.go for examples of how to generate these tokens. +jwt_cluster_setting issuers=issuer connect user=jwt_user options=--crdb:jwt_auth_enabled=true password=eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QifQ.eyJhdWQiOiJ0ZXN0X2NsdXN0ZXIiLCJleHAiOjI2NjEyNjM5NTcsImlhdCI6MTY2MTI2Mzk1NywiaXNzIjoiaXNzdWVyIiwic3ViIjoidGVzdCJ9.Z0Hyi7YbnRZRfOJxjz0K9b1bFNA4eoWa4g8kH5LoYRivvARAZLdD7Ux0OQfsrFHAjK4eOtglF4nmY0usGl8diUsL86ifinyxMNC78xzaKrV620Kzt2k2kld0cwCPc-pRAjN8RSMw6Ypt9oIpnFTsFwIhB9QN_7t6KF4NRjgqdENI4UbBTgw0cR5kExk7PGpyEIxJ_6Y0cVwCBgosnKAEA7XpA2fHU_k61zX9MIiDgdnwWl0KuB3Csr37N998T-oxQPNI8o9JVwsSYGPPVvET70PankDUNhVWrU7rxKVVQ579khhdApPpDB82lypI7W8eVcZoamTWo19o1_CMUSzb2A ---- -ERROR: JWT authentication: invalid token (SQLSTATE 28000) subtest end @@ -38,17 +37,17 @@ jwt_cluster_setting jwks={"kty":"RSA","use":"sig","alg":"RS256","kid":"test","n" # see authentication_jwt_test.go for examples of how to generate these tokens. connect user=jwt_user options=--crdb:jwt_auth_enabled=true password=eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QifQ.eyJhdWQiOiJ0ZXN0X2NsdXN0ZXIiLCJleHAiOjI2NjEyNjM5NTcsImlhdCI6MTY2MTI2Mzk1NywiaXNzIjoiaXNzdWVyIiwic3ViIjoidGVzdCJ9.Z0Hyi7YbnRZRfOJxjz0K9b1bFNA4eoWa4g8kH5LoYRivvARAZLdD7Ux0OQfsrFHAjK4eOtglF4nmY0usGl8diUsL86ifinyxMNC78xzaKrV620Kzt2k2kld0cwCPc-pRAjN8RSMw6Ypt9oIpnFTsFwIhB9QN_7t6KF4NRjgqdENI4UbBTgw0cR5kExk7PGpyEIxJ_6Y0cVwCBgosnKAEA7XpA2fHU_k61zX9MIiDgdnwWl0KuB3Csr37N998T-oxQPNI8o9JVwsSYGPPVvET70PankDUNhVWrU7rxKVVQ579khhdApPpDB82lypI7W8eVcZoamTWo19o1_CMUSzb2A ---- -ERROR: JWT authentication: invalid issuer (SQLSTATE 28000) -DETAIL: token issued by issuer +ERROR: JWT authentication: invalid principal (SQLSTATE 28000) +DETAIL: token issued for [test] and login was for jwt_user subtest end subtest multiple_jwks_key # see authentication_jwt_test.go for examples of how to generate these tokens. +jwt_cluster_setting issuers=["issuer","issuer2"] 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) # see authentication_jwt_test.go for examples of how to generate JWKS values. 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"}]} @@ -57,8 +56,8 @@ jwt_cluster_setting jwks={"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":" # see authentication_jwt_test.go for examples of how to generate these tokens. 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 issuer (SQLSTATE 28000) -DETAIL: token issued by issuer2 +ERROR: JWT authentication: invalid principal (SQLSTATE 28000) +DETAIL: token issued for [test2] and login was for jwt_user subtest end @@ -88,8 +87,8 @@ jwt_cluster_setting jwks={"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":" # see authentication_jwt_test.go for examples of how to generate these tokens. 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 issuer (SQLSTATE 28000) -DETAIL: token issued by issuer2 +ERROR: JWT authentication: invalid principal (SQLSTATE 28000) +DETAIL: token issued for [test2] and login was for jwt_user subtest end diff --git a/pkg/sql/pgwire/auth_methods.go b/pkg/sql/pgwire/auth_methods.go index 6735383a384b..4273b363f04c 100644 --- a/pkg/sql/pgwire/auth_methods.go +++ b/pkg/sql/pgwire/auth_methods.go @@ -686,7 +686,7 @@ func authSessionRevivalToken(token []byte) AuthMethod { // This interface has a method that validates whether a given JWT token is a proper // credential for a given user to login. type JWTVerifier interface { - ValidateJWTLogin(_ *cluster.Settings, + ValidateJWTLogin(_ context.Context, _ *cluster.Settings, _ username.SQLUsername, _ []byte, _ *identmap.Conf, @@ -698,7 +698,7 @@ var jwtVerifier JWTVerifier type noJWTConfigured struct{} func (c *noJWTConfigured) ValidateJWTLogin( - _ *cluster.Settings, _ username.SQLUsername, _ []byte, _ *identmap.Conf, + _ context.Context, _ *cluster.Settings, _ username.SQLUsername, _ []byte, _ *identmap.Conf, ) error { return errors.New("JWT token authentication requires CCL features") } @@ -765,7 +765,7 @@ func authJwtToken( if len(token) == 0 { return security.NewErrPasswordUserAuthFailed(user) } - if err = jwtVerifier.ValidateJWTLogin(execCfg.Settings, user, []byte(token), identMap); err != nil { + if err = jwtVerifier.ValidateJWTLogin(ctx, execCfg.Settings, user, []byte(token), identMap); err != nil { c.LogAuthFailed(ctx, eventpb.AuthFailReason_CREDENTIALS_INVALID, err) return err }