From b6fb068bf2ec58120b428b572262cb6f7eb00889 Mon Sep 17 00:00:00 2001 From: Paddy Carey Date: Fri, 22 Jan 2021 16:24:22 +0000 Subject: [PATCH] feat: initial implementation of try-login --- internal/auth/auth.go | 2 +- internal/auth/exchange.go | 47 ++++++++ internal/auth/user_info.go | 60 +++++++++++ internal/cli/root.go | 5 +- internal/cli/try_login.go | 195 ++++++++++++++++++++++++++++++++++ internal/display/try_login.go | 135 +++++++++++++++++++++++ 6 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 internal/auth/exchange.go create mode 100644 internal/auth/user_info.go create mode 100644 internal/cli/try_login.go create mode 100644 internal/display/try_login.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cf100a3c3..5653990d9 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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 create:actions create:clients create:resource_servers create:connections create:hooks create:rules delete:actions delete:clients delete:connections delete:hooks delete:rules read:actions read:clients read:resource_servers read:connections read:hooks read:logs read:rules update:actions update:clients update:resource_servers update:connections update:hooks update:rules" + scope = "openid create:actions create:clients create:resource_servers create:connections create:hooks create:rules delete:actions delete:clients delete:connections delete:hooks delete:rules read:actions read:clients read:client_keys read:resource_servers read:connections read:hooks read:logs read:rules update:actions update:clients update:resource_servers update:connections update:hooks update:rules" audiencePath = "/api/v2/" ) diff --git a/internal/auth/exchange.go b/internal/auth/exchange.go new file mode 100644 index 000000000..55be112bb --- /dev/null +++ b/internal/auth/exchange.go @@ -0,0 +1,47 @@ +package auth + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// TokenResponse stores token information as retrieved from the /oauth/token +// endpoint when exchanging a code. +type TokenResponse struct { + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` +} + +// ExchangeCodeForToken fetches an access token for the given client using the provided code. +func ExchangeCodeForToken(baseDomain, clientID, clientSecret, code, cbURL string) (*TokenResponse, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "redirect_uri": {cbURL}, + } + + r, err := http.PostForm(fmt.Sprintf("https://%s/oauth/token", baseDomain), data) + if err != nil { + return nil, fmt.Errorf("unable to exchange code for token: %w", err) + } + defer r.Body.Close() + + if r.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to exchange code for token: %s", r.Status) + } + + var res *TokenResponse + err = json.NewDecoder(r.Body).Decode(&res) + if err != nil { + return nil, fmt.Errorf("cannot decode response: %w", err) + } + + return res, nil +} diff --git a/internal/auth/user_info.go b/internal/auth/user_info.go new file mode 100644 index 000000000..13746771e --- /dev/null +++ b/internal/auth/user_info.go @@ -0,0 +1,60 @@ +package auth + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// UserInfo contains profile information for a given OIDC user. +type UserInfo struct { + Sub *string `json:"sub,omitempty"` + Name *string `json:"name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + MiddleName *string `json:"middle_name,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + Nickname *string `json:"nickname,omitempty"` + PreferredUsername *string `json:"preferred_username,omitempty"` + Profile *string `json:"profile,omitempty"` + Picture *string `json:"picture,omitempty"` + Website *string `json:"website,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` + PhoneVerified *bool `json:"phone_verified,omitempty"` + Email *string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + Gender *string `json:"gender,omitempty"` + BirthDate *string `json:"birthdate,omitempty"` + ZoneInfo *string `json:"zoneinfo,omitempty"` + Locale *string `json:"locale,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// FetchUserInfo fetches and parses user information with the provided access token. +func FetchUserInfo(baseDomain, token string) (*UserInfo, error) { + userInfoEndpoint := fmt.Sprintf("https://%s/userinfo", baseDomain) + + req, err := http.NewRequest("GET", userInfoEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("unable to exchange code for token: %w", err) + } + req.Header.Set("authorization", fmt.Sprintf("Bearer %s", token)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to exchange code for token: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to fetch user info: %s", res.Status) + } + + var u *UserInfo + err = json.NewDecoder(res.Body).Decode(&u) + if err != nil { + return nil, fmt.Errorf("cannot decode response: %w", err) + } + + return u, nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 5e7bd5b47..f5ea1b149 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -57,10 +57,7 @@ func Execute() { rootCmd.AddCommand(actionsCmd(cli)) rootCmd.AddCommand(rulesCmd(cli)) rootCmd.AddCommand(connectionsCmd(cli)) - - // TODO(cyx): backport this later on using latest auth0/v5. - // rootCmd.AddCommand(actionsCmd(cli)) - // rootCmd.AddCommand(triggersCmd(cli)) + rootCmd.AddCommand(tryLoginCmd(cli)) if err := rootCmd.ExecuteContext(context.TODO()); err != nil { cli.renderer.Errorf(err.Error()) diff --git a/internal/cli/try_login.go b/internal/cli/try_login.go new file mode 100644 index 000000000..f4aea5dc0 --- /dev/null +++ b/internal/cli/try_login.go @@ -0,0 +1,195 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/open" + "github.com/spf13/cobra" + "gopkg.in/auth0.v5" + "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" + cliLoginTestingScopes string = "openid profile" +) + +func tryLoginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "try-login", + Short: "Try out your universal login box", + Long: `$ auth0 try-login +Launch a browser to try out your universal login box for the given client. +`, + 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. + clientID, _ := cmd.Flags().GetString("client-id") + 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 + } + + // check if the client's initiate_login_uri matches the one for our + // "CLI Login Testing" app. If so, then initiate the login via the + // `/authorize` endpoint, if not, open a browser at the client's + // configured URL. If none is specified, return an error to the + // caller explaining the problem. + if client.GetInitiateLoginURI() == "" { + return fmt.Errorf( + "client %s does not specify a URL with which to initiate login", + client.GetClientID(), + ) + } + + if client.GetInitiateLoginURI() != cliLoginTestingInitiateLoginURI { + return open.URL(client.GetInitiateLoginURI()) + } + + // Build a login URL and initiate login in a browser window. + loginURL, err := buildInitiateLoginURL(tenant.Domain, client.GetClientID()) + 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) + } + + // 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 + } + + reveal, _ := cmd.Flags().GetBool("reveal") + cli.renderer.TryLogin(userInfo, tokenResponse, reveal) + return nil + }, + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.Flags().StringP("client-id", "c", "", "Client ID for which to test login.") + cmd.Flags().BoolP("reveal", "r", false, "⚠️ Reveal tokens after successful login.") + return cmd +} + +// 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 *management.ClientManager) (*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) +} + +// buildInitiateLoginURL constructs a URL + query string that can be used to +// initiate a login-flow from the CLI. +func buildInitiateLoginURL(domain, clientID 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) + + return fmt.Sprintf("https://%s%s?%s", domain, path, q.Encode()), 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) { + codeCh := make(chan string) + errCh := make(chan error) + + m := http.NewServeMux() + s := http.Server{Addr: cliLoginTestingCallbackAddr, Handler: m} + + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + authCode := r.URL.Query().Get("code") + if authCode == "" { + _, _ = 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

