diff --git a/Makefile b/Makefile index deb7e0fe..a2c69e82 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..244cac7f --- /dev/null +++ b/app/app_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "flag" + "io" + "testing" + + "github.com/stretchr/testify/require" + "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 +} + +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/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 12ee9f1e..fb311b28 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,18 +88,13 @@ func newRPCCredential(c *cli.Context) (credentials.PerRPCCredentials, error) { ) } - tokens, err := loadLoginConfig(c) + config, err := LoadTokenConfig(ctx) if err != nil { return nil, err } - if len(tokens.AccessToken) > 0 { - return oauth.NewCredential( - tokens.AccessToken, - 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 4e46ea34..027289f1 100644 --- a/app/connection_test.go +++ b/app/connection_test.go @@ -2,23 +2,20 @@ package app import ( "context" - "encoding/json" - "flag" "fmt" - "io" "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" @@ -50,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 @@ -61,15 +57,6 @@ func TestServerConnection(t *testing.T) { } func (s *ServerConnectionTestSuite) SetupTest() { - s.configDir = s.T().TempDir() - data, err := json.Marshal(OAuthTokenResponse{ - AccessToken: testAccessToken, - }) - require.NoError(s.T(), err) - - err = os.WriteFile(path.Join(s.configDir, tokenFileName), data, 0600) - require.NoError(s.T(), err) - s.listener = bufconn.Listen(1024 * 1024) s.grpcSrv = grpc.NewServer() s.testService = &testServer{} @@ -85,12 +72,20 @@ 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 - args map[string]string - expectedHeaders map[string]string - expectedErr error + name string + args map[string]string + expectedToken string + expectedErr error }{ { name: "ErrorInvalidHostname", @@ -115,52 +110,47 @@ 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 - }, - expectedHeaders: map[string]string{ - oauth.Header: "Bearer " + testAccessToken, + 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, }, - expectedHeaders: map[string]string{ - apikey.AuthorizationHeader: "Bearer " + testAPIKey, - }, + expectedToken: testAPIKey, }, } 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) + } + + loginConfig := TokenConfig{ + OAuthToken: &oauth2.Token{ + AccessToken: testAccessToken, + Expiry: time.Now().Add(24 * time.Hour), + }, + ctx: cCtx, } - require.NoError(s.T(), fs.Parse(args)) + + err := loginConfig.Store() + require.NoError(s.T(), err) opts := []grpc.DialOption{ grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { @@ -191,14 +181,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/feature.go b/app/feature.go index 488df0f0..ab13011e 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 837fdb70..10305d26 100644 --- a/app/login.go +++ b/app/login.go @@ -1,119 +1,69 @@ package app import ( - "encoding/json" - "errors" "fmt" - "io" - "net/http" "net/url" "os" - "path/filepath" + "os/exec" + "runtime" "strings" - "time" - - "github.com/temporalio/tcld/services" "github.com/urfave/cli/v2" ) const ( - scope = "openid profile user" + // Flags. + domainFlagName = "domain" + audienceFlagName = "audience" + clientIDFlagName = "client-id" + disablePopUpFlagName = "disable-pop-up" ) var ( - tokenFileName = "tokens.json" - domainFlag = &cli.StringFlag{ - Name: "domain", + domainFlag = &cli.StringFlag{ + 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, } ) -func GetLoginClient() *LoginClient { - return &LoginClient{ - loginService: services.NewLoginService(), - } -} - -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 { - configDir := ctx.Path(ConfigDirFlagName) - return filepath.Join(configDir, tokenFileName) -} - -// TODO: support login config on windows -func loadLoginConfig(ctx *cli.Context) (OAuthTokenResponse, error) { - - tokens := OAuthTokenResponse{} - configDir := ctx.Path(ConfigDirFlagName) - // Create config dir if it does not exist - if err := os.MkdirAll(configDir, 0700); err != nil { - return tokens, 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 tokens, err - } - - tokenConfigBytes, err := os.ReadFile(tokenConfig) - if err != nil { - return tokens, err - } - - if err := json.Unmarshal(tokenConfigBytes, &tokens); err != nil { - return tokens, err - } - - return tokens, nil +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 { + _, err := login(ctx, nil) + return err + }, + }}, nil } func parseURL(s string) (*url.URL, error) { @@ -134,109 +84,28 @@ 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 { - // Get device code - domainURL, err := parseURL(domain) - if err != nil { - return err - } +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) - codeResp := OAuthDeviceCodeResponse{} - if err := postFormRequest( - domainURL.JoinPath("oauth", "device", "code").String(), - url.Values{ - "client_id": {clientID}, - "scope": {scope}, - "audience": {audience}, - }, - &codeResp, - ); err != nil { - return err + if ctx.Bool(disablePopUpFlagName) { + return nil } - verificationURL, err := parseURL(codeResp.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()) - } - - fmt.Printf("Login via this url: %s\n", verificationURL.String()) - - if !disablePopUp { - if err := c.loginService.OpenBrowser(verificationURL.String()); err != nil { - fmt.Println("Unable to open browser, please open url manually.") + switch runtime.GOOS { + case "linux": + if err := exec.Command("xdg-open", url).Start(); err != nil { + return err } - } - - // 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 { + case "windows": + if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start(); err != nil { return err } - } - - tokenRespJson, err := FormatJson(tokenResp) - if err != nil { - return err - } - fmt.Println("Successfully logged in!") - - // 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) + case "darwin": + if err := exec.Command("open", url).Start(); err != nil { 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 -} - -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 + } + default: } - return json.Unmarshal(body, &resStruct) + return nil } diff --git a/app/login_test.go b/app/login_test.go index 15ccbd89..a66f3f88 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -2,14 +2,18 @@ package app import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" + "time" - "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "github.com/temporalio/tcld/services" "github.com/urfave/cli/v2" + "golang.org/x/oauth2" ) func TestLogin(t *testing.T) { @@ -18,114 +22,165 @@ 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.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, + } + + out, err := NewLoginCommand() 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, + domainFlag, + audienceFlag, + clientIDFlag, + disablePopUpFlag, + } + l.cliApp, l.configDir = NewTestApp(l.T(), cmds, flags) } -func (l *LoginTestSuite) registerPath(path string, response string) { - l.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(response)) - if err != nil { - return - } - }) +func (l *LoginTestSuite) TearDownTest() { + l.server.Close() } -func (l *LoginTestSuite) runCmd(args ...string) error { - return l.cliApp.Run(append([]string{"tcld"}, args...)) +func (l *LoginTestSuite) TestLoginSuccessful() { + resp := l.runCmd("login", "--domain", l.server.URL) + l.NoError(resp) + + _, err := os.Stat(filepath.Join(l.configDir, tokenConfigFile)) + l.NoError(err) + + cCtx := NewTestContext(l.T(), l.cliApp) + require.NoError(l.T(), cCtx.Set(domainFlagName, l.server.URL)) + + config, err := LoadTokenConfig(cCtx) + l.NoError(err) + l.NotNil(config) + + 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) 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)) +func (l *LoginTestSuite) TestRefreshToken() { resp := l.runCmd("login", "--domain", l.server.URL) l.NoError(resp) + + data, err := os.ReadFile(filepath.Join(l.configDir, tokenConfigFile)) + l.NoError(err) + + cCtx := NewTestContext(l.T(), l.cliApp) + require.NoError(l.T(), cCtx.Set(domainFlagName, l.server.URL)) + + config, err := LoadTokenConfig(cCtx) + l.NoError(err) + + 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.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, tokenConfigFile)) + l.NoError(err) + l.NotEqual(data, newData, "config file did not refresh with new token") } 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 := OAuthDeviceCodeResponse{ - DeviceCode: "ABCD-EFGH", - UserCode: "ABCD-EFGH", - VerificationURI: domain, - VerificationURIComplete: domain, - ExpiresIn: 30, - Interval: 1, - } +func (l *LoginTestSuite) handleDeviceCode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") - json, 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 + } } - - return string(json) } -func validTokenResponse(t *testing.T, domain string) string { - resp := OAuthTokenResponse{ - AccessToken: "EabWErgdh", - RefreshToken: "eWKjhgT", - IDToken: "iJktYuVk", - TokenType: "Bearer", - ExpiresIn: 3600, - } +func (l *LoginTestSuite) handleToken() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") - json, 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 + } } +} + +func (l *LoginTestSuite) runCmd(args ...string) error { + return l.cliApp.Run(append([]string{"tcld"}, args...)) +} - return string(json) +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..1fc3ce21 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,21 @@ func NewLogoutCommand(c *LoginClient) (CommandOut, error) { disablePopUpFlag, }, Action: func(ctx *cli.Context) error { - if err := c.loginService.DeleteConfigFile(getTokenConfigPath(ctx)); err != nil { - return fmt.Errorf("unable to remove config file: %w", err) + configDir := ctx.Path(ConfigDirFlagName) + if err := removeFile(filepath.Join(configDir, tokenConfigFile)); err != nil { + fmt.Printf("unable to remove config file, continuing with logout anyways: %v", 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 { - return fmt.Errorf("Unable to open browser, please open url manually.") - } - } - return nil + + return openBrowser(ctx, "Logout via this url", logoutURL) }, }}, 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..0526a057 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,25 @@ 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) -} + cCtx := NewTestContext(l.T(), l.cliApp) + + loginConfig := TokenConfig{ + OAuthConfig: oauth2.Config{ + ClientID: "test-id", + ClientSecret: "test-secret", + }, + ctx: cCtx, + } -func (l *LogoutTestSuite) TestLogoutDisablePopup() { - l.mockService.EXPECT().DeleteConfigFile(gomock.Any()).Return(nil) - resp := l.runCmd("logout", "--domain", l.server.URL, "--disable-pop-up") + err := loginConfig.Store() + l.NoError(err) + + _, err = os.Stat(filepath.Join(l.configDir, tokenConfigFile)) + 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, tokenConfigFile)) + l.ErrorIs(err, os.ErrNotExist) } diff --git a/app/namespace_test.go b/app/namespace_test.go index 5660d7c3..281e51e9 100644 --- a/app/namespace_test.go +++ b/app/namespace_test.go @@ -35,23 +35,13 @@ type NamespaceTestSuite struct { } 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 +49,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 +67,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/oauth.go b/app/oauth.go new file mode 100644 index 00000000..84bd4db3 --- /dev/null +++ b/app/oauth.go @@ -0,0 +1,89 @@ +package app + +import ( + "fmt" + "os" + + "github.com/urfave/cli/v2" + "golang.org/x/oauth2" +) + +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 *TokenConfig) (*TokenConfig, error) { + if tokenConfig == nil { + defaultConfig, err := defaultTokenConfig(ctx) + if err != nil { + return nil, err + } + tokenConfig = defaultConfig + } + + 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) + } + + domainURL, err := parseURL(tokenConfig.Domain) + if err != nil { + return nil, fmt.Errorf("failed to parse domain: %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 := 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.OAuthToken = token + tokenConfig.ctx = ctx + + err = tokenConfig.Store() + if err != nil { + return nil, fmt.Errorf("failed to store token config: %w", err) + } + + return tokenConfig, 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/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/token_config.go b/app/token_config.go new file mode 100644 index 00000000..6b3c2f66 --- /dev/null +++ b/app/token_config.go @@ -0,0 +1,143 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/urfave/cli/v2" + "golang.org/x/oauth2" +) + +const ( + tokenConfigFile = "tokens.json" +) + +type TokenConfig struct { + Audience string `json:"audience"` + Domain string `json:"domain"` + 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) + + _, err := os.Stat(tokenConfig) + if err != nil { + if os.IsNotExist(err) { + cfg, err := login(ctx, nil) + 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) + } + + data, err := os.ReadFile(tokenConfig) + if err != nil { + return nil, fmt.Errorf("failed to read login config: %w", err) + } + + 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 == nil { + // Using legacy token format, ask user to initiate a login to migrate. + fmt.Println("Re-login with `tcld login` to migrate to the new config format") + os.Exit(1) + } + + 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 { + 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 + 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 + } + + // Write file as 0600 because it contains private keys. + return os.WriteFile(filepath.Join(cfgDir, tokenConfigFile), []byte(data), 0600) +} diff --git a/app/user_test.go b/app/user_test.go index 29132585..a4b1ef59 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 3c213bcb..a2fbf456 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/go.mod b/go.mod index d1b1643c..f2a2f464 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,8 @@ require ( go.uber.org/fx v1.20.1 go.uber.org/multierr v1.11.0 golang.org/x/mod v0.12.0 - google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d + golang.org/x/oauth2 v0.15.0 + golang.org/x/term v0.15.0 google.golang.org/grpc v1.59.0 ) @@ -30,11 +31,11 @@ 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.14.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect - google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // 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/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 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 3244dbd2..ea1ad534 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,7 @@ 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.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -63,20 +64,23 @@ 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.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +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= 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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +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.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= @@ -87,13 +91,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.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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= -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/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= @@ -103,10 +110,8 @@ 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/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY= -google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +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/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 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) -}