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

Polish the login flow #11

Merged
merged 10 commits into from
Jan 25, 2021
Merged
20 changes: 11 additions & 9 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const (
deviceCodeEndpoint = "https://auth0.auth0.com/oauth/device/code"
oauthTokenEndpoint = "https://auth0.auth0.com/oauth/token"
// TODO(jfatta) extend the scope as we extend the CLI:
scope = "openid read:roles"
scope = "openid read:roles read:clients"
cyx marked this conversation as resolved.
Show resolved Hide resolved
audiencePath = "/api/v2/"
)

Expand All @@ -42,6 +42,7 @@ type Authenticator struct {

type Result struct {
Tenant string
Domain string
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We forgot to include this -- ultimately this is the parameter we thread through to the management.New call (e.g. https://github.com/go-auth0/auth0)

AccessToken string
ExpiresIn int64
}
Expand Down Expand Up @@ -106,14 +107,15 @@ func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) {
return Result{}, errors.New(res.ErrorDescription)
}

t, err := parseTenant(res.AccessToken)
ten, domain, err := parseTenant(res.AccessToken)
if err != nil {
return Result{}, fmt.Errorf("cannot parse tenant from the given access token: %w", err)
}
return Result{
AccessToken: res.AccessToken,
ExpiresIn: res.ExpiresIn,
Tenant: t,
Tenant: ten,
Domain: domain,
}, nil
}
}
Expand All @@ -139,27 +141,27 @@ func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) {
return res, nil
}

func parseTenant(accessToken string) (string, error) {
func parseTenant(accessToken string) (tenant, domain string, err error) {
parts := strings.Split(accessToken, ".")
v, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
return "", "", err
}
var payload struct {
AUDs []string `json:"aud"`
}
if err := json.Unmarshal([]byte(v), &payload); err != nil {
return "", err
return "", "", err
}
for _, aud := range payload.AUDs {
u, err := url.Parse(aud)
if err != nil {
return "", err
return "", "", err
}
if u.Path == audiencePath {
parts := strings.Split(u.Host, ".")
return parts[0], nil
return parts[0], u.Host, nil
}
}
return "", fmt.Errorf("audience not found for %s", audiencePath)
return "", "", fmt.Errorf("audience not found for %s", audiencePath)
}
171 changes: 128 additions & 43 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,111 +6,196 @@ import (
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"

"github.com/auth0/auth0-cli/internal/display"
"gopkg.in/auth0.v5/management"
)

type data struct {
// config defines the exact set of tenants, access tokens, which only exists
// for a particular user's machine.
type config struct {
Comment on lines +18 to +20
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In retrospect data didn't seem clear enough as a type name :)

DefaultTenant string `json:"default_tenant"`
Tenants map[string]tenant `json:"tenants"`
}

// tenant is the cli's concept of an auth0 tenant. The fields are tailor fit
// specifically for interacting with the management API.
type tenant struct {
Domain string `json:"domain"`

ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`

// TODO(cyx): This will be what we do with device flow.
BearerToken string `json:"bearer_token,omitempty"`
Name string `json:"name"`
Domain string `json:"domain"`
AccessToken string `json:"access_token,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using ExpiresAt yet -- but eventually we can use it to determine when to re-trigger a new login.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a backlog item so we don't lose track https://auth0team.atlassian.net/browse/A0CLI-9

}

// cli provides all the foundational things for all the commands in the CLI,
// specifically:
//
// 1. A management API instance (e.g. go-auth0/auth0)
// 2. A renderer (which provides ansi, coloring, etc).
//
// In addition, it stores a reference to all the flags passed, e.g.:
//
// 1. --format
// 2. --tenant
// 3. --verbose
//
type cli struct {
// core primitives exposed to command builders.
api *management.Management
renderer *display.Renderer

// set of flags which are user specified.
verbose bool
tenant string
format string

// config state management.
initOnce sync.Once
errOnce error
path string
data data
config config
}

// isLoggedIn encodes the domain logic for determining whether or not 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()

return c.tenant != ""
}