")) + } + codeCh <- authCode + }) + + go func() { + if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case code := <-codeCh: + err := s.Shutdown(context.Background()) + return code, err + case err := <-errCh: + return "", err + } +} diff --git a/internal/display/try_login.go b/internal/display/try_login.go new file mode 100644 index 000000000..6cf7cbfd5 --- /dev/null +++ b/internal/display/try_login.go @@ -0,0 +1,135 @@ +package display + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "time" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth" + "gopkg.in/auth0.v5" +) + +type userInfoAndTokens struct { + UserInfo *auth.UserInfo `json:"user_info"` + Tokens *auth.TokenResponse `json:"tokens"` +} + +func isNotZero(v interface{}) bool { + t := reflect.TypeOf(v) + if !t.Comparable() { + // assume non-zero if error + return true + } + return v != reflect.Zero(t).Interface() +} + +func (r *Renderer) TryLogin(u *auth.UserInfo, t *auth.TokenResponse, reveal bool) { + r.Heading(ansi.Bold(auth0.StringValue(u.Sub)), "/userinfo\n") + + if !reveal { + t.AccessToken = "[REDACTED]" + t.RefreshToken = "[REDACTED]" + t.IDToken = "[REDACTED]" + } + + switch r.Format { + case OutputFormatJSON: + out := &userInfoAndTokens{UserInfo: u, Tokens: t} + b, err := json.MarshalIndent(out, "", " ") + 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) + + // TODO: make this less verbose + if isNotZero(u.Name) { + rows = append(rows, []string{ansi.Faint("Name"), auth0.StringValue(u.Name)}) + } + if isNotZero(u.GivenName) { + rows = append(rows, []string{ansi.Faint("GivenName"), auth0.StringValue(u.GivenName)}) + } + if isNotZero(u.MiddleName) { + rows = append(rows, []string{ansi.Faint("MiddleName"), auth0.StringValue(u.MiddleName)}) + } + if isNotZero(u.FamilyName) { + rows = append(rows, []string{ansi.Faint("FamilyName"), auth0.StringValue(u.FamilyName)}) + } + if isNotZero(u.Nickname) { + rows = append(rows, []string{ansi.Faint("Nickname"), auth0.StringValue(u.Nickname)}) + } + if isNotZero(u.PreferredUsername) { + rows = append(rows, []string{ansi.Faint("PreferredUsername"), auth0.StringValue(u.PreferredUsername)}) + } + if isNotZero(u.Profile) { + rows = append(rows, []string{ansi.Faint("Profile"), auth0.StringValue(u.Profile)}) + } + if isNotZero(u.Picture) { + rows = append(rows, []string{ansi.Faint("Picture"), auth0.StringValue(u.Picture)}) + } + if isNotZero(u.Website) { + rows = append(rows, []string{ansi.Faint("Website"), auth0.StringValue(u.Website)}) + } + if isNotZero(u.PhoneNumber) { + rows = append(rows, []string{ansi.Faint("PhoneNumber"), auth0.StringValue(u.PhoneNumber)}) + } + if isNotZero(u.PhoneVerified) { + rows = append(rows, []string{ansi.Faint("PhoneVerified"), strconv.FormatBool(auth0.BoolValue(u.PhoneVerified))}) + } + if isNotZero(u.Email) { + rows = append(rows, []string{ansi.Faint("Email"), auth0.StringValue(u.Email)}) + } + if isNotZero(u.EmailVerified) { + rows = append(rows, []string{ansi.Faint("EmailVerified"), strconv.FormatBool(auth0.BoolValue(u.EmailVerified))}) + } + if isNotZero(u.Gender) { + rows = append(rows, []string{ansi.Faint("Gender"), auth0.StringValue(u.Gender)}) + } + if isNotZero(u.BirthDate) { + rows = append(rows, []string{ansi.Faint("BirthDate"), auth0.StringValue(u.BirthDate)}) + } + if isNotZero(u.ZoneInfo) { + rows = append(rows, []string{ansi.Faint("ZoneInfo"), auth0.StringValue(u.ZoneInfo)}) + } + if isNotZero(u.Locale) { + rows = append(rows, []string{ansi.Faint("Locale"), auth0.StringValue(u.Locale)}) + } + if isNotZero(u.UpdatedAt) { + rows = append(rows, []string{ansi.Faint("UpdatedAt"), auth0.TimeValue(u.UpdatedAt).Format(time.RFC3339)}) + } + if isNotZero(t.AccessToken) { + if !reveal { + t.AccessToken = ansi.Faint(t.AccessToken) + } + rows = append(rows, []string{ansi.Faint("AccessToken"), t.AccessToken}) + } + if isNotZero(t.RefreshToken) { + if !reveal { + t.RefreshToken = ansi.Faint(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) { + // if !reveal { + // t.IDToken = ansi.Faint(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) + } +}