Skip to content

Commit

Permalink
fix: update list of commands that require auth server (#1120)
Browse files Browse the repository at this point in the history
* feat: display SSO field when listing profiles

* fix(app): override env debug value with flag

* refactor: move profile logic to global data

* fix(app): update command list that requires auth server

* refactor(profile): rename subcommand variable

* fix(sso): check given profile exists

* refactor(app): display well-known url in error

* feat(auth): add debug-mode support

* fix(auth): remove unused package
  • Loading branch information
Integralist committed Jan 17, 2024
1 parent a1a5d99 commit bfcf92c
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 63 deletions.
58 changes: 15 additions & 43 deletions pkg/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ func Exec(data *global.Data) error {
displayAPIEndpoint(apiEndpoint, endpointSource, data.Output)
}

// User can set env.DebugMode env var or the --debug-mode boolean flag.
// This will prioritise the flag over the env var.
if data.Flags.Debug {
data.Env.DebugMode = "true"
}

// NOTE: Some commands need just the auth server to be running.
// But not necessarily need to process an existing token.
// e.g. `profile create example_sso_user --sso`
Expand Down Expand Up @@ -334,7 +340,7 @@ func processToken(cmds []argparser.Command, data *global.Data) (token string, to
// So we have to presume those overrides are using a long-lived token.
switch tokenSource {
case lookup.SourceFile:
profileName, profileData, err := getProfile(data)
profileName, profileData, err := data.Profile()
if err != nil {
return "", tokenSource, err
}
Expand All @@ -344,7 +350,7 @@ func processToken(cmds []argparser.Command, data *global.Data) (token string, to
}
// User now either has an existing SSO-based token or they want to migrate.
// If a long-lived token, then trigger SSO.
if longLivedToken(profileData) {
if auth.IsLongLivedToken(profileData) {
return ssoAuthentication("You've not authenticated via OAuth before", cmds, data)
}
// Otherwise, for an existing SSO token, check its freshness.
Expand Down Expand Up @@ -373,39 +379,6 @@ func processToken(cmds []argparser.Command, data *global.Data) (token string, to
return token, tokenSource, nil
}

// getProfile identifies the profile we should extract a token from.
func getProfile(data *global.Data) (string, *config.Profile, error) {
var (
profileData *config.Profile
found bool
name, profileName string
)
switch {
case data.Flags.Profile != "": // --profile
profileName = data.Flags.Profile
case data.Manifest.File.Profile != "": // `profile` field in fastly.toml
profileName = data.Manifest.File.Profile
default:
profileName = "default"
}
for name, profileData = range data.Config.Profiles {
if (profileName == "default" && profileData.Default) || name == profileName {
// Once we find the default profile we can update the variable to be the
// associated profile name so later on we can use that information to
// update the specific profile.
if profileName == "default" {
profileName = name
}
found = true
break
}
}
if !found {
return "", nil, fmt.Errorf("failed to locate '%s' profile", profileName)
}
return profileName, profileData, nil
}

// checkAndRefreshSSOToken refreshes the access/refresh tokens if expired.
func checkAndRefreshSSOToken(profileData *config.Profile, profileName string, data *global.Data) (reauth bool, err error) {
// Access Token has expired
Expand Down Expand Up @@ -483,7 +456,7 @@ func checkAndRefreshSSOToken(profileData *config.Profile, profileName string, da
// informs the user how they can use the SSO flow. It checks if the SSO
// environment variable (or flag) has been set and enables the SSO flow if so.
func shouldSkipSSO(_ string, profileData *config.Profile, data *global.Data) bool {
if longLivedToken(profileData) {
if auth.IsLongLivedToken(profileData) {
// Skip SSO if user hasn't indicated they want to migrate.
return data.Env.UseSSO != "1" && !data.Flags.SSO
// FIXME: Put back messaging once SSO is GA.
Expand All @@ -501,11 +474,6 @@ func shouldSkipSSO(_ string, profileData *config.Profile, data *global.Data) boo
return false // don't skip SSO
}

func longLivedToken(pd *config.Profile) bool {
// If user has followed SSO flow before, then these will not be zero values.
return pd.AccessToken == "" && pd.RefreshToken == "" && pd.AccessTokenCreated == 0 && pd.RefreshTokenCreated == 0
}

// ssoAuthentication executes the `sso` command to handle authentication.
func ssoAuthentication(outputMessage string, cmds []argparser.Command, data *global.Data) (token string, tokenSource lookup.Source, err error) {
for _, command := range cmds {
Expand Down Expand Up @@ -643,7 +611,11 @@ func commandCollectsData(command string) bool {
// commandRequiresAuthServer determines if the command to be executed is one that
// requires just the authentication server to be running.
func commandRequiresAuthServer(command string) bool {
return command == "profile create"
switch command {
case "profile create", "profile update":
return true
}
return false
}

// commandRequiresToken determines if the command to be executed is one that
Expand Down Expand Up @@ -675,7 +647,7 @@ func configureAuth(apiEndpoint string, args []string, f config.File, c api.HTTPC

resp, err := c.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request OpenID Connect .well-known metadata: %w", err)
return nil, fmt.Errorf("failed to request OpenID Connect .well-known metadata (%s): %w", metadataEndpoint, err)
}

openIDConfig, err := io.ReadAll(resp.Body)
Expand Down
38 changes: 36 additions & 2 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httputil"
"strconv"
"strings"
"time"
Expand All @@ -16,6 +17,7 @@ import (

"github.com/fastly/cli/pkg/api"
"github.com/fastly/cli/pkg/api/undocumented"
"github.com/fastly/cli/pkg/config"
fsterr "github.com/fastly/cli/pkg/errors"
)

Expand Down Expand Up @@ -118,10 +120,23 @@ func (s Server) GetJWT(authorizationCode string) (JWT, error) {
if err != nil {
return JWT{}, err
}

req.Header.Add("content-type", "application/x-www-form-urlencoded")

debug, _ := strconv.ParseBool(s.DebugMode)
if debug {
rc := req.Clone(context.Background())
rc.Header.Set("Fastly-Key", "REDACTED")
dump, _ := httputil.DumpRequest(rc, true)
fmt.Printf("GetJWT request dump:\n\n%#v\n\n", string(dump))
}

res, err := http.DefaultClient.Do(req)

if debug && res != nil {
dump, _ := httputil.DumpResponse(res, true)
fmt.Printf("GetJWT response dump:\n\n%#v\n\n", string(dump))
}

if err != nil {
return JWT{}, err
}
Expand Down Expand Up @@ -324,10 +339,23 @@ func (s *Server) RefreshAccessToken(refreshToken string) (JWT, error) {
if err != nil {
return JWT{}, err
}

req.Header.Add("content-type", "application/x-www-form-urlencoded")

debug, _ := strconv.ParseBool(s.DebugMode)
if debug {
rc := req.Clone(context.Background())
rc.Header.Set("Fastly-Key", "REDACTED")
dump, _ := httputil.DumpRequest(rc, true)
fmt.Printf("RefreshAccessToken request dump:\n\n%#v\n\n", string(dump))
}

res, err := http.DefaultClient.Do(req)

if debug && res != nil {
dump, _ := httputil.DumpResponse(res, true)
fmt.Printf("RefreshAccessToken response dump:\n\n%#v\n\n", string(dump))
}

if err != nil {
return JWT{}, err
}
Expand Down Expand Up @@ -404,3 +432,9 @@ func TokenExpired(ttl int, timestamp int64) bool {
ttlAgo := time.Now().Add(-d).Unix()
return timestamp < ttlAgo
}

// IsLongLivedToken identifies if profile has SSO access/refresh values set.
func IsLongLivedToken(pd *config.Profile) bool {
// If user has followed SSO flow before, then these will not be zero values.
return pd.AccessToken == "" && pd.RefreshToken == "" && pd.AccessTokenCreated == 0 && pd.RefreshTokenCreated == 0
}
14 changes: 7 additions & 7 deletions pkg/commands/profile/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,18 @@ import (
// CreateCommand represents a Kingpin command.
type CreateCommand struct {
argparser.Base
authCmd *sso.RootCommand
ssoCmd *sso.RootCommand

automationToken bool
profile string
sso bool
}

// NewCreateCommand returns a new command registered in the parent.
func NewCreateCommand(parent argparser.Registerer, g *global.Data, authCmd *sso.RootCommand) *CreateCommand {
func NewCreateCommand(parent argparser.Registerer, g *global.Data, ssoCmd *sso.RootCommand) *CreateCommand {
var c CreateCommand
c.Globals = g
c.authCmd = authCmd
c.ssoCmd = ssoCmd
c.CmdClause = parent.Command("create", "Create user profile")
c.CmdClause.Arg("profile", "Profile to create (default 'user')").Default(profile.DefaultName).Short('p').StringVar(&c.profile)
c.CmdClause.Flag("automation-token", "Expected input will be an 'automation token' instead of a 'user token'").BoolVar(&c.automationToken)
Expand Down Expand Up @@ -80,11 +80,11 @@ func (c *CreateCommand) Exec(in io.Reader, out io.Writer) (err error) {
//
// This is so the `sso` command will use this information to create
// a new 'non-default' profile.
c.authCmd.InvokedFromProfileCreate = true
c.authCmd.ProfileCreateName = c.profile
c.authCmd.ProfileDefault = makeDefault
c.ssoCmd.InvokedFromProfileCreate = true
c.ssoCmd.ProfileCreateName = c.profile
c.ssoCmd.ProfileDefault = makeDefault

err = c.authCmd.Exec(in, out)
err = c.ssoCmd.Exec(in, out)
if err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/commands/profile/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"

"github.com/fastly/cli/pkg/argparser"
"github.com/fastly/cli/pkg/auth"
"github.com/fastly/cli/pkg/config"
fsterr "github.com/fastly/cli/pkg/errors"
"github.com/fastly/cli/pkg/global"
Expand Down Expand Up @@ -74,4 +75,5 @@ func display(k string, v *config.Profile, out io.Writer, style func(a ...any) st
text.Output(out, "%s: %t", style("Default"), v.Default)
text.Output(out, "%s: %s", style("Email"), v.Email)
text.Output(out, "%s: %s", style("Token"), v.Token)
text.Output(out, "%s: %t", style("SSO"), !auth.IsLongLivedToken(v))
}
14 changes: 7 additions & 7 deletions pkg/commands/profile/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ import (
// UpdateCommand represents a Kingpin command.
type UpdateCommand struct {
argparser.Base
authCmd *sso.RootCommand
ssoCmd *sso.RootCommand

automationToken bool
profile string
sso bool
}

// NewUpdateCommand returns a usable command registered under the parent.
func NewUpdateCommand(parent argparser.Registerer, g *global.Data, authCmd *sso.RootCommand) *UpdateCommand {
func NewUpdateCommand(parent argparser.Registerer, g *global.Data, ssoCmd *sso.RootCommand) *UpdateCommand {
var c UpdateCommand
c.Globals = g
c.authCmd = authCmd
c.ssoCmd = ssoCmd
c.CmdClause = parent.Command("update", "Update user profile")
c.CmdClause.Arg("profile", "Profile to update (defaults to the currently active profile)").Short('p').StringVar(&c.profile)
c.CmdClause.Flag("automation-token", "Expected input will be an 'automation token' instead of a 'user token'").BoolVar(&c.automationToken)
Expand Down Expand Up @@ -121,13 +121,13 @@ func (c *UpdateCommand) updateToken(profileName string, p *config.Profile, in io
//
// This is so the `sso` command will use this information to update
// the specific profile.
c.authCmd.InvokedFromProfileUpdate = true
c.authCmd.ProfileUpdateName = profileName
c.authCmd.ProfileDefault = false // set to false, as later we prompt for this
c.ssoCmd.InvokedFromProfileUpdate = true
c.ssoCmd.ProfileUpdateName = profileName
c.ssoCmd.ProfileDefault = false // set to false, as later we prompt for this

// NOTE: The `sso` command already handles writing config back to disk.
// So unlike `c.staticTokenFlow` (below) we don't have to do that here.
err := c.authCmd.Exec(in, out)
err := c.ssoCmd.Exec(in, out)
if err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
Expand Down
7 changes: 3 additions & 4 deletions pkg/commands/sso/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand {
var c RootCommand
c.Globals = g
// FIXME: Unhide this command once SSO is GA.
c.CmdClause = parent.Command("sso", "Single Sign-On authentication").Hidden()
c.CmdClause = parent.Command("sso", "Single Sign-On authentication (defaults to current profile)").Hidden()
c.CmdClause.Arg("profile", "Profile to authenticate (i.e. create/update a token for)").Short('p').StringVar(&c.profile)
return &c
}
Expand Down Expand Up @@ -153,7 +153,6 @@ func (c *RootCommand) identifyProfileAndFlow() (profileName string, flow Profile
}

currentDefaultProfile, _ := profile.Default(c.Globals.Config.Profiles)

var newDefaultProfile string
if currentDefaultProfile == "" && len(c.Globals.Config.Profiles) > 0 {
newDefaultProfile, c.Globals.Config.Profiles = profile.SetADefault(c.Globals.Config.Profiles)
Expand All @@ -162,7 +161,7 @@ func (c *RootCommand) identifyProfileAndFlow() (profileName string, flow Profile
switch {
case profileOverride != "":
return profileOverride, ProfileUpdate
case c.profile != "":
case c.profile != "" && profile.Get(c.profile, c.Globals.Config.Profiles) != nil:
return c.profile, ProfileUpdate
case c.InvokedFromProfileCreate && c.ProfileCreateName != "":
return c.ProfileCreateName, ProfileCreate
Expand All @@ -186,6 +185,7 @@ func (c *RootCommand) identifyProfileAndFlow() (profileName string, flow Profile
func (c *RootCommand) processProfiles(ar auth.AuthorizationResult) error {
profileName, flow := c.identifyProfileAndFlow()

//nolint:exhaustive
switch flow {
case ProfileCreate:
c.processCreateProfile(ar, profileName)
Expand Down Expand Up @@ -230,7 +230,6 @@ func (c *RootCommand) processUpdateProfile(ar auth.AuthorizationResult, profileN
if c.InvokedFromProfileUpdate {
isDefault = c.ProfileDefault
}

ps, err := editProfile(profileName, isDefault, c.Globals.Config.Profiles, ar)
if err != nil {
return err
Expand Down
34 changes: 34 additions & 0 deletions pkg/global/global.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package global

import (
"fmt"
"io"

"github.com/fastly/cli/pkg/api"
Expand Down Expand Up @@ -88,6 +89,39 @@ type Data struct {
Versioners Versioners
}

// Profile identifies the current profile (if any).
func (d *Data) Profile() (string, *config.Profile, error) {
var (
profileData *config.Profile
found bool
name, profileName string
)
switch {
case d.Flags.Profile != "": // --profile
profileName = d.Flags.Profile
case d.Manifest.File.Profile != "": // `profile` field in fastly.toml
profileName = d.Manifest.File.Profile
default:
profileName = "default" // fallback to locating the default profile
}
for name, profileData = range d.Config.Profiles {
if (profileName == "default" && profileData.Default) || name == profileName {
// Once we find the default profile we can update the variable to be the
// associated profile name so later on we can use that information to
// update the specific profile.
if profileName == "default" {
profileName = name
}
found = true
break
}
}
if !found {
return "", nil, fmt.Errorf("failed to locate '%s' profile", profileName)
}
return profileName, profileData, nil
}

// Token yields the Fastly API token.
//
// Order of precedence:
Expand Down

0 comments on commit bfcf92c

Please sign in to comment.