From d7444686ced4167d8855e81e56e82d585f8dc0e1 Mon Sep 17 00:00:00 2001 From: Eren Yeager <92114074+wty-Bryant@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:42:45 -0400 Subject: [PATCH] Add sso session and token provider support (#4885) Update sso credential provider logic to support both sso token provider and legacy sso config, which can all be resolved from updated shared config profile and sso session section. --- CHANGELOG_PENDING.md | 2 + aws/auth/bearer/token.go | 50 ++++ aws/credentials/ssocreds/provider.go | 75 +++--- aws/credentials/ssocreds/provider_test.go | 129 ++++++++-- aws/credentials/ssocreds/sso_cached_token.go | 237 ++++++++++++++++++ .../ssocreds/sso_cached_token_test.go | 191 ++++++++++++++ .../testdata/custom_cached_token.json | 4 + .../ssocreds/testdata/expired_token.json | 8 + .../ssocreds/testdata/invalid_json.json | 1 + .../testdata/missing_accessToken.json | 7 + .../ssocreds/testdata/missing_clientId.json | 7 + .../testdata/missing_clientSecret.json | 7 + .../ssocreds/testdata/missing_expiresAt.json | 7 + .../testdata/missing_refreshToken.json | 7 + .../ssocreds/testdata/valid_token.json | 13 + aws/credentials/ssocreds/token_provider.go | 139 ++++++++++ .../ssocreds/token_provider_test.go | 224 +++++++++++++++++ aws/session/credentials.go | 23 +- aws/session/session.go | 2 +- aws/session/shared_config.go | 168 ++++++++++--- aws/session/shared_config_test.go | 30 +++ aws/session/testdata/shared_config | 17 ++ 22 files changed, 1255 insertions(+), 93 deletions(-) create mode 100644 aws/auth/bearer/token.go create mode 100644 aws/credentials/ssocreds/sso_cached_token.go create mode 100644 aws/credentials/ssocreds/sso_cached_token_test.go create mode 100644 aws/credentials/ssocreds/testdata/custom_cached_token.json create mode 100644 aws/credentials/ssocreds/testdata/expired_token.json create mode 100644 aws/credentials/ssocreds/testdata/invalid_json.json create mode 100644 aws/credentials/ssocreds/testdata/missing_accessToken.json create mode 100644 aws/credentials/ssocreds/testdata/missing_clientId.json create mode 100644 aws/credentials/ssocreds/testdata/missing_clientSecret.json create mode 100644 aws/credentials/ssocreds/testdata/missing_expiresAt.json create mode 100644 aws/credentials/ssocreds/testdata/missing_refreshToken.json create mode 100644 aws/credentials/ssocreds/testdata/valid_token.json create mode 100644 aws/credentials/ssocreds/token_provider.go create mode 100644 aws/credentials/ssocreds/token_provider_test.go diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 8a1927a39ca..299d1f71fe3 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -3,3 +3,5 @@ ### SDK Enhancements ### SDK Bugs +* `aws/credentials/ssocreds`: Implement SSO token provider support for `sso-session` in AWS shared config. + * Fixes [4649](https://github.com/aws/aws-sdk-go/issues/4649) \ No newline at end of file diff --git a/aws/auth/bearer/token.go b/aws/auth/bearer/token.go new file mode 100644 index 00000000000..dd950a286fb --- /dev/null +++ b/aws/auth/bearer/token.go @@ -0,0 +1,50 @@ +package bearer + +import ( + "github.com/aws/aws-sdk-go/aws" + "time" +) + +// Token provides a type wrapping a bearer token and expiration metadata. +type Token struct { + Value string + + CanExpire bool + Expires time.Time +} + +// Expired returns if the token's Expires time is before or equal to the time +// provided. If CanExpire is false, Expired will always return false. +func (t Token) Expired(now time.Time) bool { + if !t.CanExpire { + return false + } + now = now.Round(0) + return now.Equal(t.Expires) || now.After(t.Expires) +} + +// TokenProvider provides interface for retrieving bearer tokens. +type TokenProvider interface { + RetrieveBearerToken(aws.Context) (Token, error) +} + +// TokenProviderFunc provides a helper utility to wrap a function as a type +// that implements the TokenProvider interface. +type TokenProviderFunc func(aws.Context) (Token, error) + +// RetrieveBearerToken calls the wrapped function, returning the Token or +// error. +func (fn TokenProviderFunc) RetrieveBearerToken(ctx aws.Context) (Token, error) { + return fn(ctx) +} + +// StaticTokenProvider provides a utility for wrapping a static bearer token +// value within an implementation of a token provider. +type StaticTokenProvider struct { + Token Token +} + +// RetrieveBearerToken returns the static token specified. +func (s StaticTokenProvider) RetrieveBearerToken(aws.Context) (Token, error) { + return s.Token, nil +} diff --git a/aws/credentials/ssocreds/provider.go b/aws/credentials/ssocreds/provider.go index 6eda2a5557f..4138e725dde 100644 --- a/aws/credentials/ssocreds/provider.go +++ b/aws/credentials/ssocreds/provider.go @@ -4,13 +4,13 @@ import ( "crypto/sha1" "encoding/hex" "encoding/json" - "fmt" "io/ioutil" "path/filepath" "strings" "time" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/auth/bearer" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/client" "github.com/aws/aws-sdk-go/aws/credentials" @@ -55,6 +55,19 @@ type Provider struct { // The URL that points to the organization's AWS Single Sign-On (AWS SSO) user portal. StartURL string + + // The filepath the cached token will be retrieved from. If unset Provider will + // use the startURL to determine the filepath at. + // + // ~/.aws/sso/cache/.json + // + // If custom cached token filepath is used, the Provider's startUrl + // parameter will be ignored. + CachedTokenFilepath string + + // Used by the SSOCredentialProvider if a token configuration + // profile is used in the shared config + TokenProvider bearer.TokenProvider } // NewCredentials returns a new AWS Single Sign-On (AWS SSO) credential provider. The ConfigProvider is expected to be configured @@ -89,13 +102,31 @@ func (p *Provider) Retrieve() (credentials.Value, error) { // RetrieveWithContext retrieves temporary AWS credentials from the configured Amazon Single Sign-On (AWS SSO) user portal // by exchanging the accessToken present in ~/.aws/sso/cache. func (p *Provider) RetrieveWithContext(ctx credentials.Context) (credentials.Value, error) { - tokenFile, err := loadTokenFile(p.StartURL) - if err != nil { - return credentials.Value{}, err + var accessToken *string + if p.TokenProvider != nil { + token, err := p.TokenProvider.RetrieveBearerToken(ctx) + if err != nil { + return credentials.Value{}, err + } + accessToken = &token.Value + } else { + if p.CachedTokenFilepath == "" { + cachedTokenFilePath, err := getCachedFilePath(p.StartURL) + if err != nil { + return credentials.Value{}, err + } + p.CachedTokenFilepath = cachedTokenFilePath + } + + tokenFile, err := loadTokenFile(p.CachedTokenFilepath) + if err != nil { + return credentials.Value{}, err + } + accessToken = &tokenFile.AccessToken } output, err := p.Client.GetRoleCredentialsWithContext(ctx, &sso.GetRoleCredentialsInput{ - AccessToken: &tokenFile.AccessToken, + AccessToken: accessToken, AccountId: &p.AccountID, RoleName: &p.RoleName, }) @@ -114,32 +145,13 @@ func (p *Provider) RetrieveWithContext(ctx credentials.Context) (credentials.Val }, nil } -func getCacheFileName(url string) (string, error) { +func getCachedFilePath(startUrl string) (string, error) { hash := sha1.New() - _, err := hash.Write([]byte(url)) + _, err := hash.Write([]byte(startUrl)) if err != nil { return "", err } - return strings.ToLower(hex.EncodeToString(hash.Sum(nil))) + ".json", nil -} - -type rfc3339 time.Time - -func (r *rfc3339) UnmarshalJSON(bytes []byte) error { - var value string - - if err := json.Unmarshal(bytes, &value); err != nil { - return err - } - - parse, err := time.Parse(time.RFC3339, value) - if err != nil { - return fmt.Errorf("expected RFC3339 timestamp: %v", err) - } - - *r = rfc3339(parse) - - return nil + return filepath.Join(defaultCacheLocation(), strings.ToLower(hex.EncodeToString(hash.Sum(nil)))+".json"), nil } type token struct { @@ -153,13 +165,8 @@ func (t token) Expired() bool { return nowTime().Round(0).After(time.Time(t.ExpiresAt)) } -func loadTokenFile(startURL string) (t token, err error) { - key, err := getCacheFileName(startURL) - if err != nil { - return token{}, awserr.New(ErrCodeSSOProviderInvalidToken, invalidTokenMessage, err) - } - - fileBytes, err := ioutil.ReadFile(filepath.Join(defaultCacheLocation(), key)) +func loadTokenFile(cachedTokenPath string) (t token, err error) { + fileBytes, err := ioutil.ReadFile(cachedTokenPath) if err != nil { return token{}, awserr.New(ErrCodeSSOProviderInvalidToken, invalidTokenMessage, err) } diff --git a/aws/credentials/ssocreds/provider_test.go b/aws/credentials/ssocreds/provider_test.go index 0548d60325a..b6c9c58c0ea 100644 --- a/aws/credentials/ssocreds/provider_test.go +++ b/aws/credentials/ssocreds/provider_test.go @@ -5,11 +5,13 @@ package ssocreds import ( "fmt" + "path/filepath" "reflect" "testing" "time" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/auth/bearer" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/sso" @@ -24,14 +26,25 @@ type mockClient struct { Output *sso.GetRoleCredentialsOutput Err error - ExpectedAccountID string - ExpectedAccessToken string - ExpectedRoleName string - ExpectedClientRegion string + ExpectedAccountID string + ExpectedAccessToken string + ExpectedRoleName string Response func(mockClient) (*sso.GetRoleCredentialsOutput, error) } +type mockTokenProvider struct { + Response func() (bearer.Token, error) +} + +func (p *mockTokenProvider) RetrieveBearerToken(ctx aws.Context) (bearer.Token, error) { + if p.Response == nil { + return bearer.Token{}, nil + } + + return p.Response() +} + func (m mockClient) GetRoleCredentialsWithContext(ctx aws.Context, params *sso.GetRoleCredentialsInput, _ ...request.Option) (*sso.GetRoleCredentialsOutput, error) { m.t.Helper() @@ -88,11 +101,12 @@ func TestProvider(t *testing.T) { defer restoreTime() cases := map[string]struct { - Client mockClient - AccountID string - Region string - RoleName string - StartURL string + Client mockClient + AccountID string + RoleName string + StartURL string + CachedTokenFilePath string + TokenProvider *mockTokenProvider ExpectedErr bool ExpectedCredentials credentials.Value @@ -104,10 +118,9 @@ func TestProvider(t *testing.T) { }, "valid required parameter values": { Client: mockClient{ - ExpectedAccountID: "012345678901", - ExpectedRoleName: "TestRole", - ExpectedClientRegion: "us-west-2", - ExpectedAccessToken: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", + ExpectedAccountID: "012345678901", + ExpectedRoleName: "TestRole", + ExpectedAccessToken: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", Response: func(mock mockClient) (*sso.GetRoleCredentialsOutput, error) { return &sso.GetRoleCredentialsOutput{ RoleCredentials: &sso.RoleCredentials{ @@ -120,7 +133,6 @@ func TestProvider(t *testing.T) { }, }, AccountID: "012345678901", - Region: "us-west-2", RoleName: "TestRole", StartURL: "https://valid-required-only", ExpectedCredentials: credentials.Value{ @@ -131,22 +143,89 @@ func TestProvider(t *testing.T) { }, ExpectedExpire: time.Date(2021, 01, 20, 21, 22, 23, 0.123e9, time.UTC), }, + "custom cached token file": { + Client: mockClient{ + ExpectedAccountID: "012345678901", + ExpectedRoleName: "TestRole", + ExpectedAccessToken: "ZhbHVldGhpcyBpcyBub3QgYSByZWFsIH", + Response: func(mock mockClient) (*sso.GetRoleCredentialsOutput, error) { + return &sso.GetRoleCredentialsOutput{ + RoleCredentials: &sso.RoleCredentials{ + AccessKeyId: aws.String("AccessKey"), + SecretAccessKey: aws.String("SecretKey"), + SessionToken: aws.String("SessionToken"), + Expiration: aws.Int64(1611177743123), + }, + }, nil + }, + }, + CachedTokenFilePath: filepath.Join("testdata", "custom_cached_token.json"), + AccountID: "012345678901", + RoleName: "TestRole", + ExpectedCredentials: credentials.Value{ + AccessKeyID: "AccessKey", + SecretAccessKey: "SecretKey", + SessionToken: "SessionToken", + ProviderName: ProviderName, + }, + ExpectedExpire: time.Date(2021, 01, 20, 21, 22, 23, 0.123e9, time.UTC), + }, + "access token retrieved by token provider": { + Client: mockClient{ + ExpectedAccountID: "012345678901", + ExpectedRoleName: "TestRole", + ExpectedAccessToken: "WFsIHZhbHVldGhpcyBpcyBub3QgYSByZ", + Response: func(mock mockClient) (*sso.GetRoleCredentialsOutput, error) { + return &sso.GetRoleCredentialsOutput{ + RoleCredentials: &sso.RoleCredentials{ + AccessKeyId: aws.String("AccessKey"), + SecretAccessKey: aws.String("SecretKey"), + SessionToken: aws.String("SessionToken"), + Expiration: aws.Int64(1611177743123), + }, + }, nil + }, + }, + TokenProvider: &mockTokenProvider{ + Response: func() (bearer.Token, error) { + return bearer.Token{ + Value: "WFsIHZhbHVldGhpcyBpcyBub3QgYSByZ", + }, nil + }, + }, + AccountID: "012345678901", + RoleName: "TestRole", + StartURL: "ignored value", + ExpectedCredentials: credentials.Value{ + AccessKeyID: "AccessKey", + SecretAccessKey: "SecretKey", + SessionToken: "SessionToken", + ProviderName: ProviderName, + }, + ExpectedExpire: time.Date(2021, 01, 20, 21, 22, 23, 0.123e9, time.UTC), + }, + "token provider return error": { + TokenProvider: &mockTokenProvider{ + Response: func() (bearer.Token, error) { + return bearer.Token{}, fmt.Errorf("mock token provider return error") + }, + }, + ExpectedErr: true, + }, "expired access token": { StartURL: "https://expired", ExpectedErr: true, }, "api error": { Client: mockClient{ - ExpectedAccountID: "012345678901", - ExpectedRoleName: "TestRole", - ExpectedClientRegion: "us-west-2", - ExpectedAccessToken: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", + ExpectedAccountID: "012345678901", + ExpectedRoleName: "TestRole", + ExpectedAccessToken: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", Response: func(mock mockClient) (*sso.GetRoleCredentialsOutput, error) { return nil, fmt.Errorf("api error") }, }, AccountID: "012345678901", - Region: "us-west-2", RoleName: "TestRole", StartURL: "https://valid-required-only", ExpectedErr: true, @@ -158,10 +237,14 @@ func TestProvider(t *testing.T) { tt.Client.t = t provider := &Provider{ - Client: tt.Client, - AccountID: tt.AccountID, - RoleName: tt.RoleName, - StartURL: tt.StartURL, + Client: tt.Client, + AccountID: tt.AccountID, + RoleName: tt.RoleName, + StartURL: tt.StartURL, + CachedTokenFilepath: tt.CachedTokenFilePath, + } + if tt.TokenProvider != nil { + provider.TokenProvider = tt.TokenProvider } provider.Expiry.CurrentTime = nowTime diff --git a/aws/credentials/ssocreds/sso_cached_token.go b/aws/credentials/ssocreds/sso_cached_token.go new file mode 100644 index 00000000000..f6fa88451af --- /dev/null +++ b/aws/credentials/ssocreds/sso_cached_token.go @@ -0,0 +1,237 @@ +package ssocreds + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/aws/aws-sdk-go/internal/shareddefaults" + "io/ioutil" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +var resolvedOsUserHomeDir = shareddefaults.UserHomeDir + +// StandardCachedTokenFilepath returns the filepath for the cached SSO token file, or +// error if unable get derive the path. Key that will be used to compute a SHA1 +// value that is hex encoded. +// +// Derives the filepath using the Key as: +// +// ~/.aws/sso/cache/.json +func StandardCachedTokenFilepath(key string) (string, error) { + homeDir := resolvedOsUserHomeDir() + if len(homeDir) == 0 { + return "", fmt.Errorf("unable to get USER's home directory for cached token") + } + hash := sha1.New() + if _, err := hash.Write([]byte(key)); err != nil { + return "", fmt.Errorf("unable to compute cached token filepath key SHA1 hash, %v", err) + } + + cacheFilename := strings.ToLower(hex.EncodeToString(hash.Sum(nil))) + ".json" + + return filepath.Join(homeDir, ".aws", "sso", "cache", cacheFilename), nil +} + +type tokenKnownFields struct { + AccessToken string `json:"accessToken,omitempty"` + ExpiresAt *rfc3339 `json:"expiresAt,omitempty"` + + RefreshToken string `json:"refreshToken,omitempty"` + ClientID string `json:"clientId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` +} + +type cachedToken struct { + tokenKnownFields + UnknownFields map[string]interface{} `json:"-"` +} + +// MarshalJSON provides custom marshalling because the standard library Go marshaller ignores unknown/unspecified fields +// when marshalling from a struct: https://pkg.go.dev/encoding/json#Marshal +// This function adds some extra validation to the known fields and captures unknown fields. +func (t cachedToken) MarshalJSON() ([]byte, error) { + fields := map[string]interface{}{} + + setTokenFieldString(fields, "accessToken", t.AccessToken) + setTokenFieldRFC3339(fields, "expiresAt", t.ExpiresAt) + + setTokenFieldString(fields, "refreshToken", t.RefreshToken) + setTokenFieldString(fields, "clientId", t.ClientID) + setTokenFieldString(fields, "clientSecret", t.ClientSecret) + + for k, v := range t.UnknownFields { + if _, ok := fields[k]; ok { + return nil, fmt.Errorf("unknown token field %v, duplicates known field", k) + } + fields[k] = v + } + + return json.Marshal(fields) +} + +func setTokenFieldString(fields map[string]interface{}, key, value string) { + if value == "" { + return + } + fields[key] = value +} +func setTokenFieldRFC3339(fields map[string]interface{}, key string, value *rfc3339) { + if value == nil { + return + } + fields[key] = value +} + +// UnmarshalJSON provides custom unmarshalling because the standard library Go unmarshaller ignores unknown/unspecified +// fields when unmarshalling from a struct: https://pkg.go.dev/encoding/json#Unmarshal +// This function adds some extra validation to the known fields and captures unknown fields. +func (t *cachedToken) UnmarshalJSON(b []byte) error { + var fields map[string]interface{} + if err := json.Unmarshal(b, &fields); err != nil { + return nil + } + + t.UnknownFields = map[string]interface{}{} + + for k, v := range fields { + var err error + switch k { + case "accessToken": + err = getTokenFieldString(v, &t.AccessToken) + case "expiresAt": + err = getTokenFieldRFC3339(v, &t.ExpiresAt) + case "refreshToken": + err = getTokenFieldString(v, &t.RefreshToken) + case "clientId": + err = getTokenFieldString(v, &t.ClientID) + case "clientSecret": + err = getTokenFieldString(v, &t.ClientSecret) + default: + t.UnknownFields[k] = v + } + + if err != nil { + return fmt.Errorf("field %q, %v", k, err) + } + } + + return nil +} + +func getTokenFieldString(v interface{}, value *string) error { + var ok bool + *value, ok = v.(string) + if !ok { + return fmt.Errorf("expect value to be string, got %T", v) + } + return nil +} + +func getTokenFieldRFC3339(v interface{}, value **rfc3339) error { + var stringValue string + if err := getTokenFieldString(v, &stringValue); err != nil { + return err + } + + timeValue, err := parseRFC3339(stringValue) + if err != nil { + return err + } + + *value = &timeValue + return nil +} + +func loadCachedToken(filename string) (cachedToken, error) { + fileBytes, err := ioutil.ReadFile(filename) + if err != nil { + return cachedToken{}, fmt.Errorf("failed to read cached SSO token file, %v", err) + } + + var t cachedToken + if err := json.Unmarshal(fileBytes, &t); err != nil { + return cachedToken{}, fmt.Errorf("failed to parse cached SSO token file, %v", err) + } + + if len(t.AccessToken) == 0 || t.ExpiresAt == nil || time.Time(*t.ExpiresAt).IsZero() { + return cachedToken{}, fmt.Errorf( + "cached SSO token must contain accessToken and expiresAt fields") + } + + return t, nil +} + +func storeCachedToken(filename string, t cachedToken, fileMode os.FileMode) (err error) { + tmpFilename := filename + ".tmp-" + strconv.FormatInt(nowTime().UnixNano(), 10) + if err := writeCacheFile(tmpFilename, fileMode, t); err != nil { + return err + } + + if err := os.Rename(tmpFilename, filename); err != nil { + return fmt.Errorf("failed to replace old cached SSO token file, %v", err) + } + + return nil +} + +func writeCacheFile(filename string, fileMode os.FileMode, t cachedToken) (err error) { + var f *os.File + f, err = os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_RDWR, fileMode) + if err != nil { + return fmt.Errorf("failed to create cached SSO token file %v", err) + } + + defer func() { + closeErr := f.Close() + if err == nil && closeErr != nil { + err = fmt.Errorf("failed to close cached SSO token file, %v", closeErr) + } + }() + + encoder := json.NewEncoder(f) + + if err = encoder.Encode(t); err != nil { + return fmt.Errorf("failed to serialize cached SSO token, %v", err) + } + + return nil +} + +type rfc3339 time.Time + +// UnmarshalJSON decode rfc3339 from JSON format +func (r *rfc3339) UnmarshalJSON(bytes []byte) error { + var value string + var err error + + if err = json.Unmarshal(bytes, &value); err != nil { + return err + } + + *r, err = parseRFC3339(value) + return err +} + +func parseRFC3339(v string) (rfc3339, error) { + parsed, err := time.Parse(time.RFC3339, v) + if err != nil { + return rfc3339{}, fmt.Errorf("expected RFC3339 timestamp: %v", err) + } + + return rfc3339(parsed), nil +} + +// MarshalJSON encode rfc3339 to JSON format time +func (r *rfc3339) MarshalJSON() ([]byte, error) { + value := time.Time(*r).Format(time.RFC3339) + + // Use JSON unmarshal to unescape the quoted value making use of JSON's + // quoting rules. + return json.Marshal(value) +} diff --git a/aws/credentials/ssocreds/sso_cached_token_test.go b/aws/credentials/ssocreds/sso_cached_token_test.go new file mode 100644 index 00000000000..64197c9ac69 --- /dev/null +++ b/aws/credentials/ssocreds/sso_cached_token_test.go @@ -0,0 +1,191 @@ +//go:build go1.9 +// +build go1.9 + +package ssocreds + +import ( + "io/ioutil" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestStandardSSOCacheTokenFilepath(t *testing.T) { + origHomeDur := resolvedOsUserHomeDir + defer func() { + resolvedOsUserHomeDir = origHomeDur + }() + + cases := map[string]struct { + key string + osUserHomeDir func() string + expectFilename string + expectErr string + }{ + "success": { + key: "https://example.awsapps.com/start", + osUserHomeDir: func() string { + return os.TempDir() + }, + expectFilename: filepath.Join(os.TempDir(), ".aws", "sso", "cache", + "e8be5486177c5b5392bd9aa76563515b29358e6e.json"), + }, + "failure": { + key: "https://example.awsapps.com/start", + osUserHomeDir: func() string { + return "" + }, + expectErr: "some error", + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + resolvedOsUserHomeDir = c.osUserHomeDir + + actual, err := StandardCachedTokenFilepath(c.key) + if c.expectErr != "" { + if err == nil { + t.Fatalf("expect error, got none") + } + return + } + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if e, a := c.expectFilename, actual; e != a { + t.Errorf("expect %v filename, got %v", e, a) + } + }) + } +} + +func TestLoadCachedToken(t *testing.T) { + cases := map[string]struct { + filename string + expectToken cachedToken + expectErr string + }{ + "file not found": { + filename: filepath.Join("testdata", "does_not_exist.json"), + expectErr: "failed to read cached SSO token file", + }, + "invalid json": { + filename: filepath.Join("testdata", "invalid_json.json"), + expectErr: "failed to parse cached SSO token file", + }, + "missing accessToken": { + filename: filepath.Join("testdata", "missing_accessToken.json"), + expectErr: "must contain accessToken and expiresAt fields", + }, + "missing expiresAt": { + filename: filepath.Join("testdata", "missing_expiresAt.json"), + expectErr: "must contain accessToken and expiresAt fields", + }, + "standard token": { + filename: filepath.Join("testdata", "valid_token.json"), + expectToken: cachedToken{ + tokenKnownFields: tokenKnownFields{ + AccessToken: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", + ExpiresAt: (*rfc3339)(Time(time.Date(2044, 4, 4, 7, 0, 1, 0, time.UTC))), + ClientID: "client id", + ClientSecret: "client secret", + RefreshToken: "refresh token", + }, + UnknownFields: map[string]interface{}{ + "unknownField": "some value", + "registrationExpiresAt": "2044-04-04T07:00:01Z", + "region": "region", + "startURL": "start URL", + }, + }, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + actualToken, err := loadCachedToken(c.filename) + if c.expectErr != "" { + if err == nil { + t.Fatalf("expect %v error, got none", c.expectErr) + } + if e, a := c.expectErr, err.Error(); !strings.Contains(a, e) { + t.Fatalf("expect %v error, got %v", e, a) + } + return + } + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if !reflect.DeepEqual(c.expectToken, actualToken) { + t.Errorf("expect token file %v but got actual %v", c.expectToken, actualToken) + } + }) + } +} + +func TestStoreCachedToken(t *testing.T) { + tempDir, err := ioutil.TempDir(os.TempDir(), "aws-sdk-go-"+t.Name()) + if err != nil { + t.Fatalf("failed to create temporary test directory, %v", err) + } + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("failed to cleanup temporary test directory, %v", err) + } + }() + + cases := map[string]struct { + token cachedToken + filename string + fileMode os.FileMode + }{ + "standard token": { + filename: filepath.Join(tempDir, "token_file.json"), + fileMode: 0600, + token: cachedToken{ + tokenKnownFields: tokenKnownFields{ + AccessToken: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", + ExpiresAt: (*rfc3339)(Time(time.Date(2044, 4, 4, 7, 0, 1, 0, time.UTC))), + ClientID: "client id", + ClientSecret: "client secret", + RefreshToken: "refresh token", + }, + UnknownFields: map[string]interface{}{ + "unknownField": "some value", + "registrationExpiresAt": "2044-04-04T07:00:01Z", + "region": "region", + "startURL": "start URL", + }, + }, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + err := storeCachedToken(c.filename, c.token, c.fileMode) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + actual, err := loadCachedToken(c.filename) + if err != nil { + t.Fatalf("failed to load stored token, %v", err) + } + + if !reflect.DeepEqual(c.token, actual) { + t.Errorf("expect token file %v but got actual %v", c.token, actual) + } + }) + } +} + +// Time returns a pointer value for the time.Time value passed in. +func Time(v time.Time) *time.Time { + return &v +} diff --git a/aws/credentials/ssocreds/testdata/custom_cached_token.json b/aws/credentials/ssocreds/testdata/custom_cached_token.json new file mode 100644 index 00000000000..4b83e28fdc9 --- /dev/null +++ b/aws/credentials/ssocreds/testdata/custom_cached_token.json @@ -0,0 +1,4 @@ +{ + "accessToken": "ZhbHVldGhpcyBpcyBub3QgYSByZWFsIH", + "expiresAt": "2021-01-19T23:00:00Z" +} diff --git a/aws/credentials/ssocreds/testdata/expired_token.json b/aws/credentials/ssocreds/testdata/expired_token.json new file mode 100644 index 00000000000..7e648605571 --- /dev/null +++ b/aws/credentials/ssocreds/testdata/expired_token.json @@ -0,0 +1,8 @@ +{ + "accessToken": "expired access token", + "expiresAt": "2021-12-21T12:21:00Z", + "clientId": "client id", + "clientSecret": "client secret", + "refreshToken": "refresh token", + "unknownField": "some value" +} diff --git a/aws/credentials/ssocreds/testdata/invalid_json.json b/aws/credentials/ssocreds/testdata/invalid_json.json new file mode 100644 index 00000000000..98232c64fce --- /dev/null +++ b/aws/credentials/ssocreds/testdata/invalid_json.json @@ -0,0 +1 @@ +{ diff --git a/aws/credentials/ssocreds/testdata/missing_accessToken.json b/aws/credentials/ssocreds/testdata/missing_accessToken.json new file mode 100644 index 00000000000..dba6cace2ad --- /dev/null +++ b/aws/credentials/ssocreds/testdata/missing_accessToken.json @@ -0,0 +1,7 @@ +{ + "clientId": "client id", + "clientSecret": "client secret", + "refreshToken": "refresh token", + "missing_accessToken": "access token", + "expiresAt": "2044-04-04T07:00:01Z" +} diff --git a/aws/credentials/ssocreds/testdata/missing_clientId.json b/aws/credentials/ssocreds/testdata/missing_clientId.json new file mode 100644 index 00000000000..76dadfcfe42 --- /dev/null +++ b/aws/credentials/ssocreds/testdata/missing_clientId.json @@ -0,0 +1,7 @@ +{ + "missing_clientId": "client id", + "clientSecret": "client secret", + "refreshToken": "refresh token", + "accessToken": "access token", + "expiresAt": "2021-12-21T12:21:00Z" +} diff --git a/aws/credentials/ssocreds/testdata/missing_clientSecret.json b/aws/credentials/ssocreds/testdata/missing_clientSecret.json new file mode 100644 index 00000000000..aa28fc9f046 --- /dev/null +++ b/aws/credentials/ssocreds/testdata/missing_clientSecret.json @@ -0,0 +1,7 @@ +{ + "clientId": "client id", + "missing_clientSecret": "client secret", + "refreshToken": "refresh token", + "accessToken": "access token", + "expiresAt": "2021-12-21T12:21:00Z" +} diff --git a/aws/credentials/ssocreds/testdata/missing_expiresAt.json b/aws/credentials/ssocreds/testdata/missing_expiresAt.json new file mode 100644 index 00000000000..cd578891273 --- /dev/null +++ b/aws/credentials/ssocreds/testdata/missing_expiresAt.json @@ -0,0 +1,7 @@ +{ + "clientId": "client id", + "clientSecret": "client secret", + "refreshToken": "refresh token", + "accessToken": "access token", + "missing_expiresAt": "2044-04-04T07:00:01Z" +} diff --git a/aws/credentials/ssocreds/testdata/missing_refreshToken.json b/aws/credentials/ssocreds/testdata/missing_refreshToken.json new file mode 100644 index 00000000000..9afcff7465d --- /dev/null +++ b/aws/credentials/ssocreds/testdata/missing_refreshToken.json @@ -0,0 +1,7 @@ +{ + "clientId": "client id", + "clientSecret": "client secret", + "missing_refreshToken": "refresh token", + "accessToken": "access token", + "expiresAt": "2021-12-21T12:21:00Z" +} diff --git a/aws/credentials/ssocreds/testdata/valid_token.json b/aws/credentials/ssocreds/testdata/valid_token.json new file mode 100644 index 00000000000..528d11c4f10 --- /dev/null +++ b/aws/credentials/ssocreds/testdata/valid_token.json @@ -0,0 +1,13 @@ +{ + "accessToken": "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", + "expiresAt": "2044-04-04T07:00:01Z", + + "refreshToken": "refresh token", + "clientId": "client id", + "clientSecret": "client secret", + + "unknownField": "some value", + "region": "region", + "registrationExpiresAt": "2044-04-04T07:00:01Z", + "startURL": "start URL" +} diff --git a/aws/credentials/ssocreds/token_provider.go b/aws/credentials/ssocreds/token_provider.go new file mode 100644 index 00000000000..7562cd01350 --- /dev/null +++ b/aws/credentials/ssocreds/token_provider.go @@ -0,0 +1,139 @@ +package ssocreds + +import ( + "fmt" + "os" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/auth/bearer" + "github.com/aws/aws-sdk-go/service/ssooidc" +) + +// CreateTokenAPIClient provides the interface for the SSOTokenProvider's API +// client for calling CreateToken operation to refresh the SSO token. +type CreateTokenAPIClient interface { + CreateToken(input *ssooidc.CreateTokenInput) (*ssooidc.CreateTokenOutput, error) +} + +// SSOTokenProviderOptions provides the options for configuring the +// SSOTokenProvider. +type SSOTokenProviderOptions struct { + // Client that can be overridden + Client CreateTokenAPIClient + + // The path the file containing the cached SSO token will be read from. + // Initialized the NewSSOTokenProvider's cachedTokenFilepath parameter. + CachedTokenFilepath string +} + +// SSOTokenProvider provides a utility for refreshing SSO AccessTokens for +// Bearer Authentication. The SSOTokenProvider can only be used to refresh +// already cached SSO Tokens. This utility cannot perform the initial SSO +// create token. +// +// The initial SSO create token should be preformed with the AWS CLI before the +// Go application using the SSOTokenProvider will need to retrieve the SSO +// token. If the AWS CLI has not created the token cache file, this provider +// will return an error when attempting to retrieve the cached token. +// +// This provider will attempt to refresh the cached SSO token periodically if +// needed when RetrieveBearerToken is called. +// +// A utility such as the AWS CLI must be used to initially create the SSO +// session and cached token file. +// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html +type SSOTokenProvider struct { + options SSOTokenProviderOptions +} + +// NewSSOTokenProvider returns an initialized SSOTokenProvider that will +// periodically refresh the SSO token cached stored in the cachedTokenFilepath. +// The cachedTokenFilepath file's content will be rewritten by the token +// provider when the token is refreshed. +// +// The client must be configured for the AWS region the SSO token was created for. +func NewSSOTokenProvider(client CreateTokenAPIClient, cachedTokenFilepath string, optFns ...func(o *SSOTokenProviderOptions)) *SSOTokenProvider { + options := SSOTokenProviderOptions{ + Client: client, + CachedTokenFilepath: cachedTokenFilepath, + } + for _, fn := range optFns { + fn(&options) + } + + provider := &SSOTokenProvider{ + options: options, + } + + return provider +} + +// RetrieveBearerToken returns the SSO token stored in the cachedTokenFilepath +// the SSOTokenProvider was created with. If the token has expired +// RetrieveBearerToken will attempt to refresh it. If the token cannot be +// refreshed or is not present an error will be returned. +// +// A utility such as the AWS CLI must be used to initially create the SSO +// session and cached token file. https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html +func (p *SSOTokenProvider) RetrieveBearerToken(ctx aws.Context) (bearer.Token, error) { + cachedToken, err := loadCachedToken(p.options.CachedTokenFilepath) + if err != nil { + return bearer.Token{}, err + } + + if cachedToken.ExpiresAt != nil && nowTime().After(time.Time(*cachedToken.ExpiresAt)) { + cachedToken, err = p.refreshToken(cachedToken) + if err != nil { + return bearer.Token{}, fmt.Errorf("refresh cached SSO token failed, %v", err) + } + } + + expiresAt := toTime((*time.Time)(cachedToken.ExpiresAt)) + return bearer.Token{ + Value: cachedToken.AccessToken, + CanExpire: !expiresAt.IsZero(), + Expires: expiresAt, + }, nil +} + +func (p *SSOTokenProvider) refreshToken(token cachedToken) (cachedToken, error) { + if token.ClientSecret == "" || token.ClientID == "" || token.RefreshToken == "" { + return cachedToken{}, fmt.Errorf("cached SSO token is expired, or not present, and cannot be refreshed") + } + + createResult, err := p.options.Client.CreateToken(&ssooidc.CreateTokenInput{ + ClientId: &token.ClientID, + ClientSecret: &token.ClientSecret, + RefreshToken: &token.RefreshToken, + GrantType: aws.String("refresh_token"), + }) + if err != nil { + return cachedToken{}, fmt.Errorf("unable to refresh SSO token, %v", err) + } + + expiresAt := nowTime().Add(time.Duration(*createResult.ExpiresIn) * time.Second) + + token.AccessToken = *createResult.AccessToken + token.ExpiresAt = (*rfc3339)(&expiresAt) + token.RefreshToken = *createResult.RefreshToken + + fileInfo, err := os.Stat(p.options.CachedTokenFilepath) + if err != nil { + return cachedToken{}, fmt.Errorf("failed to stat cached SSO token file %v", err) + } + + if err = storeCachedToken(p.options.CachedTokenFilepath, token, fileInfo.Mode()); err != nil { + return cachedToken{}, fmt.Errorf("unable to cache refreshed SSO token, %v", err) + } + + return token, nil +} + +func toTime(p *time.Time) (v time.Time) { + if p == nil { + return v + } + + return *p +} diff --git a/aws/credentials/ssocreds/token_provider_test.go b/aws/credentials/ssocreds/token_provider_test.go new file mode 100644 index 00000000000..53cb265a7ba --- /dev/null +++ b/aws/credentials/ssocreds/token_provider_test.go @@ -0,0 +1,224 @@ +//go:build go1.16 +// +build go1.16 + +package ssocreds + +import ( + "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/auth/bearer" + "github.com/aws/aws-sdk-go/service/ssooidc" + "io/ioutil" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestSSOTokenProvider(t *testing.T) { + restoreTime := swapNowTime(time.Date(2021, 12, 21, 12, 21, 1, 0, time.UTC)) + defer restoreTime() + + tempDir, err := ioutil.TempDir(os.TempDir(), "aws-sdk-go-"+t.Name()) + if err != nil { + t.Fatalf("failed to create temporary test directory, %v", err) + } + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("failed to cleanup temporary test directory, %v", err) + } + }() + + cases := map[string]struct { + setup func() error + postRetrieve func() error + client CreateTokenAPIClient + cacheFilePath string + optFns []func(*SSOTokenProviderOptions) + + expectToken bearer.Token + expectErr string + }{ + "no cache file": { + cacheFilePath: filepath.Join("testdata", "file_not_exists"), + expectErr: "failed to read cached SSO token file", + }, + "invalid json cache file": { + cacheFilePath: filepath.Join("testdata", "invalid_json.json"), + expectErr: "failed to parse cached SSO token file", + }, + "missing accessToken": { + cacheFilePath: filepath.Join("testdata", "missing_accessToken.json"), + expectErr: "must contain accessToken and expiresAt fields", + }, + "missing expiresAt": { + cacheFilePath: filepath.Join("testdata", "missing_expiresAt.json"), + expectErr: "must contain accessToken and expiresAt fields", + }, + "expired no clientSecret": { + cacheFilePath: filepath.Join("testdata", "missing_clientSecret.json"), + expectErr: "cached SSO token is expired, or not present", + }, + "expired no clientId": { + cacheFilePath: filepath.Join("testdata", "missing_clientId.json"), + expectErr: "cached SSO token is expired, or not present", + }, + "expired no refreshToken": { + cacheFilePath: filepath.Join("testdata", "missing_refreshToken.json"), + expectErr: "cached SSO token is expired, or not present", + }, + "valid sso token": { + cacheFilePath: filepath.Join("testdata", "valid_token.json"), + expectToken: bearer.Token{ + Value: "dGhpcyBpcyBub3QgYSByZWFsIHZhbHVl", + CanExpire: true, + Expires: time.Date(2044, 4, 4, 7, 0, 1, 0, time.UTC), + }, + }, + "refresh expired token": { + setup: func() error { + testFile, err := os.ReadFile(filepath.Join("testdata", "expired_token.json")) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(tempDir, "expired_token.json"), testFile, 0600) + }, + postRetrieve: func() error { + actual, err := loadCachedToken(filepath.Join(tempDir, "expired_token.json")) + if err != nil { + return err + + } + expect := cachedToken{ + tokenKnownFields: tokenKnownFields{ + AccessToken: "updated access token", + ExpiresAt: (*rfc3339)(aws.Time(time.Date(2021, 12, 21, 12, 31, 1, 0, time.UTC))), + + RefreshToken: "updated refresh token", + ClientID: "client id", + ClientSecret: "client secret", + }, + UnknownFields: map[string]interface{}{ + "unknownField": "some value", + }, + } + + if !reflect.DeepEqual(expect, actual) { + return fmt.Errorf("expect token file %v but got actual %v", expect, actual) + } + return nil + }, + cacheFilePath: filepath.Join(tempDir, "expired_token.json"), + client: &mockCreateTokenAPIClient{ + expectInput: &ssooidc.CreateTokenInput{ + ClientId: aws.String("client id"), + ClientSecret: aws.String("client secret"), + RefreshToken: aws.String("refresh token"), + GrantType: aws.String("refresh_token"), + }, + output: &ssooidc.CreateTokenOutput{ + AccessToken: aws.String("updated access token"), + ExpiresIn: aws.Int64(600), + RefreshToken: aws.String("updated refresh token"), + }, + }, + expectToken: bearer.Token{ + Value: "updated access token", + CanExpire: true, + Expires: time.Date(2021, 12, 21, 12, 31, 1, 0, time.UTC), + }, + }, + "fail refresh expired token": { + setup: func() error { + testFile, err := os.ReadFile(filepath.Join("testdata", "expired_token.json")) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(tempDir, "expired_token.json"), testFile, 0600) + }, + postRetrieve: func() error { + actual, err := loadCachedToken(filepath.Join(tempDir, "expired_token.json")) + if err != nil { + return err + + } + expect := cachedToken{ + tokenKnownFields: tokenKnownFields{ + AccessToken: "access token", + ExpiresAt: (*rfc3339)(aws.Time(time.Date(2021, 12, 21, 12, 21, 1, 0, time.UTC))), + + RefreshToken: "refresh token", + ClientID: "client id", + ClientSecret: "client secret", + }, + } + + if !reflect.DeepEqual(expect, actual) { + return fmt.Errorf("expect token file %v but got actual %v", expect, actual) + } + return nil + }, + cacheFilePath: filepath.Join(tempDir, "expired_token.json"), + client: &mockCreateTokenAPIClient{ + err: fmt.Errorf("sky is falling"), + }, + expectErr: "unable to refresh SSO token, sky is falling", + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if c.setup != nil { + if err := c.setup(); err != nil { + t.Fatalf("failed to setup test, %v", err) + } + } + provider := NewSSOTokenProvider(c.client, c.cacheFilePath, c.optFns...) + + token, err := provider.RetrieveBearerToken(aws.BackgroundContext()) + if c.expectErr != "" { + if err == nil { + t.Fatalf("expect %v error, got none", c.expectErr) + } + if e, a := c.expectErr, err.Error(); !strings.Contains(a, e) { + t.Fatalf("expect %v error, got %v", e, a) + } + return + } + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if !reflect.DeepEqual(c.expectToken, token) { + t.Errorf("expect %v, got %v", c.expectToken, token) + } + + if c.postRetrieve != nil { + if err := c.postRetrieve(); err != nil { + t.Fatalf("post retrieve failed, %v", err) + } + } + }) + } +} + +type mockCreateTokenAPIClient struct { + expectInput *ssooidc.CreateTokenInput + output *ssooidc.CreateTokenOutput + err error +} + +func (c *mockCreateTokenAPIClient) CreateToken(input *ssooidc.CreateTokenInput) ( + *ssooidc.CreateTokenOutput, error, +) { + if c.expectInput != nil { + if !reflect.DeepEqual(c.expectInput, input) { + return nil, fmt.Errorf("expect token file %v but got actual %v", c.expectInput, input) + } + } + + return c.output, c.err +} diff --git a/aws/session/credentials.go b/aws/session/credentials.go index 1d3f4c3adc3..304061158a5 100644 --- a/aws/session/credentials.go +++ b/aws/session/credentials.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go/aws/defaults" "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/internal/shareddefaults" + "github.com/aws/aws-sdk-go/service/ssooidc" "github.com/aws/aws-sdk-go/service/sts" ) @@ -33,7 +34,7 @@ func resolveCredentials(cfg *aws.Config, switch { case len(sessOpts.Profile) != 0: - // User explicitly provided an Profile in the session's configuration + // User explicitly provided a Profile in the session's configuration // so load that profile from shared config first. // Github(aws/aws-sdk-go#2727) return resolveCredsFromProfile(cfg, envCfg, sharedCfg, handlers, sessOpts) @@ -173,8 +174,25 @@ func resolveSSOCredentials(cfg *aws.Config, sharedCfg sharedConfig, handlers req return nil, err } + var optFns []func(provider *ssocreds.Provider) cfgCopy := cfg.Copy() - cfgCopy.Region = &sharedCfg.SSORegion + + if sharedCfg.SSOSession != nil { + cfgCopy.Region = &sharedCfg.SSOSession.SSORegion + cachedPath, err := ssocreds.StandardCachedTokenFilepath(sharedCfg.SSOSession.Name) + if err != nil { + return nil, err + } + mySession := Must(NewSession()) + oidcClient := ssooidc.New(mySession, cfgCopy) + tokenProvider := ssocreds.NewSSOTokenProvider(oidcClient, cachedPath) + optFns = append(optFns, func(p *ssocreds.Provider) { + p.TokenProvider = tokenProvider + p.CachedTokenFilepath = cachedPath + }) + } else { + cfgCopy.Region = &sharedCfg.SSORegion + } return ssocreds.NewCredentials( &Session{ @@ -184,6 +202,7 @@ func resolveSSOCredentials(cfg *aws.Config, sharedCfg sharedConfig, handlers req sharedCfg.SSOAccountID, sharedCfg.SSORoleName, sharedCfg.SSOStartURL, + optFns..., ), nil } diff --git a/aws/session/session.go b/aws/session/session.go index cbccb60bbe8..8127c99a9a1 100644 --- a/aws/session/session.go +++ b/aws/session/session.go @@ -37,7 +37,7 @@ const ( // ErrSharedConfigSourceCollision will be returned if a section contains both // source_profile and credential_source -var ErrSharedConfigSourceCollision = awserr.New(ErrCodeSharedConfig, "only one credential type may be specified per profile: source profile, credential source, credential process, web identity token, or sso", nil) +var ErrSharedConfigSourceCollision = awserr.New(ErrCodeSharedConfig, "only one credential type may be specified per profile: source profile, credential source, credential process, web identity token", nil) // ErrSharedConfigECSContainerEnvVarEmpty will be returned if the environment // variables are empty and Environment was set as the credential source diff --git a/aws/session/shared_config.go b/aws/session/shared_config.go index 424c82b4d34..ea3ac0d0316 100644 --- a/aws/session/shared_config.go +++ b/aws/session/shared_config.go @@ -26,6 +26,13 @@ const ( roleSessionNameKey = `role_session_name` // optional roleDurationSecondsKey = "duration_seconds" // optional + // Prefix to be used for SSO sections. These are supposed to only exist in + // the shared config file, not the credentials file. + ssoSectionPrefix = `sso-session ` + + // AWS Single Sign-On (AWS SSO) group + ssoSessionNameKey = "sso_session" + // AWS Single Sign-On (AWS SSO) group ssoAccountIDKey = "sso_account_id" ssoRegionKey = "sso_region" @@ -99,6 +106,10 @@ type sharedConfig struct { CredentialProcess string WebIdentityTokenFile string + // SSO session options + SSOSessionName string + SSOSession *ssoSession + SSOAccountID string SSORegion string SSORoleName string @@ -186,6 +197,20 @@ type sharedConfigFile struct { IniData ini.Sections } +// SSOSession provides the shared configuration parameters of the sso-session +// section. +type ssoSession struct { + Name string + SSORegion string + SSOStartURL string +} + +func (s *ssoSession) setFromIniSection(section ini.Section) { + updateString(&s.Name, section, ssoSessionNameKey) + updateString(&s.SSORegion, section, ssoRegionKey) + updateString(&s.SSOStartURL, section, ssoStartURL) +} + // loadSharedConfig retrieves the configuration from the list of files using // the profile provided. The order the files are listed will determine // precedence. Values in subsequent files will overwrite values defined in @@ -266,13 +291,13 @@ func (cfg *sharedConfig) setFromIniFiles(profiles map[string]struct{}, profile s // profile only have credential provider options. cfg.clearAssumeRoleOptions() } else { - // First time a profile has been seen, It must either be a assume role - // credentials, or SSO. Assert if the credential type requires a role ARN, - // the ARN is also set, or validate that the SSO configuration is complete. + // First time a profile has been seen. Assert if the credential type + // requires a role ARN, the ARN is also set if err := cfg.validateCredentialsConfig(profile); err != nil { return err } } + profiles[profile] = struct{}{} if err := cfg.validateCredentialType(); err != nil { @@ -308,6 +333,30 @@ func (cfg *sharedConfig) setFromIniFiles(profiles map[string]struct{}, profile s cfg.SourceProfile = srcCfg } + // If the profile contains an SSO session parameter, the session MUST exist + // as a section in the config file. Load the SSO session using the name + // provided. If the session section is not found or incomplete an error + // will be returned. + if cfg.hasSSOTokenProviderConfiguration() { + skippedFiles = 0 + for _, f := range files { + section, ok := f.IniData.GetSection(fmt.Sprintf(ssoSectionPrefix + strings.TrimSpace(cfg.SSOSessionName))) + if ok { + var ssoSession ssoSession + ssoSession.setFromIniSection(section) + ssoSession.Name = cfg.SSOSessionName + cfg.SSOSession = &ssoSession + break + } + skippedFiles++ + } + if skippedFiles == len(files) { + // If all files were skipped because the sso session section is not found, return + // the sso section not found error. + return fmt.Errorf("failed to find SSO session section, %v", cfg.SSOSessionName) + } + } + return nil } @@ -363,6 +412,10 @@ func (cfg *sharedConfig) setFromIniFile(profile string, file sharedConfigFile, e cfg.S3UsEast1RegionalEndpoint = sre } + // AWS Single Sign-On (AWS SSO) + // SSO session options + updateString(&cfg.SSOSessionName, section, ssoSessionNameKey) + // AWS Single Sign-On (AWS SSO) updateString(&cfg.SSOAccountID, section, ssoAccountIDKey) updateString(&cfg.SSORegion, section, ssoRegionKey) @@ -461,32 +514,20 @@ func (cfg *sharedConfig) validateCredentialType() error { } func (cfg *sharedConfig) validateSSOConfiguration() error { - if !cfg.hasSSOConfiguration() { + if cfg.hasSSOTokenProviderConfiguration() { + err := cfg.validateSSOTokenProviderConfiguration() + if err != nil { + return err + } return nil } - var missing []string - if len(cfg.SSOAccountID) == 0 { - missing = append(missing, ssoAccountIDKey) - } - - if len(cfg.SSORegion) == 0 { - missing = append(missing, ssoRegionKey) - } - - if len(cfg.SSORoleName) == 0 { - missing = append(missing, ssoRoleNameKey) - } - - if len(cfg.SSOStartURL) == 0 { - missing = append(missing, ssoStartURL) - } - - if len(missing) > 0 { - return fmt.Errorf("profile %q is configured to use SSO but is missing required configuration: %s", - cfg.Profile, strings.Join(missing, ", ")) + if cfg.hasLegacySSOConfiguration() { + err := cfg.validateLegacySSOConfiguration() + if err != nil { + return err + } } - return nil } @@ -525,15 +566,76 @@ func (cfg *sharedConfig) clearAssumeRoleOptions() { } func (cfg *sharedConfig) hasSSOConfiguration() bool { - switch { - case len(cfg.SSOAccountID) != 0: - case len(cfg.SSORegion) != 0: - case len(cfg.SSORoleName) != 0: - case len(cfg.SSOStartURL) != 0: - default: - return false + return cfg.hasSSOTokenProviderConfiguration() || cfg.hasLegacySSOConfiguration() +} + +func (c *sharedConfig) hasSSOTokenProviderConfiguration() bool { + return len(c.SSOSessionName) > 0 +} + +func (c *sharedConfig) hasLegacySSOConfiguration() bool { + return len(c.SSORegion) > 0 || len(c.SSOAccountID) > 0 || len(c.SSOStartURL) > 0 || len(c.SSORoleName) > 0 +} + +func (c *sharedConfig) validateSSOTokenProviderConfiguration() error { + var missing []string + + if len(c.SSOSessionName) == 0 { + missing = append(missing, ssoSessionNameKey) } - return true + + if c.SSOSession == nil { + missing = append(missing, ssoSectionPrefix) + } else { + if len(c.SSOSession.SSORegion) == 0 { + missing = append(missing, ssoRegionKey) + } + + if len(c.SSOSession.SSOStartURL) == 0 { + missing = append(missing, ssoStartURL) + } + } + + if len(missing) > 0 { + return fmt.Errorf("profile %q is configured to use SSO but is missing required configuration: %s", + c.Profile, strings.Join(missing, ", ")) + } + + if len(c.SSORegion) > 0 && c.SSORegion != c.SSOSession.SSORegion { + return fmt.Errorf("%s in profile %q must match %s in %s", ssoRegionKey, c.Profile, ssoRegionKey, ssoSectionPrefix) + } + + if len(c.SSOStartURL) > 0 && c.SSOStartURL != c.SSOSession.SSOStartURL { + return fmt.Errorf("%s in profile %q must match %s in %s", ssoStartURL, c.Profile, ssoStartURL, ssoSectionPrefix) + } + + return nil +} + +func (c *sharedConfig) validateLegacySSOConfiguration() error { + var missing []string + + if len(c.SSORegion) == 0 { + missing = append(missing, ssoRegionKey) + } + + if len(c.SSOStartURL) == 0 { + missing = append(missing, ssoStartURL) + } + + if len(c.SSOAccountID) == 0 { + missing = append(missing, ssoAccountIDKey) + } + + if len(c.SSORoleName) == 0 { + missing = append(missing, ssoRoleNameKey) + } + + if len(missing) > 0 { + return fmt.Errorf("profile %q is configured to use SSO but is missing required configuration: %s", + c.Profile, strings.Join(missing, ", ")) + } + return nil } func oneOrNone(bs ...bool) bool { diff --git a/aws/session/shared_config_test.go b/aws/session/shared_config_test.go index fb3799e5f7e..d2b945014a6 100644 --- a/aws/session/shared_config_test.go +++ b/aws/session/shared_config_test.go @@ -390,6 +390,27 @@ func TestLoadSharedConfig(t *testing.T) { UseFIPSEndpoint: endpoints.FIPSEndpointStateDisabled, }, }, + { + Filenames: []string{testConfigFilename}, + Profile: "sso-session-success", + Expected: sharedConfig{ + Profile: "sso-session-success", + Region: "us-east-1", + SSOAccountID: "123456789012", + SSORoleName: "testRole", + SSOSessionName: "sso-session-success-dev", + SSOSession: &ssoSession{ + Name: "sso-session-success-dev", + SSORegion: "us-east-1", + SSOStartURL: "https://d-123456789a.awsapps.com/start", + }, + }, + }, + { + Filenames: []string{testConfigFilename}, + Profile: "sso-session-not-exist", + Err: fmt.Errorf("failed to find SSO session section, sso-session-lost"), + }, } for i, c := range cases { @@ -507,6 +528,15 @@ func TestLoadSharedConfigFromFile(t *testing.T) { S3UseARNRegion: true, }, }, + { + Profile: "sso-session-success", + Expected: sharedConfig{ + Region: "us-east-1", + SSOAccountID: "123456789012", + SSORoleName: "testRole", + SSOSessionName: "sso-session-success-dev", + }, + }, } for i, c := range cases { diff --git a/aws/session/testdata/shared_config b/aws/session/testdata/shared_config index da9cb2f4fc5..55ce6b6468e 100644 --- a/aws/session/testdata/shared_config +++ b/aws/session/testdata/shared_config @@ -187,3 +187,20 @@ use_fips_endpoint=False [profile UseFIPSEndpointInvalid] region = "us-west-2" use_fips_endpoint=invalid + +[profile sso-session-success] +region = us-east-1 +sso_session = sso-session-success-dev +sso_account_id = 123456789012 +sso_role_name = testRole + +[sso-session sso-session-success-dev] +sso_region = us-east-1 +sso_start_url = https://d-123456789a.awsapps.com/start +sso_registration_scopes = sso:account:access + +[profile sso-session-not-exist] +region = us-east-1 +sso_session = sso-session-lost +sso_account_id = 123456789012 +sso_role_name = testRole \ No newline at end of file