// setup will try to initialize the config context, as well as figure out if
// there's a readily available tenant. A management API SDK instance is initialized IFF:
//
// 1. A tenant is found.
// 2. The tenant has an access token.
func (c *cli) setup() error {
if err := c.init(); err != nil {
return err
}

t, err := c.getTenant()
if err != nil {
return err
}

if t.BearerToken != "" {
c.api, err = management.New(t.Domain,
management.WithStaticToken(t.BearerToken),
management.WithDebug(c.verbose))
} else {
if t.AccessToken != "" {
c.api, err = management.New(t.Domain,
management.WithClientCredentials(t.ClientID, t.ClientSecret),
management.WithStaticToken(t.AccessToken),
management.WithDebug(c.verbose))
}
Comment on lines -50 to 91
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally we had an if else for M2M or a static token. Given that we've made device flow work already, we can remove this until we decide we need it.


return err
}

// getTenant fetches the default tenant configured (or the tenant specified via
// the --tenant flag).
func (c *cli) getTenant() (tenant, error) {
if err := c.init(); err != nil {
return tenant{}, err
}

t, ok := c.data.Tenants[c.tenant]
t, ok := c.config.Tenants[c.tenant]
if !ok {
return tenant{}, fmt.Errorf("Unable to find tenant: %s", c.tenant)
}

return t, nil
}

func (c *cli) init() error {
var err error
c.initOnce.Do(func() {
if c.path == "" {
c.path = path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json")
}
// setTenant assigns an existing, or new tenant. This is expected to be called
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTenant is the meat of the PR. The rest of the stuff around it is cleanup and code polish.

// after a login has completed.
func (c *cli) setTenant(ten tenant) error {
// init will fail here with a `no tenant found` error if we're logging
// in for the first time and that's expected.
_ = c.init()

// If there's no existing DefaultTenant yet, might as well set the
// first successfully logged in tenant during onboarding.
if c.config.DefaultTenant == "" {
c.config.DefaultTenant = ten.Name
}

var buf []byte
if buf, err = ioutil.ReadFile(c.path); err != nil {
return
}
// 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{}
}

if err = json.Unmarshal(buf, &c.data); err != nil {
return
}
c.config.Tenants[ten.Name] = ten

if c.tenant == "" && c.data.DefaultTenant == "" {
err = fmt.Errorf("Not yet configured. Try `auth0 login`.")
return
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 c.tenant == "" {
c.tenant = c.data.DefaultTenant
if err := ioutil.WriteFile(c.path, buf, 0600); err != nil {
return err
}

return nil
}

func (c *cli) init() error {
c.initOnce.Do(func() {
// Initialize the context -- e.g. the configuration
// information, tenants, etc.
if c.errOnce = c.initContext(); c.errOnce != nil {
return
}
c.renderer.Tenant = c.tenant

// Determine what the desired output format is.
format := strings.ToLower(c.format)
if format != "" && format != string(display.OutputFormatJSON) {
err = fmt.Errorf("Invalid format. Use `--format=json` or omit this option to use the default format.")
c.errOnce = fmt.Errorf("Invalid format. Use `--format=json` or omit this option to use the default format.")
return
}

c.renderer = &display.Renderer{
Tenant: c.tenant,
MessageWriter: os.Stderr,
ResultWriter: os.Stdout,
Format: display.OutputFormat(format),
}
c.renderer.Format = display.OutputFormat(format)
Comment on lines -107 to +166
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we've already initialized a Renderer before, we don't really need to do that -- we just need to assign Format, Tenant, etc.

})

return err
// Once initialized, we'll keep returning the same err that was
// originally encountered.
return c.errOnce
Comment on lines +169 to +171
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug fix in the UX such that you'll occasionally get Unable to find tenant errors.

}

func (c *cli) initContext() (err error) {
if c.path == "" {
c.path = path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json")
woloski marked this conversation as resolved.
Show resolved Hide resolved
}

if _, err := os.Stat(c.path); os.IsNotExist(err) {
return fmt.Errorf("Not yet configured. Try `auth0 login`.")
}

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 fmt.Errorf("Not yet configured. Try `auth0 login`.")
}

if c.tenant == "" {
c.tenant = c.config.DefaultTenant
}

return nil
}
12 changes: 10 additions & 2 deletions internal/cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"time"

"github.com/auth0/auth0-cli/internal/ansi"
"github.com/auth0/auth0-cli/internal/auth"
Expand Down Expand Up @@ -42,10 +43,17 @@ func loginCmd(cli *cli) *cobra.Command {
return fmt.Errorf("login error: %w", err)
}

// TODO(jfatta): update the configuration with the token, tenant, audience, etc
cli.renderer.Infof("Successfully logged in.")
cli.renderer.Infof("Tenant: %s", res.Tenant)
return nil

return cli.setTenant(tenant{
Name: res.Tenant,
Domain: res.Domain,
AccessToken: res.AccessToken,
ExpiresAt: time.Now().Add(
time.Duration(res.ExpiresIn) * time.Second,
),
})
cyx marked this conversation as resolved.
Show resolved Hide resolved
},
}

Expand Down
14 changes: 8 additions & 6 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@ import (
"os"

"github.com/auth0/auth0-cli/internal/display"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)

// Execute is the primary entrypoint of the CLI app.
func Execute() {
// fs is a mock friendly os.File interface.
fs := afero.NewOsFs()

// cfg contains tenant related information, e.g. `travel0-dev`,
// `travel0-prod`. some of its information can be sourced via:
// 1. env var (e.g. AUTH0_API_KEY)
Expand All @@ -28,9 +24,15 @@ func Execute() {
SilenceUsage: true,
SilenceErrors: true,
Short: "Command-line tool to interact with Auth0.",
Long: "Command-line tool to interact with Auth0.\n" + getLogin(&fs, cli),
Long: "Command-line tool to interact with Auth0.\n" + getLogin(cli),

PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// If the user is trying to login, no need to go
// through setup.
if cmd.Use == "login" {
return nil
}

// Initialize everything once. Later callers can then
// freely assume that config is fully primed and ready
// to go.
Expand All @@ -40,7 +42,7 @@ func Execute() {

rootCmd.SetUsageTemplate(namespaceUsageTemplate())
rootCmd.PersistentFlags().StringVar(&cli.tenant,
"tenant", "", "Specific tenant to use.")
"tenant", cli.config.DefaultTenant, "Specific tenant to use.")

rootCmd.PersistentFlags().BoolVar(&cli.verbose,
"verbose", false, "Enable verbose mode.")
Expand Down
Loading