Skip to content

Commit eb7bc3c

Browse files
author
Mark Wolfe
authored
Merge pull request Versent#607 from sledigabel/saml_caching
Adding SAML Assertion caching feature
2 parents 3251e9c + 0f1a450 commit eb7bc3c

9 files changed

Lines changed: 330 additions & 6 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The process goes something like this:
1212
* Prompt user for credentials
1313
* Log in to Identity Provider using form based authentication
1414
* Build a SAML assertion containing AWS roles
15+
* Optionally cache the SAML assertion (the cache is not encrypted)
1516
* Exchange the role and SAML assertion with [AWS STS service](https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html) to get a temporary set of credentials
1617
* Save these credentials to an aws profile named "saml"
1718

@@ -630,6 +631,14 @@ credential_process = saml2aws login --skip-prompt --quiet --credential-process -
630631

631632
When using the aws cli with the `mybucket` profile, the authentication process will be run and the aws will then be executed based on the returned credentials.
632633

634+
# Caching the saml2aws SAML assertion for immediate reuse
635+
636+
You can use the flag `--cache-saml` in order to cache the SAML assertion at authentication time. The SAML assertion cache has a very short validity (5 min) and can be used to authenticate to several roles with a single MFA validation.
637+
638+
there is a file per saml2aws profile, the cache directory is called `saml2aws` and is located in your `.aws` directory in your user homedir.
639+
640+
You can toggle `--cache-saml` during `login` or during `list-roles`, and you can set it once during `configure` and use it implicitly.
641+
633642
# License
634643

635644
This code is Copyright (c) 2018 [Versent](http://versent.com.au) and released under the MIT license. All rights not explicitly granted in the MIT license are reserved. See the included LICENSE.md file for more details.

cmd/saml2aws/commands/list_roles.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/versent/saml2aws/v2"
1212
"github.com/versent/saml2aws/v2/helper/credentials"
1313
"github.com/versent/saml2aws/v2/pkg/flags"
14+
"github.com/versent/saml2aws/v2/pkg/samlcache"
1415
)
1516

1617
// ListRoles will list available role ARNs
@@ -23,6 +24,12 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error {
2324
return errors.Wrap(err, "error building login details")
2425
}
2526

