From 59cbd690a7c2d425ae884b3ac0369408ea8b828f Mon Sep 17 00:00:00 2001 From: "Jorge L. Fatta" Date: Fri, 26 Feb 2021 15:27:34 -0300 Subject: [PATCH 1/2] feat: renew expired access token --- internal/auth/auth.go | 20 ++-------- internal/cli/cli.go | 26 +++++++++++-- internal/cli/login.go | 87 +++++++++++++++++++++++++------------------ internal/cli/root.go | 2 +- 4 files changed, 77 insertions(+), 58 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cf7478044..c8b58f2c4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -12,22 +12,6 @@ import ( "time" ) -// 1st request -// curl --request POST \ -// --url 'https://auth0.auth0.com/oauth/device/code' \ -// --header 'content-type: application/x-www-form-urlencoded' \ -// --data 'client_id=2iZo3Uczt5LFHacKdM0zzgUO2eG2uDjT' \ -// --data 'scope=openid read:roles' \ -// --data audience=https://\*.auth0.com/api/v2/ - -// polling request -// curl --request POST \ -// --url 'https://auth0.auth0.com/oauth/token' \ -// --header 'content-type: application/x-www-form-urlencoded' \ -// --data grant_type=urn:ietf:params:oauth:grant-type:device_code \ -// --data device_code=9GtgUcsGKzXkU-i70RN74baY \ -// --data 'client_id=2iZo3Uczt5LFHacKdM0zzgUO2eG2uDjT' - const ( clientID = "2iZo3Uczt5LFHacKdM0zzgUO2eG2uDjT" deviceCodeEndpoint = "https://auth0.auth0.com/oauth/device/code" @@ -65,6 +49,9 @@ func (s *State) IntervalDuration() time.Duration { return time.Duration(s.Interval) * time.Second } +// Start kicks-off the device authentication flow +// by requesting a device code from Auth0, +// The returned state contains the URI for the next step of the flow. func (a *Authenticator) Start(ctx context.Context) (State, error) { s, err := a.getDeviceCode(ctx) if err != nil { @@ -73,6 +60,7 @@ func (a *Authenticator) Start(ctx context.Context) (State, error) { return s, nil } +// Wait waits until the user is logged in on the browser. func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) { t := time.NewTicker(state.IntervalDuration()) for { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a5c07808c..8a9d66473 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "errors" "fmt" @@ -21,7 +22,8 @@ import ( ) const ( - userAgent = "Auth0 CLI" + userAgent = "Auth0 CLI" + accessTokenExpThreshold = 5 // minutes ) // config defines the exact set of tenants, access tokens, which only exists @@ -87,7 +89,7 @@ func (c *cli) isLoggedIn() bool { // // 1. A tenant is found. // 2. The tenant has an access token. -func (c *cli) setup() error { +func (c *cli) setup(ctx context.Context) error { if err := c.init(); err != nil { return err } @@ -100,7 +102,19 @@ func (c *cli) setup() error { if t.AccessToken == "" { return errUnauthenticated - } else if t.AccessToken != "" { + } + + // check if the stored access token is expired: + if isExpired(t.ExpiresAt, accessTokenExpThreshold) { + // ask and guide the user through the login process: + err := RunLogin(ctx, c, true) + if err != nil { + return err + } + } + + // continue with the command setup: + if t.AccessToken != "" { m, err := management.New(t.Domain, management.WithStaticToken(t.AccessToken), management.WithDebug(c.debug), @@ -115,6 +129,11 @@ func (c *cli) setup() error { return err } +// isExpired is true if now() + a threshold in minutes is after the given date +func isExpired(t time.Time, threshold time.Duration) bool { + return time.Now().Add(time.Minute * threshold).After(t) +} + // getTenant fetches the default tenant configured (or the tenant specified via // the --tenant flag). func (c *cli) getTenant() (tenant, error) { @@ -201,7 +220,6 @@ func (c *cli) init() error { c.renderer.Tenant = c.tenant cobra.EnableCommandSorting = false - }) // Determine what the desired output format is. diff --git a/internal/cli/login.go b/internal/cli/login.go index b3ead4cb2..cfad76830 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "time" @@ -17,45 +18,57 @@ func loginCmd(cli *cli) *cobra.Command { Long: "sign in to your Auth0 account and authorize the CLI to access the API", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - - cli.renderer.Heading("✪ Welcome to the Auth0 CLI 🎊.") - cli.renderer.Infof("To set it up, you will need to sign in to your Auth0 account and authorize the CLI to access the API.") - cli.renderer.Infof("If you don't have an account, please go to https://auth0.com/signup, otherwise continue in the browser.\n\n") - - a := &auth.Authenticator{} - state, err := a.Start(ctx) - if err != nil { - return fmt.Errorf("could not start the authentication process: %w.", err) - } - - cli.renderer.Infof("Your pairing code is: %s\n", ansi.Bold(state.UserCode)) - cli.renderer.Infof("This pairing code verifies your authentication with Auth0.") - cli.renderer.Infof("Press Enter to open the browser (^C to quit)") - fmt.Scanln() - - err = open.URL(state.VerificationURI) - if err != nil { - cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s.", state.VerificationURI) - } - - res, err := a.Wait(ctx, state) - if err != nil { - return fmt.Errorf("login error: %w", err) - } - - cli.renderer.Infof("Successfully logged in.") - cli.renderer.Infof("Tenant: %s", res.Tenant) - - return cli.addTenant(tenant{ - Name: res.Tenant, - Domain: res.Domain, - AccessToken: res.AccessToken, - ExpiresAt: time.Now().Add( - time.Duration(res.ExpiresIn) * time.Second, - ), - }) + return RunLogin(ctx, cli, false) }, } return cmd } + +// 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) error { + if expired { + cli.renderer.Warnf("Your session expired. Please sign in to re-authorize the CLI.") + } else { + cli.renderer.Heading("✪ Welcome to the Auth0 CLI 🎊.") + cli.renderer.Infof("To set it up, you will need to sign in to your Auth0 account and authorize the CLI to access the API.") + cli.renderer.Infof("If you don't have an account, please go to https://auth0.com/signup, otherwise continue in the browser.\n\n") + } + + a := &auth.Authenticator{} + state, err := a.Start(ctx) + if err != nil { + return fmt.Errorf("could not start the authentication process: %w.", err) + } + + cli.renderer.Infof("Your pairing code is: %s\n", ansi.Bold(state.UserCode)) + cli.renderer.Infof("This pairing code verifies your authentication with Auth0.") + cli.renderer.Infof("Press Enter to open the browser (^C to quit)") + fmt.Scanln() + + err = open.URL(state.VerificationURI) + if err != nil { + cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s.", state.VerificationURI) + } + + res, err := a.Wait(ctx, state) + if err != nil { + return fmt.Errorf("login error: %w", err) + } + + cli.renderer.Infof("Successfully logged in.") + cli.renderer.Infof("Tenant: %s\n", res.Tenant) + + return cli.addTenant(tenant{ + Name: res.Tenant, + Domain: res.Domain, + AccessToken: res.AccessToken, + ExpiresAt: time.Now().Add( + time.Duration(res.ExpiresIn) * time.Second, + ), + }) + +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 47b15b6a3..62778e246 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -37,7 +37,7 @@ func Execute() { // Initialize everything once. Later callers can then // freely assume that config is fully primed and ready // to go. - return cli.setup() + return cli.setup(cmd.Context()) }, } From d625baa5239eda33d85148c64365211f55eea04e Mon Sep 17 00:00:00 2001 From: "Jorge L. Fatta" Date: Fri, 26 Feb 2021 16:02:47 -0300 Subject: [PATCH 2/2] tests --- internal/cli/cli.go | 6 +++--- internal/cli/cli_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8a9d66473..2dbdca376 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,7 +23,7 @@ import ( const ( userAgent = "Auth0 CLI" - accessTokenExpThreshold = 5 // minutes + accessTokenExpThreshold = 5 * time.Minute ) // config defines the exact set of tenants, access tokens, which only exists @@ -129,9 +129,9 @@ func (c *cli) setup(ctx context.Context) error { return err } -// isExpired is true if now() + a threshold in minutes is after the given date +// 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(time.Minute * threshold).After(t) + return time.Now().Add(threshold).After(t) } // getTenant fetches the default tenant configured (or the tenant specified via diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cf5c8e8bb..e9e75c778 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,11 +5,35 @@ import ( "fmt" "strings" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/olekukonko/tablewriter" ) +func TestIsExpired(t *testing.T) { + t.Run("is expired", func(t *testing.T) { + d := time.Date(2021, 01, 01, 10, 30, 30, 0, time.UTC) + if want, got := true, isExpired(d, 1*time.Minute); want != got { + t.Fatalf("wanted: %v, got %v", want, got) + } + }) + + t.Run("expired because of the threshold", func(t *testing.T) { + d := time.Now().Add(-2 * time.Minute) + if want, got := true, isExpired(d, 5*time.Minute); want != got { + t.Fatalf("wanted: %v, got %v", want, got) + } + }) + + t.Run("is not expired", func(t *testing.T) { + d := time.Now().Add(10 * time.Minute) + if want, got := false, isExpired(d, 5*time.Minute); want != got { + t.Fatalf("wanted: %v, got %v", want, got) + } + }) +} + // TODO(cyx): think about whether we should extract this function in the // `display` package. For now duplication might be better and less premature. func expectTable(t testing.TB, got string, header []string, data [][]string) {