aboutsummaryrefslogtreecommitdiffstats
path: root/client/go/cli/cli.go
diff options
context:
space:
mode:
Diffstat (limited to 'client/go/cli/cli.go')
-rw-r--r--client/go/cli/cli.go355
1 files changed, 355 insertions, 0 deletions
diff --git a/client/go/cli/cli.go b/client/go/cli/cli.go
new file mode 100644
index 00000000000..e1dde387b89
--- /dev/null
+++ b/client/go/cli/cli.go
@@ -0,0 +1,355 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/joeshaw/envdecode"
+ "github.com/pkg/browser"
+ "github.com/vespa-engine/vespa/client/go/util"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/jwt"
+ "github.com/vespa-engine/vespa/client/go/auth"
+)
+
+const accessTokenExpThreshold = 5 * time.Minute
+
+var errUnauthenticated = errors.New("not logged in. Try 'vespa login'")
+
+type config struct {
+ InstallID string `json:"install_id,omitempty"`
+ DefaultTenant string `json:"default_tenant"`
+ Tenants map[string]Tenant `json:"tenants"`
+}
+
+// Tenant is an auth0 Tenant.
+type Tenant struct {
+ Name string `json:"name"`
+ Domain string `json:"domain"`
+ AccessToken string `json:"access_token,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+}
+
+type Cli struct {
+ Authenticator *auth.Authenticator
+ tenant string
+ initOnce sync.Once
+ errOnce error
+ Path string
+ config config
+}
+
+// IsLoggedIn encodes the domain logic for determining whether we're
+// logged in. This might check our config storage, or just in memory.
+func (c *Cli) IsLoggedIn() bool {
+ // No need to check errors for initializing context.
+ _ = c.init()
+
+ if c.tenant == "" {
+ return false
+ }
+
+ // Parse the access token for the tenant.
+ t, err := jwt.ParseString(c.config.Tenants[c.tenant].AccessToken)
+ if err != nil {
+ return false
+ }
+
+ // Check if token is valid.
+ if err = jwt.Validate(t, jwt.WithIssuer("https://vespa-cd.auth0.com/")); err != nil {
+ return false
+ }
+
+ return true
+}
+
+// default to vespa-cd.auth0.com
+var (
+ authCfg struct {
+ Audience string `env:"AUTH0_AUDIENCE,default=https://vespa-cd.auth0.com/api/v2/"`
+ ClientID string `env:"AUTH0_CLIENT_ID,default=4wYWA496zBP28SLiz0PuvCt8ltL11DZX"`
+ DeviceCodeEndpoint string `env:"AUTH0_DEVICE_CODE_ENDPOINT,default=https://vespa-cd.auth0.com/oauth/device/code"`
+ OauthTokenEndpoint string `env:"AUTH0_OAUTH_TOKEN_ENDPOINT,default=https://vespa-cd.auth0.com/oauth/token"`
+ }
+)
+
+func ContextWithCancel() context.Context {
+ ctx, cancel := context.WithCancel(context.Background())
+ ch := make(chan os.Signal, 1)
+ signal.Notify(ch, os.Interrupt)
+ go func() {
+ <-ch
+ defer cancel()
+ os.Exit(0)
+ }()
+ return ctx
+}
+
+// Setup will try to initialize the config context, as well as figure out if
+// there's a readily available tenant.
+func GetCli(configPath string) (*Cli, error) {
+ c := Cli{}
+ c.Path = configPath
+ if err := envdecode.StrictDecode(&authCfg); err != nil {
+ return nil, fmt.Errorf("could not decode env: %w", err)
+ }
+ c.Authenticator = &auth.Authenticator{
+ Audience: authCfg.Audience,
+ ClientID: authCfg.ClientID,
+ DeviceCodeEndpoint: authCfg.DeviceCodeEndpoint,
+ OauthTokenEndpoint: authCfg.OauthTokenEndpoint,
+ }
+ return &c, nil
+}
+
+// prepareTenant loads the Tenant, refreshing its token if necessary.
+// The Tenant access token needs a refresh if:
+// 1. the Tenant scopes are different from the currently required scopes.
+// 2. the access token is expired.
+func (c *Cli) PrepareTenant(ctx context.Context) (Tenant, error) {
+ if err := c.init(); err != nil {
+ return Tenant{}, err
+ }
+ t, err := c.getTenant()
+ if err != nil {
+ return Tenant{}, err
+ }
+
+ if t.ClientID != "" && t.ClientSecret != "" {
+ return t, nil
+ }
+
+ if t.AccessToken == "" || scopesChanged(t) {
+ t, err = RunLogin(ctx, c, true)
+ if err != nil {
+ return Tenant{}, err
+ }
+ } else if isExpired(t.ExpiresAt, accessTokenExpThreshold) {
+ // check if the stored access token is expired:
+ // use the refresh token to get a new access token:
+ tr := &auth.TokenRetriever{
+ Authenticator: c.Authenticator,
+ Secrets: &auth.Keyring{},
+ Client: http.DefaultClient,
+ }
+
+ res, err := tr.Refresh(ctx, t.Domain)
+ if err != nil {
+ // ask and guide the user through the login process:
+ fmt.Println(fmt.Errorf("failed to renew access token, %s", err))
+ t, err = RunLogin(ctx, c, true)
+ if err != nil {
+ return Tenant{}, err
+ }
+ } else {
+ // persist the updated tenant with renewed access token
+ t.AccessToken = res.AccessToken
+ t.ExpiresAt = time.Now().Add(
+ time.Duration(res.ExpiresIn) * time.Second,
+ )
+
+ err = c.AddTenant(t)
+ if err != nil {
+ return Tenant{}, err
+ }
+ }
+ }
+
+ return t, nil
+}
+
+// isExpired is true if now() + a threshold is after the given date
+func isExpired(t time.Time, threshold time.Duration) bool {
+ return time.Now().Add(threshold).After(t)
+}
+
+// scopesChanged compare the Tenant scopes
+// with the currently required scopes.
+func scopesChanged(t Tenant) bool {
+ want := auth.RequiredScopes()
+ got := t.Scopes
+
+ sort.Strings(want)
+ sort.Strings(got)
+
+ if (want == nil) != (got == nil) {
+ return true
+ }
+
+ if len(want) != len(got) {
+ return true
+ }
+
+ for i := range t.Scopes {
+ if want[i] != got[i] {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (c *Cli) getTenant() (Tenant, error) {
+ if err := c.init(); err != nil {
+ return Tenant{}, err
+ }
+
+ t, ok := c.config.Tenants[c.tenant]
+ if !ok {
+ return Tenant{}, fmt.Errorf("unable to find tenant: %s; run 'vespa login' to configure a new tenant", c.tenant)
+ }
+
+ return t, nil
+}
+
+// AddTenant assigns an existing, or new Tenant. This is expected to be called
+// after a login has completed.
+func (c *Cli) AddTenant(ten Tenant) error {
+ _ = c.init()
+
+ if c.config.DefaultTenant == "" {
+ c.config.DefaultTenant = ten.Domain
+ }
+
+ // If we're dealing with an empty file, we'll need to initialize this map.
+ if c.config.Tenants == nil {
+ c.config.Tenants = map[string]Tenant{}
+ }
+
+ c.config.Tenants[ten.Domain] = ten
+
+ if err := c.persistConfig(); err != nil {
+ return fmt.Errorf("unexpected error persisting config: %w", err)
+ }
+
+ return nil
+}
+
+func (c *Cli) persistConfig() error {
+ dir := filepath.Dir(c.Path)
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+ }
+
+ buf, err := json.MarshalIndent(c.config, "", " ")
+ if err != nil {
+ return err
+ }
+
+ if err := ioutil.WriteFile(c.Path, buf, 0600); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Cli) init() error {
+ c.initOnce.Do(func() {
+ if c.errOnce = c.initContext(); c.errOnce != nil {
+ return
+ }
+ })
+ return c.errOnce
+}
+
+func (c *Cli) initContext() (err error) {
+ if _, err := os.Stat(c.Path); os.IsNotExist(err) {
+ return errUnauthenticated
+ }
+
+ var buf []byte
+ if buf, err = ioutil.ReadFile(c.Path); err != nil {
+ return err
+ }
+
+ if err := json.Unmarshal(buf, &c.config); err != nil {
+ return err
+ }
+
+ if c.tenant == "" && c.config.DefaultTenant == "" {
+ return errUnauthenticated
+ }
+
+ if c.tenant == "" {
+ c.tenant = c.config.DefaultTenant
+ }
+
+ return nil
+}
+
+// RunLogin runs the login flow guiding the user through the process
+// by showing the login instructions, opening the browser.
+// Use `expired` to run the login from other commands setup:
+// this will only affect the messages.
+func RunLogin(ctx context.Context, cli *Cli, expired bool) (Tenant, error) {
+ if expired {
+ fmt.Println("Please sign in to re-authorize the CLI.")
+ }
+
+ state, err := cli.Authenticator.Start(ctx)
+ if err != nil {
+ return Tenant{}, fmt.Errorf("could not start the authentication process: %w", err)
+ }
+
+ fmt.Printf("Your Device Confirmation code is: %s\n\n", state.UserCode)
+ fmt.Println("Press Enter to open the browser to log in or ^C to quit...")
+ fmt.Scanln()
+
+ err = browser.OpenURL(state.VerificationURI)
+
+ if err != nil {
+ fmt.Printf("Couldn't open the URL, please do it manually: %s.", state.VerificationURI)
+ }
+
+ var res auth.Result
+ err = util.Spinner("Waiting for login to complete in browser", func() error {
+ res, err = cli.Authenticator.Wait(ctx, state)
+ return err
+ })
+
+ if err != nil {
+ return Tenant{}, fmt.Errorf("login error: %w", err)
+ }
+
+ fmt.Print("\n")
+ fmt.Println("Successfully logged in.")
+ fmt.Print("\n")
+
+ // store the refresh token
+ secretsStore := &auth.Keyring{}
+ err = secretsStore.Set(auth.SecretsNamespace, res.Domain, res.RefreshToken)
+ if err != nil {
+ // log the error but move on
+ fmt.Println("Could not store the refresh token locally, please expect to login again once your access token expired.")
+ }
+
+ t := Tenant{
+ Name: res.Tenant,
+ Domain: res.Domain,
+ AccessToken: res.AccessToken,
+ ExpiresAt: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second),
+ Scopes: auth.RequiredScopes(),
+ }
+ err = cli.AddTenant(t)
+ if err != nil {
+ return Tenant{}, fmt.Errorf("could not add tenant to config: %w", err)
+ }
+
+ return t, nil
+}