27+
// creates a cacheProvider, only used when --cache is set
28+
// cacheProvider := samlcache.NewSAMLCacheProvider(account.Name, "")
29+
cacheProvider := &samlcache.SAMLCacheProvider{
30+
Account: account.Name,
31+
}
32+
2633
loginDetails, err := resolveLoginDetails(account, loginFlags)
2734
if err != nil {
2835
log.Printf("%+v", err)
@@ -41,10 +48,28 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error {
4148
return errors.Wrap(err, "error validating login details")
4249
}
4350

44-
samlAssertion, err := provider.Authenticate(loginDetails)
45-
if err != nil {
46-
return errors.Wrap(err, "error authenticating to IdP")
51+
var samlAssertion string
52+
if account.SAMLCache {
53+
if cacheProvider.IsValid() {
54+
samlAssertion, err = cacheProvider.Read()
55+
if err != nil {
56+
logger.Debug("Could not read cache:", err)
57+
}
58+
}
59+
}
4760

61+
if samlAssertion == "" {
62+
// samlAssertion was not cached
63+
samlAssertion, err = provider.Authenticate(loginDetails)
64+
if err != nil {
65+
return errors.Wrap(err, "error authenticating to IdP")
66+
}
67+
if account.SAMLCache {
68+
err = cacheProvider.Write(samlAssertion)
69+
if err != nil {
70+
logger.Error("Could not write samlAssertion:", err)
71+
}
72+
}
4873
}
4974

5075
if samlAssertion == "" {

cmd/saml2aws/commands/login.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/versent/saml2aws/v2/pkg/cfg"
1919
"github.com/versent/saml2aws/v2/pkg/creds"
2020
"github.com/versent/saml2aws/v2/pkg/flags"
21+
"github.com/versent/saml2aws/v2/pkg/samlcache"
2122
)
2223

2324
// Login login to ADFS
@@ -31,6 +32,10 @@ func Login(loginFlags *flags.LoginExecFlags) error {
3132
}
3233

3334
sharedCreds := awsconfig.NewSharedCredentials(account.Profile, account.CredentialsFile)
35+
// creates a cacheProvider, only used when --cache is set
36+
cacheProvider := &samlcache.SAMLCacheProvider{
37+
Account: account.Name,
38+
}
3439

3540
logger.Debug("check if Creds Exist")
3641

@@ -79,10 +84,30 @@ func Login(loginFlags *flags.LoginExecFlags) error {
7984

8085
log.Printf("Authenticating as %s ...", loginDetails.Username)
8186

82-
samlAssertion, err := provider.Authenticate(loginDetails)
83-
if err != nil {
84-
return errors.Wrap(err, "error authenticating to IdP")
87+
var samlAssertion string
88+
if account.SAMLCache {
89+
if cacheProvider.IsValid() {
90+
samlAssertion, err = cacheProvider.Read()
91+
if err != nil {
92+
return errors.Wrap(err, "Could not read saml cache")
93+
}
94+
} else {
95+
logger.Debug("Cache is invalid")
96+
}
97+
}
8598

99+
if samlAssertion == "" {
100+
// samlAssertion was not cached
101+
samlAssertion, err = provider.Authenticate(loginDetails)
102+
if err != nil {
103+
return errors.Wrap(err, "error authenticating to IdP")
104+
}
105+
if account.SAMLCache {
106+
err = cacheProvider.Write(samlAssertion)
107+
if err != nil {
108+
return errors.Wrap(err, "Could not write saml cache")
109+
}
110+
}
86111
}
87112

88113
if samlAssertion == "" {

cmd/saml2aws/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func main() {
9292
cmdConfigure.Flag("profile", "The AWS profile to save the temporary credentials. (env: SAML2AWS_PROFILE)").Envar("SAML2AWS_PROFILE").Short('p').StringVar(&commonFlags.Profile)
9393
cmdConfigure.Flag("resource-id", "F5APM SAML resource ID of your company account. (env: SAML2AWS_F5APM_RESOURCE_ID)").Envar("SAML2AWS_F5APM_RESOURCE_ID").StringVar(&commonFlags.ResourceID)
9494
cmdConfigure.Flag("credentials-file", "The file that will cache the credentials retrieved from AWS. When not specified, will use the default AWS credentials file location. (env: SAML2AWS_CREDENTIALS_FILE)").Envar("SAML2AWS_CREDENTIALS_FILE").StringVar(&commonFlags.CredentialsFile)
95+
cmdConfigure.Flag("cache-saml", "Caches the SAML response").Envar("SAML2AWS_CACHE_SAML").BoolVar(&commonFlags.SAMLCache)
9596
configFlags := commonFlags
9697

9798
// `login` command and settings
@@ -105,6 +106,7 @@ func main() {
105106
cmdLogin.Flag("force", "Refresh credentials even if not expired.").BoolVar(&loginFlags.Force)
106107
cmdLogin.Flag("credential-process", "Enables AWS Credential Process support by outputting credentials to STDOUT in a JSON message.").BoolVar(&loginFlags.CredentialProcess)
107108
cmdLogin.Flag("credentials-file", "The file that will cache the credentials retrieved from AWS. When not specified, will use the default AWS credentials file location. (env: SAML2AWS_CREDENTIALS_FILE)").Envar("SAML2AWS_CREDENTIALS_FILE").StringVar(&commonFlags.CredentialsFile)
109+
cmdLogin.Flag("cache-saml", "Caches the SAML response").Envar("SAML2AWS_CACHE_SAML").BoolVar(&commonFlags.SAMLCache)
108110

109111
// `exec` command and settings
110112
cmdExec := app.Command("exec", "Exec the supplied command with env vars from STS token.")
@@ -128,6 +130,7 @@ func main() {
128130

129131
// `list` command and settings
130132
cmdListRoles := app.Command("list-roles", "List available role ARNs.")
133+
cmdListRoles.Flag("cache-saml", "Caches the SAML response").Envar("SAML2AWS_CACHE_SAML").BoolVar(&commonFlags.SAMLCache)
131134
listRolesFlags := new(flags.LoginExecFlags)
132135
listRolesFlags.CommonFlags = commonFlags
133136

pkg/cfg/cfg.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const (
3030

3131
// IDPAccount saml IDP account
3232
type IDPAccount struct {
33+
Name string
3334
AppID string `ini:"app_id"` // used by OneLogin and AzureAD
3435
URL string `ini:"url"`
3536
Username string `ini:"username"`
@@ -47,6 +48,7 @@ type IDPAccount struct {
4748
HttpAttemptsCount string `ini:"http_attempts_count"`
4849
HttpRetryDelay string `ini:"http_retry_delay"`
4950
CredentialsFile string `ini:"credentials_file"`
51+
SAMLCache bool `ini:"saml_cache"`
5052
}
5153

5254
func (ia IDPAccount) String() string {
@@ -195,6 +197,9 @@ func (cm *ConfigManager) LoadIDPAccount(idpAccountName string) (*IDPAccount, err
195197
return nil, errors.Wrap(err, "Unable to read idp account")
196198
}
197199

200+
// adding Name at Load time for the IdpAccount to have awareness of "self"
201+
account.Name = idpAccountName
202+
198203
return account, nil
199204
}
200205

pkg/cfg/cfg_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func TestNewConfigManagerLoad(t *testing.T) {
2727
idpAccount, err := cfgm.LoadIDPAccount("test123")
2828
require.Nil(t, err)
2929
require.Equal(t, &IDPAccount{
30+
Name: "test123",
3031
URL: "https://id.whatever.com",
3132
Username: "abc@whatever.com",
3233
Provider: "keycloak",
@@ -61,6 +62,7 @@ func TestNewConfigManagerSave(t *testing.T) {
6162
idpAccount, err := cfgm.LoadIDPAccount("testing2")
6263
require.Nil(t, err)
6364
require.Equal(t, &IDPAccount{
65+
Name: "testing2",
6466
URL: "https://id.whatever.com",
6567
Username: "abc@whatever.com",
6668
Provider: "keycloak",

pkg/flags/flags.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type CommonFlags struct {
2828
DisableKeychain bool
2929
Region string
3030
CredentialsFile string
31+
SAMLCache bool
3132
}
3233

3334
// LoginExecFlags flags for the Login / Exec commands
@@ -98,4 +99,7 @@ func ApplyFlagOverrides(commonFlags *CommonFlags, account *cfg.IDPAccount) {
9899
if commonFlags.CredentialsFile != "" {
99100
account.CredentialsFile = commonFlags.CredentialsFile
100101
}
102+
if commonFlags.SAMLCache {
103+
account.SAMLCache = commonFlags.SAMLCache
104+
}
101105
}

pkg/samlcache/samlcache.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package samlcache
2+
3+
import (
4+
"fmt"
5+
"io/ioutil"
6+
"os"
7+
"path"
8+
"path/filepath"
9+
"runtime"
10+
"time"
11+
12+
homedir "github.com/mitchellh/go-homedir"
13+
"github.com/pkg/errors"
14+
"github.com/sirupsen/logrus"
15+
)
16+
17+
var (
18+
ErrInvalidCachePath = errors.New("Cannot evaluate Cache file path")
19+
logger = logrus.WithField("pkg", "samlcache")
20+
)
21+
22+
const (
23+
SAMLAssertionValidityTimeout = 5 * time.Minute
24+
SAMLCacheFilePermissions = 0600
25+
SAMLCacheDirPermissions = 0700
26+
SAMLCacheDir = "saml2aws"
27+
)
28+
29+
// SAMLCacheProvider loads aws credentials file
30+
type SAMLCacheProvider struct {
31+
Filename string
32+
Account string
33+
}
34+
35+
func resolveSymlink(filename string) (string, error) {
36+
sympath, err := filepath.EvalSymlinks(filename)
37+
38+
// return the un modified filename
39+
if os.IsNotExist(err) {
40+
return filename, nil
41+
}
42+
if err != nil {
43+
return "", err
44+
}
45+
46+
return sympath, nil
47+
}
48+
49+
func (p *SAMLCacheProvider) IsValid() bool {
50+
var cache_path string
51+
var err error
52+
if p.Filename == "" {
53+
cache_path, err = locateCacheFile(p.Account)
54+
if err != nil {
55+
return false
56+
}
57+
} else {
58+
cache_path = p.Filename
59+
}
60+
61+
fileInfo, err := os.Stat(cache_path)
62+
if err != nil {
63+
return false
64+
}
65+
66+
return time.Since(fileInfo.ModTime()) < SAMLAssertionValidityTimeout
67+
}
68+
69+
func locateCacheFile(account string) (string, error) {
70+
71+
var name, filename string
72+
var err error
73+
if account == "" {
74+
filename = "cache"
75+
} else {
76+
filename = fmt.Sprintf("cache_%s", account)
77+
}
78+
if runtime.GOOS == "windows" {
79+
name = path.Join(os.Getenv("USERPROFILE"), ".aws", SAMLCacheDir, filename)
80+
} else {
81+
name, err = homedir.Expand(path.Join("~", ".aws", SAMLCacheDir, filename))
82+
if err != nil {
83+
return "", ErrInvalidCachePath
84+
}
85+
}
86+
// is the filename a symlink?
87+
name, err = resolveSymlink(name)
88+
if err != nil {
89+
return "", errors.Wrap(err, "unable to resolve symlink")
90+
}
91+
92+
logger.WithField("name", name).Debug("resolveSymlink")
93+
94+
return name, nil
95+
}
96+
97+
func (p *SAMLCacheProvider) Read() (string, error) {
98+
99+
var cache_path string
100+
var err error
101+
if p.Filename == "" {
102+
cache_path, err = locateCacheFile(p.Account)
103+
if err != nil {
104+
return "", errors.Wrap(err, "Could not retrieve cache file path")
105+
}
106+
} else {
107+
cache_path = p.Filename
108+
}
109+
110+
content, err := ioutil.ReadFile(cache_path)
111+
if err != nil {
112+
return "", errors.Wrap(err, "Could not read the cache file path")
113+
}
114+
115+
return string(content), nil
116+
}
117+
118+
func (p *SAMLCacheProvider) Write(samlAssertion string) error {
119+
120+
var cache_path string
121+
var err error
122+
if p.Filename == "" {
123+
cache_path, err = locateCacheFile(p.Account)
124+
if err != nil {
125+
return errors.Wrap(err, "Could not retrieve cache file path")
126+
}
127+
} else {
128+
cache_path = p.Filename
129+
}
130+
131+
// create the directory if it doesn't exist
132+
err = os.MkdirAll(path.Dir(cache_path), SAMLCacheDirPermissions)
133+
if err != nil {
134+
return errors.Wrap(err, "Could not write the cache file directory")
135+
}
136+
err = ioutil.WriteFile(cache_path, []byte(samlAssertion), SAMLCacheFilePermissions)
137+
if err != nil {
138+
return errors.Wrap(err, "Could not write the cache file path")
139+
}
140+
141+
return nil
142+
}

0 commit comments

Comments
 (0)