diff --git a/.changes/next-release/config-feature-1611597600517230000.json b/.changes/next-release/config-feature-1611597600517230000.json new file mode 100644 index 00000000000..40691b396de --- /dev/null +++ b/.changes/next-release/config-feature-1611597600517230000.json @@ -0,0 +1,9 @@ +{ + "ID": "config-feature-1611597600517230000", + "SchemaVersion": 1, + "Module": "config", + "Type": "feature", + "Description": "Add Support for AWS Single Sign-On (SSO) credential provider", + "MinVersion": "", + "AffectedModules": null +} \ No newline at end of file diff --git a/.changes/next-release/credentials-feature-1611597655218336000.json b/.changes/next-release/credentials-feature-1611597655218336000.json new file mode 100644 index 00000000000..87d4cf11123 --- /dev/null +++ b/.changes/next-release/credentials-feature-1611597655218336000.json @@ -0,0 +1,9 @@ +{ + "ID": "credentials-feature-1611597655218336000", + "SchemaVersion": 1, + "Module": "credentials", + "Type": "feature", + "Description": "Add AWS Single Sign-On (SSO) credential provider", + "MinVersion": "", + "AffectedModules": null +} \ No newline at end of file diff --git a/config/config_test.go b/config/config_test.go index 993fddd713c..ea65b58c999 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -2,11 +2,11 @@ package config import ( "context" - "github.com/google/go-cmp/cmp" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/google/go-cmp/cmp" ) func TestConfigs_SharedConfigOptions(t *testing.T) { diff --git a/config/go.mod b/config/go.mod index b7f376d73a2..24774d3364e 100644 --- a/config/go.mod +++ b/config/go.mod @@ -6,6 +6,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.0.1-0.20210122214637-6cf9ad2f8e2f github.com/aws/aws-sdk-go-v2/credentials v1.0.0 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.0 + github.com/aws/aws-sdk-go-v2/service/sso v1.0.0 github.com/aws/aws-sdk-go-v2/service/sts v1.0.0 github.com/aws/smithy-go v1.0.0 github.com/google/go-cmp v0.5.4 diff --git a/config/go.sum b/config/go.sum index 244464e39c2..a2dd5414150 100644 --- a/config/go.sum +++ b/config/go.sum @@ -1,3 +1,5 @@ +github.com/aws/aws-sdk-go-v2/service/sso v1.0.0 h1:eNwZL0deLt9ehrTpPAO/pvztJxa4RT6+E7sbDpgMGUQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.0.0/go.mod h1:qNdDupP6xoM//zL1JmPl2XGbyPL5kKrlsoYVh8XZxzQ= github.com/aws/smithy-go v1.0.0 h1:hkhcRKG9rJ4Fn+RbfXY7Tz7b3ITLDyolBnLLBhwbg/c= github.com/aws/smithy-go v1.0.0/go.mod h1:EzMw8dbp/YJL4A5/sbhGddag+NPT7q084agLbB9LgIw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/config/load_options.go b/config/load_options.go index bd12a1fde61..bdcddbbf768 100644 --- a/config/load_options.go +++ b/config/load_options.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go-v2/credentials/endpointcreds" "github.com/aws/aws-sdk-go-v2/credentials/processcreds" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" "github.com/aws/smithy-go/logging" @@ -110,6 +111,10 @@ type LoadOptions struct { // stscreds.AssumeRoleOptions AssumeRoleCredentialOptions func(*stscreds.AssumeRoleOptions) + // SSOProviderOptions is a function for setting + // the ssocreds.Options + SSOProviderOptions func(options *ssocreds.Options) + // LogConfigurationWarnings when set to true, enables logging // configuration warnings LogConfigurationWarnings *bool @@ -592,3 +597,27 @@ func WithS3UseARNRegion(v bool) LoadOptionsFunc { return nil } } + + +// getSSOProviderOptions returns AssumeRoleCredentialOptions from LoadOptions +func (o LoadOptions) getSSOProviderOptions(context.Context) (func(options *ssocreds.Options), bool, error) { + if o.SSOProviderOptions == nil { + return nil, false, nil + } + + return o.SSOProviderOptions, true, nil +} + +// WithSSOProviderOptions is a helper function to construct +// functional options that sets a function to use ssocreds.Options +// on config's LoadOptions. If the SSO credential provider options is set to nil, +// the sso provider options value will be ignored. If multiple +// WithSSOProviderOptions calls are made, the last call overrides +// the previous call values. +func WithSSOProviderOptions(v func(*ssocreds.Options)) LoadOptionsFunc { + return func(o *LoadOptions) error { + o.SSOProviderOptions = v + return nil + } +} + diff --git a/config/provider.go b/config/provider.go index 68406c6b881..a4308368d03 100644 --- a/config/provider.go +++ b/config/provider.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go-v2/credentials/endpointcreds" "github.com/aws/aws-sdk-go-v2/credentials/processcreds" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/smithy-go/logging" "github.com/aws/smithy-go/middleware" @@ -406,3 +407,21 @@ func getLogConfigurationWarnings(ctx context.Context, configs configs) (v bool, } return } + +// ssoCredentialOptionsProvider is an interface for retrieving a function for setting +// the ssocreds.Options. +type ssoCredentialOptionsProvider interface { + getSSOProviderOptions(context.Context) (func(*ssocreds.Options), bool, error) +} + +func getSSOProviderOptions(ctx context.Context, configs configs) (v func(options *ssocreds.Options), found bool, err error) { + for _, c := range configs { + if p, ok := c.(ssoCredentialOptionsProvider); ok { + v, found, err = p.getSSOProviderOptions(ctx) + if err != nil || found { + break + } + } + } + return +} diff --git a/config/resolve_credentials.go b/config/resolve_credentials.go index 35b48c4391c..5a4decbc0b5 100644 --- a/config/resolve_credentials.go +++ b/config/resolve_credentials.go @@ -11,8 +11,10 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go-v2/credentials/endpointcreds" "github.com/aws/aws-sdk-go-v2/credentials/processcreds" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" + "github.com/aws/aws-sdk-go-v2/service/sso" "github.com/aws/aws-sdk-go-v2/service/sts" ) @@ -108,6 +110,9 @@ func resolveCredentialChain(ctx context.Context, cfg *aws.Config, configs config func resolveCredsFromProfile(ctx context.Context, cfg *aws.Config, envConfig *EnvConfig, sharedConfig *SharedConfig, configs configs) (err error) { switch { + case sharedConfig.hasSSOConfiguration(): + err = resolveSSOCredentials(ctx, cfg, sharedConfig, configs) + case sharedConfig.Source != nil: // Assume IAM role with credentials source from a different profile. err = resolveCredsFromProfile(ctx, cfg, envConfig, sharedConfig.Source, configs) @@ -151,6 +156,20 @@ func resolveCredsFromProfile(ctx context.Context, cfg *aws.Config, envConfig *En return nil } +func resolveSSOCredentials(ctx context.Context, cfg *aws.Config, sharedConfig *SharedConfig, configs configs) error { + var options []func(*ssocreds.Options) + v, found, err := getSSOProviderOptions(ctx, configs) + if err != nil { + return err + } + if found { + options = append(options, v) + } + + cfg.Credentials = ssocreds.New(sso.NewFromConfig(*cfg), sharedConfig.SSOAccountID, sharedConfig.SSORegion, sharedConfig.SSORoleName, sharedConfig.SSOStartURL, options...) + return nil +} + func ecsContainerURI(path string) string { return fmt.Sprintf("%s%s", ecsContainerEndpoint, path) } @@ -353,7 +372,7 @@ func assumeWebIdentity(ctx context.Context, cfg *aws.Config, filepath string, ro optFns = append(optFns, optFn) } - provider := stscreds.NewWebIdentityRoleProvider(sts.NewFromConfig(cfg.Copy()), roleARN, stscreds.IdentityTokenFile(filepath), optFns...) + provider := stscreds.NewWebIdentityRoleProvider(sts.NewFromConfig(*cfg), roleARN, stscreds.IdentityTokenFile(filepath), optFns...) cfg.Credentials = provider @@ -401,7 +420,7 @@ func credsFromAssumeRole(ctx context.Context, cfg *aws.Config, sharedCfg *Shared } } - cfg.Credentials = stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg.Copy()), sharedCfg.RoleARN, optFns...) + cfg.Credentials = stscreds.NewAssumeRoleProvider(sts.NewFromConfig(*cfg), sharedCfg.RoleARN, optFns...) return nil } diff --git a/config/resolve_credentials_test.go b/config/resolve_credentials_test.go index c26703ad411..b1b8fcadbc6 100644 --- a/config/resolve_credentials_test.go +++ b/config/resolve_credentials_test.go @@ -107,7 +107,7 @@ func TestSharedConfigCredentialSource(t *testing.T) { }{ "credential source and source profile": { envProfile: "invalid_source_and_credential_source", - expectedError: "only source profile or credential source can be specified", + expectedError: "only one credential type may be specified per profile", init: func() { os.Setenv("AWS_ACCESS_KEY", "access_key") os.Setenv("AWS_SECRET_KEY", "secret_key") diff --git a/config/shared_config.go b/config/shared_config.go index e6130714b14..1a715959e47 100644 --- a/config/shared_config.go +++ b/config/shared_config.go @@ -33,6 +33,12 @@ const ( roleSessionNameKey = `role_session_name` // optional roleDurationSecondsKey = "duration_seconds" // optional + // AWS Single Sign-On (AWS SSO) group + ssoAccountIDKey = "sso_account_id" + ssoRegionKey = "sso_region" + ssoRoleNameKey = "sso_role_name" + ssoStartURL = "sso_start_url" + // Additional Config fields regionKey = `region` @@ -110,6 +116,11 @@ type SharedConfig struct { CredentialProcess string WebIdentityTokenFile string + SSOAccountID string + SSORegion string + SSORoleName string + SSOStartURL string + RoleARN string ExternalID string MFASerial string @@ -750,13 +761,13 @@ func (c *SharedConfig) setFromIniSections(profiles map[string]struct{}, profile // First time a profile has been seen, It must either be a assume role // or credentials. Assert if the credential type requires a role ARN, // the ARN is also set. - if err := c.validateCredentialsRequireARN(profile); err != nil { + if err := c.validateCredentialsConfig(profile); err != nil { return err } } // if not top level profile and has credentials, return with credentials. - if len(profiles) != 0 && c.Credentials.HasKeys() { + if len(profiles) != 0 && (c.Credentials.HasKeys() || c.hasSSOConfiguration()) { return nil } @@ -787,7 +798,7 @@ func (c *SharedConfig) setFromIniSections(profiles map[string]struct{}, profile return err } - if !srcCfg.hasCredentials() { + if !srcCfg.hasCredentials() && !srcCfg.hasSSOConfiguration() { return SharedConfigAssumeRoleError{ RoleARN: c.RoleARN, Profile: c.SourceProfileName, @@ -835,6 +846,12 @@ func (c *SharedConfig) setFromIniSection(profile string, section ini.Section) er updateString(&c.CredentialSource, section, credentialSourceKey) updateString(&c.Region, section, regionKey) + // AWS Single Sign-On (AWS SSO) + updateString(&c.SSOAccountID, section, ssoAccountIDKey) + updateString(&c.SSORegion, section, ssoRegionKey) + updateString(&c.SSORoleName, section, ssoRoleNameKey) + updateString(&c.SSOStartURL, section, ssoStartURL) + if section.Has(roleDurationSecondsKey) { d := time.Duration(section.Int(roleDurationSecondsKey)) * time.Second c.RoleDurationSeconds = &d @@ -861,6 +878,18 @@ func (c *SharedConfig) setFromIniSection(profile string, section ini.Section) er return nil } +func (c *SharedConfig) validateCredentialsConfig(profile string) error { + if err := c.validateCredentialsRequireARN(profile); err != nil { + return err + } + + if err := c.validateSSOConfiguration(profile); err != nil { + return err + } + + return nil +} + func (c *SharedConfig) validateCredentialsRequireARN(profile string) error { var credSource string @@ -890,8 +919,39 @@ func (c *SharedConfig) validateCredentialType() error { len(c.CredentialSource) != 0, len(c.CredentialProcess) != 0, len(c.WebIdentityTokenFile) != 0, + c.hasSSOConfiguration(), ) { - return fmt.Errorf("only source profile or credential source can be specified, not both") + return fmt.Errorf("only one credential type may be specified per profile: source profile, credential source, credential process, web identity token, or sso") + } + + return nil +} + +func (c *SharedConfig) validateSSOConfiguration(profile string) error { + if !c.hasSSOConfiguration() { + return nil + } + + var missing []string + if len(c.SSOAccountID) == 0 { + missing = append(missing, ssoAccountIDKey) + } + + if len(c.SSORegion) == 0 { + missing = append(missing, ssoRegionKey) + } + + if len(c.SSORoleName) == 0 { + missing = append(missing, ssoRoleNameKey) + } + + if len(c.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", + profile, strings.Join(missing, ",")) } return nil @@ -911,6 +971,18 @@ func (c *SharedConfig) hasCredentials() bool { return true } +func (c *SharedConfig) hasSSOConfiguration() bool { + switch { + case len(c.SSOAccountID) != 0: + case len(c.SSORegion) != 0: + case len(c.SSORoleName) != 0: + case len(c.SSOStartURL) != 0: + default: + return false + } + return true +} + func (c *SharedConfig) clearAssumeRoleOptions() { c.RoleARN = "" c.ExternalID = "" diff --git a/config/shared_config_test.go b/config/shared_config_test.go index cf41e70a99e..6087c571eaa 100644 --- a/config/shared_config_test.go +++ b/config/shared_config_test.go @@ -218,6 +218,33 @@ func TestNewSharedConfig(t *testing.T) { }, }, }, + "AWS SSO Profile": { + Filenames: []string{testConfigFilename}, + Profile: "sso_creds", + Expected: SharedConfig{ + Profile: "sso_creds", + SSOAccountID: "012345678901", + SSORegion: "us-west-2", + SSORoleName: "TestRole", + SSOStartURL: "https://127.0.0.1/start", + }, + }, + "Assume Role with AWS SSO Credentials": { + Filenames: []string{testConfigFilename}, + Profile: "source_sso_creds", + Expected: SharedConfig{ + Profile: "source_sso_creds", + RoleARN: "source_sso_creds_arn", + SourceProfileName: "sso_creds", + Source: &SharedConfig{ + Profile: "sso_creds", + SSOAccountID: "012345678901", + SSORegion: "us-west-2", + SSORoleName: "TestRole", + SSOStartURL: "https://127.0.0.1/start", + }, + }, + }, } for name, c := range cases { diff --git a/config/testdata/shared_config b/config/testdata/shared_config index 3896cfeaf99..578fd56e469 100644 --- a/config/testdata/shared_config +++ b/config/testdata/shared_config @@ -104,3 +104,16 @@ source_profile = assume_role_with_credential_source [profile multiple_assume_role_with_credential_source2] role_arn = multiple_assume_role_with_credential_source2_role_arn source_profile = multiple_assume_role_with_credential_source + +[profile sso_creds] +sso_account_id = 012345678901 +sso_region = us-west-2 +sso_role_name = TestRole +sso_start_url = https://127.0.0.1/start + +[profile source_sso_creds] +role_arn = source_sso_creds_arn +source_profile = sso_creds + +[profile invalid_sso_creds] +sso_account_id = 012345678901 diff --git a/credentials/go.mod b/credentials/go.mod index 6121e797ce2..312721c72f5 100644 --- a/credentials/go.mod +++ b/credentials/go.mod @@ -5,7 +5,6 @@ go 1.15 require ( github.com/aws/aws-sdk-go-v2 v1.0.1-0.20210122214637-6cf9ad2f8e2f github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.0 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.0 github.com/aws/aws-sdk-go-v2/service/sso v1.0.0 github.com/aws/aws-sdk-go-v2/service/sts v1.0.0 github.com/aws/smithy-go v1.0.0 diff --git a/credentials/ssocreds/doc.go b/credentials/ssocreds/doc.go index 6e20416de87..2f396c0a118 100644 --- a/credentials/ssocreds/doc.go +++ b/credentials/ssocreds/doc.go @@ -1,4 +1,4 @@ -// Package provides a credential provider for retrieving temporary AWS credentials using an SSO access token. +// Package ssocreds provides a credential provider for retrieving temporary AWS credentials using an SSO access token. // // IMPORTANT: The provider in this package does not initiate or perform the AWS SSO login flow. The SDK provider // expects that you have already performed the SSO login flow using AWS CLI using the "aws sso login" command, or by @@ -54,4 +54,10 @@ // It is important that you wrap the Provider with aws.CredentialsCache if you are programmatically constructing the // provider directly. This prevents your application from accessing the cached access token and requesting new // credentials each time the credentials are used. +// +// Additional Resources +// +// Configuring the AWS CLI to use AWS Single Sign-On: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html +// +// AWS Single Sign-On User Guide: https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html package ssocreds diff --git a/credentials/ssocreds/provider.go b/credentials/ssocreds/provider.go index 2c672d79a77..583a42c71d6 100644 --- a/credentials/ssocreds/provider.go +++ b/credentials/ssocreds/provider.go @@ -16,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sso" ) +// ProviderName is the name of the provider used to specify the source of credentials. const ProviderName = "SSOProvider" var defaultCacheLocation = filepath.Join(getHomeDirectory(), ".aws", "sso", "cache") @@ -87,7 +88,7 @@ func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) { AccessKeyID: aws.ToString(output.RoleCredentials.AccessKeyId), SecretAccessKey: aws.ToString(output.RoleCredentials.SecretAccessKey), SessionToken: aws.ToString(output.RoleCredentials.SessionToken), - Expires: time.Unix(output.RoleCredentials.Expiration, 0).UTC(), + Expires: time.Unix(0, output.RoleCredentials.Expiration*int64(time.Millisecond)).UTC(), CanExpire: true, Source: ProviderName, }, nil @@ -117,7 +118,7 @@ func (r *rfc3339) UnmarshalJSON(bytes []byte) error { parse, err := time.Parse(time.RFC3339, value) if err != nil { - return err + return fmt.Errorf("expected RFC3339 timestamp: %w", err) } *r = rfc3339(parse) @@ -136,7 +137,8 @@ func (t token) Expired() bool { return sdk.NowTime().Round(0).After(time.Time(t.ExpiresAt)) } -// InvalidTokenError is the error type that is returned if aloaded token +// InvalidTokenError is the error type that is returned if loaded token has expired or is otherwise invalid. +// To refresh the SSO session run aws sso login with the corresponding profile. type InvalidTokenError struct { Err error } @@ -146,7 +148,11 @@ func (i *InvalidTokenError) Unwrap() error { } func (i *InvalidTokenError) Error() string { - return "the SSO session associated with this profile has expired or is otherwise invalid. To refresh this SSO session run aws sso login with the corresponding profile." + const msg = "the SSO session has expired or is invalid" + if i.Err == nil { + return msg + } + return msg + ": " + i.Err.Error() } func loadTokenFile(startURL string) (t token, err error) { diff --git a/credentials/ssocreds/provider_test.go b/credentials/ssocreds/provider_test.go index aa0962e5e87..77fee9449e6 100644 --- a/credentials/ssocreds/provider_test.go +++ b/credentials/ssocreds/provider_test.go @@ -119,7 +119,7 @@ func TestProvider(t *testing.T) { AccessKeyId: aws.String("AccessKey"), SecretAccessKey: aws.String("SecretKey"), SessionToken: aws.String("SessionToken"), - Expiration: time.Date(2021, 01, 20, 00, 00, 0, 0, time.UTC).Unix(), + Expiration: 1611177743123, }, }, nil }, @@ -133,7 +133,7 @@ func TestProvider(t *testing.T) { SecretAccessKey: "SecretKey", SessionToken: "SessionToken", CanExpire: true, - Expires: time.Date(2021, 01, 20, 00, 00, 0, 0, time.UTC), + Expires: time.Date(2021, 01, 20, 21, 22, 23, 0.123e9, time.UTC), Source: ProviderName, }, }, diff --git a/local-mod-replace.sh b/local-mod-replace.sh new file mode 100755 index 00000000000..8a2aea99e2f --- /dev/null +++ b/local-mod-replace.sh @@ -0,0 +1,39 @@ +#1/usr/bin/env bash + +PROJECT_DIR="" +SDK_SOURCE_DIR=$(cd `dirname $0` && pwd) + +usage() { + echo "Usage: $0 [-s SDK_SOURCE_DIR] [-d PROJECT_DIR]" 1>&2 + exit 1 +} + +while getopts "hs:d:" options; do + case "${options}" in + s) + SDK_SOURCE_DIR=${OPTARG} + if [ "$SDK_SOURCE_DIR" == "" ]; then + echo "path to SDK source directory is required" || exit + usage + fi + ;; + d) + PROJECT_DIR=${OPTARG} + ;; + h) + usage + ;; + *) + usage + ;; + esac +done + +if [ "$PROJECT_DIR" != "" ]; then + cd $PROJECT_DIR || exit +fi + +go mod graph | awk '{print $1}' | cut -d '@' -f 1 | sort | uniq | grep "github.com/aws/aws-sdk-go-v2" | while read x; do + repPath=${x/github.com\/aws\/aws-sdk-go-v2/${SDK_SOURCE_DIR}} + echo -replace $x=$repPath +done | xargs go mod edit