diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index 2306ceff..acd2126c 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -536,7 +536,7 @@ func AssumeCommand(c *cli.Context) error { } if assumeFlags.Bool("export-sso-token") || cfg.ExportSSOToken { - err := cfaws.ExportAccessTokenToCache(profile) + err := cfaws.ExportAccessTokenToCache(c.Context, profile) if err != nil { return err diff --git a/pkg/cfaws/assumer_aws_sso.go b/pkg/cfaws/assumer_aws_sso.go index d3f311c4..2d72df46 100644 --- a/pkg/cfaws/assumer_aws_sso.go +++ b/pkg/cfaws/assumer_aws_sso.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "os/exec" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -13,15 +12,13 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sso" ssotypes "github.com/aws/aws-sdk-go-v2/service/sso/types" - "github.com/aws/aws-sdk-go-v2/service/ssooidc" - ssooidctypes "github.com/aws/aws-sdk-go-v2/service/ssooidc/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go" "github.com/common-fate/clio" grantedConfig "github.com/common-fate/granted/pkg/config" + "github.com/common-fate/granted/pkg/idclogin" "github.com/common-fate/granted/pkg/securestorage" "github.com/hako/durafmt" - "github.com/pkg/browser" "github.com/pkg/errors" "gopkg.in/ini.v1" ) @@ -173,7 +170,7 @@ func (c *Profile) SSOLogin(ctx context.Context, configOpts ConfigOpts) (aws.Cred ssoTokenKey := rootProfile.SSOStartURL() + c.AWSConfig.SSOSessionName // if the profile has an sso user configured then suffix the sso token storage key to ensure unique logins secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage() - cachedToken := secureSSOTokenStorage.GetValidSSOToken(ssoTokenKey) + cachedToken := secureSSOTokenStorage.GetValidSSOToken(ctx, ssoTokenKey) // check if profile has a valid plaintext sso access token plainTextToken := GetValidSSOTokenFromPlaintextCache(rootProfile.SSOStartURL()) @@ -197,13 +194,16 @@ func (c *Profile) SSOLogin(ctx context.Context, configOpts ConfigOpts) (aws.Cred cmd += " --sso-region " + region } + // if the token exists but is invalid, attempt to clear it so that next login works. + secureSSOTokenStorage.ClearSSOToken(ssoTokenKey) + return aws.Credentials{}, fmt.Errorf("error when retrieving credentials from custom process. please login using '%s'", cmd) } if cachedToken == nil && plainTextToken == nil { newCfg := aws.NewConfig() newCfg.Region = rootProfile.SSORegion() - newSSOToken, err := SSODeviceCodeFlowFromStartUrl(ctx, *newCfg, rootProfile.SSOStartURL()) + newSSOToken, err := idclogin.Login(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes()) if err != nil { return aws.Credentials{}, err } @@ -252,113 +252,3 @@ func (c *Profile) getRoleCredentialsWithRetry(ctx context.Context, ssoClient *ss return nil, errors.Wrap(er, "max retries exceeded") } - -// SSODeviceCodeFlowFromStartUrl contains all the steps to complete a device code flow to retrieve an SSO token -func SSODeviceCodeFlowFromStartUrl(ctx context.Context, cfg aws.Config, startUrl string) (*securestorage.SSOToken, error) { - ssooidcClient := ssooidc.NewFromConfig(cfg) - - register, err := ssooidcClient.RegisterClient(ctx, &ssooidc.RegisterClientInput{ - ClientName: aws.String("granted-cli-client"), - ClientType: aws.String("public"), - Scopes: []string{"sso-portal:*"}, - }) - if err != nil { - return nil, err - } - - // authorize your device using the client registration response - deviceAuth, err := ssooidcClient.StartDeviceAuthorization(ctx, &ssooidc.StartDeviceAuthorizationInput{ - - ClientId: register.ClientId, - ClientSecret: register.ClientSecret, - StartUrl: aws.String(startUrl), - }) - if err != nil { - return nil, err - } - - // trigger OIDC login. open browser to login. close tab once login is done. press enter to continue - url := aws.ToString(deviceAuth.VerificationUriComplete) - clio.Info("If the browser does not open automatically, please open this link: " + url) - - // check if sso browser path is set - config, err := grantedConfig.Load() - if err != nil { - return nil, err - } - - if config.CustomSSOBrowserPath != "" { - cmd := exec.Command(config.CustomSSOBrowserPath, url) - err = cmd.Start() - if err != nil { - // fail silently - clio.Debug(err.Error()) - } else { - // detach from this new process because it continues to run - err = cmd.Process.Release() - if err != nil { - // fail silently - clio.Debug(err.Error()) - } - } - } else { - err = browser.OpenURL(url) - if err != nil { - // fail silently - clio.Debug(err.Error()) - } - } - - clio.Info("Awaiting AWS authentication in the browser") - clio.Info("You will be prompted to authenticate with AWS in the browser, then you will be prompted to 'Allow'") - clio.Infof("Code: %s", *deviceAuth.UserCode) - - pc := getPollingConfig(deviceAuth) - - token, err := PollToken(ctx, ssooidcClient, *register.ClientSecret, *register.ClientId, *deviceAuth.DeviceCode, pc) - if err != nil { - return nil, err - } - - return &securestorage.SSOToken{AccessToken: *token.AccessToken, Expiry: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)}, nil -} - -var ErrTimeout error = errors.New("polling for device authorization token timed out") - -type PollingConfig struct { - CheckInterval time.Duration - TimeoutAfter time.Duration -} - -func getPollingConfig(deviceAuth *ssooidc.StartDeviceAuthorizationOutput) PollingConfig { - return PollingConfig{ - CheckInterval: time.Duration(deviceAuth.Interval) * time.Second, - TimeoutAfter: time.Duration(deviceAuth.ExpiresIn) * time.Second, - } -} - -// PollToken will poll for a token and return it once the authentication/authorization flow has been completed in the browser -func PollToken(ctx context.Context, c *ssooidc.Client, clientSecret string, clientID string, deviceCode string, cfg PollingConfig) (*ssooidc.CreateTokenOutput, error) { - start := time.Now() - for { - time.Sleep(cfg.CheckInterval) - - token, err := c.CreateToken(ctx, &ssooidc.CreateTokenInput{ - - ClientId: &clientID, - ClientSecret: &clientSecret, - DeviceCode: &deviceCode, - GrantType: aws.String("urn:ietf:params:oauth:grant-type:device_code"), - }) - var pendingAuth *ssooidctypes.AuthorizationPendingException - if err == nil { - return token, nil - } else if !errors.As(err, &pendingAuth) { - return nil, err - } - - if time.Now().After(start.Add(cfg.TimeoutAfter)) { - return nil, ErrTimeout - } - } -} diff --git a/pkg/cfaws/cred-exporter.go b/pkg/cfaws/cred_exporter.go similarity index 93% rename from pkg/cfaws/cred-exporter.go rename to pkg/cfaws/cred_exporter.go index 1ef5d1b8..77e79a16 100644 --- a/pkg/cfaws/cred-exporter.go +++ b/pkg/cfaws/cred_exporter.go @@ -1,6 +1,7 @@ package cfaws import ( + "context" "os" "github.com/aws/aws-sdk-go-v2/aws" @@ -70,11 +71,11 @@ func ExportCredsToProfile(profileName string, creds aws.Credentials) error { } // ExportAccessTokenToCache will export access tokens to ~/.aws/sso/cache -func ExportAccessTokenToCache(profile *Profile) error { +func ExportAccessTokenToCache(ctx context.Context, profile *Profile) error { secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage() // Find the access token for the SSOStartURL and SSOSessionName tokenKey := profile.SSOStartURL() + profile.AWSConfig.SSOSessionName - cachedToken := secureSSOTokenStorage.GetValidSSOToken(tokenKey) + cachedToken := secureSSOTokenStorage.GetValidSSOToken(ctx, tokenKey) ssoPlainTextOut := CreatePlainTextSSO(profile.AWSConfig, cachedToken) err := ssoPlainTextOut.DumpToCacheDirectory() diff --git a/pkg/cfaws/profiles.go b/pkg/cfaws/profiles.go index fdc80a4a..91d8263f 100644 --- a/pkg/cfaws/profiles.go +++ b/pkg/cfaws/profiles.go @@ -61,6 +61,35 @@ func (p *Profile) SSOStartURL() string { return p.AWSConfig.SSOStartURL } +// Returns the SSOScopes from the profile. Currently, this looks up the non-standard +// 'granted_sso_registration_scopes' key on the profile. +// +// In future, we'll make this fully compatible with the 'sso_registration_scopes' config used +// in the native AWS CLI, i.e. +// +// [profile AWSAdministratorAccess-123456789012] +// sso_session = commonfate +// sso_account_id = 123456789012 +// sso_role_name = AWSAdministratorAccess +// region = ap-southeast-2 + +// [sso-session commonfate] +// sso_start_url = https://example.awsapps.com/start +// sso_region = ap-southeast-2 +// sso_registration_scopes = sso:account:access +// +// However, the AWS v2 Go SDK does not support reading 'sso_registration_scopes', so in order +// to support this we'll need to parse and lookup the `sso-session` entries in the config file separately. +func (p *Profile) SSOScopes() []string { + scopeKey, err := p.RawConfig.GetKey("granted_sso_registration_scopes") + if err != nil { + return nil + } + scopeVal := scopeKey.Value() + + return strings.Split(scopeVal, ",") +} + var ErrProfileNotInitialised error = errors.New("profile not initialised") var ErrProfileNotFound error = errors.New("profile not found") diff --git a/pkg/granted/sso.go b/pkg/granted/sso.go index 00a576f7..1a42e648 100644 --- a/pkg/granted/sso.go +++ b/pkg/granted/sso.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "sync" "net/http" @@ -24,6 +25,7 @@ import ( cfconfig "github.com/common-fate/glide-cli/pkg/config" "github.com/common-fate/glide-cli/pkg/profilesource" "github.com/common-fate/granted/pkg/cfaws" + "github.com/common-fate/granted/pkg/idclogin" "github.com/common-fate/granted/pkg/securestorage" "github.com/common-fate/granted/pkg/testable" "github.com/schollz/progressbar/v3" @@ -111,6 +113,7 @@ var PopulateCommand = cli.Command{ Flags: []cli.Flag{ &cli.StringFlag{Name: "prefix", Usage: "Specify a prefix for all generated profile names"}, &cli.StringFlag{Name: "sso-region", Usage: "Specify the SSO region"}, + &cli.StringSliceFlag{Name: "sso-scope", Usage: "Specify the SSO scopes"}, &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from", Value: cli.NewStringSlice("aws-sso")}, &cli.BoolFlag{Name: "prune", Usage: "Remove any generated profiles with the 'common_fate_generated_from' key which no longer exist"}, &cli.StringFlag{Name: "profile-template", Usage: "Specify profile name template", Value: awsconfigfile.DefaultProfileNameTemplate}, @@ -130,7 +133,7 @@ var PopulateCommand = cli.Command{ clio.Errorf("Please specify the --sso-region flag: '%s --sso-region us-east-1 %s'", fullCommand, startURL) return nil } - sso_region := c.String("sso-region") + ssoRegion := c.String("sso-region") configFilename := cfaws.GetAWSConfigPath() config, err := ini.LoadSources(ini.LoadOptions{ @@ -162,9 +165,9 @@ var PopulateCommand = cli.Command{ for _, s := range c.StringSlice("source") { switch s { case "aws-sso": - g.AddSource(AWSSSOSource{SSORegion: sso_region, StartURL: startURL}) + g.AddSource(AWSSSOSource{SSORegion: ssoRegion, StartURL: startURL, SSOScopes: c.StringSlice("sso-scope")}) case "commonfate", "common-fate", "cf": - ps, err := getCFProfileSource(c, sso_region, startURL) + ps, err := getCFProfileSource(c, ssoRegion, startURL) if err != nil { return err } @@ -193,6 +196,7 @@ var LoginCommand = cli.Command{ Flags: []cli.Flag{ &cli.StringFlag{Name: "sso-region", Usage: "Specify the SSO region"}, &cli.StringFlag{Name: "sso-start-url", Usage: "Specify the SSO start url"}, + &cli.StringSliceFlag{Name: "sso-scope", Usage: "Specify the SSO scopes"}, }, Action: func(c *cli.Context) error { ctx := c.Context @@ -209,7 +213,6 @@ var LoginCommand = cli.Command{ ssoRegion := c.String("sso-region") if ssoRegion == "" { - // fetch the start url to extract the region from the html resp, err := http.Get(ssoStartUrl) if err != nil { @@ -239,12 +242,24 @@ var LoginCommand = cli.Command{ } } + ssoScopes := c.StringSlice("sso-scope") + + if ssoScopes == nil { + var scopesString string + in2 := survey.Input{Message: "SSO Scopes", Default: "sso:account:access"} + err := testable.AskOne(&in2, &scopesString) + if err != nil { + return err + } + ssoScopes = strings.Split(scopesString, ",") + } + cfg := aws.NewConfig() cfg.Region = ssoRegion secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage() - newSSOToken, err := cfaws.SSODeviceCodeFlowFromStartUrl(ctx, *cfg, ssoStartUrl) + newSSOToken, err := idclogin.Login(ctx, *cfg, ssoStartUrl, ssoScopes) if err != nil { return err } @@ -295,6 +310,7 @@ func getCFProfileSource(c *cli.Context, region, startURL string) (profilesource. type AWSSSOSource struct { SSORegion string StartURL string + SSOScopes []string } func (s AWSSSOSource) GetProfiles(ctx context.Context) ([]awsconfigfile.SSOProfile, error) { @@ -316,11 +332,11 @@ func (s AWSSSOSource) GetProfiles(ctx context.Context) ([]awsconfigfile.SSOProfi } cfg.Region = region secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage() - ssoTokenFromSecureCache := secureSSOTokenStorage.GetValidSSOToken(s.StartURL) + ssoTokenFromSecureCache := secureSSOTokenStorage.GetValidSSOToken(ctx, s.StartURL) ssoTokenFromPlainText := cfaws.GetValidSSOTokenFromPlaintextCache(s.StartURL) // depending on whether creds come from secure storage or ~/.aws/sso/cache, we need to use different access tokens - accessToken := "" + var accessToken string // we also want to store this in the secure cache to prevent subsequent logins if ssoTokenFromPlainText != nil { @@ -329,7 +345,7 @@ func (s AWSSSOSource) GetProfiles(ctx context.Context) ([]awsconfigfile.SSOProfi if ssoTokenFromSecureCache == nil && ssoTokenFromPlainText == nil { // otherwise, login with SSO - ssoTokenFromSecureCache, err = cfaws.SSODeviceCodeFlowFromStartUrl(ctx, cfg, s.StartURL) + ssoTokenFromSecureCache, err = idclogin.Login(ctx, cfg, s.StartURL, s.SSOScopes) if err != nil { return nil, err } diff --git a/pkg/granted/tokens.go b/pkg/granted/tokens.go index dafe8093..7c4661dd 100644 --- a/pkg/granted/tokens.go +++ b/pkg/granted/tokens.go @@ -68,13 +68,14 @@ var TokenExpiryCommand = cli.Command{ Usage: "Lists expiry status for all access tokens saved in the keyring", Flags: []cli.Flag{&cli.StringFlag{Name: "url", Usage: "If provided, prints the expiry of the token for the specific SSO URL"}, &cli.BoolFlag{Name: "json", Usage: "If provided, prints the expiry of the tokens in JSON"}}, - Action: func(ctx *cli.Context) error { - url := ctx.String("url") + Action: func(c *cli.Context) error { + url := c.String("url") + ctx := c.Context secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage() if url != "" { - token := secureSSOTokenStorage.GetValidSSOToken(url) + token := secureSSOTokenStorage.GetValidSSOToken(ctx, url) var expiry string if token == nil { @@ -86,7 +87,7 @@ var TokenExpiryCommand = cli.Command{ return nil } - startUrlMap, err := MapTokens(ctx.Context) + startUrlMap, err := MapTokens(ctx) if err != nil { return err } @@ -103,7 +104,7 @@ var TokenExpiryCommand = cli.Command{ return err } - jsonflag := ctx.Bool("json") + jsonflag := c.Bool("json") type sso_expiry struct { StartURLs string `json:"start_urls"` @@ -114,7 +115,7 @@ var TokenExpiryCommand = cli.Command{ var jsonDataArray []sso_expiry for _, key := range keys { - token := secureSSOTokenStorage.GetValidSSOToken(key) + token := secureSSOTokenStorage.GetValidSSOToken(ctx, key) var expiry string if token == nil { diff --git a/pkg/idclogin/poll.go b/pkg/idclogin/poll.go new file mode 100644 index 00000000..d4feb279 --- /dev/null +++ b/pkg/idclogin/poll.go @@ -0,0 +1,50 @@ +package idclogin + +import ( + "context" + "errors" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssooidc" + ssooidctypes "github.com/aws/aws-sdk-go-v2/service/ssooidc/types" +) + +var ErrTimeout error = errors.New("polling for device authorization token timed out") + +type PollingConfig struct { + CheckInterval time.Duration + TimeoutAfter time.Duration +} + +func getPollingConfig(deviceAuth *ssooidc.StartDeviceAuthorizationOutput) PollingConfig { + return PollingConfig{ + CheckInterval: time.Duration(deviceAuth.Interval) * time.Second, + TimeoutAfter: time.Duration(deviceAuth.ExpiresIn) * time.Second, + } +} + +// pollToken will poll for a token and return it once the authentication/authorization flow has been completed in the browser +func pollToken(ctx context.Context, c *ssooidc.Client, clientSecret string, clientID string, deviceCode string, cfg PollingConfig) (*ssooidc.CreateTokenOutput, error) { + start := time.Now() + for { + time.Sleep(cfg.CheckInterval) + + token, err := c.CreateToken(ctx, &ssooidc.CreateTokenInput{ + ClientId: &clientID, + ClientSecret: &clientSecret, + DeviceCode: &deviceCode, + GrantType: aws.String("urn:ietf:params:oauth:grant-type:device_code"), + }) + var pendingAuth *ssooidctypes.AuthorizationPendingException + if err == nil { + return token, nil + } else if !errors.As(err, &pendingAuth) { + return nil, err + } + + if time.Now().After(start.Add(cfg.TimeoutAfter)) { + return nil, ErrTimeout + } + } +} diff --git a/pkg/cfaws/assume_aws_sso_test.go b/pkg/idclogin/poll_test.go similarity index 96% rename from pkg/cfaws/assume_aws_sso_test.go rename to pkg/idclogin/poll_test.go index 2c594eba..c6511ea8 100644 --- a/pkg/cfaws/assume_aws_sso_test.go +++ b/pkg/idclogin/poll_test.go @@ -1,4 +1,4 @@ -package cfaws +package idclogin import ( "testing" diff --git a/pkg/idclogin/run.go b/pkg/idclogin/run.go new file mode 100644 index 00000000..0e2e92f0 --- /dev/null +++ b/pkg/idclogin/run.go @@ -0,0 +1,101 @@ +package idclogin + +import ( + "context" + "os/exec" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssooidc" + "github.com/common-fate/clio" + grantedConfig "github.com/common-fate/granted/pkg/config" + "github.com/common-fate/granted/pkg/securestorage" + "github.com/pkg/browser" +) + +// Login contains all the steps to complete a device code flow to retrieve an SSO token +func Login(ctx context.Context, cfg aws.Config, startUrl string, scopes []string) (*securestorage.SSOToken, error) { + ssooidcClient := ssooidc.NewFromConfig(cfg) + + // If scopes aren't provided, default to the legacy non-refreshable configuration + // by specifying the "sso-portal:*" scope + // there is a little more info here on this, although the specific "sso-portal:*" scope was taken from the AWS CLI source code. + // https://docs.aws.amazon.com/cli/latest/userguide/sso-configure-profile-legacy.html + if len(scopes) == 0 { + scopes = []string{"sso-portal:*"} + } + + client, err := ssooidcClient.RegisterClient(ctx, &ssooidc.RegisterClientInput{ + ClientName: aws.String("Granted CLI"), + ClientType: aws.String("public"), + Scopes: scopes, + }) + if err != nil { + return nil, err + } + + // authorize your device using the client registration response + deviceAuth, err := ssooidcClient.StartDeviceAuthorization(ctx, &ssooidc.StartDeviceAuthorizationInput{ + ClientId: client.ClientId, + ClientSecret: client.ClientSecret, + StartUrl: aws.String(startUrl), + }) + if err != nil { + return nil, err + } + + // trigger OIDC login. open browser to login. close tab once login is done. press enter to continue + url := aws.ToString(deviceAuth.VerificationUriComplete) + clio.Info("If the browser does not open automatically, please open this link: " + url) + + // check if sso browser path is set + config, err := grantedConfig.Load() + if err != nil { + return nil, err + } + + if config.CustomSSOBrowserPath != "" { + cmd := exec.Command(config.CustomSSOBrowserPath, url) + err = cmd.Start() + if err != nil { + // fail silently + clio.Debug(err.Error()) + } else { + // detach from this new process because it continues to run + err = cmd.Process.Release() + if err != nil { + // fail silently + clio.Debug(err.Error()) + } + } + } else { + err = browser.OpenURL(url) + if err != nil { + // fail silently + clio.Debug(err.Error()) + } + } + + clio.Info("Awaiting AWS authentication in the browser") + clio.Info("You will be prompted to authenticate with AWS in the browser, then you will be prompted to 'Allow'") + clio.Infof("Code: %s", *deviceAuth.UserCode) + + pc := getPollingConfig(deviceAuth) + + token, err := pollToken(ctx, ssooidcClient, *client.ClientSecret, *client.ClientId, *deviceAuth.DeviceCode, pc) + if err != nil { + return nil, err + } + + result := securestorage.SSOToken{ + AccessToken: *token.AccessToken, + Expiry: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second), + ClientID: *client.ClientId, + ClientSecret: *client.ClientSecret, + RegistrationExpiresAt: time.Unix(client.ClientSecretExpiresAt, 0), + RefreshToken: token.RefreshToken, + Region: cfg.Region, + } + + return &result, nil +} diff --git a/pkg/securestorage/sso_token_storage.go b/pkg/securestorage/sso_token_storage.go index 80a386e3..51c7d8db 100644 --- a/pkg/securestorage/sso_token_storage.go +++ b/pkg/securestorage/sso_token_storage.go @@ -1,10 +1,13 @@ package securestorage import ( + "context" "time" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ssooidc" "github.com/common-fate/clio" - "github.com/pkg/errors" ) type SSOTokensSecureStorage struct { @@ -20,36 +23,108 @@ func NewSecureSSOTokenStorage() SSOTokensSecureStorage { } type SSOToken struct { - AccessToken string - Expiry time.Time + // AccessToken is serialized as "AccessToken" to preserve backwards compatibility + // with earlier versions of Granted. The native AWS CLI serializes this field in camelCase + // as 'accessToken'. This field key may be changed in future to 'accessToken'. + AccessToken string `json:"AccessToken"` + // Expiry is serialized as "Expiry" to preserve backwards compatibility + // with earlier versions of Granted. The native AWS CLI serializes this field in camelCase + // as 'expiry'. This field key may be changed in future to 'expiry'. + Expiry time.Time `json:"Expiry"` + ClientID string `json:"clientId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` + RegistrationExpiresAt time.Time `json:"registrationExpiresAt,omitempty"` + Region string `json:"region,omitempty"` + RefreshToken *string `json:"refreshToken,omitempty"` } -// GetValidSSOToken returns nil if no token was found, or if it is expired -func (s *SSOTokensSecureStorage) GetValidSSOToken(profileKey string) *SSOToken { +// GetValidSSOToken loads and potentially refreshes an AWS SSO access token from secure storage. +// It returns nil if no token was found, or if it is expired +func (s *SSOTokensSecureStorage) GetValidSSOToken(ctx context.Context, profileKey string) *SSOToken { var t SSOToken err := s.SecureStorage.Retrieve(profileKey, &t) if err != nil { - clio.Debugf("%s\n", errors.Wrap(err, "GetValidCachedToken").Error()) + clio.Debugf("error retrieving IAM Identity Center token from secure storage: %s", err.Error()) + return nil + } + now := time.Now() + isExpired := t.Expiry.Before(now) + + if !isExpired { + // token is valid + return &t } - if t.Expiry.Before(time.Now()) { + + if t.RefreshToken == nil { + // can't refresh the token, so return nil + return nil + } + + if *t.RefreshToken == "" { + // can't refresh the token, so return nil return nil } - return &t + + // if we get here, we can attempt to refresh the token + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + clio.Errorf("error loading default AWS config for token refresh: %s", err.Error()) + // token is invalid + return nil + } + + if t.Region == "" { + // if the region is not set, the AWS SSO OIDC client will make an invalid API call and will return an + // 'InvalidGrantException' error. + clio.Errorf("existing token had no SSO region set") + // token is invalid + return nil + } + + cfg.Region = t.Region + + client := ssooidc.NewFromConfig(cfg) + + res, err := client.CreateToken(ctx, &ssooidc.CreateTokenInput{ + ClientId: &t.ClientID, + ClientSecret: &t.ClientSecret, + GrantType: aws.String("refresh_token"), + RefreshToken: t.RefreshToken, + }) + if err != nil { + clio.Errorf("error refreshing AWS IAM Identity Center token: %s", err.Error()) + // token is invalid + return nil + } + + newToken := SSOToken{ + AccessToken: *res.AccessToken, + Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second), + ClientID: t.ClientID, // same as the previous token, because the same client was used to refresh + ClientSecret: t.ClientSecret, // same as the previous token, because the same client was used to refresh + RegistrationExpiresAt: t.RegistrationExpiresAt, // same as the previous token, because the same client was used to refresh + RefreshToken: res.RefreshToken, + Region: t.Region, + } + + // save the refreshed token to secure storage + s.StoreSSOToken(profileKey, newToken) + + return &newToken } // Attempts to store the token, any errors will be logged to debug logging func (s *SSOTokensSecureStorage) StoreSSOToken(profileKey string, ssoTokenValue SSOToken) { err := s.SecureStorage.Store(profileKey, ssoTokenValue) if err != nil { - clio.Debugf("%s\n", errors.Wrap(err, "writing sso token to credentials cache").Error()) + clio.Debugf("writing sso token to credentials cache: %s", err.Error()) } - } // Attempts to clear the token, any errors will be logged to debug logging func (s *SSOTokensSecureStorage) ClearSSOToken(profileKey string) { err := s.SecureStorage.Clear(profileKey) if err != nil { - clio.Debugf("%s\n", errors.Wrap(err, "clearing sso token from the credentials cache").Error()) + clio.Debugf("clearing sso token from the credentials cache: %s", err) } }