Skip to content

Instantly share code, notes, and snippets.

@Integralist
Created November 24, 2022 09:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Integralist/91ebbc27690439687a804fa6860fd355 to your computer and use it in GitHub Desktop.
Save Integralist/91ebbc27690439687a804fa6860fd355 to your computer and use it in GitHub Desktop.
[CLI Device Authorization Flow with Auth0] #auth #auth0 #device #cli
package authenticate
// https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow
// https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-device-authorization-flow
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/fastly/cli/pkg/cmd"
"github.com/fastly/cli/pkg/config"
fsterr "github.com/fastly/cli/pkg/errors"
"github.com/fastly/cli/pkg/text"
)
// RootCommand is the parent command for all subcommands in this package.
// It should be installed under the primary root command.
type RootCommand struct {
cmd.Base
}
// Auth0DeviceCodeURL is the Auth0 device code URL.
const Auth0DeviceCodeURL = "https://<YOUR_DOMAIN>.us.auth0.com"
// Auth0ClientID is the Auth0 Client ID.
const Auth0ClientID = "<YOUR_CLIENT_ID>"
// Auth0Audience is the unique identifier of the API your app wants to access.
const Auth0Audience = "https://<YOUR_API>/"
// Auth0GrantType is an extension grant type (MUST be URL encoded).
var Auth0GrantType = url.QueryEscape("urn:ietf:params:oauth:grant-type:device_code")
// NewRootCommand returns a new command registered in the parent.
func NewRootCommand(parent cmd.Registerer, globals *config.Data) *RootCommand {
var c RootCommand
c.Globals = globals
c.CmdClause = parent.Command("authenticate", "Authenticate with Fastly (returns temporary, auto-rotated, API token)")
return &c
}
// Exec implements the command interface.
func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error {
deviceCodeResponse, err := getDeviceCode()
if err != nil {
return err
}
intro := "Please open the following URL and enter your user code: " + deviceCodeResponse.UserCode
text.Description(out, intro, deviceCodeResponse.VerificationURI)
var accessTokenResponse chan *AccessTokenResponse
interval := time.Duration(deviceCodeResponse.Interval) * time.Second
deviceCodeExpiration := time.Duration(deviceCodeResponse.ExpiresIn) * time.Second
go pollForAccessToken(
deviceCodeResponse.DeviceCode,
interval,
deviceCodeExpiration,
accessTokenResponse,
c.Globals.ErrLog,
)
select {
case atr := <-accessTokenResponse:
fmt.Printf("%+v\n", atr)
case <-time.After(deviceCodeExpiration):
return fsterr.RemediationError{
Inner: fmt.Errorf("user code expired"),
Remediation: "Please re-run the command and complete the authorization flow.",
}
}
return nil
}
// getDeviceCode retrieves a device code from Auth0.
func getDeviceCode() (deviceCodeResponse DeviceCodeResponse, err error) {
path := "/oauth/device/code"
// TODO: In the future we may want to restrict the API scope (see 'scope').
// https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-device-authorization-flow#device-code-parameters
payload := fmt.Sprintf("client_id=%s&audience=%s", Auth0ClientID, url.QueryEscape(Auth0Audience))
req, err := http.NewRequest("POST", Auth0DeviceCodeURL+path, strings.NewReader(payload))
if err != nil {
return deviceCodeResponse, err
}
req.Header.Add("content-type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)
if err != nil {
return deviceCodeResponse, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return deviceCodeResponse, err
}
err = json.Unmarshal(body, &deviceCodeResponse)
if err != nil {
return deviceCodeResponse, err
}
return deviceCodeResponse, nil
}
// DeviceCodeResponse is the API response for an Auth0 Device Code request.
type DeviceCodeResponse struct {
// DeviceCode is the unique code for the device.
DeviceCode string `json:"device_code"`
// ExpiresIn indicates the lifetime (in seconds) of the device_code and user_code.
ExpiresIn int `json:"expires_in"`
// Interval indicates the interval (in seconds) at which the app should poll the token URL to request a token.
Interval int `json:"interval"`
// UserCode contains the code that should be input at the verification_uri to authorize the device.
UserCode string `json:"user_code"`
// VerificationURI contains the URL the user should visit to authorize the device.
VerificationURI string `json:"verification_uri"`
// VerificationURIComplete contains the complete URL the user should visit to authorize the device.
VerificationURIComplete string `json:"verification_uri_complete"`
}
func pollForAccessToken(
deviceCode string,
interval time.Duration,
deviceCodeExpiration time.Duration,
accessTokenResponse chan *AccessTokenResponse,
errLog fsterr.LogInterface,
) {
path := "/oauth/token"
payload := fmt.Sprintf("grant_type=%s&device_code=%s&client_id=%s", Auth0GrantType, deviceCode, Auth0ClientID)
ctx := map[string]any{
"path": path,
"payload": payload,
}
req, err := http.NewRequest("POST", Auth0DeviceCodeURL+path, strings.NewReader(payload))
if err != nil {
errLog.AddWithContext(err, ctx)
return
}
req.Header.Add("content-type", "application/x-www-form-urlencoded")
ticker := time.NewTicker(interval)
defer ticker.Stop()
done := make(chan bool)
go func() {
time.Sleep(deviceCodeExpiration)
done <- true
}()
for {
select {
case <-done:
return
case <-ticker.C:
// NOTE: We extract the logic into a func to avoid a defer within a loop.
checkAccessToken(req, errLog, ctx, accessTokenResponse, done)
}
}
}
func checkAccessToken(
req *http.Request,
errLog fsterr.LogInterface,
ctx map[string]any,
accessTokenResponse chan *AccessTokenResponse,
done chan bool,
) {
// TODO: Handle all the different error scenarios appropriately.
// https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-device-authorization-flow#token-responses
res, err := http.DefaultClient.Do(req)
if err != nil {
errLog.AddWithContext(err, ctx)
return
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
errLog.AddWithContext(err, ctx)
return
}
var atr *AccessTokenResponse
err = json.Unmarshal(body, atr)
if err != nil {
errLog.AddWithContext(err, ctx)
return
}
done <- true
accessTokenResponse <- atr
}
// AccessTokenResponse is the API response for an Auth0 Access Token request.
type AccessTokenResponse struct {
// AccessToken can be exchanged for a Fastly API token.
AccessToken string `json:"access_token"`
// ExpiresIn indicates the lifetime (in seconds) of the access token.
ExpiresIn int `json:"expires_in"`
// IDToken contains user information that must be decoded and extracted.
IDToken string `json:"id_token"`
// RefreshToken is used to obtain a new Access Token or ID Token after the previous one has expired.
RefreshToken string `json:"refresh_token"`
// TokenType indicates which HTTP authentication scheme is used (e.g. Bearer).
TokenType string `json:"token_type"`
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment