From 2d6a8ae7b88975ae98149d0f7c74a81524751302 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 21 Oct 2023 12:52:27 -0700 Subject: [PATCH 1/3] clientv3: allow setting JWT directly etcd supports using signed JWTs in a verify-only mode where the server has access to only a public key and therefore can not create tokens but can validate them. For this to work a client must not call Authenticate and must instead submit a pre-signed JWT with their request. The server will validate this token, extract the username from it, and may allow the client access. This change allows setting the JWT directly and not setting a username and password. If a JWT is provided the client will no longer call Authenticate, which would not work anyhow. It also provides a public method UpdateAuthToken to allow a user of the client to update their auth token, for example, if it expires. In this flow all token lifecycle management is handled outside of the client as a concern of the client user. Signed-off-by: Mike Crute --- client/v3/client.go | 20 ++++++++++- client/v3/client_test.go | 77 ++++++++++++++++++++++++++++++++++++++++ client/v3/config.go | 7 +++- client/v3/config_test.go | 19 ++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/client/v3/client.go b/client/v3/client.go index 8789acb38..8402ce9f0 100644 --- a/client/v3/client.go +++ b/client/v3/client.go @@ -69,7 +69,10 @@ type Client struct { // Username is a user name for authentication. Username string // Password is a password for authentication. - Password string + Password string + // Token is a JWT used for authentication instead of a password. + Token string + authTokenBundle credentials.PerRPCCredentialsBundle callOpts []grpc.CallOption @@ -288,6 +291,11 @@ func (c *Client) Dial(ep string) (*grpc.ClientConn, error) { func (c *Client) getToken(ctx context.Context) error { var err error // return last error in a case of fail + if c.Token != "" { + c.authTokenBundle.UpdateAuthToken(c.Token) + return nil + } + if c.Username == "" || c.Password == "" { return nil } @@ -376,6 +384,10 @@ func newClient(cfg *Config) (*Client, error) { creds = credentials.NewTransportCredential(cfg.TLS) } + if cfg.Token != "" && (cfg.Username != "" || cfg.Password != "") { + return nil, errors.New("Username/Password and Token configurations are mutually exclusive") + } + // use a temporary skeleton client to bootstrap first connection baseCtx := context.TODO() if cfg.Context != nil { @@ -414,6 +426,12 @@ func newClient(cfg *Config) (*Client, error) { client.Password = cfg.Password client.authTokenBundle = credentials.NewPerRPCCredentialBundle() } + + if cfg.Token != "" { + client.Token = cfg.Token + client.authTokenBundle = credentials.NewPerRPCCredentialBundle() + } + if cfg.MaxCallSendMsgSize > 0 || cfg.MaxCallRecvMsgSize > 0 { if cfg.MaxCallRecvMsgSize > 0 && cfg.MaxCallSendMsgSize > cfg.MaxCallRecvMsgSize { return nil, fmt.Errorf("gRPC message recv limit (%d bytes) must be greater than send limit (%d bytes)", cfg.MaxCallRecvMsgSize, cfg.MaxCallSendMsgSize) diff --git a/client/v3/client_test.go b/client/v3/client_test.go index db8bb1773..8f3aa52b9 100644 --- a/client/v3/client_test.go +++ b/client/v3/client_test.go @@ -337,6 +337,75 @@ func TestAuthTokenBundleNoOverwrite(t *testing.T) { } } +func TestNewWithOnlyJWT(t *testing.T) { + // This call in particular changes working directory to the tmp dir of + // the test. The `etcd-auth-test:1` can be created in local directory, + // not exceeding the longest allowed path on OsX. + testutil.BeforeTest(t) + + // Create a mock AuthServer to handle Authenticate RPCs. + lis, err := net.Listen("unix", "etcd-auth-test:1") + if err != nil { + t.Fatal(err) + } + defer lis.Close() + addr := "unix://" + lis.Addr().String() + srv := grpc.NewServer() + // Having a token removes the need to ever call Authenticate on the + // server. If that happens then this will cause a connection failure. + etcdserverpb.RegisterAuthServer(srv, mockFailingAuthServer{}) + go srv.Serve(lis) + defer srv.Stop() + + c, err := NewClient(t, Config{ + DialTimeout: 5 * time.Second, + Endpoints: []string{addr}, + Token: "foo", + }) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + meta, err := c.authTokenBundle.PerRPCCredentials().GetRequestMetadata(context.Background(), "") + if err != nil { + t.Errorf("Error building request metadata: %s", err) + } + + if tok, ok := meta[rpctypes.TokenFieldNameGRPC]; !ok { + t.Error("Token was not successfuly set in the auth bundle") + } else if tok != "foo" { + t.Errorf("Incorrect token set in auth bundle, got '%s', expected 'foo'", tok) + } +} + +func TestNewOnlyJWTExclusivity(t *testing.T) { + testutil.BeforeTest(t) + + // Create a mock AuthServer to handle Authenticate RPCs. + lis, err := net.Listen("unix", "etcd-auth-test:1") + if err != nil { + t.Fatal(err) + } + defer lis.Close() + addr := "unix://" + lis.Addr().String() + srv := grpc.NewServer() + // Having a token removes the need to ever call Authenticate on the + // server. If that happens then this will cause a connection failure. + etcdserverpb.RegisterAuthServer(srv, mockFailingAuthServer{}) + go srv.Serve(lis) + defer srv.Stop() + + _, err = NewClient(t, Config{ + DialTimeout: 5 * time.Second, + Endpoints: []string{addr}, + Token: "foo", + Username: "user", + Password: "pass", + }) + require.Error(t, err, "Username/Password and Token configurations are mutually exclusive") +} + func TestSyncFiltersMembers(t *testing.T) { c, _ := NewClient(t, Config{Endpoints: []string{"http://254.0.0.1:12345"}}) defer c.Close() @@ -498,6 +567,14 @@ func (mm mockMaintenance) Downgrade(ctx context.Context, action DowngradeAction, return nil, nil } +type mockFailingAuthServer struct { + *etcdserverpb.UnimplementedAuthServer +} + +func (mockFailingAuthServer) Authenticate(context.Context, *etcdserverpb.AuthenticateRequest) (*etcdserverpb.AuthenticateResponse, error) { + return nil, errors.New("this auth server always fails") +} + type mockAuthServer struct { *etcdserverpb.UnimplementedAuthServer } diff --git a/client/v3/config.go b/client/v3/config.go index 89b40ce12..e643adff2 100644 --- a/client/v3/config.go +++ b/client/v3/config.go @@ -66,6 +66,9 @@ type Config struct { // Password is a password for authentication. Password string `json:"password"` + // Token is a JWT used for authentication instead of a password. + Token string `json:"token"` + // RejectOldCluster when set will refuse to create a client against an outdated cluster. RejectOldCluster bool `json:"reject-old-cluster"` @@ -128,10 +131,11 @@ type SecureConfig struct { type AuthConfig struct { Username string `json:"username"` Password string `json:"password"` + Token string `json:"token"` } func (cfg AuthConfig) Empty() bool { - return cfg.Username == "" && cfg.Password == "" + return cfg.Username == "" && cfg.Password == "" && cfg.Token == "" } // NewClientConfig creates a Config based on the provided ConfigSpec. @@ -152,6 +156,7 @@ func NewClientConfig(confSpec *ConfigSpec, lg *zap.Logger) (*Config, error) { if confSpec.Auth != nil { cfg.Username = confSpec.Auth.Username cfg.Password = confSpec.Auth.Password + cfg.Token = confSpec.Auth.Token } return cfg, nil diff --git a/client/v3/config_test.go b/client/v3/config_test.go index a99c3fd58..0cd3ae382 100644 --- a/client/v3/config_test.go +++ b/client/v3/config_test.go @@ -68,6 +68,25 @@ func TestNewClientConfig(t *testing.T) { Password: "changeme", }, }, + { + name: "JWT specified", + spec: ConfigSpec{ + Endpoints: []string{"http://192.168.0.12:2379"}, + DialTimeout: 1 * time.Second, + KeepAliveTime: 4 * time.Second, + KeepAliveTimeout: 6 * time.Second, + Auth: &AuthConfig{ + Token: "test", + }, + }, + expectedConf: Config{ + Endpoints: []string{"http://192.168.0.12:2379"}, + DialTimeout: 1 * time.Second, + DialKeepAliveTime: 4 * time.Second, + DialKeepAliveTimeout: 6 * time.Second, + Token: "test", + }, + }, { name: "default secure transport", spec: ConfigSpec{ From 87d9a468d53d952cb8d58848db64b5a6b77745b4 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 21 Oct 2023 14:30:59 -0700 Subject: [PATCH 2/3] etcdclt: support passing JWT auth token Signed-off-by: Mike Crute --- etcdctl/ctlv3/command/global.go | 12 +++++++++++- etcdctl/ctlv3/ctl.go | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/etcdctl/ctlv3/command/global.go b/etcdctl/ctlv3/command/global.go index 93b62068a..38a0332f9 100644 --- a/etcdctl/ctlv3/command/global.go +++ b/etcdctl/ctlv3/command/global.go @@ -56,6 +56,7 @@ type GlobalFlags struct { User string Password string + Token string Debug bool } @@ -270,13 +271,22 @@ func authCfgFromCmd(cmd *cobra.Command) *clientv3.AuthConfig { if err != nil { cobrautl.ExitWithError(cobrautl.ExitBadArgs, err) } + tokenFlag, err := cmd.Flags().GetString("auth-jwt-token") + if err != nil { + cobrautl.ExitWithError(cobrautl.ExitBadArgs, err) + } - if userFlag == "" { + if userFlag == "" && tokenFlag == "" { return nil } var cfg clientv3.AuthConfig + if tokenFlag != "" { + cfg.Token = tokenFlag + return &cfg + } + if passwordFlag == "" { splitted := strings.SplitN(userFlag, ":", 2) if len(splitted) < 2 { diff --git a/etcdctl/ctlv3/ctl.go b/etcdctl/ctlv3/ctl.go index 4f9c31055..8a799fd5c 100644 --- a/etcdctl/ctlv3/ctl.go +++ b/etcdctl/ctlv3/ctl.go @@ -70,6 +70,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&globalFlags.TLS.CertFile, "cert", "", "identify secure client using this TLS certificate file") rootCmd.PersistentFlags().StringVar(&globalFlags.TLS.KeyFile, "key", "", "identify secure client using this TLS key file") rootCmd.PersistentFlags().StringVar(&globalFlags.TLS.TrustedCAFile, "cacert", "", "verify certificates of TLS-enabled secure servers using this CA bundle") + rootCmd.PersistentFlags().StringVar(&globalFlags.Token, "auth-jwt-token", "", "JWT token used for authentication (if this option is used, --user and --password should not be set)") rootCmd.PersistentFlags().StringVar(&globalFlags.User, "user", "", "username[:password] for authentication (prompt if password is not supplied)") rootCmd.PersistentFlags().StringVar(&globalFlags.Password, "password", "", "password for authentication (if this option is used, --user option shouldn't include password)") rootCmd.PersistentFlags().StringVarP(&globalFlags.TLS.ServerName, "discovery-srv", "d", "", "domain name to query for SRV records describing cluster endpoints") From 4f46fb4465b8bae610fe2e9c4c3c5d4d2f64ccf4 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Tue, 23 Jul 2024 10:53:23 -0700 Subject: [PATCH 3/3] test: validate direct JWT passing and acceptance Signed-off-by: Mike Crute --- tests/common/auth_test.go | 24 ++++++++++++- tests/common/auth_util.go | 49 ++++++++++++++++++++++++++ tests/common/e2e_test.go | 4 +++ tests/common/integration_test.go | 4 +++ tests/common/unit_test.go | 4 +++ tests/framework/e2e/etcdctl.go | 12 ++++++- tests/framework/integration/cluster.go | 7 ++++ tests/go.mod | 2 +- 8 files changed, 103 insertions(+), 3 deletions(-) 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