Skip to content

Commit

Permalink
Merge pull request #11 from auth0/polish-the-login-flow
Browse files Browse the repository at this point in the history
Polish the login flow
  • Loading branch information
cyx authored Jan 25, 2021
2 parents 5dbd056 + 6f91d54 commit 430935a
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 77 deletions.
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 read:logs"
audiencePath = "/api/v2/"
)

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

type Result struct {
Tenant string
Domain string
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 {
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"`
}

// 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))
}

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")
}
// 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 {
// 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)
})

return err
// Once initialized, we'll keep returning the same err that was
// originally encountered.
return c.errOnce
}

func (c *cli) initContext() (err error) {
if c.path == "" {
c.path = path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json")
}

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.addTenant(tenant{
Name: res.Tenant,
Domain: res.Domain,
AccessToken: res.AccessToken,
ExpiresAt: time.Now().Add(
time.Duration(res.ExpiresIn) * time.Second,
),
})
},
}

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

0 comments on commit 430935a

Please sign in to comment.