diff --git a/temporalcli/client.go b/temporalcli/client.go index 9fe3f73b..ee94ca11 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -4,11 +4,14 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/base64" + "encoding/json" "fmt" "net/http" "os" "os/user" "strings" + "time" "go.temporal.io/api/common/v1" "go.temporal.io/sdk/client" @@ -37,6 +40,13 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) clientOptions.Credentials = client.NewAPIKeyStaticCredentials(c.ApiKey) } + // Cloud options + if c.Cloud { + if err := c.applyCloudOptions(cctx, &clientOptions); err != nil { + return nil, err + } + } + // Headers if len(c.GrpcMeta) > 0 { headers := make(stringMapHeadersProvider, len(c.GrpcMeta)) @@ -77,9 +87,62 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) return client.Dial(clientOptions) } +func (c *ClientOptions) applyCloudOptions(cctx *CommandContext, clientOptions *client.Options) error { + // Must have non-default namespace with single dot + if strings.Count(c.Namespace, ".") != 1 { + return fmt.Errorf("namespace must be provided and be a cloud namespace") + } + // Address must have been left at default or be expected address + // TODO(cretz): This endpoint is not currently working + clientOptions.HostPort = c.Namespace + ".tmprl.cloud:7233" + if c.Address != "127.0.0.1:7233" && c.Address != clientOptions.HostPort { + return fmt.Errorf("address should not be provided for cloud") + } + // If there is no API key and no TLS auth, try to use login token or fail + if c.ApiKey == "" && c.TlsCertData == "" && c.TlsCertPath == "" { + file := defaultCloudLoginTokenFile() + if file == "" { + return fmt.Errorf("no auth provided and unable to find home dir for cloud token file") + } + resp, err := readCloudLoginTokenFile(file) + if err != nil { + return fmt.Errorf("failed reading cloud token file: %w", err) + } else if resp == nil { + return fmt.Errorf("no auth provided and no cloud token present") + } + // Help the user out with a simple expiration check, but never fail if + // unable to parse + if t := getJWTExpiry(resp.AccessToken); !t.IsZero() { + if t.Before(time.Now()) { + cctx.Logger.Warn("Cloud token expired", "expiration", t) + } else { + cctx.Logger.Debug("Cloud token expires", "expiration", t) + } + } + // TODO(cretz): Use gRPC OAuth creds with refresh token + clientOptions.Credentials = client.NewAPIKeyStaticCredentials(resp.AccessToken) + } + return nil +} + +// Zero time if unable to get +func getJWTExpiry(token string) time.Time { + if tokenPieces := strings.Split(token, "."); len(tokenPieces) == 3 { + if b, err := base64.RawURLEncoding.DecodeString(tokenPieces[1]); err == nil { + var withExp struct { + Exp int64 `json:"exp"` + } + if json.Unmarshal(b, &withExp) == nil && withExp.Exp > 0 { + return time.Unix(withExp.Exp, 0) + } + } + } + return time.Time{} +} + 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 00000000..3e3f36df --- /dev/null +++ b/temporalcli/commands.cloud_login.go @@ -0,0 +1,185 @@ +package temporalcli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/temporalio/cli/temporalcli/internal/printer" +) + +func (c *TemporalCloudLoginCommand) run(cctx *CommandContext, _ []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.VerificationURIComplete) + } else { + cctx.Printer.Printlnf("Attempting to open browser to: %v", codeResp.VerificationURIComplete) + if err := cctx.openBrowser(codeResp.VerificationURIComplete); 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{}) + } else if file := defaultCloudLoginTokenFile(); file == "" { + return fmt.Errorf("unable to find home directory for token file") + } else if err := writeCloudLoginTokenFile(file, 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, _ []string) error { + // Set defaults + if c.Domain == "" { + c.Domain = "https://login.tmprl.cloud" + } + // Delete file then do browser logout + if file := defaultCloudLoginTokenFile(); file != "" { + if err := deleteCloudLoginTokenFile(file); err != nil { + return fmt.Errorf("failed deleting cloud token: %w", err) + } + } + 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, + allowedStatusCodes ...int, +) error { + req, err := http.NewRequestWithContext( + cctx, + "POST", + strings.TrimRight(c.Domain, "/")+"/"+strings.TrimLeft(path, "/"), + strings.NewReader(form.Encode())) + if err != nil { + return err + } + 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 && !slices.Contains(allowedStatusCodes, resp.StatusCode) { + 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, + // 403 is returned while polling + http.StatusForbidden, + ) + 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 fe4ffc2a..ac7009c5 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 ApiKey string @@ -1242,6 +1325,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 c612a08e..a7198a52 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,51 @@ func writeEnvConfigFile(file string, env map[string]map[string]string) error { return nil } +// This can be empty if no user HOME dir found +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) +} + +// Does not error if file does not exist +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 0e3e0237..3128e44e 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 f72d9e9a..1479593a 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: