From 01e8d5a82ff8486aea63be045332b72a08185941 Mon Sep 17 00:00:00 2001 From: itsdalmo Date: Thu, 26 Mar 2020 15:18:47 +0100 Subject: [PATCH] Add support for AWS SSO profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rickard Löfström --- README.md | 35 +++- USAGE.md | 19 ++ cli/login.go | 8 +- vault/config.go | 32 ++++ vault/ssorolecredentialsprovider.go | 257 ++++++++++++++++++++++++++++ vault/vault.go | 37 ++++ 6 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 vault/ssorolecredentialsprovider.go diff --git a/README.md b/README.md index f1cffbcd2..628aecfcc 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,33 @@ mfa_serial = arn:aws:iam::111111111111:mfa/jonsmith Here's what you can expect from aws-vault -| Command | Credentials | Cached | MFA | -| ---------------------------------------- | -----------------------------| ------------- | ----- | -| `aws-vault exec jonsmith --no-session` | Long-term credentials | No | No | -| `aws-vault exec jonsmith` | session-token | session-token | Yes | -| `aws-vault exec foo-readonly` | role | No | No | -| `aws-vault exec foo-admin` | session-token + role | session-token | Yes | -| `aws-vault exec foo-admin --duration=2h` | role | No | Yes | -| `aws-vault exec bar-role2` | session-token + role + role | session-token | Yes | -| `aws-vault exec bar-role2 --no-session` | role + role | No | Yes | +| Command | Credentials | Cached | MFA | +|------------------------------------------|-----------------------------|---------------|-----| +| `aws-vault exec jonsmith --no-session` | Long-term credentials | No | No | +| `aws-vault exec jonsmith` | session-token | session-token | Yes | +| `aws-vault exec foo-readonly` | role | No | No | +| `aws-vault exec foo-admin` | session-token + role | session-token | Yes | +| `aws-vault exec foo-admin --duration=2h` | role | No | Yes | +| `aws-vault exec bar-role2` | session-token + role + role | session-token | Yes | +| `aws-vault exec bar-role2 --no-session` | role + role | No | Yes | +## AWS SSO integration + +If your organization uses AWS Single Sign-On ([AWS SSO](https://aws.amazon.com/single-sign-on/)), AWS Vault provides a method for using the credential information defined by [AWS SSO CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html). The integration supports caching of the temporary credentials for each profile, and will automatically refresh the credentials using an SSO Access Token (with a life-time that is specific to your integration). For more information about AWS SSO, please see this [blog post](https://aws.amazon.com/blogs/aws/the-next-evolution-in-aws-single-sign-on/) from AWS. + +The AWS CLI v2 provides a wizard to generate the required profile configuration, but it's also possible to directly input this information in your `~/.aws/config` file. + +Here's an example configuration using AWS SSO: + +```ini +[profile Administrator-123456789012] +sso_start_url=https://aws-sso-portal.awsapps.com/start +sso_region=eu-west-1 +sso_account_id=123456789012 +sso_role_name=Administrator +``` + +This profile should work expected with AWS Vault commands, e.g. `exec` and `login`. See [Basic Usage](#basic-usage) for more information. ## Development diff --git a/USAGE.md b/USAGE.md index b62f461f8..295242c90 100644 --- a/USAGE.md +++ b/USAGE.md @@ -222,6 +222,25 @@ role_arn = arn:aws:iam::123456789012:role/target You can also set the `mfa_serial` with the environment variable `AWS_MFA_SERIAL`. +## AWS Single Sign-On (AWS SSO) + +The AWS CLI can [generate the SSO profile configuration](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html), but it's also possible to directly input this information in your `~/.aws/config` file. The configuration options are as follows: +* `sso_start_url` The URL that points to the organization's AWS SSO user portal. +* `sso_region` The AWS Region that contains the AWS SSO portal host. This is separate from, and can be a different region than the default CLI region parameter. +* `sso_account_id` The AWS account ID that contains the IAM role that you want to use with this profile. +* `sso_role_name` The name of the IAM role that defines the user's permissions when using this profile. + +### Example ~/.aws/config + +Here is an example `~/.aws/config` file, to help show the configuration for use with AWS SSO. + +```ini +[profile Administrator-123456789012] +sso_start_url=https://aws-sso-portal.awsapps.com/start +sso_region=eu-west-1 +sso_account_id=123456789012 +sso_role_name=Administrator +``` ## Removing stored sessions diff --git a/cli/login.go b/cli/login.go index 203403685..72efd58cc 100644 --- a/cli/login.go +++ b/cli/login.go @@ -79,11 +79,11 @@ func LoginCommand(input LoginCommandInput) error { var creds *credentials.Credentials - // if AssumeRole isn't used, GetFederationToken has to be used for IAM credentials - if config.RoleARN == "" { - creds, err = vault.NewFederationTokenCredentials(input.ProfileName, input.Keyring, config) - } else { + // If AssumeRole or sso.GetRoleCredentials isn't used, GetFederationToken has to be used for IAM credentials + if config.HasRole() || config.HasSSOStartURL() { creds, err = vault.NewTempCredentials(config, input.Keyring) + } else { + creds, err = vault.NewFederationTokenCredentials(input.ProfileName, input.Keyring, config) } if err != nil { return err diff --git a/vault/config.go b/vault/config.go index 4eafd3d22..3cc66434c 100644 --- a/vault/config.go +++ b/vault/config.go @@ -136,6 +136,10 @@ type ProfileSection struct { DurationSeconds uint `ini:"duration_seconds,omitempty"` SourceProfile string `ini:"source_profile,omitempty"` ParentProfile string `ini:"parent_profile,omitempty"` + SSOStartURL string `ini:"sso_start_url,omitempty"` + SSORegion string `ini:"sso_region,omitempty"` + SSOAccountID string `ini:"sso_account_id,omitempty"` + SSORoleName string `ini:"sso_role_name,omitempty"` } func (s ProfileSection) IsEmpty() bool { @@ -296,6 +300,18 @@ func (cl *ConfigLoader) populateFromConfigFile(config *Config, profileName strin if config.SourceProfileName == "" { config.SourceProfileName = psection.SourceProfile } + if config.SSOStartURL == "" { + config.SSOStartURL = psection.SSOStartURL + } + if config.SSORegion == "" { + config.SSORegion = psection.SSORegion + } + if config.SSOAccountID == "" { + config.SSOAccountID = psection.SSOAccountID + } + if config.SSORoleName == "" { + config.SSORoleName = psection.SSORoleName + } if psection.ParentProfile != "" { err := cl.populateFromConfigFile(config, psection.ParentProfile) @@ -448,6 +464,18 @@ type Config struct { // GetFederationTokenDuration specifies the wanted duration for credentials generated with GetFederationToken GetFederationTokenDuration time.Duration + + // SSOStartURL specifies the URL for the AWS SSO user portal. + SSOStartURL string + + // SSORegion specifies the region for the AWS SSO user portal. + SSORegion string + + // SSOAccountID specifies the AWS account ID for the profile. + SSOAccountID string + + // SSORoleName specifies the AWS SSO Role name to target. + SSORoleName string } func (c *Config) IsChained() bool { @@ -466,6 +494,10 @@ func (c *Config) HasRole() bool { return c.RoleARN != "" } +func (c *Config) HasSSOStartURL() bool { + return c.SSOStartURL != "" +} + // CanUseGetSessionToken determines if GetSessionToken should be used, and if not returns a reason func (c *Config) CanUseGetSessionToken() (bool, string) { if !UseSession { diff --git a/vault/ssorolecredentialsprovider.go b/vault/ssorolecredentialsprovider.go new file mode 100644 index 000000000..ca5ce2d71 --- /dev/null +++ b/vault/ssorolecredentialsprovider.go @@ -0,0 +1,257 @@ +package vault + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/99designs/keyring" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/service/sso" + "github.com/aws/aws-sdk-go/service/ssooidc" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/skratchdot/open-golang/open" +) + +const ( + ssoClientName = "aws-vault" + ssoClientType = "public" + oAuthTokenGrantType = "urn:ietf:params:oauth:grant-type:device_code" + authorizationTemplate = ` +Attempting to automatically open the SSO authorization page in your default +browser. If the browser does not open or you wish to use a different device to +authorize this request, open the following URL: + +%s +(Use Ctrl-C to abort) + +` +) + +// CachedSSORoleCredentialsProvider uses the keyring to cache SSO Role sessions. +type CachedSSORoleCredentialsProvider struct { + CredentialsName string + Keyring *CredentialKeyring + Provider *SSORoleCredentialsProvider + ExpiryWindow time.Duration + credentials.Expiry +} + +// Retrieve the cached credentials or generate new ones. +func (p *CachedSSORoleCredentialsProvider) Retrieve() (credentials.Value, error) { + sessions := p.Keyring.Sessions() + + session, err := sessions.Retrieve(p.CredentialsName, "") + if err != nil { + // session lookup missed, we need to create a new one. + session, err = p.Provider.GetRoleCredentials() + if err != nil { + return credentials.Value{}, err + } + + err = sessions.Store(p.CredentialsName, "", session) + if err != nil { + return credentials.Value{}, err + } + } else { + log.Printf("Re-using cached credentials %s generated from GetRoleCredentials, expires in %s", FormatKeyForDisplay(*session.AccessKeyId), time.Until(*session.Expiration).String()) + } + + p.SetExpiration(*session.Expiration, p.ExpiryWindow) + + return credentials.Value{ + AccessKeyID: *session.AccessKeyId, + SecretAccessKey: *session.SecretAccessKey, + SessionToken: *session.SessionToken, + }, nil +} + +// SSORoleCredentialsProvider creates temporary credentials for an SSO Role. +type SSORoleCredentialsProvider struct { + OIDCProvider *SSOOIDCProvider + SSOClient *sso.SSO + AccountID string + RoleName string + ExpiryWindow time.Duration + credentials.Expiry +} + +// Retrieve generates a new set of temporary credentials using SSO GetRoleCredentials. +func (p *SSORoleCredentialsProvider) Retrieve() (credentials.Value, error) { + creds, err := p.GetRoleCredentials() + if err != nil { + return credentials.Value{}, err + } + + p.SetExpiration(*creds.Expiration, p.ExpiryWindow) + return credentials.Value{ + AccessKeyID: *creds.AccessKeyId, + SecretAccessKey: *creds.SecretAccessKey, + SessionToken: *creds.SessionToken, + }, nil +} + +func (p *SSORoleCredentialsProvider) GetRoleCredentials() (*sts.Credentials, error) { + token, err := p.OIDCProvider.GetAccessToken() + if err != nil { + return nil, err + } + + resp, err := p.SSOClient.GetRoleCredentials(&sso.GetRoleCredentialsInput{ + AccessToken: aws.String(token.Token), + AccountId: aws.String(p.AccountID), + RoleName: aws.String(p.RoleName), + }) + if err != nil { + return nil, err + } + + expiration := aws.MillisecondsTimeValue(resp.RoleCredentials.Expiration) + + // This is needed because sessions.Store expects a sts.Credentials object. + creds := &sts.Credentials{ + AccessKeyId: resp.RoleCredentials.AccessKeyId, + SecretAccessKey: resp.RoleCredentials.SecretAccessKey, + SessionToken: resp.RoleCredentials.SessionToken, + Expiration: aws.Time(expiration), + } + + log.Printf("Got credentials %s for SSO role %s (account: %s), expires in %s", FormatKeyForDisplay(*resp.RoleCredentials.AccessKeyId), p.RoleName, p.AccountID, time.Until(expiration).String()) + + return creds, nil +} + +type SSOClientCredentials struct { + ID string + Secret string + Expiration time.Time +} + +type SSOAccessToken struct { + Token string + Expiration time.Time +} + +type SSOOIDCProvider struct { + OIDCClient *ssooidc.SSOOIDC + Keyring *CredentialKeyring + StartURL string +} + +func (p *SSOOIDCProvider) GetAccessToken() (*SSOAccessToken, error) { + var ( + creds = &struct { + Token *SSOAccessToken + Client *SSOClientCredentials + }{ + Client: &SSOClientCredentials{}, + Token: &SSOAccessToken{}, + } + credsUpdated bool + ) + + item, err := p.Keyring.Keyring.Get(p.StartURL) + if err != nil && err != keyring.ErrKeyNotFound { + return nil, err + } + + if item.Data != nil { + if err = json.Unmarshal(item.Data, &creds); err != nil { + return nil, fmt.Errorf("Invalid data in keyring: %v", err) + } + } + + if creds.Client.Expiration.Before(time.Now()) { + creds.Client, err = p.registerNewClient() + if err != nil { + return nil, err + } + log.Printf("Created new SSO client for %s (expires at: %s)", p.StartURL, creds.Client.Expiration.String()) + credsUpdated = true + } + + if creds.Token.Expiration.Before(time.Now()) { + creds.Token, err = p.createClientToken(creds.Client) + if err != nil { + return nil, err + } + log.Printf("Created new SSO access token for %s (expires at: %s)", p.StartURL, creds.Token.Expiration.String()) + credsUpdated = true + } + + if credsUpdated { + bytes, err := json.Marshal(creds) + if err != nil { + return nil, err + } + err = p.Keyring.Keyring.Set(keyring.Item{ + Key: p.StartURL, + Label: fmt.Sprintf("aws-vault (%s)", p.StartURL), + Data: bytes, + KeychainNotTrustApplication: true, + }) + if err != nil { + return nil, err + } + } + + return creds.Token, nil +} + +func (p *SSOOIDCProvider) registerNewClient() (*SSOClientCredentials, error) { + c, err := p.OIDCClient.RegisterClient(&ssooidc.RegisterClientInput{ + ClientName: aws.String(ssoClientName), + ClientType: aws.String(ssoClientType), + }) + if err != nil { + return nil, err + } + return &SSOClientCredentials{ + ID: aws.StringValue(c.ClientId), + Secret: aws.StringValue(c.ClientSecret), + Expiration: time.Unix(aws.Int64Value(c.ClientSecretExpiresAt), 0), + }, nil +} + +func (p *SSOOIDCProvider) createClientToken(creds *SSOClientCredentials) (*SSOAccessToken, error) { + auth, err := p.OIDCClient.StartDeviceAuthorization(&ssooidc.StartDeviceAuthorizationInput{ + ClientId: aws.String(creds.ID), + ClientSecret: aws.String(creds.Secret), + StartUrl: aws.String(p.StartURL), + }) + if err != nil { + return nil, err + } + + fmt.Printf(authorizationTemplate, aws.StringValue(auth.VerificationUriComplete)) + + if err := open.Run(aws.StringValue(auth.VerificationUriComplete)); err != nil { + log.Printf("failed to open browser: %s", err) + } + + for { + // Sleep to allow the user to complete the login flow + time.Sleep(3 * time.Second) + + t, err := p.OIDCClient.CreateToken(&ssooidc.CreateTokenInput{ + ClientId: aws.String(creds.ID), + ClientSecret: aws.String(creds.Secret), + DeviceCode: auth.DeviceCode, + GrantType: aws.String(oAuthTokenGrantType), + }) + if err != nil { + e, ok := err.(awserr.Error) + if !ok || e.Code() != ssooidc.ErrCodeAuthorizationPendingException { + return nil, err + } + continue + } + return &SSOAccessToken{ + Token: aws.StringValue(t.AccessToken), + Expiration: time.Now().Add(time.Duration(aws.Int64Value(t.ExpiresIn)) * time.Second), + }, nil + } +} diff --git a/vault/vault.go b/vault/vault.go index 145f40780..0f99950b3 100644 --- a/vault/vault.go +++ b/vault/vault.go @@ -10,6 +10,8 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sso" + "github.com/aws/aws-sdk-go/service/ssooidc" "github.com/aws/aws-sdk-go/service/sts" ) @@ -113,6 +115,39 @@ func NewAssumeRoleProvider(creds *credentials.Credentials, config *Config) (*Ass }, nil } +// NewSSORoleCredentialsProvider creates a provider for SSO credentials +func NewSSORoleCredentialsProvider(k *CredentialKeyring, config *Config) (credentials.Provider, error) { + sess, err := session.NewSession(&aws.Config{Region: aws.String(config.SSORegion)}) + if err != nil { + return nil, err + } + + ssoOIDCProvider := &SSOOIDCProvider{ + Keyring: k, + OIDCClient: ssooidc.New(sess), + StartURL: config.SSOStartURL, + } + + ssoRoleCredentialsProvider := &SSORoleCredentialsProvider{ + OIDCProvider: ssoOIDCProvider, + SSOClient: sso.New(sess), + AccountID: config.SSOAccountID, + RoleName: config.SSORoleName, + ExpiryWindow: defaultExpirationWindow, + } + + if UseSessionCache { + return &CachedSSORoleCredentialsProvider{ + CredentialsName: config.ProfileName, + Keyring: k, + ExpiryWindow: defaultExpirationWindow, + Provider: ssoRoleCredentialsProvider, + }, nil + } + + return ssoRoleCredentialsProvider, nil +} + type tempCredsCreator struct { keyring *CredentialKeyring chainedMfa string @@ -136,6 +171,8 @@ func (t *tempCredsCreator) provider(config *Config) (credentials.Provider, error if err != nil { return nil, err } + } else if config.HasSSOStartURL() { + return NewSSORoleCredentialsProvider(t.keyring, config) } else { return nil, fmt.Errorf("profile %s: credentials missing", config.ProfileName) }