diff --git a/tests/common/auth_test.go b/tests/common/auth_test.go index 0c34b800a..7f120bfad 100644 --- a/tests/common/auth_test.go +++ b/tests/common/auth_test.go @@ -29,8 +29,11 @@ import ( ) var tokenTTL = time.Second +var defaultKeyPath = mustAbsPath("../fixtures/server.key.insecure") var defaultAuthToken = fmt.Sprintf("jwt,pub-key=%s,priv-key=%s,sign-method=RS256,ttl=%s", - mustAbsPath("../fixtures/server.crt"), mustAbsPath("../fixtures/server.key.insecure"), tokenTTL) + mustAbsPath("../fixtures/server.crt"), defaultKeyPath, tokenTTL) +var verifyJWTOnlyAuth = fmt.Sprintf("jwt,pub-key=%s,sign-method=RS256,ttl=%s", + mustAbsPath("../fixtures/server.crt"), tokenTTL) const ( PermissionDenied = "etcdserver: permission denied" @@ -758,6 +761,25 @@ func TestAuthJWTExpire(t *testing.T) { }) } +func TestAuthJWTOnly(t *testing.T) { + testRunner.BeforeTest(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + clus := testRunner.NewCluster(ctx, t, config.WithClusterConfig(config.ClusterConfig{ClusterSize: 1, AuthToken: verifyJWTOnlyAuth})) + defer clus.Close() + cc := testutils.MustClient(clus.Client()) + testutils.ExecuteUntil(ctx, t, func() { + authRev, err := setupAuthAndGetRevision(cc, []authRole{testRole}, []authUser{rootUser, testUser}) + require.NoErrorf(t, err, "failed to enable auth") + + token, err := createSignedJWT(defaultKeyPath, "RS256", testUserName, authRev) + require.NoErrorf(t, err, "failed to create test user JWT") + + testUserAuthClient := testutils.MustClient(clus.Client(WithAuthToken(token))) + require.NoError(t, testUserAuthClient.Put(ctx, "foo", "bar", config.PutOptions{})) + }) +} + // TestAuthRevisionConsistency ensures auth revision is the same after member restarts func TestAuthRevisionConsistency(t *testing.T) { testRunner.BeforeTest(t) diff --git a/tests/common/auth_util.go b/tests/common/auth_util.go index 313bfb46d..a4013822c 100644 --- a/tests/common/auth_util.go +++ b/tests/common/auth_util.go @@ -17,8 +17,11 @@ package common import ( "context" "fmt" + "os" "testing" + "time" + "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/require" "go.etcd.io/etcd/api/v3/authpb" @@ -93,6 +96,29 @@ func createUsers(c interfaces.Client, users []authUser) error { return nil } +func createSignedJWT(keyPath, alg, username string, authRevision uint64) (string, error) { + signMethod := jwt.GetSigningMethod(alg) + + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return "", err + } + + key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + if err != nil { + return "", err + } + + tk := jwt.NewWithClaims(signMethod, + jwt.MapClaims{ + "username": username, + "revision": authRevision, + "exp": time.Now().Add(time.Minute).Unix(), + }) + + return tk.SignedString(key) +} + func setupAuth(c interfaces.Client, roles []authRole, users []authUser) error { // create roles if err := createRoles(c, roles); err != nil { @@ -107,6 +133,29 @@ func setupAuth(c interfaces.Client, roles []authRole, users []authUser) error { return c.AuthEnable(context.TODO()) } +func setupAuthAndGetRevision(c interfaces.Client, roles []authRole, users []authUser) (uint64, error) { + // create roles + if err := createRoles(c, roles); err != nil { + return 0, err + } + + if err := createUsers(c, users); err != nil { + return 0, err + } + + // This needs to happen before enabling auth for the TestAuthJWTOnly + // test case because once auth is enabled we can no longer mint a valid + // auth token without the revision, which we won't be able to obtain + // without a valid auth token. + authrev, err := c.AuthStatus(context.TODO()) + if err != nil { + return 0, err + } + + // enable auth + return authrev.AuthRevision, c.AuthEnable(context.TODO()) +} + func requireRolePermissionEqual(t *testing.T, expectRole authRole, actual []*authpb.Permission) { require.Equal(t, 1, len(actual)) require.Equal(t, expectRole.permission, clientv3.PermissionType(actual[0].PermType)) diff --git a/tests/common/e2e_test.go b/tests/common/e2e_test.go index 11c4f94a3..e9e2f1921 100644 --- a/tests/common/e2e_test.go +++ b/tests/common/e2e_test.go @@ -78,6 +78,10 @@ func WithAuth(userName, password string) config.ClientOption { return e2e.WithAuth(userName, password) } +func WithAuthToken(token string) config.ClientOption { + return e2e.WithAuthToken(token) +} + func WithEndpoints(endpoints []string) config.ClientOption { return e2e.WithEndpoints(endpoints) } diff --git a/tests/common/integration_test.go b/tests/common/integration_test.go index c4cabeeb1..f41055c44 100644 --- a/tests/common/integration_test.go +++ b/tests/common/integration_test.go @@ -56,6 +56,10 @@ func WithAuth(userName, password string) config.ClientOption { return integration.WithAuth(userName, password) } +func WithAuthToken(token string) config.ClientOption { + return integration.WithAuthToken(token) +} + func WithEndpoints(endpoints []string) config.ClientOption { return integration.WithEndpoints(endpoints) } diff --git a/tests/common/unit_test.go b/tests/common/unit_test.go index 4b172e7a3..48e3a51cd 100644 --- a/tests/common/unit_test.go +++ b/tests/common/unit_test.go @@ -37,6 +37,10 @@ func WithAuth(userName, password string) config.ClientOption { return func(any) {} } +func WithAuthToken(token string) config.ClientOption { + return func(any) {} +} + func WithEndpoints(endpoints []string) config.ClientOption { return func(any) {} } diff --git a/tests/framework/e2e/etcdctl.go b/tests/framework/e2e/etcdctl.go index 81d57c088..e9266cfb2 100644 --- a/tests/framework/e2e/etcdctl.go +++ b/tests/framework/e2e/etcdctl.go @@ -55,6 +55,7 @@ func NewEtcdctl(cfg ClientConfig, endpoints []string, opts ...config.ClientOptio DialOptions: []grpc.DialOption{grpc.WithBlock()}, Username: ctl.authConfig.Username, Password: ctl.authConfig.Password, + Token: ctl.authConfig.Token, }) if err != nil { return nil, err @@ -73,6 +74,13 @@ func WithAuth(userName, password string) config.ClientOption { } } +func WithAuthToken(token string) config.ClientOption { + return func(c any) { + ctl := c.(*EtcdctlV3) + ctl.authConfig.Token = token + } +} + func WithEndpoints(endpoints []string) config.ClientOption { return func(c any) { ctl := c.(*EtcdctlV3) @@ -344,7 +352,9 @@ func (ctl *EtcdctlV3) flags() map[string]string { } } fmap["endpoints"] = strings.Join(ctl.endpoints, ",") - if !ctl.authConfig.Empty() { + if ctl.authConfig.Token != "" { + fmap["auth-jwt-token"] = ctl.authConfig.Token + } else if !ctl.authConfig.Empty() { fmap["user"] = ctl.authConfig.Username + ":" + ctl.authConfig.Password } return fmap diff --git a/tests/framework/integration/cluster.go b/tests/framework/integration/cluster.go index 95b5c88d9..a164fcc72 100644 --- a/tests/framework/integration/cluster.go +++ b/tests/framework/integration/cluster.go @@ -1478,6 +1478,13 @@ func WithAuth(userName, password string) framecfg.ClientOption { } } +func WithAuthToken(token string) framecfg.ClientOption { + return func(c any) { + cfg := c.(*clientv3.Config) + cfg.Token = token + } +} + func WithEndpoints(endpoints []string) framecfg.ClientOption { return func(c any) { cfg := c.(*clientv3.Config) diff --git a/tests/go.mod b/tests/go.mod index 57e6b857e..2ca1ad816 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -18,6 +18,7 @@ replace ( require ( github.com/anishathalye/porcupine v0.1.4 github.com/coreos/go-semver v0.3.1 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang/protobuf v1.5.4 github.com/google/go-cmp v0.6.0 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 @@ -65,7 +66,6 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect github.com/google/uuid v1.6.0 // indirect