From 31c665b523890887cf2922250af6dfe24f196d41 Mon Sep 17 00:00:00 2001 From: Paddy Carey Date: Wed, 27 Jan 2021 13:04:17 +0000 Subject: [PATCH] feat: add get-token command for fetching user-facing tokens for an API --- internal/cli/get_token.go | 77 ++++++++++++++ internal/cli/root.go | 3 +- internal/cli/try_login.go | 190 +++++++++++++++++++++------------- internal/display/get_token.go | 49 +++++++++ 4 files changed, 245 insertions(+), 74 deletions(-) create mode 100644 internal/cli/get_token.go create mode 100644 internal/display/get_token.go diff --git a/internal/cli/get_token.go b/internal/cli/get_token.go new file mode 100644 index 000000000..8eac336e9 --- /dev/null +++ b/internal/cli/get_token.go @@ -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 +} diff --git a/internal/cli/root.go b/internal/cli/root.go index efe4ce838..c8487f78c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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.") @@ -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)) diff --git a/internal/cli/try_login.go b/internal/cli/try_login.go index 1ce26d58d..739971805 100644 --- a/internal/cli/try_login.go +++ b/internal/cli/try_login.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "github.com/auth0/auth0-cli/internal/ansi" @@ -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 { @@ -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 { @@ -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 } @@ -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. @@ -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, diff --git a/internal/display/get_token.go b/internal/display/get_token.go new file mode 100644 index 000000000..761a6a1cf --- /dev/null +++ b/internal/display/get_token.go @@ -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) + } +}