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

[A0CLI-4] feat: initial implementation of try-login #8

Merged
merged 3 commits into from
Jan 25, 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
2 changes: 1 addition & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
)

Expand Down
48 changes: 48 additions & 0 deletions internal/auth/exchange.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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},
}

u := url.URL{Scheme: "https", Host: baseDomain, Path: "/oauth/token"}
r, err := http.PostForm(u.String(), 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
}
61 changes: 61 additions & 0 deletions internal/auth/user_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package auth

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"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) {
endpoint := url.URL{Scheme: "https", Host: baseDomain, Path: "/userinfo"}

req, err := http.NewRequest("GET", endpoint.String(), 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
}
5 changes: 1 addition & 4 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
206 changes: 206 additions & 0 deletions internal/cli/try_login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package cli

import (
"context"
"fmt"
"net/http"
"net/url"
"time"

"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"
cyx marked this conversation as resolved.
Show resolved Hide resolved
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)

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) {
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("<p>&#10060; Unable to extract code from request, please try authenticating again</p>"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can also do fmt.Fprint(w, ...) to avoid the underscore checks IIRC.

} else {
_, _ = w.Write([]byte("<p>&#128075; You can close the window and go back to the CLI to see the user info and tokens</p>"))
}
codeCh <- authCode
})

go func() {
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()

select {
case code := <-codeCh:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

err := s.Shutdown(ctx)
return code, err
case err := <-errCh:
return "", err
}
}
Loading