From 9b2eaf59fab72639c8ee06e6ed3850824e3d30cd Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Mon, 18 Mar 2024 16:55:24 -0500 Subject: [PATCH 1/2] Work on cloud login command --- go.mod | 2 +- temporalcli/client.go | 2 +- temporalcli/commands.cloud_login.go | 166 ++++++++++++++++++++++++++++ temporalcli/commands.gen.go | 84 ++++++++++++++ temporalcli/commands.go | 97 ++++++++++++---- temporalcli/commandsmd/code.go | 3 + temporalcli/commandsmd/commands.md | 29 +++++ temporalcli/commandsmd/parse.go | 3 + 8 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 temporalcli/commands.cloud_login.go diff --git a/go.mod b/go.mod index 0287c5d26..42ab64920 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 replace go.temporal.io/api => go.temporal.io/api v1.26.1-0.20240123194300-5b253c84a3cc require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.16.0 github.com/google/uuid v1.5.0 @@ -38,7 +39,6 @@ require ( github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/coreos/go-oidc/v3 v3.1.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/felixge/httpsnoop v1.0.4 // indirect diff --git a/temporalcli/client.go b/temporalcli/client.go index 05c0199cd..2b2d0452e 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -74,7 +74,7 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) func (c *ClientOptions) tlsConfig() (*tls.Config, error) { // We need TLS if any of these TLS options are set - if !c.Tls && + if !c.Cloud && !c.Tls && c.TlsCaPath == "" && c.TlsCertPath == "" && c.TlsKeyPath == "" && c.TlsCaData == "" && c.TlsCertData == "" && c.TlsKeyData == "" { return nil, nil diff --git a/temporalcli/commands.cloud_login.go b/temporalcli/commands.cloud_login.go new file mode 100644 index 000000000..261e4051b --- /dev/null +++ b/temporalcli/commands.cloud_login.go @@ -0,0 +1,166 @@ +package temporalcli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/temporalio/cli/temporalcli/internal/printer" +) + +func (c *TemporalCloudLoginCommand) run(cctx *CommandContext, args []string) error { + // Set defaults + if c.Domain == "" { + c.Domain = "https://login.tmprl.cloud" + } + if c.Audience == "" { + c.Audience = "https://saas-api.tmprl.cloud" + } + if c.ClientId == "" { + c.ClientId = "d7V5bZMLCbRLfRVpqC567AqjAERaWHhl" + } + + // Get device code + var codeResp CloudOAuthDeviceCodeResponse + err := c.postToLogin( + cctx, + "oauth/device/code", + url.Values{"client_id": {c.ClientId}, "scope": {"openid profile user"}, "audience": {c.Audience}}, + &codeResp, + ) + if err != nil { + return fmt.Errorf("failed getting device code: %w", err) + } + + // Confirm URL same as domain URL + if domainURL, err := url.Parse(c.Domain); err != nil { + return fmt.Errorf("failed parsing domain URL: %w", err) + } else if verifURL, err := url.Parse(codeResp.VerificationURI); err != nil { + return fmt.Errorf("failed parsing verification URL: %w", err) + } else if domainURL.Hostname() != verifURL.Hostname() { + return fmt.Errorf("domain URL %q does not match verification URL %q in response", + domainURL.Hostname(), verifURL.Hostname()) + } + + if c.DisablePopUp { + cctx.Printer.Printlnf("Login via this URL: %v", codeResp.VerificationURI) + } else { + cctx.Printer.Printlnf("Attempting to open browser to: %v", codeResp.VerificationURI) + if err := cctx.openBrowser(codeResp.VerificationURI); err != nil { + cctx.Logger.Debug("Failed opening browser", "error", err) + cctx.Printer.Println("Failed opening browser, visit URL manually") + } + } + + // 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 + } + + // Poll for token + tokenResp, err := c.pollForToken(cctx, codeResp.DeviceCode, time.Duration(codeResp.Interval)*time.Second) + if err != nil { + return fmt.Errorf("failed polling for token response: %w", err) + } + if c.NoPersist { + return cctx.Printer.PrintStructured(tokenResp, printer.StructuredOptions{}) + } + if err := writeCloudLoginTokenFile(defaultCloudLoginTokenFile(), tokenResp); err != nil { + return fmt.Errorf("failed writing token file: %w", err) + } + cctx.Printer.Println("Login successful") + return nil +} + +func (c *TemporalCloudLogoutCommand) run(cctx *CommandContext, args []string) error { + // Set defaults + if c.Domain == "" { + c.Domain = "https://login.tmprl.cloud" + } + logoutURL := c.Domain + "/v2/logout" + if c.DisablePopUp { + cctx.Printer.Printlnf("Logout via this URL: %v", logoutURL) + } else { + cctx.Printer.Printlnf("Attempting to open browser to: %v", logoutURL) + if err := cctx.openBrowser(logoutURL); err != nil { + cctx.Logger.Debug("Failed opening browser", "error", err) + cctx.Printer.Println("Failed opening browser, visit URL manually") + } + } + return nil +} + +type CloudOAuthDeviceCodeResponse 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"` +} + +type CloudOAuthTokenResponse 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 (c *TemporalCloudLoginCommand) postToLogin(cctx *CommandContext, path string, form url.Values, resJSON any) error { + req, err := http.NewRequestWithContext(cctx, "POST", c.Domain, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.URL = req.URL.JoinPath(path) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } else if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("HTTP call failed, status: %v, body: %s", resp.StatusCode, b) + } + return json.Unmarshal(b, resJSON) +} + +func (c *TemporalCloudLoginCommand) pollForToken( + cctx *CommandContext, + deviceCode string, + interval time.Duration, +) (*CloudOAuthTokenResponse, error) { + var tokenResp CloudOAuthTokenResponse + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-cctx.Done(): + return nil, cctx.Err() + case <-ticker.C: + } + err := c.postToLogin( + cctx, + "oauth/token", + url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {c.ClientId}, + }, + &tokenResp, + ) + if err != nil { + return nil, err + } else if len(tokenResp.AccessToken) > 0 { + return &tokenResp, nil + } + } +} diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index b974826aa..1043386a0 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -36,6 +36,7 @@ func NewTemporalCommand(cctx *CommandContext) *TemporalCommand { s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewTemporalActivityCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalBatchCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalCloudCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalEnvCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalOperatorCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalServerCommand(cctx, &s).Command) @@ -255,6 +256,87 @@ func NewTemporalBatchTerminateCommand(cctx *CommandContext, parent *TemporalBatc return &s } +type TemporalCloudCommand struct { + Parent *TemporalCommand + Command cobra.Command +} + +func NewTemporalCloudCommand(cctx *CommandContext, parent *TemporalCommand) *TemporalCloudCommand { + var s TemporalCloudCommand + s.Parent = parent + s.Command.Use = "cloud" + s.Command.Short = "Manage Temporal Cloud." + s.Command.Long = "Commands to manage Temporal cloud." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewTemporalCloudLoginCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalCloudLogoutCommand(cctx, &s).Command) + return &s +} + +type TemporalCloudLoginCommand struct { + Parent *TemporalCloudCommand + Command cobra.Command + Domain string + Audience string + ClientId string + DisablePopUp bool + NoPersist bool +} + +func NewTemporalCloudLoginCommand(cctx *CommandContext, parent *TemporalCloudCommand) *TemporalCloudLoginCommand { + var s TemporalCloudLoginCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "login [flags]" + s.Command.Short = "Login as a cloud user." + if hasHighlighting { + s.Command.Long = "Login as a cloud user. This will open a browser to allow login. The token will then be used for all \x1b[1m--cloud\x1b[0m calls that\ndon't otherwise specify a \x1b[1m--api-key\x1b[0m or \x1b[1m--tls-*\x1b[0m options." + } else { + s.Command.Long = "Login as a cloud user. This will open a browser to allow login. The token will then be used for all `--cloud` calls that\ndon't otherwise specify a `--api-key` or `--tls-*` options." + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Domain, "domain", "", "Domain for login.") + s.Command.Flags().Lookup("domain").Hidden = true + s.Command.Flags().StringVar(&s.Audience, "audience", "", "Audience for login.") + s.Command.Flags().Lookup("audience").Hidden = true + s.Command.Flags().StringVar(&s.ClientId, "client-id", "", "Client ID for login.") + s.Command.Flags().Lookup("client-id").Hidden = true + s.Command.Flags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Disable the browser pop-up.") + s.Command.Flags().BoolVar(&s.NoPersist, "no-persist", false, "Show the generated token in output and do not persist to a config.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type TemporalCloudLogoutCommand struct { + Parent *TemporalCloudCommand + Command cobra.Command + Domain string + DisablePopUp bool +} + +func NewTemporalCloudLogoutCommand(cctx *CommandContext, parent *TemporalCloudCommand) *TemporalCloudLogoutCommand { + var s TemporalCloudLogoutCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "logout [flags]" + s.Command.Short = "Logout a cloud user." + s.Command.Long = "Logout a cloud user. This will open a browser to allow logout even if a login may not be present." + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Domain, "domain", "", "Domain for login.") + s.Command.Flags().Lookup("domain").Hidden = true + s.Command.Flags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Disable the browser pop-up.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type TemporalEnvCommand struct { Parent *TemporalCommand Command cobra.Command @@ -1224,6 +1306,7 @@ func NewTemporalTaskQueueUpdateBuildIdsPromoteSetCommand(cctx *CommandContext, p } type ClientOptions struct { + Cloud bool Address string Namespace string GrpcMeta []string @@ -1241,6 +1324,7 @@ type ClientOptions struct { } func (v *ClientOptions) buildFlags(cctx *CommandContext, f *pflag.FlagSet) { + f.BoolVar(&v.Cloud, "cloud", false, "Use Temporal Cloud. If present, namespace must be provided, address cannot be provided, TLS is assumed, and will use `cloud login` token unless API key or mTLS option present.") f.StringVar(&v.Address, "address", "127.0.0.1:7233", "Temporal server address.") cctx.BindFlagEnvVar(f.Lookup("address"), "TEMPORAL_ADDRESS") f.StringVarP(&v.Namespace, "namespace", "n", "default", "Temporal server namespace.") diff --git a/temporalcli/commands.go b/temporalcli/commands.go index c612a08e8..99e35165f 100644 --- a/temporalcli/commands.go +++ b/temporalcli/commands.go @@ -8,8 +8,10 @@ import ( "io" "log/slog" "os" + "os/exec" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "time" @@ -34,6 +36,29 @@ import ( // replaced at build time via ldflags. var Version = "0.0.0-DEV" +// Execute runs the Temporal CLI with the given context and options. This +// intentionally does not return an error but rather invokes Fail on the +// options. +func Execute(ctx context.Context, options CommandOptions) { + // Create context and run + cctx, cancel, err := NewCommandContext(ctx, options) + if err == nil { + defer cancel() + cmd := NewTemporalCommand(cctx) + cmd.Command.SetArgs(cctx.Options.Args) + err = cmd.Command.ExecuteContext(cctx) + } + + // Use failure handler, but can still return + if err != nil { + cctx.Options.Fail(err) + } + // If no command ever actually got run, exit nonzero + if !cctx.ActuallyRanCommand { + cctx.Options.Fail(fmt.Errorf("unknown command")) + } +} + type CommandContext struct { // This context is closed on interrupt context.Context @@ -304,27 +329,16 @@ func (c *CommandContext) promptString(message string, expected string, autoConfi return line == expected, nil } -// Execute runs the Temporal CLI with the given context and options. This -// intentionally does not return an error but rather invokes Fail on the -// options. -func Execute(ctx context.Context, options CommandOptions) { - // Create context and run - cctx, cancel, err := NewCommandContext(ctx, options) - if err == nil { - defer cancel() - cmd := NewTemporalCommand(cctx) - cmd.Command.SetArgs(cctx.Options.Args) - err = cmd.Command.ExecuteContext(cctx) - } - - // Use failure handler, but can still return - if err != nil { - cctx.Options.Fail(err) - } - // If no command ever actually got run, exit nonzero - if !cctx.ActuallyRanCommand { - cctx.Options.Fail(fmt.Errorf("unknown command")) +func (c *CommandContext) openBrowser(url string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() } + return fmt.Errorf("unrecognized OS") } func (c *TemporalCommand) initCommand(cctx *CommandContext) { @@ -461,6 +475,49 @@ func writeEnvConfigFile(file string, env map[string]map[string]string) error { return nil } +func defaultCloudLoginTokenFile() string { + // No env file if no $HOME + if dir, err := os.UserHomeDir(); err == nil { + return filepath.Join(dir, ".config/temporalio/cloud-token.json") + } + return "" +} + +// Both response and error can be nil if none found +func readCloudLoginTokenFile(file string) (*CloudOAuthTokenResponse, error) { + var resp CloudOAuthTokenResponse + if _, err := os.Stat(file); os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, err + } else if b, err := os.ReadFile(file); err != nil { + return nil, err + } else if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func writeCloudLoginTokenFile(file string, resp *CloudOAuthTokenResponse) error { + // Make parent directories as needed + b, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return err + } else if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + return err + } + return os.WriteFile(file, b, 0600) +} + +func deleteCloudLoginTokenFile(file string) error { + if _, err := os.Stat(file); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + return os.Remove(file) +} + func newNopLogger() *slog.Logger { return slog.New(discardLogHandler{}) } type discardLogHandler struct{} diff --git a/temporalcli/commandsmd/code.go b/temporalcli/commandsmd/code.go index 0e3e02375..3128e44e2 100644 --- a/temporalcli/commandsmd/code.go +++ b/temporalcli/commandsmd/code.go @@ -341,5 +341,8 @@ func (c *CommandOption) writeFlagBuilding(selfVar, flagVar string, w *codeWriter if c.EnvVar != "" { w.writeLinef("cctx.BindFlagEnvVar(%v.Lookup(%q), %q)", flagVar, c.Name, c.EnvVar) } + if c.Hidden { + w.writeLinef("%v.Lookup(%q).Hidden = true", flagVar, c.Name) + } return nil } diff --git a/temporalcli/commandsmd/commands.md b/temporalcli/commandsmd/commands.md index 5242bb225..95f0eecd8 100644 --- a/temporalcli/commandsmd/commands.md +++ b/temporalcli/commandsmd/commands.md @@ -28,6 +28,7 @@ This document has a specific structure used by a parser. Here are the rules: * `Default: .` - Sets the default value of the option. No default means zero value of the type. * `Options: