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

feat: add get-token command for fetching user-facing tokens for an API #69

Merged
merged 1 commit into from
Jan 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions internal/cli/get_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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,
"", // We don't want to force a prompt for get-token
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
190 changes: 117 additions & 73 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,31 @@ 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
"login", // force a login page when using try-login
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 +104,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, prompt 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, prompt, 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 +268,30 @@ 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, prompt 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 prompt != "" {
q.Add("prompt", prompt)
}

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