Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloud Login #501

Draft
wants to merge 3 commits into
base: cli-rewrite
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion temporalcli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
185 changes: 185 additions & 0 deletions temporalcli/commands.cloud_login.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
84 changes: 84 additions & 0 deletions temporalcli/commands.gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1224,6 +1306,7 @@ func NewTemporalTaskQueueUpdateBuildIdsPromoteSetCommand(cctx *CommandContext, p
}

type ClientOptions struct {
Cloud bool
Address string
Namespace string
ApiKey string
Expand All @@ -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.")
Expand Down
Loading
Loading