From 61776d5f546540acf5a20d0da4642a2fdbad01b2 Mon Sep 17 00:00:00 2001 From: babusrithar Date: Sat, 23 Dec 2023 15:30:33 +0000 Subject: [PATCH] CRDB-28040 : JWKS fetch from jwks_uri This commit adds capability to fetch remote JWKS from issuer's jwks_uri endpoint. This will satisfy the requirement to have an ability to automatically fetch the new JWK when the existing JWK is rotated - without human intervention or custom scripts. Changes include 1. The existing order of token signature verification first and rest of claims next is modified to get issuer first and then the token signature verification. This change is requied to determine the issuer for which the jwks has to be fetched remotely. 2. Introduction of a new cluster setting called `server.jwt_authentication.jwks_auto_fetch.enabled` 3. Depending on the value of `server.jwt_authentication.jwks_auto_fetch.enabled` use JWKS configured through cluster setting or remotely fetch JWKS from jwks_uri of the issuer 4. Modification to exiting test cases to match the new order of verification steps. The change is backward compatible and no changes required in existing deployments and JWT Auth usage. --- pkg/ccl/jwtauthccl/BUILD.bazel | 5 + pkg/ccl/jwtauthccl/authentication_jwt.go | 120 +++++++- pkg/ccl/jwtauthccl/authentication_jwt_test.go | 266 ++++++++++++++---- pkg/ccl/jwtauthccl/settings.go | 28 +- ....idp1.com_.well-known_openid-configuration | 58 ++++ .../testdata/www.idp1apis.com_oauth2_v3_certs | 37 +++ .../www.idp1apis.com_oauth2_v3_certs_private | 17 ++ pkg/ccl/testccl/authccl/auth_test.go | 9 + pkg/ccl/testccl/authccl/testdata/jwt | 17 +- pkg/sql/pgwire/auth_methods.go | 6 +- 10 files changed, 479 insertions(+), 84 deletions(-) create mode 100644 pkg/ccl/jwtauthccl/testdata/accounts.idp1.com_.well-known_openid-configuration create mode 100644 pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs create mode 100644 pkg/ccl/jwtauthccl/testdata/www.idp1apis.com_oauth2_v3_certs_private 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 }