diff --git a/internal/auth/authutil/browser.go b/internal/auth/authutil/browser.go new file mode 100644 index 000000000..afed22280 --- /dev/null +++ b/internal/auth/authutil/browser.go @@ -0,0 +1,62 @@ +package authutil + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// WaitForBrowserCallback lauches a new HTTP server listening on the provided +// address and waits for a request. Once received, the code is extracted from +// the query string (if any), and returned it to the caller. +func WaitForBrowserCallback(addr string) (string, error) { + type callback struct { + code string + err string + errDescription string + } + + cbCh := make(chan *callback) + errCh := make(chan error) + + m := http.NewServeMux() + s := &http.Server{Addr: addr, Handler: m} + + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + cb := &callback{ + code: r.URL.Query().Get("code"), + err: r.URL.Query().Get("error"), + errDescription: r.URL.Query().Get("error_description"), + } + + if cb.code == "" { + _, _ = w.Write([]byte("

❌ Unable to extract code from request, please try authenticating again.

")) + } else { + _, _ = w.Write([]byte("

👋 You can close the window and go back to the CLI to see the user info and tokens.

")) + } + + cbCh <- cb + }) + + go func() { + if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case cb := <-cbCh: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + defer func(c context.Context) { _ = s.Shutdown(ctx) }(ctx) + + var err error + if cb.err != "" { + err = fmt.Errorf("%s: %s", cb.err, cb.errDescription) + } + return cb.code, err + case err := <-errCh: + return "", err + } +} diff --git a/internal/auth/exchange.go b/internal/auth/authutil/exchange.go similarity index 98% rename from internal/auth/exchange.go rename to internal/auth/authutil/exchange.go index 1643a48cd..d399d8417 100644 --- a/internal/auth/exchange.go +++ b/internal/auth/authutil/exchange.go @@ -1,4 +1,4 @@ -package auth +package authutil import ( "encoding/json" diff --git a/internal/auth/authutil/login.go b/internal/auth/authutil/login.go new file mode 100644 index 000000000..5df350d23 --- /dev/null +++ b/internal/auth/authutil/login.go @@ -0,0 +1,42 @@ +package authutil + +import ( + "net/url" + "strings" +) + +// BuildLoginURL constructs a URL + query string that can be used to +// initiate a user-facing login-flow from the CLI. +func BuildLoginURL(domain, clientID, callbackURL, 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("redirect_uri", callbackURL) + + 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, + Path: path, + RawQuery: q.Encode(), + } + + return u.String(), nil +} diff --git a/internal/auth/user_info.go b/internal/auth/authutil/user_info.go similarity index 99% rename from internal/auth/user_info.go rename to internal/auth/authutil/user_info.go index b411fd6cf..43ac8740d 100644 --- a/internal/auth/user_info.go +++ b/internal/auth/authutil/user_info.go @@ -1,4 +1,4 @@ -package auth +package authutil import ( "encoding/json" diff --git a/internal/cli/get_token.go b/internal/cli/get_token.go index 8eac336e9..e64ae82e4 100644 --- a/internal/cli/get_token.go +++ b/internal/cli/get_token.go @@ -44,8 +44,7 @@ Fetch an access token for the given client and API. // 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 { + if proceed := runLoginFlowPreflightChecks(cli, client); !proceed { return nil } @@ -54,7 +53,6 @@ Fetch an access token for the given client and API. 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, diff --git a/internal/cli/try_login.go b/internal/cli/try_login.go index a955a0a4c..27bd2401a 100644 --- a/internal/cli/try_login.go +++ b/internal/cli/try_login.go @@ -1,32 +1,11 @@ package cli import ( - "context" "fmt" - "net/http" - "net/url" - "strings" - "time" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth" - "github.com/auth0/auth0-cli/internal/auth0" - "github.com/auth0/auth0-cli/internal/open" - "github.com/auth0/auth0-cli/internal/prompt" + "github.com/auth0/auth0-cli/internal/auth/authutil" "github.com/spf13/cobra" - "gopkg.in/auth0.v5/management" -) - -const ( - cliLoginTestingClientName string = "CLI Login Testing" - cliLoginTestingClientDescription string = "A client used for testing logins using the Auth0 CLI." - cliLoginTestingCallbackAddr string = "localhost:8484" - cliLoginTestingCallbackURL string = "http://localhost:8484" - cliLoginTestingInitiateLoginURI string = "https://cli.auth0.com" -) - -var ( - cliLoginTestingScopes []string = []string{"openid", "profile"} ) func tryLoginCmd(cli *cli) *cobra.Command { @@ -40,7 +19,7 @@ func tryLoginCmd(cli *cli) *cobra.Command { 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 userInfo *authutil.UserInfo tenant, err := cli.getTenant() if err != nil { @@ -64,8 +43,7 @@ Launch a browser to try out your universal login box for the given client. return err } - abort, needsLocalCallbackURL := runLoginFlowPreflightChecks(cli, client) - if abort { + if proceed := runLoginFlowPreflightChecks(cli, client); !proceed { return nil } @@ -74,7 +52,6 @@ Launch a browser to try out your universal login box for the given client. tenant, client, connectionName, - needsLocalCallbackURL, "", // audience is only supported for get-token "login", // force a login page when using try-login cliLoginTestingScopes, @@ -86,7 +63,7 @@ Launch a browser to try out your universal login box for the given client. 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) + userInfo, err = authutil.FetchUserInfo(tenant.Domain, tokenResponse.AccessToken) return err }); err != nil { return err @@ -103,256 +80,3 @@ Launch a browser to try out your universal login box for the given client. cmd.Flags().StringVarP(&connectionName, "connection", "", "", "Connection to test during login.") 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. -func getOrCreateCLITesterClient(clientManager auth0.ClientAPI) (*management.Client, error) { - clients, err := clientManager.List() - if err != nil { - return nil, err - } - - for _, client := range clients.Clients { - if client.GetName() == cliLoginTestingClientName { - return client, nil - } - } - - // we couldn't find the default client, so let's create it - client := &management.Client{ - Name: auth0.String(cliLoginTestingClientName), - Description: auth0.String(cliLoginTestingClientDescription), - Callbacks: []interface{}{cliLoginTestingCallbackURL}, - InitiateLoginURI: auth0.String(cliLoginTestingInitiateLoginURI), - } - return client, clientManager.Create(client) -} - -// check if a client is already configured with our local callback URL -func checkForLocalCallbackURL(client *management.Client) bool { - for _, rawCallbackURL := range client.Callbacks { - callbackURL := rawCallbackURL.(string) - if callbackURL == cliLoginTestingCallbackURL { - return true - } - } - - return false -} - -// adds the localhost callback URL to a given client -func addLocalCallbackURLToClient(clientManager auth0.ClientAPI, client *management.Client) error { - for _, rawCallbackURL := range client.Callbacks { - callbackURL := rawCallbackURL.(string) - if callbackURL == cliLoginTestingCallbackURL { - return nil - } - } - - updatedClient := &management.Client{ - Callbacks: append(client.Callbacks, cliLoginTestingCallbackURL), - } - // reflect the changes in the original client instance so when we check it - // later it has the proper values in Callbacks - client.Callbacks = updatedClient.Callbacks - return clientManager.Update(client.GetClientID(), updatedClient) -} - -func removeLocalCallbackURLFromClient(clientManager auth0.ClientAPI, client *management.Client) error { - callbacks := []interface{}{} - for _, rawCallbackURL := range client.Callbacks { - callbackURL := rawCallbackURL.(string) - if callbackURL != cliLoginTestingCallbackURL { - callbacks = append(callbacks, callbackURL) - } - } - - // no callback URLs to remove, so don't attempt to do so - if len(client.Callbacks) == len(callbacks) { - return nil - } - - // can't update a client to have 0 callback URLs, so don't attempt it - if len(callbacks) == 0 { - return nil - } - - updatedClient := &management.Client{ - Callbacks: callbacks, - } - return clientManager.Update(client.GetClientID(), updatedClient) - -} - -// buildInitiateLoginURL constructs a URL + query string that can be used to -// initiate a login-flow from the CLI. -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("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, - Path: path, - RawQuery: q.Encode(), - } - - return u.String(), nil -} - -// waitForBrowserCallback lauches a new HTTP server listening on -// `cliLoginTestingCallbackAddr` and waits for a request. Once received, the -// `code` is extracted from the query string (if any), and returns it to the -// caller. -func waitForBrowserCallback() (string, error) { - type callback struct { - code string - err string - errDescription string - } - - cbCh := make(chan *callback) - errCh := make(chan error) - - m := http.NewServeMux() - s := http.Server{Addr: cliLoginTestingCallbackAddr, Handler: m} - - m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - cb := &callback{ - code: r.URL.Query().Get("code"), - err: r.URL.Query().Get("error"), - errDescription: r.URL.Query().Get("error_description"), - } - - if cb.code == "" { - _, _ = w.Write([]byte("

❌ Unable to extract code from request, please try authenticating again

")) - } else { - _, _ = w.Write([]byte("

👋 You can close the window and go back to the CLI to see the user info and tokens

")) - } - - cbCh <- cb - }) - - go func() { - if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errCh <- err - } - }() - - select { - case cb := <-cbCh: - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - defer func(c context.Context) { _ = s.Shutdown(ctx) }(ctx) - - var err error - if cb.err != "" { - err = fmt.Errorf("%s: %s", cb.err, cb.errDescription) - } - return cb.code, err - case err := <-errCh: - return "", err - } -} diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go new file mode 100644 index 000000000..0a58420dc --- /dev/null +++ b/internal/cli/utils_shared.go @@ -0,0 +1,185 @@ +package cli + +import ( + "fmt" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth/authutil" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/open" + "github.com/auth0/auth0-cli/internal/prompt" + "gopkg.in/auth0.v5/management" +) + +const ( + cliLoginTestingClientName string = "CLI Login Testing" + cliLoginTestingClientDescription string = "A client used for testing logins using the Auth0 CLI." + cliLoginTestingCallbackAddr string = "localhost:8484" + cliLoginTestingCallbackURL string = "http://localhost:8484" + cliLoginTestingInitiateLoginURI string = "https://cli.auth0.com" +) + +var ( + cliLoginTestingScopes []string = []string{"openid", "profile"} +) + +// runLoginFlowPreflightChecks checks if we need to make any updates to the +// client being tested in order to log in successfully. If so, it asks the user +// to confirm whether to proceed. +func runLoginFlowPreflightChecks(cli *cli, c *management.Client) (abort 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). + if !hasLocalCallbackURL(c) { + 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 false + } + fmt.Fprint(cli.renderer.MessageWriter, "\n") + + return true +} + +// 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, audience, prompt string, scopes []string) (*authutil.TokenResponse, error) { + var tokenResponse *authutil.TokenResponse + + err := ansi.Spinner("Waiting for login flow to complete", func() error { + callbackAdded, err := addLocalCallbackURLToClient(cli.api.Client, c) + if err != nil { + return err + } + + // Build a login URL and initiate login in a browser window. + loginURL, err := authutil.BuildLoginURL(t.Domain, c.GetClientID(), cliLoginTestingCallbackURL, 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 := authutil.WaitForBrowserCallback(cliLoginTestingCallbackAddr) + if err != nil { + return err + } + + // once the callback is received, exchange the code for an access + // token. + tokenResponse, err = authutil.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 callbackAdded { + 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. +func getOrCreateCLITesterClient(clientManager auth0.ClientAPI) (*management.Client, error) { + clients, err := clientManager.List() + if err != nil { + return nil, err + } + + for _, client := range clients.Clients { + if client.GetName() == cliLoginTestingClientName { + return client, nil + } + } + + // we couldn't find the default client, so let's create it + client := &management.Client{ + Name: auth0.String(cliLoginTestingClientName), + Description: auth0.String(cliLoginTestingClientDescription), + Callbacks: []interface{}{cliLoginTestingCallbackURL}, + InitiateLoginURI: auth0.String(cliLoginTestingInitiateLoginURI), + } + return client, clientManager.Create(client) +} + +// check if a client is already configured with our local callback URL +func hasLocalCallbackURL(client *management.Client) bool { + for _, rawCallbackURL := range client.Callbacks { + callbackURL := rawCallbackURL.(string) + if callbackURL == cliLoginTestingCallbackURL { + return true + } + } + + return false +} + +// adds the localhost callback URL to a given client +func addLocalCallbackURLToClient(clientManager auth0.ClientAPI, client *management.Client) (bool, error) { + for _, rawCallbackURL := range client.Callbacks { + callbackURL := rawCallbackURL.(string) + if callbackURL == cliLoginTestingCallbackURL { + return false, nil + } + } + + updatedClient := &management.Client{ + Callbacks: append(client.Callbacks, cliLoginTestingCallbackURL), + } + // reflect the changes in the original client instance so when we check it + // later it has the proper values in Callbacks + client.Callbacks = updatedClient.Callbacks + return true, clientManager.Update(client.GetClientID(), updatedClient) +} + +func removeLocalCallbackURLFromClient(clientManager auth0.ClientAPI, client *management.Client) error { + callbacks := []interface{}{} + for _, rawCallbackURL := range client.Callbacks { + callbackURL := rawCallbackURL.(string) + if callbackURL != cliLoginTestingCallbackURL { + callbacks = append(callbacks, callbackURL) + } + } + + // no callback URLs to remove, so don't attempt to do so + if len(client.Callbacks) == len(callbacks) { + return nil + } + + // can't update a client to have 0 callback URLs, so don't attempt it + if len(callbacks) == 0 { + return nil + } + + updatedClient := &management.Client{ + Callbacks: callbacks, + } + return clientManager.Update(client.GetClientID(), updatedClient) + +} diff --git a/internal/display/get_token.go b/internal/display/get_token.go index 761a6a1cf..76fcc6e13 100644 --- a/internal/display/get_token.go +++ b/internal/display/get_token.go @@ -6,12 +6,12 @@ import ( "strconv" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/auth/authutil" "github.com/auth0/auth0-cli/internal/auth0" "gopkg.in/auth0.v5/management" ) -func (r *Renderer) GetToken(c *management.Client, t *auth.TokenResponse) { +func (r *Renderer) GetToken(c *management.Client, t *authutil.TokenResponse) { r.Heading(ansi.Bold(auth0.StringValue(c.Name)), "tokens\n") switch r.Format { diff --git a/internal/display/try_login.go b/internal/display/try_login.go index c08bd5131..087155701 100644 --- a/internal/display/try_login.go +++ b/internal/display/try_login.go @@ -8,13 +8,13 @@ import ( "time" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/auth/authutil" "github.com/auth0/auth0-cli/internal/auth0" ) type userInfoAndTokens struct { - UserInfo *auth.UserInfo `json:"user_info"` - Tokens *auth.TokenResponse `json:"tokens"` + UserInfo *authutil.UserInfo `json:"user_info"` + Tokens *authutil.TokenResponse `json:"tokens"` } func isNotZero(v interface{}) bool { @@ -26,7 +26,7 @@ func isNotZero(v interface{}) bool { return v != reflect.Zero(t).Interface() } -func (r *Renderer) TryLogin(u *auth.UserInfo, t *auth.TokenResponse) { +func (r *Renderer) TryLogin(u *authutil.UserInfo, t *authutil.TokenResponse) { r.Heading(ansi.Bold(auth0.StringValue(u.Sub)), "/userinfo\n") switch r.Format {