Skip to content

Commit

Permalink
feat: device auth (#9)
Browse files Browse the repository at this point in the history
* feat: device auth

* fix:linter

* feat: parse tenant from the token

* fix: lint

* fix: scope const

* refactor: split auth flow (start + wait)

* "fix: error handling"
  • Loading branch information
jfatta authored Jan 22, 2021
1 parent 769c0f1 commit 3d453ad
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 1 deletion.
165 changes: 165 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -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)
}
53 changes: 53 additions & 0 deletions internal/cli/login.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 3 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"context"
"os"

"github.com/auth0/auth0-cli/internal/display"
Expand Down Expand Up @@ -47,14 +48,15 @@ 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))

// TODO(cyx): backport this later on using latest auth0/v5.
// 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)
}
Expand Down
23 changes: 23 additions & 0 deletions internal/open/open.go
Original file line number Diff line number Diff line change
@@ -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()
}

0 comments on commit 3d453ad

Please sign in to comment.