Skip to content

Commit

Permalink
Add support for refreshable AWS SSO tokens (#616)
Browse files Browse the repository at this point in the history
* Add support for refreshable AWS SSO tokens

* fixes a token refresh issue due to the SSO region not being set
  • Loading branch information
chrnorm committed Mar 5, 2024
1 parent a8b3e6d commit 3ae786e
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 145 deletions.
2 changes: 1 addition & 1 deletion pkg/assume/assume.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 6 additions & 116 deletions pkg/cfaws/assumer_aws_sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"os/exec"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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())

Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
}
}
5 changes: 3 additions & 2 deletions pkg/cfaws/cred-exporter.go → pkg/cfaws/cred_exporter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cfaws

import (
"context"
"os"

"github.com/aws/aws-sdk-go-v2/aws"
Expand Down Expand Up @@ -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()

Expand Down
29 changes: 29 additions & 0 deletions pkg/cfaws/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
32 changes: 24 additions & 8 deletions pkg/granted/sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"strings"
"sync"

"net/http"
Expand All @@ -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"
Expand Down Expand Up @@ -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},
Expand All @@ -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{
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
13 changes: 7 additions & 6 deletions pkg/granted/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -86,7 +87,7 @@ var TokenExpiryCommand = cli.Command{
return nil
}

startUrlMap, err := MapTokens(ctx.Context)
startUrlMap, err := MapTokens(ctx)
if err != nil {
return err
}
Expand All @@ -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"`
Expand All @@ -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 {
Expand Down

0 comments on commit 3ae786e

Please sign in to comment.