Repository: Versent/saml2aws
Pull request #362 (2019-10-11 14:56:06)

Update exec to perform role assumption from profile.
The method created by @85matthew worked for a large majority of tools
but we found that some don't properly utilize the AWS_PROFILE env var.
In particular the Sops tool has issues.

This update makes it such that saml2aws behaves more like some other
tools in this space ([aws-vault](https://github.com/99designs/aws-vault) and [aws-okta](https://github.com/segmentio/aws-okta)). When the `--exec-profile`
flag is specified saml2aws now performs the role assumption via the STS
client from the AWS SDK. This means it will now do multiple levels of
role assumption _before_ executing the given command. Because saml2aws
is performing the assumption it no longer exports the AWS_PROFILE env
vars and the credentials that it export are fully fledged credentials
for the target profile.

BTW: @85matthew and I work together so this update doesn't break anything he was trying to achieve with #335.


Patch:
diff --git a/README.md b/README.md
index ea16abd..e86185e 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ The process goes something like this:
   * [Shibboleth](pkg/provider/shibboleth/README.md)
   * [F5APM](pkg/provider/f5apm/README.md)
   * [PSU](pkg/provider/psu/README.md)
+  * OneLogin
 * AWS SAML Provider configured
 
 ## Caveats
@@ -174,7 +175,8 @@ function s2a { eval $( $(which saml2aws) script --shell=bash --profile=$@); }
 If the `exec` sub-command is called, `saml2aws` will execute the command given as an argument:
 By default saml2aws will execute the command with temp credentials generated via `saml2aws login`.
 
-The `--exec-profile` flag allows for a command to execute using an aws profile which may have chained "assume role" actions. (via 'source_profile' in ~/.aws/config) *See section "blah" for scenario where this is useful as well as example below.
+The `--exec-profile` flag allows for a command to execute using an aws profile which may have chained
+"assume role" actions. (via 'source_profile' in ~/.aws/config)
 
 ```
 options:
@@ -358,6 +360,11 @@ x_security_token_expires = 2019-08-19T15:00:56-06:00
 source_profile=saml
 role_arn=arn:aws:iam::123456789012:role/OtherRoleInAnyFederatedAccount # Note the different account number here
 role_session_name=myAccountName
+
+[profile extraRroleIn2ndAwsAccount]
+# this profile uses a _third_ level of role assumption
+source_profile=roleIn2ndAwsAccount
+role_arn=arn:aws:iam::123456789012:role/OtherRoleInAnyFederatedAccount
 ```
 
 Running saml2aws without --exec-profile flag:
@@ -372,14 +379,16 @@ saml2aws exec aws sts get-caller-identity
 ```
 
 Running saml2aws with --exec-profile flag:
+
+When using '--exec-profile' I can assume-role into a different AWS account without re-authenticating. Note that it
+does not re-authenticate since we are already authenticated via the SSO account.
+
 ```
 saml2aws exec --exec-profile roleIn2ndAwsAccount aws sts get-caller-identity
 {
     "UserId": "YOOYOOYOOYOOYOOA:/myAccountName",
     "Account": "123456789012",
-    "Arn": "arn:aws:sts::123456789012:assumed-role/myAccountName"  # When using '--exec-profile' I can assume-role into a                                                                        # different AWS account without re-authenticating. 
-                                                                   # Note that it does not re-authenitcate since we are 
-                                                                   # alredy authenticated via the SSO account
+    "Arn": "arn:aws:sts::123456789012:assumed-role/myAccountName" 
 }
 ```
 
diff --git a/cmd/saml2aws/commands/exec.go b/cmd/saml2aws/commands/exec.go
index 3ec2720..4c670bf 100644
--- a/cmd/saml2aws/commands/exec.go
+++ b/cmd/saml2aws/commands/exec.go
@@ -4,10 +4,12 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/sts"
 	"github.com/pkg/errors"
+
 	"github.com/versent/saml2aws/pkg/awsconfig"
 	"github.com/versent/saml2aws/pkg/flags"
 	"github.com/versent/saml2aws/pkg/shell"
@@ -60,9 +62,51 @@ func Exec(execFlags *flags.LoginExecFlags, cmdline []string) error {
 		return errors.Wrap(err, "error logging in")
 	}
 
+	if execFlags.ExecProfile != "" {
+		// Assume the desired role before generating env vars
+		awsCreds, err = assumeRoleWithProfile(execFlags.ExecProfile)
+		if err != nil {
+			return errors.Wrap(err,
+				fmt.Sprintf("error acquiring credentials for profile: %s", execFlags.ExecProfile))
+		}
+	}
+
 	return shell.ExecShellCmd(cmdline, shell.BuildEnvVars(awsCreds, account, execFlags))
 }
 
+// assumeRoleWithProfile uses an AWS profile (via ~/.aws/config) and performs (multiple levels of) role assumption
+// This is extremely useful in the case of a central "authentication account" which then requires secondary, and
+// often tertiary, role assumptions to acquire credentials for the target role.
+func assumeRoleWithProfile(targetProfile string) (*awsconfig.AWSCredentials, error)  {
+	// AWS session config with verbose errors on chained credential errors
+	config := *aws.NewConfig().WithCredentialsChainVerboseErrors(true)
+
+	// a session forcing usage of the aws config file, sets the target profile which will be found in the config
+	sess := session.Must(session.NewSessionWithOptions(session.Options{
+		Config:            config,
+		Profile:           targetProfile,
+		SharedConfigState: session.SharedConfigEnable,
+	}))
+
+	// use an STS client to perform the multiple role assumptions
+	stsClient := sts.New(sess)
+	input := &sts.GetCallerIdentityInput{}
+	_, err := stsClient.GetCallerIdentity(input)
+	if err != nil {
+		return nil, err
+	}
+
+	creds, err := sess.Config.Credentials.Get()
+	if err != nil {
+		return nil, err
+	}
+	return &awsconfig.AWSCredentials{
+		AWSAccessKey:     creds.AccessKeyID,
+		AWSSecretKey:     creds.SecretAccessKey,
+		AWSSessionToken:  creds.SessionToken,
+	}, nil
+}
+
 func checkToken(profile string) (bool, error) {
 	sess, err := session.NewSessionWithOptions(session.Options{
 		Profile: profile,
@@ -86,6 +130,5 @@ func checkToken(profile string) (bool, error) {
 		return false, err
 	}
 
-	//fmt.Fprintln(os.Stderr, "Running command as:", aws.StringValue(resp.Arn))
 	return true, nil
 }
diff --git a/go.mod b/go.mod
index 074024e..d3dd5e9 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/alecthomas/units v0.0.0-20190910110746-680d30ca3117 // indirect
 	github.com/andybalholm/cascadia v1.0.0 // indirect
 	github.com/aulanov/go.dbus v0.0.0-20150729231527-25c3068a42a0 // indirect
-	github.com/aws/aws-sdk-go v1.14.8
+	github.com/aws/aws-sdk-go v1.23.15
 	github.com/beevik/etree v1.0.1
 	github.com/briandowns/spinner v0.0.0-20170614154858-48dbb65d7bd5
 	github.com/danieljoos/wincred v1.0.1
@@ -24,7 +24,6 @@ require (
 	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
 	github.com/headzoo/surf v1.0.1-0.20180909134844-a4a8c16c01dc
 	github.com/headzoo/ut v0.0.0-20181013193318-a13b5a7a02ca // indirect
-	github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 // indirect
 	github.com/keybase/go-keychain v0.0.0-20181011010623-f1daa725cce4 // indirect
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/mattn/go-colorable v0.0.9 // indirect
diff --git a/go.sum b/go.sum
index bc709ab..c1ae01d 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,8 @@ github.com/aulanov/go.dbus v0.0.0-20150729231527-25c3068a42a0 h1:EEDvbomAQ+MFWqJ
 github.com/aulanov/go.dbus v0.0.0-20150729231527-25c3068a42a0/go.mod h1:VHvUx+4lTCaJ8zUnEXF4cWEc9c8lnDt4PGLwlZ+3yaM=
 github.com/aws/aws-sdk-go v1.14.8 h1:vO7TW0IyYlGTjs2eWlHbv88uK9kjj8eiadyKEo4GUO4=
 github.com/aws/aws-sdk-go v1.14.8/go.mod h1:ZRmQr0FajVIyZ4ZzBYKG5P3ZqPz9IHG41ZoMu1ADI3k=
+github.com/aws/aws-sdk-go v1.23.15 h1:ut2ZzO0A34Ds18NXvvkWWKyO4aZqQ9uZquslWzCQvGU=
+github.com/aws/aws-sdk-go v1.23.15/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/beevik/etree v1.0.1 h1:lWzdj5v/Pj1X360EV7bUudox5SRipy4qZLjY0rhb0ck=
 github.com/beevik/etree v1.0.1/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
 github.com/briandowns/spinner v0.0.0-20170614154858-48dbb65d7bd5 h1:osZyZB7J4kE1tKLeaUjV6+uZVBfS835T0I/RxmwWw1w=
@@ -54,6 +56,8 @@ github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/keybase/go-keychain v0.0.0-20181011010623-f1daa725cce4 h1:YB7bZpTYGkkRZrUQ6mtE9Mq0lukJDSWj5XCcd5FO6Uc=
diff --git a/pkg/shell/env.go b/pkg/shell/env.go
index 6f78f1e..9c53148 100644
--- a/pkg/shell/env.go
+++ b/pkg/shell/env.go
@@ -14,17 +14,14 @@ func BuildEnvVars(awsCreds *awsconfig.AWSCredentials, account *cfg.IDPAccount, e
 		fmt.Sprintf("AWS_SESSION_TOKEN=%s", awsCreds.AWSSessionToken),
 		fmt.Sprintf("AWS_SECURITY_TOKEN=%s", awsCreds.AWSSecurityToken),
 		fmt.Sprintf("EC2_SECURITY_TOKEN=%s", awsCreds.AWSSecurityToken),
+		fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", awsCreds.AWSAccessKey),
+		fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", awsCreds.AWSSecretKey),
 	}
 
-	//To run exec with a non default (saml) profile 'AWS_ACCESS_KEY_ID' and 'AWS_SECRET_ACCESS_KEY' must not be set
 	if execFlags.ExecProfile == "" {
-		environmentVars = append(environmentVars, fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", awsCreds.AWSAccessKey))
-		environmentVars = append(environmentVars, fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", awsCreds.AWSSecretKey))
+		// Only set profile env vars if we haven't already assumed a role via a profile
 		environmentVars = append(environmentVars, fmt.Sprintf("AWS_PROFILE=%s", account.Profile))
 		environmentVars = append(environmentVars, fmt.Sprintf("AWS_DEFAULT_PROFILE=%s", account.Profile))
-	} else {
-		environmentVars = append(environmentVars, fmt.Sprintf("AWS_PROFILE=%s", execFlags.ExecProfile))
-		environmentVars = append(environmentVars, fmt.Sprintf("AWS_DEFAULT_PROFILE=%s", execFlags.ExecProfile))
 	}
 	return environmentVars
 }


Test patch:
diff --git a/pkg/shell/env_test.go b/pkg/shell/env_test.go
index 07d208a..dfc7535 100644
--- a/pkg/shell/env_test.go
+++ b/pkg/shell/env_test.go
@@ -1,30 +1,18 @@
 package shell
 
 import (
+	"reflect"
 	"testing"
 
-	"github.com/stretchr/testify/assert"
 	"github.com/versent/saml2aws/pkg/awsconfig"
 	"github.com/versent/saml2aws/pkg/cfg"
 	"github.com/versent/saml2aws/pkg/flags"
 )
 
 func TestBuildEnvVars(t *testing.T) {
-
-	expectedArray := []string{
-		"AWS_SESSION_TOKEN=567",
-		"AWS_SECURITY_TOKEN=567",
-		"EC2_SECURITY_TOKEN=567",
-		"AWS_ACCESS_KEY_ID=123",
-		"AWS_SECRET_ACCESS_KEY=345",
-		"AWS_PROFILE=saml",
-		"AWS_DEFAULT_PROFILE=saml",
-	}
-
 	account := &cfg.IDPAccount{
 		Profile: "saml",
 	}
-
 	awsCreds := &awsconfig.AWSCredentials{
 		AWSAccessKey:     "123",
 		AWSSecretKey:     "345",
@@ -32,7 +20,43 @@ func TestBuildEnvVars(t *testing.T) {
 		AWSSessionToken:  "567",
 	}
 
-	flags := new(flags.LoginExecFlags)
-
-	assert.Equal(t, expectedArray, BuildEnvVars(awsCreds, account, flags))
+	tests := []struct {
+		name  string
+		flags *flags.LoginExecFlags
+		want  []string
+	}{
+		{
+			name:  "build-env",
+			flags: &flags.LoginExecFlags{},
+			want: []string{
+				"AWS_SESSION_TOKEN=567",
+				"AWS_SECURITY_TOKEN=567",
+				"EC2_SECURITY_TOKEN=567",
+				"AWS_ACCESS_KEY_ID=123",
+				"AWS_SECRET_ACCESS_KEY=345",
+				"AWS_PROFILE=saml",
+				"AWS_DEFAULT_PROFILE=saml",
+			},
+		},
+		{
+			name: "build-env-with-profile",
+			flags: &flags.LoginExecFlags{
+				ExecProfile: "testing",
+			},
+			want: []string{
+				"AWS_SESSION_TOKEN=567",
+				"AWS_SECURITY_TOKEN=567",
+				"EC2_SECURITY_TOKEN=567",
+				"AWS_ACCESS_KEY_ID=123",
+				"AWS_SECRET_ACCESS_KEY=345",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := BuildEnvVars(awsCreds, account, tt.flags); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("BuildEnvVars() = %v, want %v", got, tt.want)
+			}
+		})
+	}
 }