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 }