aboutsummaryrefslogtreecommitdiffstats
path: root/client/go/auth0/auth0.go
diff options
context:
space:
mode:
Diffstat (limited to 'client/go/auth0/auth0.go')
-rw-r--r--client/go/auth0/auth0.go331
1 files changed, 331 insertions, 0 deletions
diff --git a/client/go/auth0/auth0.go b/client/go/auth0/auth0.go
new file mode 100644
index 00000000000..92bd1178fec
--- /dev/null
+++ b/client/go/auth0/auth0.go
@@ -0,0 +1,331 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package auth0
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/joeshaw/envdecode"
+ "github.com/lestrrat-go/jwx/jwt"
+ "github.com/pkg/browser"
+ "github.com/vespa-engine/vespa/client/go/auth"
+ "github.com/vespa-engine/vespa/client/go/util"
+)
+
+const accessTokenExpThreshold = 5 * time.Minute
+
+var errUnauthenticated = errors.New("not logged in. Try 'vespa login'")
+
+type config struct {
+ Systems map[string]System `json:"systems"`
+}
+
+type System struct {
+ AccessToken string `json:"access_token,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+type Auth0 struct {
+ Authenticator *auth.Authenticator
+ system string
+ initOnce sync.Once
+ errOnce error
+ Path string
+ config config
+}
+
+// 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
+}
+
+// GetAuth0 will try to initialize the config context, as well as figure out if
+// there's a readily available system.
+func GetAuth0(configPath string, systemName string) (*Auth0, error) {
+ a := Auth0{}
+ a.Path = configPath
+ a.system = systemName
+ if err := envdecode.StrictDecode(&authCfg); err != nil {
+ return nil, fmt.Errorf("could not decode env: %w", err)
+ }
+ a.Authenticator = &auth.Authenticator{
+ Audience: authCfg.Audience,
+ ClientID: authCfg.ClientID,
+ DeviceCodeEndpoint: authCfg.DeviceCodeEndpoint,
+ OauthTokenEndpoint: authCfg.OauthTokenEndpoint,
+ }
+ return &a, nil
+}
+
+// IsLoggedIn encodes the domain logic for determining whether we're
+// logged in. This might check our config storage, or just in memory.
+func (a *Auth0) IsLoggedIn() bool {
+ // No need to check errors for initializing context.
+ _ = a.init()
+
+ if a.system == "" {
+ return false
+ }
+
+ // Parse the access token for the system.
+ token, err := jwt.ParseString(a.config.Systems[a.system].AccessToken)
+ if err != nil {
+ return false
+ }
+
+ // Check if token is valid.
+ if err = jwt.Validate(token, jwt.WithIssuer("https://vespa-cd.auth0.com/")); err != nil {
+ return false
+ }
+
+ return true
+}
+
+// PrepareSystem loads the System, refreshing its token if necessary.
+// The System access token needs a refresh if:
+// 1. the System scopes are different from the currently required scopes - (auth0 changes).
+// 2. the access token is expired.
+func (a *Auth0) PrepareSystem(ctx context.Context) (System, error) {
+ if err := a.init(); err != nil {
+ return System{}, err
+ }
+ s, err := a.getSystem()
+ if err != nil {
+ return System{}, err
+ }
+
+ if s.AccessToken == "" || scopesChanged(s) {
+ s, err = RunLogin(ctx, a, true)
+ if err != nil {
+ return System{}, err
+ }
+ } else if isExpired(s.ExpiresAt, accessTokenExpThreshold) {
+ // check if the stored access token is expired:
+ // use the refresh token to get a new access token:
+ tr := &auth.TokenRetriever{
+ Authenticator: a.Authenticator,
+ Secrets: &auth.Keyring{},
+ Client: http.DefaultClient,
+ }
+
+ res, err := tr.Refresh(ctx, a.system)
+ if err != nil {
+ // ask and guide the user through the login process:
+ fmt.Println(fmt.Errorf("failed to renew access token, %s", err))
+ s, err = RunLogin(ctx, a, true)
+ if err != nil {
+ return System{}, err
+ }
+ } else {
+ // persist the updated system with renewed access token
+ s.AccessToken = res.AccessToken
+ s.ExpiresAt = time.Now().Add(
+ time.Duration(res.ExpiresIn) * time.Second,
+ )
+
+ err = a.AddSystem(s)
+ if err != nil {
+ return System{}, err
+ }
+ }
+ }
+
+ return s, 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 System scopes
+// with the currently required scopes.
+func scopesChanged(s System) bool {
+ want := auth.RequiredScopes()
+ got := s.Scopes
+
+ sort.Strings(want)
+ sort.Strings(got)
+
+ if (want == nil) != (got == nil) {
+ return true
+ }
+
+ if len(want) != len(got) {
+ return true
+ }
+
+ for i := range s.Scopes {
+ if want[i] != got[i] {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (a *Auth0) getSystem() (System, error) {
+ if err := a.init(); err != nil {
+ return System{}, err
+ }
+
+ s, ok := a.config.Systems[a.system]
+ if !ok {
+ return System{}, fmt.Errorf("unable to find system: %s; run 'vespa login' to configure a new system", a.system)
+ }
+
+ return s, nil
+}
+
+// AddSystem assigns an existing, or new System. This is expected to be called
+// after a login has completed.
+func (a *Auth0) AddSystem(s System) error {
+ _ = a.init()
+
+ // If we're dealing with an empty file, we'll need to initialize this map.
+ if a.config.Systems == nil {
+ a.config.Systems = map[string]System{}
+ }
+
+ a.config.Systems[a.system] = s
+
+ if err := a.persistConfig(); err != nil {
+ return fmt.Errorf("unexpected error persisting config: %w", err)
+ }
+
+ return nil
+}
+
+func (a *Auth0) persistConfig() error {
+ dir := filepath.Dir(a.Path)
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+ }
+
+ buf, err := json.MarshalIndent(a.config, "", " ")
+ if err != nil {
+ return err
+ }
+
+ if err := ioutil.WriteFile(a.Path, buf, 0600); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (a *Auth0) init() error {
+ a.initOnce.Do(func() {
+ if a.errOnce = a.initContext(); a.errOnce != nil {
+ return
+ }
+ })
+ return a.errOnce
+}
+
+func (a *Auth0) initContext() (err error) {
+ if _, err := os.Stat(a.Path); os.IsNotExist(err) {
+ return errUnauthenticated
+ }
+
+ var buf []byte
+ if buf, err = ioutil.ReadFile(a.Path); err != nil {
+ return err
+ }
+
+ if err := json.Unmarshal(buf, &a.config); err != nil {
+ return err
+ }
+
+ 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, a *Auth0, expired bool) (System, error) {
+ if expired {
+ fmt.Println("Please sign in to re-authorize the CLI.")
+ }
+
+ state, err := a.Authenticator.Start(ctx)
+ if err != nil {
+ return System{}, 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 = a.Authenticator.Wait(ctx, state)
+ return err
+ })
+
+ if err != nil {
+ return System{}, 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, a.system, 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.")
+ }
+
+ s := System{
+ AccessToken: res.AccessToken,
+ ExpiresAt: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second),
+ Scopes: auth.RequiredScopes(),
+ }
+ err = a.AddSystem(s)
+ if err != nil {
+ return System{}, fmt.Errorf("could not add system to config: %w", err)
+ }
+
+ return s, nil
+}