github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/auth/auth.go (about)

     1  package auth
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/http/httputil"
    11  	"strconv"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/hashicorp/cap/jwt"
    16  	"github.com/hashicorp/cap/oidc"
    17  
    18  	"github.com/fastly/cli/pkg/api"
    19  	"github.com/fastly/cli/pkg/api/undocumented"
    20  	"github.com/fastly/cli/pkg/config"
    21  	fsterr "github.com/fastly/cli/pkg/errors"
    22  )
    23  
    24  // Remediation is a generic remediation message for an error authorizing.
    25  const Remediation = "Please re-run the command. If the problem persists, please file an issue: https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md"
    26  
    27  // ClientID is the auth provider's Client ID.
    28  const ClientID = "fastly-cli"
    29  
    30  // RedirectURL is the endpoint the auth provider will pass an authorization code to.
    31  const RedirectURL = "http://localhost:8080/callback"
    32  
    33  // OIDCMetadata is OpenID Connect's metadata discovery mechanism.
    34  // https://swagger.io/docs/specification/authentication/openid-connect-discovery/
    35  const OIDCMetadata = "%s/realms/fastly/.well-known/openid-configuration"
    36  
    37  // WellKnownEndpoints represents the OpenID Connect metadata.
    38  type WellKnownEndpoints struct {
    39  	// Auth is the authorization_endpoint.
    40  	Auth string `json:"authorization_endpoint"`
    41  	// Certs is the jwks_uri.
    42  	Certs string `json:"jwks_uri"`
    43  	// Token is the token_endpoint.
    44  	Token string `json:"token_endpoint"`
    45  }
    46  
    47  // Runner defines the behaviour for the authentication server.
    48  type Runner interface {
    49  	// AuthURL returns a fully qualified authorization_endpoint.
    50  	// i.e. path + audience + scope + code_challenge etc.
    51  	AuthURL() (string, error)
    52  	// GetResult returns the results channel
    53  	GetResult() chan AuthorizationResult
    54  	// RefreshAccessToken constructs and calls the token_endpoint with the
    55  	// refresh token so we can refresh and return the access token.
    56  	RefreshAccessToken(refreshToken string) (JWT, error)
    57  	// Start starts a local server for handling authentication processing.
    58  	Start() error
    59  	// ValidateAndRetrieveAPIToken verifies the signature and the claims and
    60  	// exchanges the access token for an API token.
    61  	ValidateAndRetrieveAPIToken(accessToken string) (string, *APIToken, error)
    62  }
    63  
    64  // Server is a local server responsible for authentication processing.
    65  type Server struct {
    66  	// APIEndpoint is the API endpoint.
    67  	APIEndpoint string
    68  	// AccountEndpoint is the accounts endpoint.
    69  	AccountEndpoint string
    70  	// DebugMode indicates to the CLI it can display debug information.
    71  	DebugMode string
    72  	// HTTPClient is a HTTP client used to call the API to exchange the access token for a session token.
    73  	HTTPClient api.HTTPClient
    74  	// Result is a channel that reports the result of authorization.
    75  	Result chan AuthorizationResult
    76  	// Router is an HTTP request multiplexer.
    77  	Router *http.ServeMux
    78  	// Verifier represents an OAuth PKCE code verifier that uses the S256 challenge method.
    79  	Verifier *oidc.S256Verifier
    80  	// WellKnownEndpoints is the .well-known metadata.
    81  	WellKnownEndpoints WellKnownEndpoints
    82  }
    83  
    84  // AuthURL returns a fully qualified authorization_endpoint.
    85  // i.e. path + audience + scope + code_challenge etc.
    86  func (s Server) AuthURL() (string, error) {
    87  	challenge, err := oidc.CreateCodeChallenge(s.Verifier)
    88  	if err != nil {
    89  		return "", err
    90  	}
    91  
    92  	authorizationURL := fmt.Sprintf(
    93  		"%s?audience=%s"+
    94  			"&scope=openid"+
    95  			"&response_type=code&client_id=%s"+
    96  			"&code_challenge=%s"+
    97  			"&code_challenge_method=S256&redirect_uri=%s",
    98  		s.WellKnownEndpoints.Auth, s.APIEndpoint, ClientID, challenge, RedirectURL)
    99  
   100  	return authorizationURL, nil
   101  }
   102  
   103  // GetResult returns the result channel.
   104  func (s Server) GetResult() chan AuthorizationResult {
   105  	return s.Result
   106  }
   107  
   108  // GetJWT constructs and calls the token_endpoint path, returning a JWT
   109  // containing the access and refresh tokens and associated TTLs.
   110  func (s Server) GetJWT(authorizationCode string) (JWT, error) {
   111  	payload := fmt.Sprintf(
   112  		"grant_type=authorization_code&client_id=%s&code_verifier=%s&code=%s&redirect_uri=%s",
   113  		ClientID,
   114  		s.Verifier.Verifier(),
   115  		authorizationCode,
   116  		"http://localhost:8080/callback", // NOTE: not redirected to, just a security check.
   117  	)
   118  
   119  	req, err := http.NewRequest("POST", s.WellKnownEndpoints.Token, strings.NewReader(payload))
   120  	if err != nil {
   121  		return JWT{}, err
   122  	}
   123  	req.Header.Add("content-type", "application/x-www-form-urlencoded")
   124  
   125  	debug, _ := strconv.ParseBool(s.DebugMode)
   126  	if debug {
   127  		rc := req.Clone(context.Background())
   128  		rc.Header.Set("Fastly-Key", "REDACTED")
   129  		dump, _ := httputil.DumpRequest(rc, true)
   130  		fmt.Printf("GetJWT request dump:\n\n%#v\n\n", string(dump))
   131  	}
   132  
   133  	res, err := http.DefaultClient.Do(req)
   134  
   135  	if debug && res != nil {
   136  		dump, _ := httputil.DumpResponse(res, true)
   137  		fmt.Printf("GetJWT response dump:\n\n%#v\n\n", string(dump))
   138  	}
   139  
   140  	if err != nil {
   141  		return JWT{}, err
   142  	}
   143  	defer res.Body.Close()
   144  
   145  	if res.StatusCode != http.StatusOK {
   146  		return JWT{}, fmt.Errorf("failed to exchange code for jwt (status: %s)", res.Status)
   147  	}
   148  
   149  	body, err := io.ReadAll(res.Body)
   150  	if err != nil {
   151  		return JWT{}, err
   152  	}
   153  
   154  	var j JWT
   155  	err = json.Unmarshal(body, &j)
   156  	if err != nil {
   157  		return JWT{}, err
   158  	}
   159  
   160  	return j, nil
   161  }
   162  
   163  // SetVerifier sets the code verifier endpoint.
   164  func (s *Server) SetVerifier(verifier *oidc.S256Verifier) {
   165  	s.Verifier = verifier
   166  }
   167  
   168  // Start starts a local server for handling authentication processing.
   169  func (s *Server) Start() error {
   170  	server := &http.Server{
   171  		Addr:         ":8080",
   172  		Handler:      s.Router,
   173  		ReadTimeout:  10 * time.Second,
   174  		WriteTimeout: 10 * time.Second,
   175  	}
   176  
   177  	err := server.ListenAndServe()
   178  	if err != nil {
   179  		return fsterr.RemediationError{
   180  			Inner:       fmt.Errorf("failed to start local server: %w", err),
   181  			Remediation: Remediation,
   182  		}
   183  	}
   184  	return nil
   185  }
   186  
   187  // HandleCallback processes the callback from the authentication service.
   188  func (s *Server) HandleCallback() http.HandlerFunc {
   189  	return func(w http.ResponseWriter, r *http.Request) {
   190  		authorizationCode := r.URL.Query().Get("code")
   191  		if authorizationCode == "" {
   192  			fmt.Fprint(w, "ERROR: no authorization code returned\n")
   193  			s.Result <- AuthorizationResult{
   194  				Err: fmt.Errorf("no authorization code returned"),
   195  			}
   196  			return
   197  		}
   198  
   199  		// Exchange the authorization code and the code verifier for a JWT.
   200  		// NOTE: I use the identifier `j` to avoid overlap with the `jwt` package.
   201  		j, err := s.GetJWT(authorizationCode)
   202  		if err != nil || j.AccessToken == "" || j.IDToken == "" {
   203  			fmt.Fprint(w, "ERROR: failed to exchange code for JWT\n")
   204  			s.Result <- AuthorizationResult{
   205  				Err: fmt.Errorf("failed to exchange code for JWT"),
   206  			}
   207  			return
   208  		}
   209  
   210  		email, at, err := s.ValidateAndRetrieveAPIToken(j.AccessToken)
   211  		if err != nil {
   212  			s.Result <- AuthorizationResult{
   213  				Err: err,
   214  			}
   215  			return
   216  		}
   217  
   218  		fmt.Fprint(w, "Authenticated successfully. Please close this page and return to the Fastly CLI in your terminal.")
   219  		s.Result <- AuthorizationResult{
   220  			Email:        email,
   221  			Jwt:          j,
   222  			SessionToken: at.AccessToken,
   223  		}
   224  	}
   225  }
   226  
   227  // ValidateAndRetrieveAPIToken verifies the signature and the claims and
   228  // exchanges the access token for an API token.
   229  //
   230  // NOTE: This function exists as it's called by this package + app.Run().
   231  func (s *Server) ValidateAndRetrieveAPIToken(accessToken string) (string, *APIToken, error) {
   232  	claims, err := s.VerifyJWTSignature(accessToken)
   233  	if err != nil {
   234  		return "", nil, err
   235  	}
   236  
   237  	azp, ok := claims["azp"]
   238  	if !ok {
   239  		return "", nil, errors.New("failed to extract azp from JWT claims")
   240  	}
   241  	if azp != ClientID {
   242  		if !ok {
   243  			return "", nil, fmt.Errorf("failed to match expected azp: %s", azp)
   244  		}
   245  	}
   246  
   247  	aud, ok := claims["aud"]
   248  	if !ok {
   249  		return "", nil, errors.New("failed to extract aud from JWT claims")
   250  	}
   251  
   252  	if aud != s.APIEndpoint {
   253  		if !ok {
   254  			return "", nil, fmt.Errorf("failed to match expected aud: %s", s.APIEndpoint)
   255  		}
   256  	}
   257  
   258  	email, ok := claims["email"]
   259  	if !ok {
   260  		return "", nil, errors.New("failed to extract email from JWT claims")
   261  	}
   262  
   263  	// Exchange the access token for a Fastly API token.
   264  	at, err := s.ExchangeAccessToken(accessToken)
   265  	if err != nil {
   266  		return "", nil, fmt.Errorf("failed to exchange access token for an API token: %w", err)
   267  	}
   268  
   269  	e, ok := email.(string)
   270  	if !ok {
   271  		return "", nil, fmt.Errorf("failed to type assert 'email' (%#v) to a string", email)
   272  	}
   273  	return e, at, nil
   274  }
   275  
   276  // VerifyJWTSignature calls the jwks_uri endpoint and extracts its claims.
   277  func (s *Server) VerifyJWTSignature(accessToken string) (claims map[string]any, err error) {
   278  	ctx := context.Background()
   279  
   280  	// NOTE: The last argument is optional and is for validating the JWKs endpoint
   281  	// (which we don't need to do, so we pass an empty string)
   282  	keySet, err := jwt.NewJSONWebKeySet(ctx, s.WellKnownEndpoints.Certs, "")
   283  	if err != nil {
   284  		return claims, fmt.Errorf("failed to verify signature of access token: %w", err)
   285  	}
   286  
   287  	claims, err = keySet.VerifySignature(ctx, accessToken)
   288  	if err != nil {
   289  		return nil, fmt.Errorf("failed to verify signature of access token: %w", err)
   290  	}
   291  
   292  	return claims, nil
   293  }
   294  
   295  // ExchangeAccessToken exchanges `accessToken` for a Fastly API token.
   296  func (s *Server) ExchangeAccessToken(accessToken string) (*APIToken, error) {
   297  	debug, _ := strconv.ParseBool(s.DebugMode)
   298  	resp, err := undocumented.Call(undocumented.CallOptions{
   299  		APIEndpoint: s.APIEndpoint,
   300  		HTTPClient:  s.HTTPClient,
   301  		HTTPHeaders: []undocumented.HTTPHeader{
   302  			{
   303  				Key:   "Authorization",
   304  				Value: fmt.Sprintf("Bearer %s", accessToken),
   305  			},
   306  		},
   307  		Method: http.MethodPost,
   308  		Path:   "/login-enhanced",
   309  		Debug:  debug,
   310  	})
   311  	if err != nil {
   312  		if apiErr, ok := err.(undocumented.APIError); ok {
   313  			if apiErr.StatusCode != http.StatusConflict {
   314  				err = fmt.Errorf("%w: %d %s", err, apiErr.StatusCode, http.StatusText(apiErr.StatusCode))
   315  			}
   316  		}
   317  		return nil, err
   318  	}
   319  
   320  	at := &APIToken{}
   321  	err = json.Unmarshal(resp, at)
   322  	if err != nil {
   323  		return nil, fmt.Errorf("failed to unmarshal json containing API token: %w", err)
   324  	}
   325  
   326  	return at, nil
   327  }
   328  
   329  // RefreshAccessToken constructs and calls the token_endpoint with the
   330  // refresh token so we can refresh and return the access token.
   331  func (s *Server) RefreshAccessToken(refreshToken string) (JWT, error) {
   332  	payload := fmt.Sprintf(
   333  		"grant_type=refresh_token&client_id=%s&refresh_token=%s",
   334  		ClientID,
   335  		refreshToken,
   336  	)
   337  
   338  	req, err := http.NewRequest("POST", s.WellKnownEndpoints.Token, strings.NewReader(payload))
   339  	if err != nil {
   340  		return JWT{}, err
   341  	}
   342  	req.Header.Add("content-type", "application/x-www-form-urlencoded")
   343  
   344  	debug, _ := strconv.ParseBool(s.DebugMode)
   345  	if debug {
   346  		rc := req.Clone(context.Background())
   347  		rc.Header.Set("Fastly-Key", "REDACTED")
   348  		dump, _ := httputil.DumpRequest(rc, true)
   349  		fmt.Printf("RefreshAccessToken request dump:\n\n%#v\n\n", string(dump))
   350  	}
   351  
   352  	res, err := http.DefaultClient.Do(req)
   353  
   354  	if debug && res != nil {
   355  		dump, _ := httputil.DumpResponse(res, true)
   356  		fmt.Printf("RefreshAccessToken response dump:\n\n%#v\n\n", string(dump))
   357  	}
   358  
   359  	if err != nil {
   360  		return JWT{}, err
   361  	}
   362  	defer res.Body.Close()
   363  
   364  	body, err := io.ReadAll(res.Body)
   365  	if err != nil {
   366  		return JWT{}, err
   367  	}
   368  
   369  	if res.StatusCode != http.StatusOK {
   370  		return JWT{}, fmt.Errorf("failed to refresh the access token (status: %s)", res.Status)
   371  	}
   372  
   373  	var j JWT
   374  	err = json.Unmarshal(body, &j)
   375  	if err != nil {
   376  		return JWT{}, err
   377  	}
   378  
   379  	return j, nil
   380  }
   381  
   382  // APIToken is returned from the /login-enhanced endpoint.
   383  type APIToken struct {
   384  	// AccessToken is used to access the Fastly API.
   385  	AccessToken string `json:"access_token"`
   386  	// CustomerID is the customer ID.
   387  	CustomerID string `json:"customer_id"`
   388  	// ExpiresAt is when the access token will expire.
   389  	ExpiresAt string `json:"expires_at"`
   390  	// ID is a unique ID.
   391  	ID string `json:"id"`
   392  	// Name is a description of the token.
   393  	Name string `json:"name"`
   394  	// UserID is the user's ID.
   395  	UserID string `json:"user_id"`
   396  }
   397  
   398  // AuthorizationResult represents the result of the authorization process.
   399  type AuthorizationResult struct {
   400  	// Email address extracted from JWT claims.
   401  	Email string
   402  	// Err is any error received during authentication.
   403  	Err error
   404  	// Jwt is the JWT token returned by the authorization server.
   405  	Jwt JWT
   406  	// SessionToken is a temporary API token.
   407  	SessionToken string
   408  }
   409  
   410  // JWT is the API response for a Token request.
   411  //
   412  // Access Token typically has a TTL of 5mins.
   413  // Refresh Token typically has a TTL of 30mins.
   414  type JWT struct {
   415  	// AccessToken can be exchanged for a Fastly API token.
   416  	AccessToken string `json:"access_token"`
   417  	// ExpiresIn indicates the lifetime (in seconds) of the access token.
   418  	ExpiresIn int `json:"expires_in"`
   419  	// IDToken contains user information that must be decoded and extracted.
   420  	IDToken string `json:"id_token"`
   421  	// RefreshExpiresIn indicates the lifetime (in seconds) of the refresh token.
   422  	RefreshExpiresIn int `json:"refresh_expires_in"`
   423  	// RefreshToken contains a token used to refresh the issued access token.
   424  	RefreshToken string `json:"refresh_token"`
   425  	// TokenType indicates which HTTP authentication scheme is used (e.g. Bearer).
   426  	TokenType string `json:"token_type"`
   427  }
   428  
   429  // TokenExpired indicates if the specified TTL has past.
   430  func TokenExpired(ttl int, timestamp int64) bool {
   431  	d := time.Duration(ttl) * time.Second
   432  	ttlAgo := time.Now().Add(-d).Unix()
   433  	return timestamp < ttlAgo
   434  }
   435  
   436  // IsLongLivedToken identifies if profile has SSO access/refresh values set.
   437  func IsLongLivedToken(pd *config.Profile) bool {
   438  	// If user has followed SSO flow before, then these will not be zero values.
   439  	return pd.AccessToken == "" && pd.RefreshToken == "" && pd.AccessTokenCreated == 0 && pd.RefreshTokenCreated == 0
   440  }