diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 000000000..4b9d82c53 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,165 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// 1st request +// curl --request POST \ +// --url 'https://auth0.auth0.com/oauth/device/code' \ +// --header 'content-type: application/x-www-form-urlencoded' \ +// --data 'client_id=2iZo3Uczt5LFHacKdM0zzgUO2eG2uDjT' \ +// --data 'scope=openid read:roles' \ +// --data audience=https://\*.auth0.com/api/v2/ + +// polling request +// curl --request POST \ +// --url 'https://auth0.auth0.com/oauth/token' \ +// --header 'content-type: application/x-www-form-urlencoded' \ +// --data grant_type=urn:ietf:params:oauth:grant-type:device_code \ +// --data device_code=9GtgUcsGKzXkU-i70RN74baY \ +// --data 'client_id=2iZo3Uczt5LFHacKdM0zzgUO2eG2uDjT' + +const ( + clientID = "2iZo3Uczt5LFHacKdM0zzgUO2eG2uDjT" + 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 read:roles" + audiencePath = "/api/v2/" +) + +type Authenticator struct { +} + +type Result struct { + Tenant string + AccessToken string + ExpiresIn int64 +} + +type State struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +func (s *State) IntervalDuration() time.Duration { + return time.Duration(s.Interval) * time.Second +} + +func (a *Authenticator) Start(ctx context.Context) (State, error) { + s, err := a.getDeviceCode(ctx) + if err != nil { + return State{}, fmt.Errorf("cannot get device code: %w", err) + } + return s, nil +} + +func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) { + t := time.NewTicker(state.IntervalDuration()) + for { + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + case <-t.C: + data := url.Values{ + "client_id": {clientID}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {state.DeviceCode}, + } + r, err := http.PostForm(oauthTokenEndpoint, data) + if err != nil { + return Result{}, fmt.Errorf("cannot get device code: %w", err) + } + defer r.Body.Close() + + var res struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + Scope string `json:"scope"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + Error *string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + } + + err = json.NewDecoder(r.Body).Decode(&res) + if err != nil { + return Result{}, fmt.Errorf("cannot decode response: %w", err) + } + + if res.Error != nil { + if *res.Error == "authorization_pending" { + continue + } + return Result{}, errors.New(res.ErrorDescription) + } + + t, err := parseTenant(res.AccessToken) + if err != nil { + return Result{}, fmt.Errorf("cannot parse tenant from the given access token: %w", err) + } + return Result{ + AccessToken: res.AccessToken, + ExpiresIn: res.ExpiresIn, + Tenant: t, + }, nil + } + } +} + +func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) { + data := url.Values{ + "client_id": {clientID}, + "scope": {scope}, + "audience": {"https://*.auth0.com/api/v2/"}, + } + r, err := http.PostForm(deviceCodeEndpoint, data) + if err != nil { + return State{}, fmt.Errorf("cannot get device code: %w", err) + } + defer r.Body.Close() + var res State + err = json.NewDecoder(r.Body).Decode(&res) + if err != nil { + return State{}, fmt.Errorf("cannot decode response: %w", err) + } + + return res, nil +} + +func parseTenant(accessToken string) (string, error) { + parts := strings.Split(accessToken, ".") + v, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + var payload struct { + AUDs []string `json:"aud"` + } + if err := json.Unmarshal([]byte(v), &payload); err != nil { + return "", err + } + for _, aud := range payload.AUDs { + u, err := url.Parse(aud) + if err != nil { + return "", err + } + if u.Path == audiencePath { + parts := strings.Split(u.Host, ".") + return parts[0], nil + } + } + return "", fmt.Errorf("audience not found for %s", audiencePath) +} diff --git a/internal/cli/login.go b/internal/cli/login.go new file mode 100644 index 000000000..af0f37cea --- /dev/null +++ b/internal/cli/login.go @@ -0,0 +1,53 @@ +package cli + +import ( + "fmt" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/open" + "github.com/spf13/cobra" +) + +func loginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "authenticate the Auth0 CLI.", + Long: "sign in to your Auth0 account and authorize the CLI to access the API", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cli.renderer.Heading("✪ Welcome to the Auht0 CLI 🎊.") + cli.renderer.Infof("To set it up, you will need to sign in to your Auth0 account and authorize the CLI to access the API.") + cli.renderer.Infof("If you don't have an account, please go to https://auth0.com/signup, otherwise continue in the browser.\n\n") + + a := &auth.Authenticator{} + state, err := a.Start(ctx) + if err != nil { + return fmt.Errorf("could not start the authentication process: %w.", err) + } + + cli.renderer.Infof("Your pairing code is: %s\n", ansi.Bold(state.UserCode)) + cli.renderer.Infof("This pairing code verifies your authentication with Auth0.") + cli.renderer.Infof("Press Enter to open the browser (^C to quit)") + fmt.Scanln() + + err = open.URL(state.VerificationURI) + if err != nil { + cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s.", state.VerificationURI) + } + + res, err := a.Wait(ctx, state) + if err != nil { + return fmt.Errorf("login error: %w", err) + } + + // TODO(jfatta): update the configuration with the token, tenant, audience, etc + cli.renderer.Infof("Successfully logged in.") + cli.renderer.Infof("Tenant: %s", res.Tenant) + return nil + }, + } + + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 359639a9e..716b3ba02 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,6 +1,7 @@ package cli import ( + "context" "os" "github.com/auth0/auth0-cli/internal/display" @@ -47,6 +48,7 @@ func Execute() { rootCmd.PersistentFlags().StringVar(&cli.format, "format", "", "Command output format. Options: json.") + rootCmd.AddCommand(loginCmd(cli)) rootCmd.AddCommand(clientsCmd(cli)) rootCmd.AddCommand(logsCmd(cli)) @@ -54,7 +56,7 @@ func Execute() { // rootCmd.AddCommand(actionsCmd(cli)) // rootCmd.AddCommand(triggersCmd(cli)) - if err := rootCmd.Execute(); err != nil { + if err := rootCmd.ExecuteContext(context.TODO()); err != nil { cli.renderer.Errorf(err.Error()) os.Exit(1) } diff --git a/internal/open/open.go b/internal/open/open.go new file mode 100644 index 000000000..8d53bc694 --- /dev/null +++ b/internal/open/open.go @@ -0,0 +1,23 @@ +package open + +import ( + "os/exec" + "runtime" +) + +func URL(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + return exec.Command(cmd, args...).Start() +}