Skip to content

Commit

Permalink
feat: add get-token command for fetching user-facing tokens for an API
Browse files Browse the repository at this point in the history
  • Loading branch information
Paddy Carey committed Jan 27, 2021
1 parent bac0b29 commit 0064b56
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 73 deletions.
76 changes: 76 additions & 0 deletions internal/cli/get_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"
)

func getTokenCmd(cli *cli) *cobra.Command {
var clientID string
var audience string
var scopes []string

cmd := &cobra.Command{
Use: "get-token",
Short: "fetch a token for the given client and API.",
Long: `$ auth0 get-token
Fetch an access token for the given client and API.
`,
RunE: func(cmd *cobra.Command, args []string) error {
tenant, err := cli.getTenant()
if err != nil {
return err
}

// use the client ID as passed in by the user, or default to the
// "CLI Login Testing" client if none passed. This client is only
// used for testing login from the CLI and will be created if it
// does not exist.
if clientID == "" {
client, err := getOrCreateCLITesterClient(cli.api.Client)
if err != nil {
return err
}
clientID = client.GetClientID()
}

client, err := cli.api.Client.Read(clientID)
if err != nil {
return err
}

// TODO: We can check here if the client is an m2m client, and if so
// initiate the client credentials flow instead to fetch a token,
// avoiding the browser and HTTP server shenanigans altogether.

abort, needsLocalCallbackURL := runLoginFlowPreflightChecks(cli, client)
if abort {
return nil
}

tokenResponse, err := runLoginFlow(
cli,
tenant,
client,
"", // specifying a connection is only supported for try-login
needsLocalCallbackURL,
audience,
scopes,
)
if err != nil {
return err
}

fmt.Fprint(cli.renderer.MessageWriter, "\n")
cli.renderer.GetToken(client, tokenResponse)
return nil
},
}

cmd.SetUsageTemplate(resourceUsageTemplate())
cmd.Flags().StringVarP(&clientID, "client-id", "c", "", "Client ID for which to fetch a token.")
cmd.Flags().StringVarP(&audience, "audience", "a", "", "The unique identifier of the target API you want to access.")
cmd.Flags().StringSliceVarP(&scopes, "scope", "s", []string{}, "Client ID for which to test login.")
return cmd
}
3 changes: 2 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func Execute() {

rootCmd.PersistentFlags().BoolVar(&cli.force,
"force", false, "Skip confirmation.")

rootCmd.PersistentFlags().BoolVar(&cli.noInput,
"no-input", false, "Disable interactivity.")

Expand All @@ -67,6 +67,7 @@ func Execute() {
rootCmd.AddCommand(completionCmd(cli))
rootCmd.AddCommand(rolesCmd(cli))
rootCmd.AddCommand(customDomainsCmd(cli))
rootCmd.AddCommand(getTokenCmd(cli))

// TODO(cyx): backport this later on using latest auth0/v5.
// rootCmd.AddCommand(actionsCmd(cli))
Expand Down
184 changes: 112 additions & 72 deletions internal/cli/try_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/auth0/auth0-cli/internal/ansi"
Expand All @@ -22,7 +23,10 @@ const (
cliLoginTestingCallbackAddr string = "localhost:8484"
cliLoginTestingCallbackURL string = "http://localhost:8484"
cliLoginTestingInitiateLoginURI string = "https://cli.auth0.com"
cliLoginTestingScopes string = "openid profile"
)

var (
cliLoginTestingScopes []string = []string{"openid", "profile"}
)

func tryLoginCmd(cli *cli) *cobra.Command {
Expand All @@ -37,7 +41,6 @@ Launch a browser to try out your universal login box for the given client.
`,
RunE: func(cmd *cobra.Command, args []string) error {
var userInfo *auth.UserInfo
var tokenResponse *auth.TokenResponse

tenant, err := cli.getTenant()
if err != nil {
Expand All @@ -61,81 +64,30 @@ Launch a browser to try out your universal login box for the given client.
return err
}

cli.renderer.Infof("A browser window will open to begin this client's login flow.")
cli.renderer.Infof("Once login is complete, you can return to the CLI to view user profile information and tokens.\n")

// check if the chosen client includes our local callback URL in its
// allowed list. If not we'll need to add it (after asking the user
// for permission).
needsLocalCallbackURL := !checkForLocalCallbackURL(client)
if needsLocalCallbackURL {
cli.renderer.Warnf("The client you are using does not currently allow callbacks to localhost.")
cli.renderer.Warnf("To complete the login flow the CLI needs to redirect logins to a local server and record the result.\n")
cli.renderer.Warnf("The client will be modified to update the allowed callback URLs, we'll remove them when done.")
cli.renderer.Warnf("If you do not wish to modify the client, you can abort now.\n")
}

if confirmed := prompt.Confirm("Do you wish to proceed?"); !confirmed {
abort, needsLocalCallbackURL := runLoginFlowPreflightChecks(cli, client)
if abort {
return nil
}
fmt.Fprint(cli.renderer.MessageWriter, "\n")

err = ansi.Spinner("Waiting for login flow to complete", func() error {
if needsLocalCallbackURL {
if err := addLocalCallbackURLToClient(cli.api.Client, client); err != nil {
return err
}
}

// Build a login URL and initiate login in a browser window.
loginURL, err := buildInitiateLoginURL(tenant.Domain, client.GetClientID(), connectionName)
if err != nil {
return err
}

if err := open.URL(loginURL); err != nil {
return err
}

// launch a HTTP server to wait for the callback to capture the auth
// code.
authCode, err := waitForBrowserCallback()
if err != nil {
return err
}

// once the callback is received, exchange the code for an access
// token.
tokenResponse, err = auth.ExchangeCodeForToken(
tenant.Domain,
client.GetClientID(),
client.GetClientSecret(),
authCode,
cliLoginTestingCallbackURL,
)
if err != nil {
return fmt.Errorf("%w", err)
}
tokenResponse, err := runLoginFlow(
cli,
tenant,
client,
connectionName,
needsLocalCallbackURL,
"", // audience is only supported for get-token
cliLoginTestingScopes,
)
if err != nil {
return err
}

if err := ansi.Spinner("Fetching user metadata", func() error {
// Use the access token to fetch user information from the /userinfo
// endpoint.
userInfo, err = auth.FetchUserInfo(tenant.Domain, tokenResponse.AccessToken)
if err != nil {
return err
}

// if we added the local callback URL to the client then we need to
// remove it when we're done
if needsLocalCallbackURL {
if err := removeLocalCallbackURLFromClient(cli.api.Client, client); err != nil {
return err
}
}

return nil
})

if err != nil {
return err
}); err != nil {
return err
}

Expand All @@ -151,6 +103,87 @@ Launch a browser to try out your universal login box for the given client.
return cmd
}

// runLoginFlowPreflightChecks checks if we need to make any updates to the
// client being tested, and asks the user to confirm whether to proceed.
func runLoginFlowPreflightChecks(cli *cli, c *management.Client) (bool, bool) {
cli.renderer.Infof("A browser window will open to begin this client's login flow.")
cli.renderer.Infof("Once login is complete, you can return to the CLI to view user profile information and tokens.\n")

// check if the chosen client includes our local callback URL in its
// allowed list. If not we'll need to add it (after asking the user
// for permission).
needsLocalCallbackURL := !checkForLocalCallbackURL(c)
if needsLocalCallbackURL {
cli.renderer.Warnf("The client you are using does not currently allow callbacks to localhost.")
cli.renderer.Warnf("To complete the login flow the CLI needs to redirect logins to a local server and record the result.\n")
cli.renderer.Warnf("The client will be modified to update the allowed callback URLs, we'll remove them when done.")
cli.renderer.Warnf("If you do not wish to modify the client, you can abort now.\n")
}

if confirmed := prompt.Confirm("Do you wish to proceed?"); !confirmed {
return true, needsLocalCallbackURL
}
fmt.Fprint(cli.renderer.MessageWriter, "\n")

return false, needsLocalCallbackURL
}

// runLoginFlow initiates a full user-facing login flow, waits for a response
// and returns the retrieved tokens to the caller when done.
func runLoginFlow(cli *cli, t tenant, c *management.Client, connName string, needsLocalCallbackURL bool, audience string, scopes []string) (*auth.TokenResponse, error) {
var tokenResponse *auth.TokenResponse

err := ansi.Spinner("Waiting for login flow to complete", func() error {
if needsLocalCallbackURL {
if err := addLocalCallbackURLToClient(cli.api.Client, c); err != nil {
return err
}
}

// Build a login URL and initiate login in a browser window.
loginURL, err := buildInitiateLoginURL(t.Domain, c.GetClientID(), connName, audience, scopes)
if err != nil {
return err
}

if err := open.URL(loginURL); err != nil {
return err
}

// launch a HTTP server to wait for the callback to capture the auth
// code.
authCode, err := waitForBrowserCallback()
if err != nil {
return err
}

// once the callback is received, exchange the code for an access
// token.
tokenResponse, err = auth.ExchangeCodeForToken(
t.Domain,
c.GetClientID(),
c.GetClientSecret(),
authCode,
cliLoginTestingCallbackURL,
)
if err != nil {
return fmt.Errorf("%w", err)
}

// if we added the local callback URL to the client then we need to
// remove it when we're done
if needsLocalCallbackURL {
if err := removeLocalCallbackURLFromClient(cli.api.Client, c); err != nil {
return err
}
}

return nil
})

return tokenResponse, err
}

// getOrCreateCLITesterClient uses the manage API to look for an existing client
// named `cliLoginTestingClientName`, and if it doesn't find one creates it with
// default settings.
Expand Down Expand Up @@ -234,20 +267,27 @@ func removeLocalCallbackURLFromClient(clientManager auth0.ClientAPI, client *man

// buildInitiateLoginURL constructs a URL + query string that can be used to
// initiate a login-flow from the CLI.
func buildInitiateLoginURL(domain, clientID, connectionName string) (string, error) {
func buildInitiateLoginURL(domain, clientID, connectionName, audience string, scopes []string) (string, error) {
var path string = "/authorize"

q := url.Values{}
q.Add("client_id", clientID)
q.Add("response_type", "code")
q.Add("prompt", "login")
q.Add("scope", cliLoginTestingScopes)
q.Add("redirect_uri", cliLoginTestingCallbackURL)

if connectionName != "" {
q.Add("connection", connectionName)
}

if audience != "" {
q.Add("audience", audience)
}

if len(scopes) > 0 {
q.Add("scope", strings.Join(scopes, " "))
}

u := &url.URL{
Scheme: "https",
Host: domain,
Expand Down
49 changes: 49 additions & 0 deletions internal/display/get_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package display

import (
"encoding/json"
"fmt"
"strconv"

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

func (r *Renderer) GetToken(c *management.Client, t *auth.TokenResponse) {
r.Heading(ansi.Bold(auth0.StringValue(c.Name)), "tokens\n")

switch r.Format {
case OutputFormatJSON:
b, err := json.MarshalIndent(t, "", " ")
if err != nil {
r.Errorf("couldn't marshal results as JSON: %v", err)
return
}
fmt.Fprint(r.ResultWriter, string(b))
default:
rows := make([][]string, 0)

if isNotZero(t.AccessToken) {
rows = append(rows, []string{ansi.Faint("AccessToken"), t.AccessToken})
}
if isNotZero(t.RefreshToken) {
rows = append(rows, []string{ansi.Faint("RefreshToken"), t.RefreshToken})
}
// TODO: This is a long string and it messes up formatting when printed
// to the table, so need to come back to this one and fix it later.
// if isNotZero(t.IDToken) {
// rows = append(rows, []string{ansi.Faint("IDToken"), t.IDToken})
// }
if isNotZero(t.TokenType) {
rows = append(rows, []string{ansi.Faint("TokenType"), t.TokenType})
}
if isNotZero(t.ExpiresIn) {
rows = append(rows, []string{ansi.Faint("ExpiresIn"), strconv.FormatInt(t.ExpiresIn, 10)})
}

tableHeader := []string{"Field", "Value"}
writeTable(r.ResultWriter, tableHeader, rows, nil)
}
}

0 comments on commit 0064b56

Please sign in to comment.