Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for refreshable AWS SSO tokens #616

Merged
merged 3 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
26 changes: 2 additions & 24 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ require (
)

require (
github.com/alessio/shellescape v1.4.2
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran go mod tidy and cleaned up go.mod and go.sum

github.com/briandowns/spinner v1.23.0
github.com/common-fate/clio v1.2.3
github.com/common-fate/common-fate v0.15.13
github.com/common-fate/glide-cli v0.5.0
github.com/fatih/color v1.13.0
github.com/lithammer/fuzzysearch v1.1.5
github.com/schollz/progressbar/v3 v3.13.1
Expand All @@ -29,28 +31,9 @@ require (
)

require (
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/TylerBrock/saw v0.2.2 // indirect
github.com/alessio/shellescape v1.4.2 // indirect
github.com/aws-cloudformation/rain v1.2.0 // indirect
github.com/aws/aws-sdk-go v1.44.213 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.4 // indirect
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.40.2 // indirect
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.31.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.4 // indirect
github.com/aws/aws-sdk-go-v2/service/lambda v1.48.2 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.45.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/common-fate/apikit v0.2.1-0.20220526131641-1d860b34f6ed // indirect
github.com/common-fate/cloudform v0.6.0 // indirect
github.com/common-fate/glide-cli v0.5.0 // indirect
github.com/common-fate/iso8601 v1.1.0 // indirect
github.com/common-fate/provider-registry-sdk-go v0.19.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deepmap/oapi-codegen v1.11.0 // indirect
github.com/getkin/kin-openapi v0.107.0 // indirect
Expand All @@ -59,19 +42,14 @@ require (
github.com/go-openapi/swag v0.22.3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/gookit/color v1.5.1 // indirect
github.com/invopop/yaml v0.2.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/sethvargo/go-retry v0.2.4 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
Expand Down
443 changes: 0 additions & 443 deletions go.sum

Large diffs are not rendered by default.

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
119 changes: 3 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 Down Expand Up @@ -203,7 +200,7 @@ func (c *Profile) SSOLogin(ctx context.Context, configOpts ConfigOpts) (aws.Cred
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 +249,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