Skip to content

Commit

Permalink
clientv3: allow setting JWT directly
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
mcrute committed Nov 25, 2023
1 parent 6db5e00 commit f0dcb7b
Show file tree
Hide file tree
Showing 4 changed files with 99 additions and 4 deletions.
27 changes: 24 additions & 3 deletions client/v3/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,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
Expand Down Expand Up @@ -262,22 +265,34 @@ func (c *Client) Dial(ep string) (*grpc.ClientConn, error) {
return c.dial(creds, grpc.WithResolvers(resolver.New(ep)))
}

// UpdateAuthToken allows updating the JWT auth token held by the
// client. It is safe to call this function concurrently with other
// operations.
func (c *Client) UpdateAuthToken(token string) {
c.authTokenBundle.UpdateAuthToken(token)
}

func (c *Client) getToken(ctx context.Context) error {
var err error // return last error in a case of fail

if c.Token != "" {
c.UpdateAuthToken(c.Token)
return nil
}

if c.Username == "" || c.Password == "" {
return nil
}

resp, err := c.Auth.Authenticate(ctx, c.Username, c.Password)
if err != nil {
if err == rpctypes.ErrAuthNotEnabled {
c.authTokenBundle.UpdateAuthToken("")
c.UpdateAuthToken("")
return nil
}
return err
}
c.authTokenBundle.UpdateAuthToken(resp.Token)
c.UpdateAuthToken(resp.Token)
return nil
}

Expand Down Expand Up @@ -391,6 +406,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)
Expand Down
50 changes: 50 additions & 0 deletions client/v3/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,48 @@ 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 TestSyncFiltersMembers(t *testing.T) {
c, _ := NewClient(t, Config{Endpoints: []string{"http://254.0.0.1:12345"}})
defer c.Close()
Expand Down Expand Up @@ -456,6 +498,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
}
Expand Down
7 changes: 6 additions & 1 deletion client/v3/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down Expand Up @@ -119,10 +122,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.
Expand All @@ -143,6 +147,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
Expand Down
19 changes: 19 additions & 0 deletions client/v3/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down

0 comments on commit f0dcb7b

Please sign in to comment.