From e12eb0e776d371dcc96a3ff8c65ce618ee65a850 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Tue, 19 Sep 2023 11:02:41 -0700 Subject: [PATCH 01/17] Add refresh token support for logins --- app/connection.go | 6 +- app/connection_test.go | 40 +++++----- app/credentials/apikey/apikey.go | 15 ++-- app/credentials/header.go | 6 ++ app/credentials/oauth/oauth.go | 37 +++++----- app/login.go | 121 +++++++++++++------------------ app/login_test.go | 25 ++++--- go.mod | 14 ++-- go.sum | 41 +++++++---- 9 files changed, 155 insertions(+), 150 deletions(-) create mode 100644 app/credentials/header.go diff --git a/app/connection.go b/app/connection.go index 282c00cf..3d9d9458 100644 --- a/app/connection.go +++ b/app/connection.go @@ -88,14 +88,14 @@ func newRPCCredential(c *cli.Context) (credentials.PerRPCCredentials, error) { ) } - tokens, err := loadLoginConfig(c) + tokenSource, err := loadLoginConfig(c) if err != nil { return nil, err } - if len(tokens.AccessToken) > 0 { + if tokenSource != nil { return oauth.NewCredential( - tokens.AccessToken, + tokenSource, oauth.WithInsecureTransport(insecure), ) } diff --git a/app/connection_test.go b/app/connection_test.go index 4e46ea34..56a2cf79 100644 --- a/app/connection_test.go +++ b/app/connection_test.go @@ -9,16 +9,18 @@ import ( "net" "os" "path" + "strings" "testing" + "time" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "github.com/temporalio/tcld/app/credentials/apikey" - "github.com/temporalio/tcld/app/credentials/oauth" + "github.com/temporalio/tcld/app/credentials" "github.com/temporalio/tcld/protogen/api/request/v1" "github.com/temporalio/tcld/protogen/api/requestservice/v1" "github.com/urfave/cli/v2" + "golang.org/x/oauth2" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" @@ -40,6 +42,8 @@ func (s *testServer) GetRequestStatus(ctx context.Context, req *requestservice.G md, _ := metadata.FromIncomingContext(ctx) s.receivedMD = md.Copy() + fmt.Printf("Received md is %#v\n", md) + return &requestservice.GetRequestStatusResponse{ RequestStatus: &request.RequestStatus{ RequestId: "test-request-id", @@ -62,8 +66,9 @@ func TestServerConnection(t *testing.T) { func (s *ServerConnectionTestSuite) SetupTest() { s.configDir = s.T().TempDir() - data, err := json.Marshal(OAuthTokenResponse{ + data, err := json.Marshal(oauth2.Token{ AccessToken: testAccessToken, + Expiry: time.Now().Add(24 * time.Hour), }) require.NoError(s.T(), err) @@ -87,10 +92,10 @@ func (s *ServerConnectionTestSuite) TeardownTest() { func (s *ServerConnectionTestSuite) TestGetServerConnection() { testcases := []struct { - name string - args map[string]string - expectedHeaders map[string]string - expectedErr error + name string + args map[string]string + expectedToken string + expectedErr error }{ { name: "ErrorInvalidHostname", @@ -119,9 +124,7 @@ func (s *ServerConnectionTestSuite) TestGetServerConnection() { args: map[string]string{ InsecureConnectionFlagName: "", // required for bufconn }, - expectedHeaders: map[string]string{ - oauth.Header: "Bearer " + testAccessToken, - }, + expectedToken: testAccessToken, }, { name: "APIKeySucess", @@ -129,9 +132,7 @@ func (s *ServerConnectionTestSuite) TestGetServerConnection() { InsecureConnectionFlagName: "", // required for bufconn APIKeyFlagName: testAPIKey, }, - expectedHeaders: map[string]string{ - apikey.AuthorizationHeader: "Bearer " + testAPIKey, - }, + expectedToken: testAPIKey, }, } for _, tc := range testcases { @@ -191,14 +192,11 @@ func (s *ServerConnectionTestSuite) TestGetServerConnection() { commit := getHeaderValue(md, CommitHeader) s.Equal(buildInfo.Commit, commit) - _, usingAPIKeys := tc.args[APIKeyFlagName] - if usingAPIKeys { - authHeader := getHeaderValue(md, apikey.AuthorizationHeader) - s.Equal("Bearer "+testAPIKey, authHeader) - } else { - token := getHeaderValue(md, oauth.Header) - s.Equal("Bearer "+testAccessToken, token) - } + auth := strings.SplitN(getHeaderValue(md, credentials.AuthorizationHeader), " ", 2) + require.Len(s.T(), auth, 2) + + s.Equal(strings.ToLower(credentials.AuthorizationBearer), strings.ToLower(auth[0])) + s.Equal(auth[1], tc.expectedToken) }) } } diff --git a/app/credentials/apikey/apikey.go b/app/credentials/apikey/apikey.go index f24c3ea5..06c16e90 100644 --- a/app/credentials/apikey/apikey.go +++ b/app/credentials/apikey/apikey.go @@ -4,13 +4,12 @@ import ( "context" "fmt" - "google.golang.org/grpc/credentials" + "github.com/temporalio/tcld/app/credentials" + grpccreds "google.golang.org/grpc/credentials" ) const ( - AuthorizationHeader = "Authorization" - AuthorizationHeaderPrefix = "Bearer" - Separator = "_" + Separator = "_" ) type Credential struct { @@ -42,20 +41,20 @@ func NewCredential(key string, opts ...Option) (Credential, error) { } func (c Credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { - ri, ok := credentials.RequestInfoFromContext(ctx) + ri, ok := grpccreds.RequestInfoFromContext(ctx) if !ok { return nil, fmt.Errorf("failed to retrieve request info from context") } if !c.allowInsecureTransport { // Ensure the API key, AKA bearer token, is sent over a secure connection - meaning TLS. - if err := credentials.CheckSecurityLevel(ri.AuthInfo, credentials.PrivacyAndIntegrity); err != nil { + if err := grpccreds.CheckSecurityLevel(ri.AuthInfo, grpccreds.PrivacyAndIntegrity); err != nil { return nil, fmt.Errorf("the connection's transport security level is too low for API keys: %v", err) } } return map[string]string{ - AuthorizationHeader: fmt.Sprintf("%s %s", AuthorizationHeaderPrefix, c.Key), + credentials.AuthorizationHeader: fmt.Sprintf("%s %s", credentials.AuthorizationBearer, c.Key), }, nil } @@ -63,4 +62,4 @@ func (c Credential) RequireTransportSecurity() bool { return !c.allowInsecureTransport } -var _ credentials.PerRPCCredentials = (*Credential)(nil) +var _ grpccreds.PerRPCCredentials = (*Credential)(nil) diff --git a/app/credentials/header.go b/app/credentials/header.go new file mode 100644 index 00000000..7413452f --- /dev/null +++ b/app/credentials/header.go @@ -0,0 +1,6 @@ +package credentials + +const ( + AuthorizationHeader = "Authorization" + AuthorizationBearer = "Bearer" +) diff --git a/app/credentials/oauth/oauth.go b/app/credentials/oauth/oauth.go index e09e9adb..e8695ebf 100644 --- a/app/credentials/oauth/oauth.go +++ b/app/credentials/oauth/oauth.go @@ -4,15 +4,11 @@ import ( "context" "fmt" - "google.golang.org/grpc/credentials" + "github.com/temporalio/tcld/app/credentials" + "golang.org/x/oauth2" + grpccreds "google.golang.org/grpc/credentials" ) -const ( - Header = "authorization" -) - -var _ credentials.PerRPCCredentials = (*Credential)(nil) - type Option = func(c *Credential) func WithInsecureTransport(insecure bool) Option { @@ -22,17 +18,17 @@ func WithInsecureTransport(insecure bool) Option { } type Credential struct { - accessToken string // keep unexported to prevent accidental leakage of the token. + source oauth2.TokenSource allowInsecureTransport bool } -func NewCredential(accessToken string, opts ...Option) (Credential, error) { - if len(accessToken) == 0 { - return Credential{}, fmt.Errorf("an empty access token was provided") +func NewCredential(source oauth2.TokenSource, opts ...Option) (Credential, error) { + if source == nil { + return Credential{}, fmt.Errorf("a nil token source was provided") } c := Credential{ - accessToken: accessToken, + source: source, } for _, opt := range opts { opt(&c) @@ -42,20 +38,25 @@ func NewCredential(accessToken string, opts ...Option) (Credential, error) { } func (c Credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { - ri, ok := credentials.RequestInfoFromContext(ctx) + ri, ok := grpccreds.RequestInfoFromContext(ctx) if !ok { return nil, fmt.Errorf("failed to retrieve request info from context") } if !c.allowInsecureTransport { // Ensure the bearer token is sent over a secure connection - meaning TLS. - if err := credentials.CheckSecurityLevel(ri.AuthInfo, credentials.PrivacyAndIntegrity); err != nil { - return nil, fmt.Errorf("the connection's transport security level is too low for OAuth: %v", err) + if err := grpccreds.CheckSecurityLevel(ri.AuthInfo, grpccreds.PrivacyAndIntegrity); err != nil { + return nil, fmt.Errorf("the connection's transport security level is too low for OAuth: %w", err) } } + token, err := c.source.Token() + if err != nil { + return nil, fmt.Errorf("failed to retrieve token from token source: %w", err) + } + return map[string]string{ - Header: c.token(), + credentials.AuthorizationHeader: fmt.Sprintf("%s %s", token.Type(), token.AccessToken), }, nil } @@ -63,6 +64,4 @@ func (c Credential) RequireTransportSecurity() bool { return !c.allowInsecureTransport } -func (c Credential) token() string { - return "Bearer " + c.accessToken -} +var _ grpccreds.PerRPCCredentials = (*Credential)(nil) diff --git a/app/login.go b/app/login.go index 837fdb70..421a91c0 100644 --- a/app/login.go +++ b/app/login.go @@ -10,17 +10,13 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/temporalio/tcld/services" + "golang.org/x/oauth2" "github.com/urfave/cli/v2" ) -const ( - scope = "openid profile user" -) - var ( tokenFileName = "tokens.json" domainFlag = &cli.StringFlag{ @@ -61,23 +57,6 @@ type ( LoginClient struct { loginService services.LoginService } - - OAuthDeviceCodeResponse struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - VerificationURIComplete string `json:"verification_uri_complete"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` - } - - OAuthTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - } ) func getTokenConfigPath(ctx *cli.Context) string { @@ -86,34 +65,38 @@ func getTokenConfigPath(ctx *cli.Context) string { } // TODO: support login config on windows -func loadLoginConfig(ctx *cli.Context) (OAuthTokenResponse, error) { - - tokens := OAuthTokenResponse{} +func loadLoginConfig(ctx *cli.Context) (oauth2.TokenSource, error) { configDir := ctx.Path(ConfigDirFlagName) // Create config dir if it does not exist if err := os.MkdirAll(configDir, 0700); err != nil { - return tokens, err + return nil, err } tokenConfig := getTokenConfigPath(ctx) if _, err := os.Stat(tokenConfig); err != nil { // Skip if file does not exist if errors.Is(err, os.ErrNotExist) { - return tokens, nil + return nil, nil } - return tokens, err + return nil, err } tokenConfigBytes, err := os.ReadFile(tokenConfig) if err != nil { - return tokens, err + return nil, err } - if err := json.Unmarshal(tokenConfigBytes, &tokens); err != nil { - return tokens, err + var token oauth2.Token + if err := json.Unmarshal(tokenConfigBytes, &token); err != nil { + return nil, err + } + + oauthConfig, err := oauthConfig(ctx) + if err != nil { + return nil, err } - return tokens, nil + return oauthConfig.TokenSource(ctx.Context, &token), nil } func parseURL(s string) (*url.URL, error) { @@ -135,26 +118,26 @@ func parseURL(s string) (*url.URL, error) { } func (c *LoginClient) login(ctx *cli.Context, domain string, audience string, clientID string, disablePopUp bool) error { - // Get device code - domainURL, err := parseURL(domain) + config, err := oauthConfig(ctx) if err != nil { - return err + return fmt.Errorf("failed to retrieve oauth2 config: %w", err) } - codeResp := OAuthDeviceCodeResponse{} - if err := postFormRequest( - domainURL.JoinPath("oauth", "device", "code").String(), - url.Values{ - "client_id": {clientID}, - "scope": {scope}, - "audience": {audience}, - }, - &codeResp, - ); err != nil { + fmt.Printf("Oauth config is %v\n", config) + + resp, err := config.DeviceAuth(ctx.Context, oauth2.SetAuthURLParam("audience", audience)) + if err != nil { + return fmt.Errorf("failed to perform device auth: %w", err) + } + + fmt.Printf("Received device response is %v\n", resp) + + domainURL, err := parseURL(ctx.String("domain")) + if err != nil { return err } - verificationURL, err := parseURL(codeResp.VerificationURIComplete) + verificationURL, err := parseURL(resp.VerificationURIComplete) if err != nil { return fmt.Errorf("failed to parse verification URL: %w", err) } else if verificationURL.Hostname() != domainURL.Hostname() { @@ -171,35 +154,16 @@ func (c *LoginClient) login(ctx *cli.Context, domain string, audience string, cl } } - // According to RFC, we should set a default polling interval if not provided. - // https://tools.ietf.org/html/draft-ietf-oauth-device-flow-07#section-3.5 - if codeResp.Interval == 0 { - codeResp.Interval = 10 - } - - // Get access token - tokenResp := OAuthTokenResponse{} - for len(tokenResp.AccessToken) == 0 { - time.Sleep(time.Duration(codeResp.Interval) * time.Second) - - if err := postFormRequest( - domainURL.JoinPath("oauth", "token").String(), - url.Values{ - "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - "device_code": {codeResp.DeviceCode}, - "client_id": {clientID}, - }, - &tokenResp, - ); err != nil { - return err - } + token, err := config.DeviceAccessToken(ctx.Context, resp) + if err != nil { + return fmt.Errorf("failed to retrieve access token: %w", err) } + fmt.Println("Successfully logged in!") - tokenRespJson, err := FormatJson(tokenResp) + tokenRespJson, err := FormatJson(token) if err != nil { return err } - fmt.Println("Successfully logged in!") // Save token info locally return c.loginService.WriteToConfigFile(getTokenConfigPath(ctx), tokenRespJson) @@ -240,3 +204,20 @@ func postFormRequest(url string, values url.Values, resStruct interface{}) error } return json.Unmarshal(body, &resStruct) } + +func oauthConfig(ctx *cli.Context) (oauth2.Config, error) { + domainURL, err := parseURL(ctx.String("domain")) + if err != nil { + return oauth2.Config{}, err + } + + return oauth2.Config{ + ClientID: ctx.String("client-id"), + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: domainURL.JoinPath("oauth", "device", "code").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"openid", "profile", "user", "offline_access"}, + }, nil +} diff --git a/app/login_test.go b/app/login_test.go index 15ccbd89..9f47e48a 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -2,14 +2,17 @@ package app import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" "github.com/temporalio/tcld/services" "github.com/urfave/cli/v2" + "golang.org/x/oauth2" ) func TestLogin(t *testing.T) { @@ -45,6 +48,7 @@ func (l *LoginTestSuite) SetupTest() { func (l *LoginTestSuite) registerPath(path string, response string) { l.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, err := w.Write([]byte(response)) if err != nil { @@ -96,36 +100,39 @@ func (l *LoginTestSuite) AfterTest(_, _ string) { } func validCodeResponse(t *testing.T, domain string) string { - resp := OAuthDeviceCodeResponse{ + resp := oauth2.DeviceAuthResponse{ DeviceCode: "ABCD-EFGH", UserCode: "ABCD-EFGH", VerificationURI: domain, VerificationURIComplete: domain, - ExpiresIn: 30, + Expiry: time.Now().Add(24 * time.Hour), Interval: 1, } - json, err := json.Marshal(resp) + data, err := json.Marshal(resp) if err != nil { t.Fatalf("failed to marshal device code response: %v", err) } - return string(json) + fmt.Printf("Returning code response %v\n", string(data)) + + return string(data) } func validTokenResponse(t *testing.T, domain string) string { - resp := OAuthTokenResponse{ + resp := oauth2.Token{ AccessToken: "EabWErgdh", RefreshToken: "eWKjhgT", - IDToken: "iJktYuVk", TokenType: "Bearer", - ExpiresIn: 3600, + Expiry: time.Now().Add(24 * time.Hour), } - json, err := json.Marshal(resp) + data, err := json.Marshal(resp) if err != nil { t.Fatalf("failed to marshal device code response: %v", err) } - return string(json) + fmt.Printf("Returning token response %v\n", string(data)) + + return string(data) } diff --git a/go.mod b/go.mod index cc157710..01077f0e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/go-playground/validator/v10 v10.13.0 github.com/gogo/protobuf v1.3.2 github.com/golang/mock v1.6.0 - github.com/golang/protobuf v1.5.2 + github.com/golang/protobuf v1.5.3 github.com/google/uuid v1.3.0 github.com/kylelemons/godebug v1.1.0 github.com/stretchr/testify v1.8.2 @@ -14,6 +14,7 @@ require ( go.uber.org/fx v1.19.2 go.uber.org/multierr v1.6.0 golang.org/x/mod v0.12.0 + golang.org/x/oauth2 v0.13.0 google.golang.org/grpc v1.54.0 ) @@ -29,11 +30,12 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/dig v1.16.1 // indirect go.uber.org/zap v1.23.0 // indirect - golang.org/x/crypto v0.7.0 // indirect - golang.org/x/net v0.8.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/text v0.8.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.16.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect - google.golang.org/protobuf v1.28.1 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a7756a2f..11e6b442 100644 --- a/go.sum +++ b/go.sum @@ -15,9 +15,10 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -62,22 +63,29 @@ go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.12.1-0.20230912160149-2d9e4a2adf33 h1:JtxNM2/PG/evek3/ZT0uu4BnPlOI3e6KwfW4w2Vh8WI= +golang.org/x/oauth2 v0.12.1-0.20230912160149-2d9e4a2adf33/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -88,13 +96,16 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -104,14 +115,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w= google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From c47fe265d8492c940009a8f76eb27840337baaab Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:49:24 -0700 Subject: [PATCH 02/17] Run go mod tidy --- go.sum | 8 -------- 1 file changed, 8 deletions(-) diff --git a/go.sum b/go.sum index 11e6b442..be6cb245 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,6 @@ go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -78,12 +76,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/oauth2 v0.12.1-0.20230912160149-2d9e4a2adf33 h1:JtxNM2/PG/evek3/ZT0uu4BnPlOI3e6KwfW4w2Vh8WI= -golang.org/x/oauth2 v0.12.1-0.20230912160149-2d9e4a2adf33/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -96,8 +90,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From 75b9982b427ed7a5e818a7f3eb601b932f416348 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:33:33 -0700 Subject: [PATCH 03/17] Remove debug logs --- app/login.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/login.go b/app/login.go index 421a91c0..a1f139e5 100644 --- a/app/login.go +++ b/app/login.go @@ -123,15 +123,11 @@ func (c *LoginClient) login(ctx *cli.Context, domain string, audience string, cl return fmt.Errorf("failed to retrieve oauth2 config: %w", err) } - fmt.Printf("Oauth config is %v\n", config) - resp, err := config.DeviceAuth(ctx.Context, oauth2.SetAuthURLParam("audience", audience)) if err != nil { return fmt.Errorf("failed to perform device auth: %w", err) } - fmt.Printf("Received device response is %v\n", resp) - domainURL, err := parseURL(ctx.String("domain")) if err != nil { return err From a1bafc4370441c18a937438d22e7936c34fe7540 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:38:35 -0700 Subject: [PATCH 04/17] Fix lint error --- app/login.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/login.go b/app/login.go index a1f139e5..7119c033 100644 --- a/app/login.go +++ b/app/login.go @@ -4,8 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" - "net/http" "net/url" "os" "path/filepath" @@ -187,20 +185,6 @@ func NewLoginCommand(c *LoginClient) (CommandOut, error) { }}, nil } -func postFormRequest(url string, values url.Values, resStruct interface{}) error { - res, err := http.PostForm(url, values) - if err != nil { - return err - } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return err - } - return json.Unmarshal(body, &resStruct) -} - func oauthConfig(ctx *cli.Context) (oauth2.Config, error) { domainURL, err := parseURL(ctx.String("domain")) if err != nil { From 73a7b6c672e19e1607cbd38962f0e3c635b07f32 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Mon, 30 Oct 2023 17:13:55 -0700 Subject: [PATCH 05/17] Remove debug log --- app/connection_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/connection_test.go b/app/connection_test.go index 56a2cf79..4fa82309 100644 --- a/app/connection_test.go +++ b/app/connection_test.go @@ -42,8 +42,6 @@ func (s *testServer) GetRequestStatus(ctx context.Context, req *requestservice.G md, _ := metadata.FromIncomingContext(ctx) s.receivedMD = md.Copy() - fmt.Printf("Received md is %#v\n", md) - return &requestservice.GetRequestStatusResponse{ RequestStatus: &request.RequestStatus{ RequestId: "test-request-id", From 28f78f496470da073b55bce9e447f55426f09f96 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 23 Nov 2023 12:30:42 -0800 Subject: [PATCH 06/17] Update tokens in config, and fix nil panics in test due to config dir --- Makefile | 3 - app/account_test.go | 14 +-- app/apikey_test.go | 14 +-- app/app_test.go | 20 +++++ app/certificates_test.go | 13 ++- app/connection.go | 15 ++-- app/connection_test.go | 19 +++-- app/feature.go | 6 ++ app/login.go | 161 +++++++++++++++++++++-------------- app/login_test.go | 150 ++++++++++++++++---------------- app/logout.go | 21 ++++- app/logout_test.go | 69 ++++++++------- app/namespace_test.go | 29 +++---- app/request_test.go | 7 +- app/user_test.go | 14 +-- app/version_test.go | 5 +- cmd/tcld/fx.go | 1 - services/loginservice.go | 51 ----------- services/loginservicemock.go | 76 ----------------- 19 files changed, 318 insertions(+), 370 deletions(-) create mode 100644 app/app_test.go delete mode 100644 services/loginservice.go delete mode 100644 services/loginservicemock.go diff --git a/Makefile b/Makefile index 574a09d8..5002937e 100644 --- a/Makefile +++ b/Makefile @@ -51,9 +51,6 @@ tools: lint: golangci-lint run -mocks: - @mockgen -source services/loginservice.go -destination services/loginservicemock.go -package services - $(COVER_ROOT): @mkdir -p $(COVER_ROOT) diff --git a/app/account_test.go b/app/account_test.go index 91dec5b6..aa2a0989 100644 --- a/app/account_test.go +++ b/app/account_test.go @@ -41,14 +41,14 @@ func (s *AccountTestSuite) SetupTest() { }, nil }) s.Require().NoError(err) - AutoConfirmFlag.Value = true - s.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - Flags: []cli.Flag{ - AutoConfirmFlag, - }, + + cmds := []*cli.Command{ + out.Command, + } + flags := []cli.Flag{ + AutoConfirmFlag, } + s.cliApp, _ = NewTestApp(s.T(), cmds, flags) } func (s *AccountTestSuite) RunCmd(args ...string) error { diff --git a/app/apikey_test.go b/app/apikey_test.go index 27e9800e..f24e17e8 100644 --- a/app/apikey_test.go +++ b/app/apikey_test.go @@ -36,14 +36,14 @@ func (s *APIKeyTestSuite) SetupTest() { }, nil }) s.Require().NoError(err) - AutoConfirmFlag.Value = true - s.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - Flags: []cli.Flag{ - AutoConfirmFlag, - }, + + cmds := []*cli.Command{ + out.Command, + } + flags := []cli.Flag{ + AutoConfirmFlag, } + s.cliApp, _ = NewTestApp(s.T(), cmds, flags) } func (s *APIKeyTestSuite) RunCmd(args ...string) error { diff --git a/app/app_test.go b/app/app_test.go new file mode 100644 index 00000000..0a4eb61f --- /dev/null +++ b/app/app_test.go @@ -0,0 +1,20 @@ +package app + +import ( + "testing" + + "github.com/urfave/cli/v2" +) + +func NewTestApp(t *testing.T, cmds []*cli.Command, flags []cli.Flag) (*cli.App, string) { + tmpDir := t.TempDir() + ConfigDirFlag.Value = tmpDir + disablePopUpFlag.Value = true + AutoConfirmFlag.Value = true + + return &cli.App{ + Name: t.Name(), + Commands: cmds, + Flags: flags, + }, tmpDir +} diff --git a/app/certificates_test.go b/app/certificates_test.go index 98c5be40..ee3dc834 100644 --- a/app/certificates_test.go +++ b/app/certificates_test.go @@ -26,14 +26,13 @@ func (s *CertificatesTestSuite) SetupTest() { out, err := NewCertificatesCommand() s.Require().NoError(err) - AutoConfirmFlag.Value = true - s.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - Flags: []cli.Flag{ - AutoConfirmFlag, - }, + cmds := []*cli.Command{ + out.Command, + } + flags := []cli.Flag{ + AutoConfirmFlag, } + s.cliApp, _ = NewTestApp(s.T(), cmds, flags) } func (s *CertificatesTestSuite) RunCmd(args ...string) error { diff --git a/app/connection.go b/app/connection.go index 3d9d9458..0fd780a3 100644 --- a/app/connection.go +++ b/app/connection.go @@ -77,10 +77,10 @@ func defaultDialOptions(c *cli.Context, addr *url.URL) ([]grpc.DialOption, error return opts, nil } -func newRPCCredential(c *cli.Context) (credentials.PerRPCCredentials, error) { - insecure := c.Bool(InsecureConnectionFlagName) +func newRPCCredential(ctx *cli.Context) (credentials.PerRPCCredentials, error) { + insecure := ctx.Bool(InsecureConnectionFlagName) - apiKey := c.String(APIKeyFlagName) + apiKey := ctx.String(APIKeyFlagName) if len(apiKey) > 0 { return apikey.NewCredential( apiKey, @@ -88,14 +88,15 @@ func newRPCCredential(c *cli.Context) (credentials.PerRPCCredentials, error) { ) } - tokenSource, err := loadLoginConfig(c) + loginConfig, err := NewLoginConfig(ctx.Context, ctx.Path(ConfigDirFlagName)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to load login config: %w", err) } - if tokenSource != nil { + source := loginConfig.TokenSource() + if source != nil { return oauth.NewCredential( - tokenSource, + source, oauth.WithInsecureTransport(insecure), ) } diff --git a/app/connection_test.go b/app/connection_test.go index 4fa82309..c67fa817 100644 --- a/app/connection_test.go +++ b/app/connection_test.go @@ -2,13 +2,10 @@ package app import ( "context" - "encoding/json" "flag" "fmt" "io" "net" - "os" - "path" "strings" "testing" "time" @@ -64,13 +61,17 @@ func TestServerConnection(t *testing.T) { func (s *ServerConnectionTestSuite) SetupTest() { s.configDir = s.T().TempDir() - data, err := json.Marshal(oauth2.Token{ - AccessToken: testAccessToken, - Expiry: time.Now().Add(24 * time.Hour), - }) - require.NoError(s.T(), err) + ConfigDirFlag.Value = s.configDir + + loginConfig := LoginConfig{ + StoredToken: oauth2.Token{ + AccessToken: testAccessToken, + Expiry: time.Now().Add(24 * time.Hour), + }, + configDir: s.configDir, + } - err = os.WriteFile(path.Join(s.configDir, tokenFileName), data, 0600) + err := loginConfig.StoreConfig() require.NoError(s.T(), err) s.listener = bufconn.Listen(1024 * 1024) diff --git a/app/feature.go b/app/feature.go index 8657169c..4ae91a26 100644 --- a/app/feature.go +++ b/app/feature.go @@ -43,6 +43,12 @@ func contains(f FeatureFlag, ffs []string) bool { } func getFeatureFlagsFromConfigFile(featureFlagConfigPath string) ([]FeatureFlag, error) { + // Ensure the config directory exists. + configDir := filepath.Dir(featureFlagConfigPath) + if err := os.MkdirAll(configDir, 0700); err != nil { + return nil, err + } + // create config file if not exist if _, err := os.Stat(featureFlagConfigPath); err != nil { if err := os.WriteFile(featureFlagConfigPath, []byte("[]"), 0644); err != nil { diff --git a/app/login.go b/app/login.go index 7119c033..d1464406 100644 --- a/app/login.go +++ b/app/login.go @@ -1,23 +1,25 @@ package app import ( + "context" "encoding/json" - "errors" "fmt" "net/url" "os" + "os/exec" "path/filepath" + "runtime" "strings" + "time" - "github.com/temporalio/tcld/services" "golang.org/x/oauth2" "github.com/urfave/cli/v2" ) var ( - tokenFileName = "tokens.json" - domainFlag = &cli.StringFlag{ + tokenFile = "tokens.json" + domainFlag = &cli.StringFlag{ Name: "domain", Value: "login.tmprl.cloud", Aliases: []string{"d"}, @@ -45,56 +47,93 @@ var ( } ) -func GetLoginClient() *LoginClient { - return &LoginClient{ - loginService: services.NewLoginService(), - } +func NewLoginCommand() (CommandOut, error) { + return CommandOut{Command: &cli.Command{ + Name: "login", + Usage: "Login as user", + Aliases: []string{"l"}, + Flags: []cli.Flag{ + domainFlag, + audienceFlag, + clientIDFlag, + disablePopUpFlag, + }, + Action: func(ctx *cli.Context) error { + return login(ctx, ctx.String("domain"), ctx.String("audience"), ctx.String("client-id"), ctx.Bool("disable-pop-up")) + }, + }}, nil } -type ( - LoginClient struct { - loginService services.LoginService - } -) +type LoginConfig struct { + Config oauth2.Config `json:"config"` + StoredToken oauth2.Token `json:"token"` -func getTokenConfigPath(ctx *cli.Context) string { - configDir := ctx.Path(ConfigDirFlagName) - return filepath.Join(configDir, tokenFileName) + ctx context.Context // used for token refreshes. + configDir string } -// TODO: support login config on windows -func loadLoginConfig(ctx *cli.Context) (oauth2.TokenSource, error) { - configDir := ctx.Path(ConfigDirFlagName) +func NewLoginConfig(ctx context.Context, configDir string) (*LoginConfig, error) { // Create config dir if it does not exist if err := os.MkdirAll(configDir, 0700); err != nil { return nil, err } - tokenConfig := getTokenConfigPath(ctx) + tokenConfig := filepath.Join(configDir, tokenFile) if _, err := os.Stat(tokenConfig); err != nil { - // Skip if file does not exist - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err + return nil, fmt.Errorf("failed to stat login config: %w", err) } - tokenConfigBytes, err := os.ReadFile(tokenConfig) + data, err := os.ReadFile(tokenConfig) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read login config: %w", err) } - var token oauth2.Token - if err := json.Unmarshal(tokenConfigBytes, &token); err != nil { - return nil, err + var config LoginConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal login config: %w", err) + } + config.ctx = ctx + + return &config, nil +} + +func (c *LoginConfig) TokenSource() oauth2.TokenSource { + if c == nil { + return nil + } + + return oauth2.ReuseTokenSource(nil, c) +} + +func (c *LoginConfig) Token() (*oauth2.Token, error) { + if c == nil { + return nil, fmt.Errorf("nil token source") + } + + grace := c.StoredToken.Expiry.Add(-1 * time.Minute) + if c.StoredToken.Expiry.IsZero() || time.Now().Before(grace) { + // Token has not expired, use it. + return &c.StoredToken, nil } - oauthConfig, err := oauthConfig(ctx) + // Token has expired, refresh it. + token, err := c.Config.TokenSource(c.ctx, &c.StoredToken).Token() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to refresh access token: %w", err) } + c.StoredToken = *token - return oauthConfig.TokenSource(ctx.Context, &token), nil + return token, nil +} + +func (c *LoginConfig) StoreConfig() error { + data, err := FormatJson(c) + if err != nil { + return fmt.Errorf("failed to format login config update: %w", err) + } + + // Write file as 0600 because it contains private keys. + return os.WriteFile(filepath.Join(c.configDir, tokenFile), []byte(data), 0600) } func parseURL(s string) (*url.URL, error) { @@ -115,7 +154,7 @@ func parseURL(s string) (*url.URL, error) { return u, err } -func (c *LoginClient) login(ctx *cli.Context, domain string, audience string, clientID string, disablePopUp bool) error { +func login(ctx *cli.Context, domain string, audience string, clientID string, disablePopUp bool) error { config, err := oauthConfig(ctx) if err != nil { return fmt.Errorf("failed to retrieve oauth2 config: %w", err) @@ -143,7 +182,7 @@ func (c *LoginClient) login(ctx *cli.Context, domain string, audience string, cl fmt.Printf("Login via this url: %s\n", verificationURL.String()) if !disablePopUp { - if err := c.loginService.OpenBrowser(verificationURL.String()); err != nil { + if err := openBrowser(verificationURL.String()); err != nil { fmt.Println("Unable to open browser, please open url manually.") } } @@ -154,35 +193,12 @@ func (c *LoginClient) login(ctx *cli.Context, domain string, audience string, cl } fmt.Println("Successfully logged in!") - tokenRespJson, err := FormatJson(token) - if err != nil { - return err + loginConfig := LoginConfig{ + Config: config, + StoredToken: *token, + configDir: ctx.Path(ConfigDirFlagName), } - - // Save token info locally - return c.loginService.WriteToConfigFile(getTokenConfigPath(ctx), tokenRespJson) -} - -func NewLoginCommand(c *LoginClient) (CommandOut, error) { - return CommandOut{Command: &cli.Command{ - Name: "login", - Usage: "Login as user", - Aliases: []string{"l"}, - Before: func(ctx *cli.Context) error { - // attempt to create and or load the login config at the beginning - _, err := loadLoginConfig(ctx) - return err - }, - Flags: []cli.Flag{ - domainFlag, - audienceFlag, - clientIDFlag, - disablePopUpFlag, - }, - Action: func(ctx *cli.Context) error { - return c.login(ctx, ctx.String("domain"), ctx.String("audience"), ctx.String("client-id"), ctx.Bool("disable-pop-up")) - }, - }}, nil + return loginConfig.StoreConfig() } func oauthConfig(ctx *cli.Context) (oauth2.Config, error) { @@ -201,3 +217,22 @@ func oauthConfig(ctx *cli.Context) (oauth2.Config, error) { Scopes: []string{"openid", "profile", "user", "offline_access"}, }, nil } + +func openBrowser(url string) error { + switch runtime.GOOS { + case "linux": + if err := exec.Command("xdg-open", url).Start(); err != nil { + return err + } + case "windows": + if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start(); err != nil { + return err + } + case "darwin": + if err := exec.Command("open", url).Start(); err != nil { + return err + } + default: + } + return nil +} diff --git a/app/login_test.go b/app/login_test.go index 9f47e48a..4c70a514 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -1,6 +1,7 @@ package app import ( + "context" "encoding/json" "fmt" "net/http" @@ -8,9 +9,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" - "github.com/temporalio/tcld/services" "github.com/urfave/cli/v2" "golang.org/x/oauth2" ) @@ -21,118 +20,117 @@ func TestLogin(t *testing.T) { type LoginTestSuite struct { suite.Suite - cliApp *cli.App - server *httptest.Server - mux *http.ServeMux - mockCtrl *gomock.Controller - mockService *services.MockLoginService + + token oauth2.Token + deviceAuth oauth2.DeviceAuthResponse + + cliApp *cli.App + server *httptest.Server + mux *http.ServeMux + configDir string } func (l *LoginTestSuite) SetupTest() { - l.mockCtrl = gomock.NewController(l.T()) - l.mockService = services.NewMockLoginService(l.mockCtrl) l.mux = http.NewServeMux() + l.mux.Handle("/oauth/device/code", l.handleDeviceCode()) + l.mux.Handle("/oauth/token", l.handleToken()) + l.server = httptest.NewServer(l.mux) - out, err := NewLoginCommand(&LoginClient{ - loginService: l.mockService, - }) - l.Require().NoError(err) - l.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - Flags: []cli.Flag{ - ConfigDirFlag, - }, + + l.token = oauth2.Token{ + AccessToken: "EabWErgdh", + RefreshToken: "eWKjhgT", + TokenType: "Bearer", + Expiry: time.Now().Add(24 * time.Hour), + } + l.deviceAuth = oauth2.DeviceAuthResponse{ + DeviceCode: "ABCD-EFGH", + UserCode: "ABCD-EFGH", + Expiry: time.Now().Add(24 * time.Hour), + Interval: 1, + VerificationURI: l.server.URL, + VerificationURIComplete: l.server.URL, } -} -func (l *LoginTestSuite) registerPath(path string, response string) { - l.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(response)) - if err != nil { - return - } - }) + out, err := NewLoginCommand() + l.Require().NoError(err) + + cmds := []*cli.Command{ + out.Command, + } + flags := []cli.Flag{ + ConfigDirFlag, + domainFlag, + audienceFlag, + clientIDFlag, + disablePopUpFlag, + } + l.cliApp, l.configDir = NewTestApp(l.T(), cmds, flags) } -func (l *LoginTestSuite) runCmd(args ...string) error { - return l.cliApp.Run(append([]string{"tcld"}, args...)) +func (l *LoginTestSuite) TearDownTest() { + l.server.Close() } func (l *LoginTestSuite) TestLoginSuccessful() { - l.mockService.EXPECT().OpenBrowser(gomock.Any()).Return(nil) - l.mockService.EXPECT().WriteToConfigFile(gomock.Any(), gomock.Any()).Return(nil) - l.registerPath("/oauth/device/code", validCodeResponse(l.T(), l.server.URL)) - l.registerPath("/oauth/token", validTokenResponse(l.T(), l.server.URL)) resp := l.runCmd("login", "--domain", l.server.URL) l.NoError(resp) + + config, err := NewLoginConfig(context.Background(), l.configDir) + l.NoError(err) + l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) + l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) } func (l *LoginTestSuite) TestLoginFailureAtDeviceVerification() { - l.registerPath("/oauth/device/code", ``) + l.deviceAuth = oauth2.DeviceAuthResponse{} l.Error(l.runCmd("login", "--domain", l.server.URL)) } func (l *LoginTestSuite) TestLoginFailureAtTokenResponse() { - l.mockService.EXPECT().OpenBrowser(gomock.Any()).Return(nil) - l.registerPath("/oauth/device/code", validCodeResponse(l.T(), l.server.URL)) - l.registerPath("/oauth/token", ``) + l.token = oauth2.Token{} l.Error(l.runCmd("login", "--domain", l.server.URL)) } func (l *LoginTestSuite) TestLoginWithInvalidDomain() { - l.registerPath("/oauth/device/code", validCodeResponse(l.T(), l.server.URL)) - l.registerPath("/oauth/token", validTokenResponse(l.T(), l.server.URL)) l.Error(l.runCmd("login", "--domain", "test")) } func (l *LoginTestSuite) TestLoginWithInvalidCodeResponseURL() { - l.registerPath("/oauth/device/code", validCodeResponse(l.T(), "temporal.io")) - l.registerPath("/oauth/token", validTokenResponse(l.T(), "temporal.io")) - l.Error(l.runCmd("login", "--domain", l.server.URL)) -} + l.deviceAuth.VerificationURI = "https://temporal.io" + l.deviceAuth.VerificationURIComplete = "https://temporal.io" -func (l *LoginTestSuite) AfterTest(_, _ string) { - l.mockCtrl.Finish() - l.server.Close() + l.Error(l.runCmd("login", "--domain", l.server.URL)) } -func validCodeResponse(t *testing.T, domain string) string { - resp := oauth2.DeviceAuthResponse{ - DeviceCode: "ABCD-EFGH", - UserCode: "ABCD-EFGH", - VerificationURI: domain, - VerificationURIComplete: domain, - Expiry: time.Now().Add(24 * time.Hour), - Interval: 1, - } +func (l *LoginTestSuite) handleDeviceCode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal device code response: %v", err) + err := json.NewEncoder(w).Encode(l.deviceAuth) + if err != nil { + writeError(w, fmt.Errorf("failed to write token: %w", err)) + return + } } - - fmt.Printf("Returning code response %v\n", string(data)) - - return string(data) } -func validTokenResponse(t *testing.T, domain string) string { - resp := oauth2.Token{ - AccessToken: "EabWErgdh", - RefreshToken: "eWKjhgT", - TokenType: "Bearer", - Expiry: time.Now().Add(24 * time.Hour), - } +func (l *LoginTestSuite) handleToken() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal device code response: %v", err) + err := json.NewEncoder(w).Encode(l.token) + if err != nil { + writeError(w, fmt.Errorf("failed to write token: %w", err)) + return + } } +} - fmt.Printf("Returning token response %v\n", string(data)) +func (l *LoginTestSuite) runCmd(args ...string) error { + return l.cliApp.Run(append([]string{"tcld"}, args...)) +} - return string(data) +func writeError(w http.ResponseWriter, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/app/logout.go b/app/logout.go index 9340db9a..cbe1ec8e 100644 --- a/app/logout.go +++ b/app/logout.go @@ -2,10 +2,13 @@ package app import ( "fmt" + "os" + "path/filepath" + "github.com/urfave/cli/v2" ) -func NewLogoutCommand(c *LoginClient) (CommandOut, error) { +func NewLogoutCommand() (CommandOut, error) { return CommandOut{Command: &cli.Command{ Name: "logout", Usage: "Logout current user", @@ -15,17 +18,29 @@ func NewLogoutCommand(c *LoginClient) (CommandOut, error) { disablePopUpFlag, }, Action: func(ctx *cli.Context) error { - if err := c.loginService.DeleteConfigFile(getTokenConfigPath(ctx)); err != nil { + configDir := ctx.Path(ConfigDirFlagName) + + if err := removeFile(filepath.Join(configDir, tokenFile)); err != nil { return fmt.Errorf("unable to remove config file: %w", err) } + logoutURL := fmt.Sprintf("https://%s/v2/logout", ctx.String("domain")) fmt.Printf("Logout via this url: %s\n", logoutURL) + if !ctx.Bool("disable-pop-up") { - if err := c.loginService.OpenBrowser(logoutURL); err != nil { + if err := openBrowser(logoutURL); err != nil { return fmt.Errorf("Unable to open browser, please open url manually.") } } + return nil }, }}, nil } + +func removeFile(path string) error { + if _, err := os.Stat(path); err == nil { + return os.Remove(path) + } + return nil +} diff --git a/app/logout_test.go b/app/logout_test.go index 9417c337..53d17278 100644 --- a/app/logout_test.go +++ b/app/logout_test.go @@ -3,12 +3,13 @@ package app import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" - "github.com/temporalio/tcld/services" "github.com/urfave/cli/v2" + "golang.org/x/oauth2" ) func TestLogout(t *testing.T) { @@ -17,29 +18,33 @@ func TestLogout(t *testing.T) { type LogoutTestSuite struct { suite.Suite - cliApp *cli.App - server *httptest.Server - mux *http.ServeMux - mockCtrl *gomock.Controller - mockService *services.MockLoginService + + cliApp *cli.App + server *httptest.Server + mux *http.ServeMux + configDir string } func (l *LogoutTestSuite) SetupTest() { - l.mockCtrl = gomock.NewController(l.T()) - l.mockService = services.NewMockLoginService(l.mockCtrl) l.mux = http.NewServeMux() + l.server = httptest.NewServer(l.mux) - out, err := NewLogoutCommand(&LoginClient{ - loginService: l.mockService, - }) + + out, err := NewLogoutCommand() l.Require().NoError(err) - l.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - Flags: []cli.Flag{ - ConfigDirFlag, - }, + + cmds := []*cli.Command{ + out.Command, } + flags := []cli.Flag{ + ConfigDirFlag, + disablePopUpFlag, + } + l.cliApp, l.configDir = NewTestApp(l.T(), cmds, flags) +} + +func (l *LogoutTestSuite) TearDownTest() { + l.server.Close() } func (l *LogoutTestSuite) runCmd(args ...string) error { @@ -47,19 +52,23 @@ func (l *LogoutTestSuite) runCmd(args ...string) error { } func (l *LogoutTestSuite) TestLogoutSuccessful() { - l.mockService.EXPECT().DeleteConfigFile(gomock.Any()).Return(nil) - l.mockService.EXPECT().OpenBrowser(gomock.Any()).Return(nil) - resp := l.runCmd("logout", "--domain", l.server.URL) - l.NoError(resp) -} + loginConfig := LoginConfig{ + Config: oauth2.Config{ + ClientID: "test-id", + ClientSecret: "test-secret", + }, + configDir: l.configDir, + } + + err := loginConfig.StoreConfig() + l.NoError(err) -func (l *LogoutTestSuite) TestLogoutDisablePopup() { - l.mockService.EXPECT().DeleteConfigFile(gomock.Any()).Return(nil) - resp := l.runCmd("logout", "--domain", l.server.URL, "--disable-pop-up") + _, err = os.Stat(filepath.Join(l.configDir, tokenFile)) + l.NoError(err) + + resp := l.runCmd("logout", "--domain", l.server.URL) l.NoError(resp) -} -func (l *LogoutTestSuite) AfterTest(_, _ string) { - l.mockCtrl.Finish() - l.server.Close() + _, err = os.Stat(filepath.Join(l.configDir, tokenFile)) + l.ErrorIs(err, os.ErrNotExist) } diff --git a/app/namespace_test.go b/app/namespace_test.go index 374c53d8..4d2401b8 100644 --- a/app/namespace_test.go +++ b/app/namespace_test.go @@ -32,26 +32,17 @@ type NamespaceTestSuite struct { mockCtrl *gomock.Controller mockService *namespaceservicemock.MockNamespaceServiceClient mockAuthService *authservicemock.MockAuthServiceClient + configDir string } func (s *NamespaceTestSuite) SetupTest() { - feature, err := NewFeatureCommand() - s.Require().NoError(err) - - s.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{feature.Command}, - Flags: []cli.Flag{ - AutoConfirmFlag, - }, - } - - err = s.RunCmd("feature", "toggle-gcp-sink") + err := toggleFeature(GCPSinkFeatureFlag) s.Require().NoError(err) s.mockCtrl = gomock.NewController(s.T()) s.mockService = namespaceservicemock.NewMockNamespaceServiceClient(s.mockCtrl) s.mockAuthService = authservicemock.NewMockAuthServiceClient(s.mockCtrl) + out, err := NewNamespaceCommand(func(ctx *cli.Context) (*NamespaceClient, error) { return &NamespaceClient{ ctx: context.TODO(), @@ -59,10 +50,17 @@ func (s *NamespaceTestSuite) SetupTest() { authClient: s.mockAuthService, }, nil }) - s.Require().NoError(err) - AutoConfirmFlag.Value = true - s.cliApp.Commands = []*cli.Command{out.Command} + + cmds := []*cli.Command{ + out.Command, + } + flags := []cli.Flag{ + AutoConfirmFlag, + } + + s.cliApp, _ = NewTestApp(s.T(), cmds, flags) + } func (s *NamespaceTestSuite) RunCmd(args ...string) error { @@ -70,7 +68,6 @@ func (s *NamespaceTestSuite) RunCmd(args ...string) error { } func (s *NamespaceTestSuite) AfterTest(suiteName, testName string) { - defer os.Remove(getFeatureFlagConfigFilePath()) s.mockCtrl.Finish() } diff --git a/app/request_test.go b/app/request_test.go index 5d4f565e..d6a070ae 100644 --- a/app/request_test.go +++ b/app/request_test.go @@ -33,10 +33,11 @@ func (s *RequestTestSuite) SetupTest() { }, nil }) s.Require().NoError(err) - s.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, + + cmds := []*cli.Command{ + out.Command, } + s.cliApp, _ = NewTestApp(s.T(), cmds, nil) } func (s *RequestTestSuite) AfterTest(suiteName, testName string) { diff --git a/app/user_test.go b/app/user_test.go index 579fa8d2..33f64227 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -35,14 +35,14 @@ func (s *UserTestSuite) SetupTest() { }, nil }) s.Require().NoError(err) - AutoConfirmFlag.Value = true - s.cliApp = &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - Flags: []cli.Flag{ - AutoConfirmFlag, - }, + + cmds := []*cli.Command{ + out.Command, + } + flags := []cli.Flag{ + AutoConfirmFlag, } + s.cliApp, _ = NewTestApp(s.T(), cmds, flags) } func (s *UserTestSuite) RunCmd(args ...string) error { diff --git a/app/version_test.go b/app/version_test.go index b3af85fa..648e932c 100644 --- a/app/version_test.go +++ b/app/version_test.go @@ -12,10 +12,7 @@ func TestVersionCommand(t *testing.T) { out, err := NewVersionCommand() assert.NoError(t, err) - cliApp := &cli.App{ - Name: "test", - Commands: []*cli.Command{out.Command}, - } + cliApp, _ := NewTestApp(t, []*cli.Command{out.Command}, nil) err = cliApp.Run([]string{"tcld", "version"}) assert.NoError(t, err) } diff --git a/cmd/tcld/fx.go b/cmd/tcld/fx.go index ec52ef12..c67df9df 100644 --- a/cmd/tcld/fx.go +++ b/cmd/tcld/fx.go @@ -18,7 +18,6 @@ func fxOptions() fx.Option { app.NewNamespaceCommand, app.NewUserCommand, app.NewRequestCommand, - app.GetLoginClient, app.NewLoginCommand, app.NewLogoutCommand, app.NewCertificatesCommand, diff --git a/services/loginservice.go b/services/loginservice.go deleted file mode 100644 index 51606f16..00000000 --- a/services/loginservice.go +++ /dev/null @@ -1,51 +0,0 @@ -package services - -import ( - "os" - "os/exec" - "runtime" -) - -type loginService struct { -} - -type LoginService interface { - OpenBrowser(URL string) error - WriteToConfigFile(configPath string, data string) error - DeleteConfigFile(configPath string) error -} - -func NewLoginService() LoginService { - return &loginService{} -} - -func (c *loginService) OpenBrowser(url string) error { - switch runtime.GOOS { - case "linux": - if err := exec.Command("xdg-open", url).Start(); err != nil { - return err - } - case "windows": - if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start(); err != nil { - return err - } - case "darwin": - if err := exec.Command("open", url).Start(); err != nil { - return err - } - default: - } - return nil -} - -func (c *loginService) WriteToConfigFile(configPath string, data string) error { - // Write file as 0600 since it contains private keys. - return os.WriteFile(configPath, []byte(data), 0600) -} - -func (c *loginService) DeleteConfigFile(configPath string) error { - if _, err := os.Stat(configPath); err == nil { - return os.RemoveAll(configPath) - } - return nil -} diff --git a/services/loginservicemock.go b/services/loginservicemock.go deleted file mode 100644 index c8bc122c..00000000 --- a/services/loginservicemock.go +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: services/loginservice.go - -// Package services is a generated GoMock package. -package services - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockLoginService is a mock of LoginService interface. -type MockLoginService struct { - ctrl *gomock.Controller - recorder *MockLoginServiceMockRecorder -} - -// MockLoginServiceMockRecorder is the mock recorder for MockLoginService. -type MockLoginServiceMockRecorder struct { - mock *MockLoginService -} - -// NewMockLoginService creates a new mock instance. -func NewMockLoginService(ctrl *gomock.Controller) *MockLoginService { - mock := &MockLoginService{ctrl: ctrl} - mock.recorder = &MockLoginServiceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockLoginService) EXPECT() *MockLoginServiceMockRecorder { - return m.recorder -} - -// DeleteConfigFile mocks base method. -func (m *MockLoginService) DeleteConfigFile(configPath string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteConfigFile", configPath) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteConfigFile indicates an expected call of DeleteConfigFile. -func (mr *MockLoginServiceMockRecorder) DeleteConfigFile(configPath interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteConfigFile", reflect.TypeOf((*MockLoginService)(nil).DeleteConfigFile), configPath) -} - -// OpenBrowser mocks base method. -func (m *MockLoginService) OpenBrowser(URL string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OpenBrowser", URL) - ret0, _ := ret[0].(error) - return ret0 -} - -// OpenBrowser indicates an expected call of OpenBrowser. -func (mr *MockLoginServiceMockRecorder) OpenBrowser(URL interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenBrowser", reflect.TypeOf((*MockLoginService)(nil).OpenBrowser), URL) -} - -// WriteToConfigFile mocks base method. -func (m *MockLoginService) WriteToConfigFile(configPath, data string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "WriteToConfigFile", configPath, data) - ret0, _ := ret[0].(error) - return ret0 -} - -// WriteToConfigFile indicates an expected call of WriteToConfigFile. -func (mr *MockLoginServiceMockRecorder) WriteToConfigFile(configPath, data interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteToConfigFile", reflect.TypeOf((*MockLoginService)(nil).WriteToConfigFile), configPath, data) -} From b7704c8936147929536ef3301440e9cf482f1074 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 23 Nov 2023 12:59:27 -0800 Subject: [PATCH 07/17] Fix token refreshing and add test --- app/login.go | 4 ++++ app/login_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/app/login.go b/app/login.go index d1464406..3ac0d5ab 100644 --- a/app/login.go +++ b/app/login.go @@ -92,7 +92,9 @@ func NewLoginConfig(ctx context.Context, configDir string) (*LoginConfig, error) if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to unmarshal login config: %w", err) } + config.ctx = ctx + config.configDir = configDir return &config, nil } @@ -121,7 +123,9 @@ func (c *LoginConfig) Token() (*oauth2.Token, error) { if err != nil { return nil, fmt.Errorf("failed to refresh access token: %w", err) } + c.StoredToken = *token + c.StoreConfig() return token, nil } diff --git a/app/login_test.go b/app/login_test.go index 4c70a514..14b62162 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" @@ -76,10 +78,55 @@ func (l *LoginTestSuite) TestLoginSuccessful() { resp := l.runCmd("login", "--domain", l.server.URL) l.NoError(resp) + _, err := os.Stat(filepath.Join(l.configDir, tokenFile)) + l.NoError(err) + + config, err := NewLoginConfig(context.Background(), l.configDir) + l.NoError(err) + l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) + l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) + + token, err := config.Token() + l.NoError(err) + l.Equal(l.token.AccessToken, token.AccessToken) + l.Equal(l.token.RefreshToken, token.RefreshToken) + + // Ensure it does not refresh the token, as it has not expired. + token, err = config.Token() + l.NoError(err) + l.Equal(l.token.AccessToken, token.AccessToken) + l.Equal(l.token.RefreshToken, token.RefreshToken) +} + +func (l *LoginTestSuite) TestRefreshToken() { + resp := l.runCmd("login", "--domain", l.server.URL) + l.NoError(resp) + + data, err := os.ReadFile(filepath.Join(l.configDir, tokenFile)) + l.NoError(err) + config, err := NewLoginConfig(context.Background(), l.configDir) l.NoError(err) l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) + + token, err := config.Token() + l.NoError(err) + l.Equal(l.token.AccessToken, token.AccessToken) + l.Equal(l.token.RefreshToken, token.RefreshToken) + + l.token.AccessToken = "some-new-access-token" + l.token.RefreshToken = "some-new-refresh-token" + config.StoredToken.Expiry = time.Now().Add(-30 * time.Minute) + + token, err = config.Token() + l.NoError(err) + l.Equal(l.token.AccessToken, token.AccessToken) + l.Equal(l.token.RefreshToken, token.RefreshToken) + + newData, err := os.ReadFile(filepath.Join(l.configDir, tokenFile)) + l.NoError(err) + l.NotEqual(data, newData, "config file did not refresh with new token") } func (l *LoginTestSuite) TestLoginFailureAtDeviceVerification() { From 34fe416fa6a3afa34ea82a9dd84b141f8cd5622d Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:36:53 -0800 Subject: [PATCH 08/17] Add legacy token support and tests --- app/login.go | 41 +++++++++++++++++++++++++++++++++++++--- app/login_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/app/login.go b/app/login.go index 3ac0d5ab..17eafcdf 100644 --- a/app/login.go +++ b/app/login.go @@ -70,6 +70,7 @@ type LoginConfig struct { ctx context.Context // used for token refreshes. configDir string + isLegacy bool } func NewLoginConfig(ctx context.Context, configDir string) (*LoginConfig, error) { @@ -79,7 +80,8 @@ func NewLoginConfig(ctx context.Context, configDir string) (*LoginConfig, error) } tokenConfig := filepath.Join(configDir, tokenFile) - if _, err := os.Stat(tokenConfig); err != nil { + fileInfo, err := os.Stat(tokenConfig) + if err != nil { return nil, fmt.Errorf("failed to stat login config: %w", err) } @@ -91,6 +93,20 @@ func NewLoginConfig(ctx context.Context, configDir string) (*LoginConfig, error) var config LoginConfig if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to unmarshal login config: %w", err) + } else if config.StoredToken == (oauth2.Token{}) { + var legacy legacyOAuthToken + + err = json.Unmarshal(data, &legacy) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal legacy login config: %w", err) + } + + config.StoredToken, err = legacy.convert(fileInfo.ModTime()) + if err != nil { + return nil, fmt.Errorf("failed to convert legacy token: %w", err) + } + + config.isLegacy = true } config.ctx = ctx @@ -113,8 +129,8 @@ func (c *LoginConfig) Token() (*oauth2.Token, error) { } grace := c.StoredToken.Expiry.Add(-1 * time.Minute) - if c.StoredToken.Expiry.IsZero() || time.Now().Before(grace) { - // Token has not expired, use it. + if c.isLegacy || c.StoredToken.Expiry.IsZero() || time.Now().Before(grace) { + // Token has not expired, or is a legacy token, use it. return &c.StoredToken, nil } @@ -240,3 +256,22 @@ func openBrowser(url string) error { } return nil } + +// legacyOAuthToken is the legacy token version, which is kept around to +// ensure a seamless updating experience from an older tcld version. +type legacyOAuthToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func (l legacyOAuthToken) convert(modTime time.Time) (oauth2.Token, error) { + return oauth2.Token{ + AccessToken: l.AccessToken, + TokenType: l.TokenType, + RefreshToken: l.RefreshToken, + Expiry: modTime.Add(time.Duration(l.ExpiresIn) * time.Second), + }, nil +} diff --git a/app/login_test.go b/app/login_test.go index 14b62162..4b8d7ef5 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -83,8 +83,6 @@ func (l *LoginTestSuite) TestLoginSuccessful() { config, err := NewLoginConfig(context.Background(), l.configDir) l.NoError(err) - l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) - l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) token, err := config.Token() l.NoError(err) @@ -107,8 +105,6 @@ func (l *LoginTestSuite) TestRefreshToken() { config, err := NewLoginConfig(context.Background(), l.configDir) l.NoError(err) - l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) - l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) token, err := config.Token() l.NoError(err) @@ -150,6 +146,50 @@ func (l *LoginTestSuite) TestLoginWithInvalidCodeResponseURL() { l.Error(l.runCmd("login", "--domain", l.server.URL)) } +func (l *LoginTestSuite) TestLegacyTokenSupport() { + legacy := legacyOAuthToken{ + AccessToken: l.token.AccessToken, + RefreshToken: l.token.RefreshToken, + TokenType: l.token.TokenType, + ExpiresIn: 3600, + } + + configFile, err := os.Create(filepath.Join(l.configDir, tokenFile)) + l.NoError(err) + + encoder := json.NewEncoder(configFile) + encoder.SetIndent("", "\t") + + err = encoder.Encode(legacy) + l.NoError(err) + configFile.Close() + + config, err := NewLoginConfig(context.Background(), l.configDir) + l.NoError(err) + l.True(config.isLegacy) + + token, err := config.Token() + l.NoError(err) + l.Equal(l.token.AccessToken, token.AccessToken) + l.Equal(l.token.RefreshToken, token.RefreshToken) + l.WithinDuration(time.Now().Add(3600*time.Second), token.Expiry, time.Minute) + + // Login to upgrade to new token type. + resp := l.runCmd("login", "--domain", l.server.URL) + l.NoError(resp) + + config, err = NewLoginConfig(context.Background(), l.configDir) + l.NoError(err) + l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) + l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) + l.False(config.isLegacy) + + token, err = config.Token() + l.NoError(err) + l.Equal(l.token.AccessToken, token.AccessToken) + l.Equal(l.token.RefreshToken, token.RefreshToken) +} + func (l *LoginTestSuite) handleDeviceCode() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From b19ea6723820f206944f3b9a6d9090728007eba0 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:40:30 -0800 Subject: [PATCH 09/17] Fix lint errors --- app/login.go | 5 ++++- app/namespace_test.go | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/login.go b/app/login.go index 17eafcdf..5b249844 100644 --- a/app/login.go +++ b/app/login.go @@ -141,7 +141,10 @@ func (c *LoginConfig) Token() (*oauth2.Token, error) { } c.StoredToken = *token - c.StoreConfig() + err = c.StoreConfig() + if err != nil { + return nil, fmt.Errorf("failed to store refreshed token: %w", err) + } return token, nil } diff --git a/app/namespace_test.go b/app/namespace_test.go index 4d2401b8..2bb69f11 100644 --- a/app/namespace_test.go +++ b/app/namespace_test.go @@ -32,7 +32,6 @@ type NamespaceTestSuite struct { mockCtrl *gomock.Controller mockService *namespaceservicemock.MockNamespaceServiceClient mockAuthService *authservicemock.MockAuthServiceClient - configDir string } func (s *NamespaceTestSuite) SetupTest() { From 8914a8b02669ddf6371f4dbbf275ac6ef689374e Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 7 Dec 2023 13:58:53 -0800 Subject: [PATCH 10/17] Bring internal library changes to connection/login funnctions --- app/app_test.go | 13 +++ app/connection.go | 18 ++-- app/connection_test.go | 68 ++++++-------- app/login.go | 207 ++++------------------------------------- app/login_test.go | 64 +++---------- app/logout.go | 12 +-- app/logout_test.go | 14 +-- app/oauth.go | 116 +++++++++++++++++++++++ app/token_config.go | 125 +++++++++++++++++++++++++ go.mod | 9 +- go.sum | 10 ++ 11 files changed, 346 insertions(+), 310 deletions(-) create mode 100644 app/oauth.go create mode 100644 app/token_config.go diff --git a/app/app_test.go b/app/app_test.go index 0a4eb61f..244cac7f 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -1,8 +1,11 @@ package app import ( + "flag" + "io" "testing" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" ) @@ -18,3 +21,13 @@ func NewTestApp(t *testing.T, cmds []*cli.Command, flags []cli.Flag) (*cli.App, Flags: flags, }, tmpDir } + +func NewTestContext(t *testing.T, app *cli.App) *cli.Context { + fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + for _, f := range app.Flags { + require.NoError(t, f.Apply(fs)) + } + fs.SetOutput(io.Discard) + + return cli.NewContext(app, fs, nil) +} diff --git a/app/connection.go b/app/connection.go index 0fd780a3..a5c86b98 100644 --- a/app/connection.go +++ b/app/connection.go @@ -88,19 +88,13 @@ func newRPCCredential(ctx *cli.Context) (credentials.PerRPCCredentials, error) { ) } - loginConfig, err := NewLoginConfig(ctx.Context, ctx.Path(ConfigDirFlagName)) + config, err := ensureLogin(ctx) if err != nil { - return nil, fmt.Errorf("failed to load login config: %w", err) + return nil, err } - source := loginConfig.TokenSource() - if source != nil { - return oauth.NewCredential( - source, - oauth.WithInsecureTransport(insecure), - ) - } - - // Use no credentials for this connection. - return nil, nil + return oauth.NewCredential( + config.TokenSource(), + oauth.WithInsecureTransport(insecure), + ) } diff --git a/app/connection_test.go b/app/connection_test.go index c67fa817..3d8ed283 100644 --- a/app/connection_test.go +++ b/app/connection_test.go @@ -2,9 +2,7 @@ package app import ( "context" - "flag" "fmt" - "io" "net" "strings" "testing" @@ -49,7 +47,6 @@ func (s *testServer) GetRequestStatus(ctx context.Context, req *requestservice.G type ServerConnectionTestSuite struct { suite.Suite - configDir string listener *bufconn.Listener grpcSrv *grpc.Server testService *testServer @@ -60,20 +57,6 @@ func TestServerConnection(t *testing.T) { } func (s *ServerConnectionTestSuite) SetupTest() { - s.configDir = s.T().TempDir() - ConfigDirFlag.Value = s.configDir - - loginConfig := LoginConfig{ - StoredToken: oauth2.Token{ - AccessToken: testAccessToken, - Expiry: time.Now().Add(24 * time.Hour), - }, - configDir: s.configDir, - } - - err := loginConfig.StoreConfig() - require.NoError(s.T(), err) - s.listener = bufconn.Listen(1024 * 1024) s.grpcSrv = grpc.NewServer() s.testService = &testServer{} @@ -89,6 +72,14 @@ func (s *ServerConnectionTestSuite) TeardownTest() { s.listener.Close() } +func (s *ServerConnectionTestSuite) SetupSubtest() { + s.SetupTest() +} + +func (s *ServerConnectionTestSuite) TeardownSubtest() { + s.TeardownTest() +} + func (s *ServerConnectionTestSuite) TestGetServerConnection() { testcases := []struct { name string @@ -119,16 +110,16 @@ func (s *ServerConnectionTestSuite) TestGetServerConnection() { expectedErr: fmt.Errorf("the credentials require transport level security"), }, { - name: "OAuthSucess", + name: "OAuthSuccess", args: map[string]string{ - InsecureConnectionFlagName: "", // required for bufconn + InsecureConnectionFlagName: "true", // required for bufconn }, expectedToken: testAccessToken, }, { - name: "APIKeySucess", + name: "APIKeySuccess", args: map[string]string{ - InsecureConnectionFlagName: "", // required for bufconn + InsecureConnectionFlagName: "true", // required for bufconn APIKeyFlagName: testAPIKey, }, expectedToken: testAPIKey, @@ -136,31 +127,30 @@ func (s *ServerConnectionTestSuite) TestGetServerConnection() { } for _, tc := range testcases { s.Run(tc.name, func() { - fs := flag.NewFlagSet(tc.name, flag.ContinueOnError) - - flags := []cli.Flag{ + app, cfgDir := NewTestApp(s.T(), nil, []cli.Flag{ ServerFlag, ConfigDirFlag, APIKeyFlag, InsecureConnectionFlag, - } - for _, f := range flags { - require.NoError(s.T(), f.Apply(fs)) - } - fs.SetOutput(io.Discard) + }) + cCtx := NewTestContext(s.T(), app) - cCtx := cli.NewContext(nil, fs, nil) - args := []string{ - "--" + ConfigDirFlagName, s.configDir, - "--" + ServerFlagName, "bufnet", - } + require.NoError(s.T(), cCtx.Set(ServerFlagName, "bufnet")) + require.NoError(s.T(), cCtx.Set(ConfigDirFlagName, cfgDir)) for k, v := range tc.args { - args = append(args, "--"+k) - if len(v) > 0 { - args = append(args, v) - } + require.NoError(s.T(), cCtx.Set(k, v), "failed to parse flag %q set to %q", k, v) } - require.NoError(s.T(), fs.Parse(args)) + + loginConfig := TokenConfig{ + OAuthToken: oauth2.Token{ + AccessToken: testAccessToken, + Expiry: time.Now().Add(24 * time.Hour), + }, + ctx: cCtx, + } + + err := loginConfig.Store() + require.NoError(s.T(), err) opts := []grpc.DialOption{ grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { diff --git a/app/login.go b/app/login.go index 5b249844..413302b8 100644 --- a/app/login.go +++ b/app/login.go @@ -1,47 +1,48 @@ package app import ( - "context" - "encoding/json" "fmt" "net/url" "os" "os/exec" - "path/filepath" "runtime" "strings" - "time" - - "golang.org/x/oauth2" "github.com/urfave/cli/v2" ) +const ( + // Flags. + domainFlagName = "domain" + audienceFlagName = "audience" + clientIDFlagName = "client-id" + disablePopUpFlagName = "disable-pop-up" +) + var ( - tokenFile = "tokens.json" domainFlag = &cli.StringFlag{ - Name: "domain", + Name: domainFlagName, Value: "login.tmprl.cloud", Aliases: []string{"d"}, Required: false, Hidden: true, } audienceFlag = &cli.StringFlag{ - Name: "audience", + Name: audienceFlagName, Value: "https://saas-api.tmprl.cloud", Aliases: []string{"a"}, Required: false, Hidden: true, } clientIDFlag = &cli.StringFlag{ - Name: "client-id", + Name: clientIDFlagName, Value: "d7V5bZMLCbRLfRVpqC567AqjAERaWHhl", Aliases: []string{"id"}, Required: false, Hidden: true, } disablePopUpFlag = &cli.BoolFlag{ - Name: "disable-pop-up", + Name: disablePopUpFlagName, Usage: "disable browser pop-up", Required: false, } @@ -59,106 +60,12 @@ func NewLoginCommand() (CommandOut, error) { disablePopUpFlag, }, Action: func(ctx *cli.Context) error { - return login(ctx, ctx.String("domain"), ctx.String("audience"), ctx.String("client-id"), ctx.Bool("disable-pop-up")) + _, err := login(ctx) + return err }, }}, nil } -type LoginConfig struct { - Config oauth2.Config `json:"config"` - StoredToken oauth2.Token `json:"token"` - - ctx context.Context // used for token refreshes. - configDir string - isLegacy bool -} - -func NewLoginConfig(ctx context.Context, configDir string) (*LoginConfig, error) { - // Create config dir if it does not exist - if err := os.MkdirAll(configDir, 0700); err != nil { - return nil, err - } - - tokenConfig := filepath.Join(configDir, tokenFile) - fileInfo, err := os.Stat(tokenConfig) - if err != nil { - return nil, fmt.Errorf("failed to stat login config: %w", err) - } - - data, err := os.ReadFile(tokenConfig) - if err != nil { - return nil, fmt.Errorf("failed to read login config: %w", err) - } - - var config LoginConfig - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to unmarshal login config: %w", err) - } else if config.StoredToken == (oauth2.Token{}) { - var legacy legacyOAuthToken - - err = json.Unmarshal(data, &legacy) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal legacy login config: %w", err) - } - - config.StoredToken, err = legacy.convert(fileInfo.ModTime()) - if err != nil { - return nil, fmt.Errorf("failed to convert legacy token: %w", err) - } - - config.isLegacy = true - } - - config.ctx = ctx - config.configDir = configDir - - return &config, nil -} - -func (c *LoginConfig) TokenSource() oauth2.TokenSource { - if c == nil { - return nil - } - - return oauth2.ReuseTokenSource(nil, c) -} - -func (c *LoginConfig) Token() (*oauth2.Token, error) { - if c == nil { - return nil, fmt.Errorf("nil token source") - } - - grace := c.StoredToken.Expiry.Add(-1 * time.Minute) - if c.isLegacy || c.StoredToken.Expiry.IsZero() || time.Now().Before(grace) { - // Token has not expired, or is a legacy token, use it. - return &c.StoredToken, nil - } - - // Token has expired, refresh it. - token, err := c.Config.TokenSource(c.ctx, &c.StoredToken).Token() - if err != nil { - return nil, fmt.Errorf("failed to refresh access token: %w", err) - } - - c.StoredToken = *token - err = c.StoreConfig() - if err != nil { - return nil, fmt.Errorf("failed to store refreshed token: %w", err) - } - - return token, nil -} - -func (c *LoginConfig) StoreConfig() error { - data, err := FormatJson(c) - if err != nil { - return fmt.Errorf("failed to format login config update: %w", err) - } - - // Write file as 0600 because it contains private keys. - return os.WriteFile(filepath.Join(c.configDir, tokenFile), []byte(data), 0600) -} - func parseURL(s string) (*url.URL, error) { // Without a scheme, url.Parse would interpret the path as a relative file path. if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { @@ -177,71 +84,14 @@ func parseURL(s string) (*url.URL, error) { return u, err } -func login(ctx *cli.Context, domain string, audience string, clientID string, disablePopUp bool) error { - config, err := oauthConfig(ctx) - if err != nil { - return fmt.Errorf("failed to retrieve oauth2 config: %w", err) - } - - resp, err := config.DeviceAuth(ctx.Context, oauth2.SetAuthURLParam("audience", audience)) - if err != nil { - return fmt.Errorf("failed to perform device auth: %w", err) - } - - domainURL, err := parseURL(ctx.String("domain")) - if err != nil { - return err - } - - verificationURL, err := parseURL(resp.VerificationURIComplete) - if err != nil { - return fmt.Errorf("failed to parse verification URL: %w", err) - } else if verificationURL.Hostname() != domainURL.Hostname() { - // We expect the verification URL to be the same host as the domain URL. - // Otherwise the response could have us POST to any arbitrary URL. - return fmt.Errorf("domain URL `%s` does not match verification URL `%s` in response", domainURL.Hostname(), verificationURL.Hostname()) - } +func openBrowser(ctx *cli.Context, message string, url string) error { + // Print to stderr so other tooling can parse the command output. + fmt.Fprintf(os.Stderr, "%s: %s\n", message, url) - fmt.Printf("Login via this url: %s\n", verificationURL.String()) - - if !disablePopUp { - if err := openBrowser(verificationURL.String()); err != nil { - fmt.Println("Unable to open browser, please open url manually.") - } - } - - token, err := config.DeviceAccessToken(ctx.Context, resp) - if err != nil { - return fmt.Errorf("failed to retrieve access token: %w", err) - } - fmt.Println("Successfully logged in!") - - loginConfig := LoginConfig{ - Config: config, - StoredToken: *token, - configDir: ctx.Path(ConfigDirFlagName), - } - return loginConfig.StoreConfig() -} - -func oauthConfig(ctx *cli.Context) (oauth2.Config, error) { - domainURL, err := parseURL(ctx.String("domain")) - if err != nil { - return oauth2.Config{}, err + if ctx.Bool(disablePopUpFlagName) { + return nil } - return oauth2.Config{ - ClientID: ctx.String("client-id"), - Endpoint: oauth2.Endpoint{ - DeviceAuthURL: domainURL.JoinPath("oauth", "device", "code").String(), - TokenURL: domainURL.JoinPath("oauth", "token").String(), - AuthStyle: oauth2.AuthStyleInParams, - }, - Scopes: []string{"openid", "profile", "user", "offline_access"}, - }, nil -} - -func openBrowser(url string) error { switch runtime.GOOS { case "linux": if err := exec.Command("xdg-open", url).Start(); err != nil { @@ -259,22 +109,3 @@ func openBrowser(url string) error { } return nil } - -// legacyOAuthToken is the legacy token version, which is kept around to -// ensure a seamless updating experience from an older tcld version. -type legacyOAuthToken struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` -} - -func (l legacyOAuthToken) convert(modTime time.Time) (oauth2.Token, error) { - return oauth2.Token{ - AccessToken: l.AccessToken, - TokenType: l.TokenType, - RefreshToken: l.RefreshToken, - Expiry: modTime.Add(time.Duration(l.ExpiresIn) * time.Second), - }, nil -} diff --git a/app/login_test.go b/app/login_test.go index 4b8d7ef5..8e9a98fb 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -1,7 +1,6 @@ package app import ( - "context" "encoding/json" "fmt" "net/http" @@ -78,11 +77,15 @@ func (l *LoginTestSuite) TestLoginSuccessful() { resp := l.runCmd("login", "--domain", l.server.URL) l.NoError(resp) - _, err := os.Stat(filepath.Join(l.configDir, tokenFile)) + _, err := os.Stat(filepath.Join(l.configDir, tokenConfigFile)) l.NoError(err) - config, err := NewLoginConfig(context.Background(), l.configDir) + cCtx := NewTestContext(l.T(), l.cliApp) + cCtx.Set(domainFlagName, l.server.URL) + + config, err := LoadTokenConfig(cCtx) l.NoError(err) + l.NotNil(config) token, err := config.Token() l.NoError(err) @@ -100,10 +103,13 @@ func (l *LoginTestSuite) TestRefreshToken() { resp := l.runCmd("login", "--domain", l.server.URL) l.NoError(resp) - data, err := os.ReadFile(filepath.Join(l.configDir, tokenFile)) + data, err := os.ReadFile(filepath.Join(l.configDir, tokenConfigFile)) l.NoError(err) - config, err := NewLoginConfig(context.Background(), l.configDir) + cCtx := NewTestContext(l.T(), l.cliApp) + cCtx.Set(domainFlagName, l.server.URL) + + config, err := LoadTokenConfig(cCtx) l.NoError(err) token, err := config.Token() @@ -113,14 +119,14 @@ func (l *LoginTestSuite) TestRefreshToken() { l.token.AccessToken = "some-new-access-token" l.token.RefreshToken = "some-new-refresh-token" - config.StoredToken.Expiry = time.Now().Add(-30 * time.Minute) + config.OAuthToken.Expiry = time.Now().Add(-30 * time.Minute) token, err = config.Token() l.NoError(err) l.Equal(l.token.AccessToken, token.AccessToken) l.Equal(l.token.RefreshToken, token.RefreshToken) - newData, err := os.ReadFile(filepath.Join(l.configDir, tokenFile)) + newData, err := os.ReadFile(filepath.Join(l.configDir, tokenConfigFile)) l.NoError(err) l.NotEqual(data, newData, "config file did not refresh with new token") } @@ -146,50 +152,6 @@ func (l *LoginTestSuite) TestLoginWithInvalidCodeResponseURL() { l.Error(l.runCmd("login", "--domain", l.server.URL)) } -func (l *LoginTestSuite) TestLegacyTokenSupport() { - legacy := legacyOAuthToken{ - AccessToken: l.token.AccessToken, - RefreshToken: l.token.RefreshToken, - TokenType: l.token.TokenType, - ExpiresIn: 3600, - } - - configFile, err := os.Create(filepath.Join(l.configDir, tokenFile)) - l.NoError(err) - - encoder := json.NewEncoder(configFile) - encoder.SetIndent("", "\t") - - err = encoder.Encode(legacy) - l.NoError(err) - configFile.Close() - - config, err := NewLoginConfig(context.Background(), l.configDir) - l.NoError(err) - l.True(config.isLegacy) - - token, err := config.Token() - l.NoError(err) - l.Equal(l.token.AccessToken, token.AccessToken) - l.Equal(l.token.RefreshToken, token.RefreshToken) - l.WithinDuration(time.Now().Add(3600*time.Second), token.Expiry, time.Minute) - - // Login to upgrade to new token type. - resp := l.runCmd("login", "--domain", l.server.URL) - l.NoError(resp) - - config, err = NewLoginConfig(context.Background(), l.configDir) - l.NoError(err) - l.Equal(l.token.AccessToken, config.StoredToken.AccessToken) - l.Equal(l.token.RefreshToken, config.StoredToken.RefreshToken) - l.False(config.isLegacy) - - token, err = config.Token() - l.NoError(err) - l.Equal(l.token.AccessToken, token.AccessToken) - l.Equal(l.token.RefreshToken, token.RefreshToken) -} - func (l *LoginTestSuite) handleDeviceCode() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/app/logout.go b/app/logout.go index cbe1ec8e..d387470f 100644 --- a/app/logout.go +++ b/app/logout.go @@ -19,21 +19,13 @@ func NewLogoutCommand() (CommandOut, error) { }, Action: func(ctx *cli.Context) error { configDir := ctx.Path(ConfigDirFlagName) - - if err := removeFile(filepath.Join(configDir, tokenFile)); err != nil { + if err := removeFile(filepath.Join(configDir, tokenConfigFile)); err != nil { return fmt.Errorf("unable to remove config file: %w", err) } logoutURL := fmt.Sprintf("https://%s/v2/logout", ctx.String("domain")) - fmt.Printf("Logout via this url: %s\n", logoutURL) - - if !ctx.Bool("disable-pop-up") { - if err := openBrowser(logoutURL); err != nil { - return fmt.Errorf("Unable to open browser, please open url manually.") - } - } - return nil + return openBrowser(ctx, "Logout via this url", logoutURL) }, }}, nil } diff --git a/app/logout_test.go b/app/logout_test.go index 53d17278..0526a057 100644 --- a/app/logout_test.go +++ b/app/logout_test.go @@ -52,23 +52,25 @@ func (l *LogoutTestSuite) runCmd(args ...string) error { } func (l *LogoutTestSuite) TestLogoutSuccessful() { - loginConfig := LoginConfig{ - Config: oauth2.Config{ + cCtx := NewTestContext(l.T(), l.cliApp) + + loginConfig := TokenConfig{ + OAuthConfig: oauth2.Config{ ClientID: "test-id", ClientSecret: "test-secret", }, - configDir: l.configDir, + ctx: cCtx, } - err := loginConfig.StoreConfig() + err := loginConfig.Store() l.NoError(err) - _, err = os.Stat(filepath.Join(l.configDir, tokenFile)) + _, err = os.Stat(filepath.Join(l.configDir, tokenConfigFile)) l.NoError(err) resp := l.runCmd("logout", "--domain", l.server.URL) l.NoError(resp) - _, err = os.Stat(filepath.Join(l.configDir, tokenFile)) + _, err = os.Stat(filepath.Join(l.configDir, tokenConfigFile)) l.ErrorIs(err, os.ErrNotExist) } diff --git a/app/oauth.go b/app/oauth.go new file mode 100644 index 00000000..fd4050ba --- /dev/null +++ b/app/oauth.go @@ -0,0 +1,116 @@ +package app + +import ( + "errors" + "fmt" + "os" + + "github.com/urfave/cli/v2" + "golang.org/x/oauth2" + "golang.org/x/term" +) + +const ( + // OAuth error defined in RFC-6749. + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + invalidGrantErr = "invalid_grant" +) + +func login(ctx *cli.Context) (*TokenConfig, error) { + domainURL, err := parseURL(ctx.String(domainFlagName)) + if err != nil { + return nil, fmt.Errorf("failed to parse domain: %w", err) + } + + config := oauth2.Config{ + ClientID: ctx.String("client-id"), + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: domainURL.JoinPath("oauth", "device", "code").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"openid", "profile", "user", "offline_access"}, + } + + resp, err := config.DeviceAuth(ctx.Context, oauth2.SetAuthURLParam("audience", ctx.String(audienceFlagName))) + if err != nil { + return nil, fmt.Errorf("failed to perform device auth: %w", err) + } + + verificationURL, err := parseURL(resp.VerificationURIComplete) + if err != nil { + return nil, fmt.Errorf("failed to parse verification URL: %w", err) + } else if verificationURL.Hostname() != domainURL.Hostname() { + // We expect the verification URL to be the same host as the domain URL. + // Otherwise the response could have us POST to any arbitrary URL. + return nil, fmt.Errorf("domain URL `%s` does not match verification URL `%s` in response", domainURL.Hostname(), verificationURL.Hostname()) + } + + err = openBrowser(ctx, "Login via this url", verificationURL.String()) + if err != nil { + // Notify the user but ensure they can continue the process. + fmt.Printf("Failed to open the browser, click the link to continue: %v", err) + } + + token, err := config.DeviceAccessToken(ctx.Context, resp) + if err != nil { + return nil, fmt.Errorf("failed to retrieve access token: %w", err) + } + // Print to stderr so other tooling can parse the command output. + fmt.Fprintln(os.Stderr, "Successfully logged in!") + + tokenConfig := &TokenConfig{ + OAuthConfig: config, + OAuthToken: *token, + ctx: ctx, + } + + err = tokenConfig.Store() + if err != nil { + return nil, fmt.Errorf("failed to store token config: %w", err) + } + + return tokenConfig, nil +} + +func ensureLogin(ctx *cli.Context) (*TokenConfig, error) { + cfg, err := LoadTokenConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load token config: %w", err) + } + + _, err = cfg.Token() + if err != nil { + var retrieveErr *oauth2.RetrieveError + + // Handle one of two cases: + // 1. Refresh token has expired. + // 2. Refresh tokens were enabled, but the user has not logged in to receive one yet. + if (errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == invalidGrantErr) || + len(cfg.OAuthToken.RefreshToken) == 0 { + // Only attempt a forced login if used in an interactive terminal. + if term.IsTerminal(int(os.Stdout.Fd())) { + cfg, err = login(ctx) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + _, err := cfg.Token() + if err != nil { + return nil, fmt.Errorf("failed to retrieve auth token: %w", err) + } + + err = cfg.Store() + if err != nil { + return nil, fmt.Errorf("failed to store new tokens: %w", err) + } + + return cfg, nil + } + } + + return nil, err + } + + return cfg, nil +} diff --git a/app/token_config.go b/app/token_config.go new file mode 100644 index 00000000..b85d2178 --- /dev/null +++ b/app/token_config.go @@ -0,0 +1,125 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/urfave/cli/v2" + "golang.org/x/oauth2" + "golang.org/x/term" +) + +const ( + tokenConfigFile = "tokens.json" +) + +type TokenConfig struct { + OAuthConfig oauth2.Config `json:"oauth_config"` + OAuthToken oauth2.Token `json:"oauth_token"` + + ctx *cli.Context // used for token refreshes. +} + +func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { + tokenConfig := filepath.Join(ctx.String(ConfigDirFlagName), tokenConfigFile) + + fmt.Printf("loading token config at %v\n", tokenConfig) + + _, err := os.Stat(tokenConfig) + if err != nil { + // Only attempt a forced login if used in an interactive terminal. + if os.IsNotExist(err) && term.IsTerminal(int(os.Stdout.Fd())) { + cfg, err := login(ctx) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + return cfg, nil + } + + return nil, fmt.Errorf("failed to stat login config: %w", err) + } + fmt.Printf("token config found\n") + + data, err := os.ReadFile(tokenConfig) + if err != nil { + return nil, fmt.Errorf("failed to read login config: %w", err) + } + + fmt.Printf("token config read\n") + + var config *TokenConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal login config: %w", err) + } else if config.OAuthToken == (oauth2.Token{}) { + // Using legacy token format, initiate a login to migrate. + cfg, err := login(ctx) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + config = cfg + } + + fmt.Printf("returning token config\n") + + config.ctx = ctx // used for token refreshes. + + return config, nil +} + +func (c *TokenConfig) TokenSource() oauth2.TokenSource { + if c == nil { + return nil + } + + return oauth2.ReuseTokenSource(nil, c) +} + +func (c *TokenConfig) Token() (*oauth2.Token, error) { + if c == nil { + return nil, fmt.Errorf("nil token source") + } + + grace := c.OAuthToken.Expiry.Add(-1 * time.Minute) + if c.OAuthToken.Expiry.IsZero() || time.Now().Before(grace) { + // Token has not expired, or is a legacy token, use it. + return &c.OAuthToken, nil + } + + // Token has expired, refresh it. + token, err := c.OAuthConfig.TokenSource(c.ctx.Context, &c.OAuthToken).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh access token: %w", err) + } + + c.OAuthToken = *token + err = c.Store() + if err != nil { + return nil, fmt.Errorf("failed to store refreshed token: %w", err) + } + + return token, nil +} + +func (c *TokenConfig) Store() error { + cfgDir := c.ctx.String(ConfigDirFlagName) + + data, err := FormatJson(c) + if err != nil { + return fmt.Errorf("failed to format login config update: %w", err) + } + + // Create config dir if it does not exist + if err := os.MkdirAll(cfgDir, 0700); err != nil { + return err + } + + fmt.Printf("Storing token config at %s\n", filepath.Join(cfgDir, tokenConfigFile)) + + // Write file as 0600 because it contains private keys. + return os.WriteFile(filepath.Join(cfgDir, tokenConfigFile), []byte(data), 0600) +} diff --git a/go.mod b/go.mod index c88b3221..d40e2882 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( go.uber.org/fx v1.20.1 go.uber.org/multierr v1.11.0 golang.org/x/mod v0.12.0 - golang.org/x/oauth2 v0.14.0 + golang.org/x/oauth2 v0.15.0 google.golang.org/grpc v1.59.0 ) @@ -30,9 +30,10 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/dig v1.17.0 // indirect go.uber.org/zap v1.23.0 // indirect - golang.org/x/crypto v0.15.0 // indirect - golang.org/x/net v0.18.0 // indirect - golang.org/x/sys v0.14.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect diff --git a/go.sum b/go.sum index 14d4bbe4..d90a7cb7 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -79,8 +81,12 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= +golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -93,7 +99,11 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= From 3f377346588d4b52681389cae5b883b0b6a1e7d4 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 7 Dec 2023 14:03:02 -0800 Subject: [PATCH 11/17] Remove debug logs --- app/token_config.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/token_config.go b/app/token_config.go index b85d2178..affee680 100644 --- a/app/token_config.go +++ b/app/token_config.go @@ -26,8 +26,6 @@ type TokenConfig struct { func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { tokenConfig := filepath.Join(ctx.String(ConfigDirFlagName), tokenConfigFile) - fmt.Printf("loading token config at %v\n", tokenConfig) - _, err := os.Stat(tokenConfig) if err != nil { // Only attempt a forced login if used in an interactive terminal. @@ -42,15 +40,12 @@ func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { return nil, fmt.Errorf("failed to stat login config: %w", err) } - fmt.Printf("token config found\n") data, err := os.ReadFile(tokenConfig) if err != nil { return nil, fmt.Errorf("failed to read login config: %w", err) } - fmt.Printf("token config read\n") - var config *TokenConfig if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to unmarshal login config: %w", err) @@ -64,8 +59,6 @@ func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { config = cfg } - fmt.Printf("returning token config\n") - config.ctx = ctx // used for token refreshes. return config, nil @@ -118,8 +111,6 @@ func (c *TokenConfig) Store() error { return err } - fmt.Printf("Storing token config at %s\n", filepath.Join(cfgDir, tokenConfigFile)) - // Write file as 0600 because it contains private keys. return os.WriteFile(filepath.Join(cfgDir, tokenConfigFile), []byte(data), 0600) } From f17b48a313d6a9780341145e8d15c8d447355265 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:06:11 -0800 Subject: [PATCH 12/17] Updates from testing --- app/login.go | 2 +- app/oauth.go | 59 +++++++++++++++++++++++++++++---------------- app/token_config.go | 27 +++++++++------------ go.mod | 2 +- go.sum | 8 ------ 5 files changed, 51 insertions(+), 47 deletions(-) diff --git a/app/login.go b/app/login.go index 413302b8..10305d26 100644 --- a/app/login.go +++ b/app/login.go @@ -60,7 +60,7 @@ func NewLoginCommand() (CommandOut, error) { disablePopUpFlag, }, Action: func(ctx *cli.Context) error { - _, err := login(ctx) + _, err := login(ctx, nil) return err }, }}, nil diff --git a/app/oauth.go b/app/oauth.go index fd4050ba..23185ab9 100644 --- a/app/oauth.go +++ b/app/oauth.go @@ -16,25 +16,23 @@ const ( invalidGrantErr = "invalid_grant" ) -func login(ctx *cli.Context) (*TokenConfig, error) { - domainURL, err := parseURL(ctx.String(domainFlagName)) - if err != nil { - return nil, fmt.Errorf("failed to parse domain: %w", err) +func login(ctx *cli.Context, tokenConfig *TokenConfig) (*TokenConfig, error) { + if tokenConfig == nil { + defaultConfig, err := defaultTokenConfig(ctx) + if err != nil { + return nil, err + } + tokenConfig = defaultConfig } - config := oauth2.Config{ - ClientID: ctx.String("client-id"), - Endpoint: oauth2.Endpoint{ - DeviceAuthURL: domainURL.JoinPath("oauth", "device", "code").String(), - TokenURL: domainURL.JoinPath("oauth", "token").String(), - AuthStyle: oauth2.AuthStyleInParams, - }, - Scopes: []string{"openid", "profile", "user", "offline_access"}, + resp, err := tokenConfig.OAuthConfig.DeviceAuth(ctx.Context, oauth2.SetAuthURLParam("audience", tokenConfig.Audience)) + if err != nil { + return nil, fmt.Errorf("failed to perform device auth: %w", err) } - resp, err := config.DeviceAuth(ctx.Context, oauth2.SetAuthURLParam("audience", ctx.String(audienceFlagName))) + domainURL, err := parseURL(tokenConfig.Domain) if err != nil { - return nil, fmt.Errorf("failed to perform device auth: %w", err) + return nil, fmt.Errorf("failed to parse domain: %w", err) } verificationURL, err := parseURL(resp.VerificationURIComplete) @@ -52,18 +50,15 @@ func login(ctx *cli.Context) (*TokenConfig, error) { fmt.Printf("Failed to open the browser, click the link to continue: %v", err) } - token, err := config.DeviceAccessToken(ctx.Context, resp) + token, err := tokenConfig.OAuthConfig.DeviceAccessToken(ctx.Context, resp) if err != nil { return nil, fmt.Errorf("failed to retrieve access token: %w", err) } // Print to stderr so other tooling can parse the command output. fmt.Fprintln(os.Stderr, "Successfully logged in!") - tokenConfig := &TokenConfig{ - OAuthConfig: config, - OAuthToken: *token, - ctx: ctx, - } + tokenConfig.OAuthToken = *token + tokenConfig.ctx = ctx err = tokenConfig.Store() if err != nil { @@ -90,7 +85,7 @@ func ensureLogin(ctx *cli.Context) (*TokenConfig, error) { len(cfg.OAuthToken.RefreshToken) == 0 { // Only attempt a forced login if used in an interactive terminal. if term.IsTerminal(int(os.Stdout.Fd())) { - cfg, err = login(ctx) + cfg, err = login(ctx, cfg) if err != nil { return nil, fmt.Errorf("failed to login: %w", err) } @@ -114,3 +109,25 @@ func ensureLogin(ctx *cli.Context) (*TokenConfig, error) { return cfg, nil } + +func defaultTokenConfig(ctx *cli.Context) (*TokenConfig, error) { + domainURL, err := parseURL(ctx.String(domainFlagName)) + if err != nil { + return nil, fmt.Errorf("failed to parse domain URL: %w", err) + } + + return &TokenConfig{ + Audience: ctx.String(audienceFlagName), + Domain: domainURL.String(), + OAuthConfig: oauth2.Config{ + ClientID: ctx.String("client-id"), + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: domainURL.JoinPath("oauth", "device", "code").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"openid", "profile", "user", "offline_access"}, + }, + ctx: ctx, + }, nil +} diff --git a/app/token_config.go b/app/token_config.go index affee680..ed17451c 100644 --- a/app/token_config.go +++ b/app/token_config.go @@ -9,14 +9,19 @@ import ( "github.com/urfave/cli/v2" "golang.org/x/oauth2" - "golang.org/x/term" ) const ( tokenConfigFile = "tokens.json" ) +var ( + unauthenticatedErr = fmt.Errorf("must authenticate by running `tcld login`") +) + type TokenConfig struct { + Audience string `json:"audience"` + Domain string `json:"domain"` OAuthConfig oauth2.Config `json:"oauth_config"` OAuthToken oauth2.Token `json:"oauth_token"` @@ -29,16 +34,11 @@ func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { _, err := os.Stat(tokenConfig) if err != nil { // Only attempt a forced login if used in an interactive terminal. - if os.IsNotExist(err) && term.IsTerminal(int(os.Stdout.Fd())) { - cfg, err := login(ctx) - if err != nil { - return nil, fmt.Errorf("failed to login: %w", err) - } - - return cfg, nil + if os.IsNotExist(err) { + return nil, unauthenticatedErr } - return nil, fmt.Errorf("failed to stat login config: %w", err) + return nil, err } data, err := os.ReadFile(tokenConfig) @@ -50,13 +50,8 @@ func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to unmarshal login config: %w", err) } else if config.OAuthToken == (oauth2.Token{}) { - // Using legacy token format, initiate a login to migrate. - cfg, err := login(ctx) - if err != nil { - return nil, fmt.Errorf("failed to login: %w", err) - } - - config = cfg + // Using legacy token format, ask user to initiate a login to migrate. + return nil, unauthenticatedErr } config.ctx = ctx // used for token refreshes. diff --git a/go.mod b/go.mod index d40e2882..f2a2f464 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( go.uber.org/multierr v1.11.0 golang.org/x/mod v0.12.0 golang.org/x/oauth2 v0.15.0 + golang.org/x/term v0.15.0 google.golang.org/grpc v1.59.0 ) @@ -33,7 +34,6 @@ require ( golang.org/x/crypto v0.16.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect diff --git a/go.sum b/go.sum index d90a7cb7..ea1ad534 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,6 @@ go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -79,12 +77,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= -golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -97,8 +91,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From 88d85f0808c157ea76c239f00cb68de5171982e4 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:07:37 -0800 Subject: [PATCH 13/17] Fix lint errors --- app/login_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/login_test.go b/app/login_test.go index 8e9a98fb..a66f3f88 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/urfave/cli/v2" "golang.org/x/oauth2" @@ -81,7 +82,7 @@ func (l *LoginTestSuite) TestLoginSuccessful() { l.NoError(err) cCtx := NewTestContext(l.T(), l.cliApp) - cCtx.Set(domainFlagName, l.server.URL) + require.NoError(l.T(), cCtx.Set(domainFlagName, l.server.URL)) config, err := LoadTokenConfig(cCtx) l.NoError(err) @@ -107,7 +108,7 @@ func (l *LoginTestSuite) TestRefreshToken() { l.NoError(err) cCtx := NewTestContext(l.T(), l.cliApp) - cCtx.Set(domainFlagName, l.server.URL) + require.NoError(l.T(), cCtx.Set(domainFlagName, l.server.URL)) config, err := LoadTokenConfig(cCtx) l.NoError(err) From 6d434132c30fb9d0540b31793a4e1b758601bf96 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Fri, 3 May 2024 09:26:28 -0700 Subject: [PATCH 14/17] Small fixes --- app/connection.go | 2 +- app/oauth.go | 46 +------------------------------------- app/token_config.go | 54 +++++++++++++++++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 55 deletions(-) diff --git a/app/connection.go b/app/connection.go index 6f4b2970..fb311b28 100644 --- a/app/connection.go +++ b/app/connection.go @@ -88,7 +88,7 @@ func newRPCCredential(ctx *cli.Context) (credentials.PerRPCCredentials, error) { ) } - config, err := ensureLogin(ctx) + config, err := LoadTokenConfig(ctx) if err != nil { return nil, err } diff --git a/app/oauth.go b/app/oauth.go index 23185ab9..84bd4db3 100644 --- a/app/oauth.go +++ b/app/oauth.go @@ -1,13 +1,11 @@ package app import ( - "errors" "fmt" "os" "github.com/urfave/cli/v2" "golang.org/x/oauth2" - "golang.org/x/term" ) const ( @@ -57,7 +55,7 @@ func login(ctx *cli.Context, tokenConfig *TokenConfig) (*TokenConfig, error) { // Print to stderr so other tooling can parse the command output. fmt.Fprintln(os.Stderr, "Successfully logged in!") - tokenConfig.OAuthToken = *token + tokenConfig.OAuthToken = token tokenConfig.ctx = ctx err = tokenConfig.Store() @@ -68,48 +66,6 @@ func login(ctx *cli.Context, tokenConfig *TokenConfig) (*TokenConfig, error) { return tokenConfig, nil } -func ensureLogin(ctx *cli.Context) (*TokenConfig, error) { - cfg, err := LoadTokenConfig(ctx) - if err != nil { - return nil, fmt.Errorf("failed to load token config: %w", err) - } - - _, err = cfg.Token() - if err != nil { - var retrieveErr *oauth2.RetrieveError - - // Handle one of two cases: - // 1. Refresh token has expired. - // 2. Refresh tokens were enabled, but the user has not logged in to receive one yet. - if (errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == invalidGrantErr) || - len(cfg.OAuthToken.RefreshToken) == 0 { - // Only attempt a forced login if used in an interactive terminal. - if term.IsTerminal(int(os.Stdout.Fd())) { - cfg, err = login(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("failed to login: %w", err) - } - - _, err := cfg.Token() - if err != nil { - return nil, fmt.Errorf("failed to retrieve auth token: %w", err) - } - - err = cfg.Store() - if err != nil { - return nil, fmt.Errorf("failed to store new tokens: %w", err) - } - - return cfg, nil - } - } - - return nil, err - } - - return cfg, nil -} - func defaultTokenConfig(ctx *cli.Context) (*TokenConfig, error) { domainURL, err := parseURL(ctx.String(domainFlagName)) if err != nil { diff --git a/app/token_config.go b/app/token_config.go index ed17451c..15578a98 100644 --- a/app/token_config.go +++ b/app/token_config.go @@ -2,6 +2,7 @@ package app import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -23,7 +24,7 @@ type TokenConfig struct { Audience string `json:"audience"` Domain string `json:"domain"` OAuthConfig oauth2.Config `json:"oauth_config"` - OAuthToken oauth2.Token `json:"oauth_token"` + OAuthToken *oauth2.Token `json:"oauth_token"` ctx *cli.Context // used for token refreshes. } @@ -33,12 +34,16 @@ func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { _, err := os.Stat(tokenConfig) if err != nil { - // Only attempt a forced login if used in an interactive terminal. if os.IsNotExist(err) { - return nil, unauthenticatedErr + cfg, err := login(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + return cfg, nil } - return nil, err + return nil, fmt.Errorf("failed to stat login config: %w", err) } data, err := os.ReadFile(tokenConfig) @@ -49,9 +54,10 @@ func LoadTokenConfig(ctx *cli.Context) (*TokenConfig, error) { var config *TokenConfig if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to unmarshal login config: %w", err) - } else if config.OAuthToken == (oauth2.Token{}) { + } else if config.OAuthToken == nil { // Using legacy token format, ask user to initiate a login to migrate. - return nil, unauthenticatedErr + fmt.Println("Re-login with `tcld login` to migrate to the new config format") + os.Exit(1) } config.ctx = ctx // used for token refreshes. @@ -75,16 +81,46 @@ func (c *TokenConfig) Token() (*oauth2.Token, error) { grace := c.OAuthToken.Expiry.Add(-1 * time.Minute) if c.OAuthToken.Expiry.IsZero() || time.Now().Before(grace) { // Token has not expired, or is a legacy token, use it. - return &c.OAuthToken, nil + return c.OAuthToken, nil } // Token has expired, refresh it. - token, err := c.OAuthConfig.TokenSource(c.ctx.Context, &c.OAuthToken).Token() + token, err := c.OAuthConfig.TokenSource(c.ctx.Context, c.OAuthToken).Token() if err != nil { + var retrieveErr *oauth2.RetrieveError + + // Handle one of two cases: + // 1. Refresh token has expired. + // 2. Refresh tokens were enabled, but the user has not logged in to receive one yet. + if (errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == invalidGrantErr) || + len(c.OAuthToken.RefreshToken) == 0 { + cfg, err := login(c.ctx, c) + if err != nil { + return nil, fmt.Errorf("failed to login to retrieve new refresh token: %w", err) + } + + token, err = cfg.OAuthConfig.TokenSource(cfg.ctx.Context, cfg.OAuthToken).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh access token after login: %w", err) + } + + // Make sure the current config reflects the new config. + c.OAuthConfig = cfg.OAuthConfig + c.OAuthToken = token + + // Store the new config for the next CLI invocation. + err = cfg.Store() + if err != nil { + return nil, fmt.Errorf("failed to store new refresh and access tokens: %w", err) + } + + return token, nil + } + return nil, fmt.Errorf("failed to refresh access token: %w", err) } - c.OAuthToken = *token + c.OAuthToken = token err = c.Store() if err != nil { return nil, fmt.Errorf("failed to store refreshed token: %w", err) From b4b996ab4f32f77756cc32d1d47b19b1680363db Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Fri, 3 May 2024 14:30:49 -0700 Subject: [PATCH 15/17] Fix unit tests --- app/connection_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/connection_test.go b/app/connection_test.go index 3d8ed283..027289f1 100644 --- a/app/connection_test.go +++ b/app/connection_test.go @@ -142,7 +142,7 @@ func (s *ServerConnectionTestSuite) TestGetServerConnection() { } loginConfig := TokenConfig{ - OAuthToken: oauth2.Token{ + OAuthToken: &oauth2.Token{ AccessToken: testAccessToken, Expiry: time.Now().Add(24 * time.Hour), }, From e16fa2c047959a6723dcb3e9febe1f5cb62a8166 Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Fri, 3 May 2024 14:33:42 -0700 Subject: [PATCH 16/17] Fix lint --- app/token_config.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/token_config.go b/app/token_config.go index 15578a98..6b3c2f66 100644 --- a/app/token_config.go +++ b/app/token_config.go @@ -16,10 +16,6 @@ const ( tokenConfigFile = "tokens.json" ) -var ( - unauthenticatedErr = fmt.Errorf("must authenticate by running `tcld login`") -) - type TokenConfig struct { Audience string `json:"audience"` Domain string `json:"domain"` From 3aa867094483056c932a7746ad1476226cfd3a4f Mon Sep 17 00:00:00 2001 From: Travis Szucs <1060873+tminusplus@users.noreply.github.com> Date: Wed, 8 May 2024 16:23:09 -0700 Subject: [PATCH 17/17] Always logout --- app/logout.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/logout.go b/app/logout.go index d387470f..1fc3ce21 100644 --- a/app/logout.go +++ b/app/logout.go @@ -20,7 +20,7 @@ func NewLogoutCommand() (CommandOut, error) { Action: func(ctx *cli.Context) error { configDir := ctx.Path(ConfigDirFlagName) if err := removeFile(filepath.Join(configDir, tokenConfigFile)); err != nil { - return fmt.Errorf("unable to remove config file: %w", err) + fmt.Printf("unable to remove config file, continuing with logout anyways: %v", err) } logoutURL := fmt.Sprintf("https://%s/v2/logout", ctx.String("domain"))