From 04dbc61e481e427872dd738f02ad2d9aa2fd04c4 Mon Sep 17 00:00:00 2001 From: Josh Chorlton Date: Mon, 11 Dec 2023 06:59:29 +0000 Subject: [PATCH 1/2] add support for sso auth --- pkg/credentials/credentials-sso.sample | 8 + pkg/credentials/file_aws_credentials.go | 204 ++++++++++++++++++++---- pkg/credentials/file_test.go | 66 ++++++++ 3 files changed, 250 insertions(+), 28 deletions(-) create mode 100644 pkg/credentials/credentials-sso.sample diff --git a/pkg/credentials/credentials-sso.sample b/pkg/credentials/credentials-sso.sample new file mode 100644 index 000000000..eeb9862c8 --- /dev/null +++ b/pkg/credentials/credentials-sso.sample @@ -0,0 +1,8 @@ +[profile p1] +sso_session = main +sso_account_id = 123456789 +sso_role_name = myrole + +[sso-session main] +sso_region = us-test-2 +sso_start_url = https://testacct.awsapps.com/start diff --git a/pkg/credentials/file_aws_credentials.go b/pkg/credentials/file_aws_credentials.go index 5b073763e..253b83b2a 100644 --- a/pkg/credentials/file_aws_credentials.go +++ b/pkg/credentials/file_aws_credentials.go @@ -18,8 +18,13 @@ package credentials import ( + "crypto/sha1" + "encoding/hex" "encoding/json" "errors" + "fmt" + "io/ioutil" + "net/http" "os" "os/exec" "path/filepath" @@ -29,6 +34,9 @@ import ( ini "gopkg.in/ini.v1" ) +var ErrNoExternalProcessDefined = errors.New("config file does not specify credential_process") +var ErrNoSSOConfig = errors.New("the specified config does not have sso configurations") + // A externalProcessCredentials stores the output of a credential_process type externalProcessCredentials struct { Version int @@ -38,6 +46,25 @@ type externalProcessCredentials struct { Expiration time.Time } +// A ssoCredentials stores the result of getting role credentials for an +// SSO role. +type ssoCredentials struct { + RoleCredentials ssoRoleCredentials `json:"roleCredentials"` +} + +// A ssoRoleCredentials stores the role-specific credentials portion of +// an sso credentials request. +type ssoRoleCredentials struct { + AccessKeyID string `json:"accessKeyId"` + Expiration int64 `json:"expiration"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` +} + +func (s ssoRoleCredentials) GetExpiration() time.Time { + return time.Unix(0, s.Expiration*int64(time.Millisecond)) +} + // A FileAWSCredentials retrieves credentials from the current user's home // directory, and keeps track if those credentials are expired. // @@ -60,6 +87,18 @@ type FileAWSCredentials struct { // retrieved states if the credentials have been successfully retrieved. retrieved bool + + // overrideSSOCacheDir allows tests to override the path where SSO cached + // credentials are stored (usually ~/.aws/sso/cache/ is used). + overrideSSOCacheDir string + + // overrideSSOPortalURL allows tests to override the http URL that + // serves SSO role tokens. + overrideSSOPortalURL string + + // timeNow allows tests to override getting the current time to test + // for expiration. + timeNow func() time.Time } // NewFileAWSCredentials returns a pointer to a new Credentials object @@ -68,6 +107,8 @@ func NewFileAWSCredentials(filename, profile string) *Credentials { return New(&FileAWSCredentials{ Filename: filename, Profile: profile, + + timeNow: time.Now, }) } @@ -98,6 +139,32 @@ func (p *FileAWSCredentials) Retrieve() (Value, error) { return Value{}, err } + if externalProcessCreds, err := getExternalProcessCredentials(iniProfile); err == nil { + p.retrieved = true + p.SetExpiration(externalProcessCreds.Expiration, DefaultExpiryWindow) + return Value{ + AccessKeyID: externalProcessCreds.AccessKeyID, + SecretAccessKey: externalProcessCreds.SecretAccessKey, + SessionToken: externalProcessCreds.SessionToken, + SignerType: SignatureV4, + }, nil + } else if err != ErrNoExternalProcessDefined { + return Value{}, err + } + + if ssoCreds, err := p.getSSOCredentials(iniProfile); err == nil { + p.retrieved = true + p.SetExpiration(ssoCreds.RoleCredentials.GetExpiration(), DefaultExpiryWindow) + return Value{ + AccessKeyID: ssoCreds.RoleCredentials.AccessKeyID, + SecretAccessKey: ssoCreds.RoleCredentials.SecretAccessKey, + SessionToken: ssoCreds.RoleCredentials.SessionToken, + SignerType: SignatureV4, + }, nil + } else if err != ErrNoSSOConfig { + return Value{}, err + } + // Default to empty string if not found. id := iniProfile.Key("aws_access_key_id") // Default to empty string if not found. @@ -105,33 +172,6 @@ func (p *FileAWSCredentials) Retrieve() (Value, error) { // Default to empty string if not found. token := iniProfile.Key("aws_session_token") - // If credential_process is defined, obtain credentials by executing - // the external process - credentialProcess := strings.TrimSpace(iniProfile.Key("credential_process").String()) - if credentialProcess != "" { - args := strings.Fields(credentialProcess) - if len(args) <= 1 { - return Value{}, errors.New("invalid credential process args") - } - cmd := exec.Command(args[0], args[1:]...) - out, err := cmd.Output() - if err != nil { - return Value{}, err - } - var externalProcessCredentials externalProcessCredentials - err = json.Unmarshal([]byte(out), &externalProcessCredentials) - if err != nil { - return Value{}, err - } - p.retrieved = true - p.SetExpiration(externalProcessCredentials.Expiration, DefaultExpiryWindow) - return Value{ - AccessKeyID: externalProcessCredentials.AccessKeyID, - SecretAccessKey: externalProcessCredentials.SecretAccessKey, - SessionToken: externalProcessCredentials.SessionToken, - SignerType: SignatureV4, - }, nil - } p.retrieved = true return Value{ AccessKeyID: id.String(), @@ -141,6 +181,106 @@ func (p *FileAWSCredentials) Retrieve() (Value, error) { }, nil } +// getExternalProcessCredentials calls the config credential_process, parses the process' response, +// and returns the result. If the profile ini passed does not have a credential_process, +// ErrNoExternalProcessDefined is returned. +func getExternalProcessCredentials(iniProfile *ini.Section) (externalProcessCredentials, error) { + // If credential_process is defined, obtain credentials by executing + // the external process + credentialProcess := strings.TrimSpace(iniProfile.Key("credential_process").String()) + if credentialProcess == "" { + return externalProcessCredentials{}, ErrNoExternalProcessDefined + } + + args := strings.Fields(credentialProcess) + if len(args) <= 1 { + return externalProcessCredentials{}, errors.New("invalid credential process args") + } + cmd := exec.Command(args[0], args[1:]...) + out, err := cmd.Output() + if err != nil { + return externalProcessCredentials{}, err + } + var externalProcessCreds externalProcessCredentials + err = json.Unmarshal([]byte(out), &externalProcessCreds) + if err != nil { + return externalProcessCredentials{}, err + } + return externalProcessCreds, nil +} + +type ssoCredentialsCacheFile struct { + AccessToken string `json:"accessToken"` + ExpiresAt time.Time `json:"expiresAt"` + Region string `json:"region"` +} + +func (p *FileAWSCredentials) getSSOCredentials(iniProfile *ini.Section) (ssoCredentials, error) { + ssoRoleName := iniProfile.Key("sso_role_name").String() + if ssoRoleName == "" { + return ssoCredentials{}, ErrNoSSOConfig + } + + ssoSessionName := iniProfile.Key("sso_session").String() + hash := sha1.New() + if _, err := hash.Write([]byte(ssoSessionName)); err != nil { + return ssoCredentials{}, fmt.Errorf("hashing sso session name \"%s\": %w", ssoSessionName, err) + } + + cachedCredsFilename := fmt.Sprintf("%s.json", strings.ToLower(hex.EncodeToString(hash.Sum(nil)))) + + cachedCredsFileDir := p.overrideSSOCacheDir + if cachedCredsFileDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return ssoCredentials{}, fmt.Errorf("getting home dir: %w", err) + } + cachedCredsFileDir = filepath.Join(homeDir, ".aws", "sso", "cache") + } + cachedCredsFilepath := filepath.Join(cachedCredsFileDir, cachedCredsFilename) + cachedCredsContentsRaw, err := ioutil.ReadFile(cachedCredsFilepath) + if err != nil { + return ssoCredentials{}, fmt.Errorf("reading credentials cache file \"%s\": %w", cachedCredsFilepath, err) + } + + var cachedCredsContents ssoCredentialsCacheFile + if err := json.Unmarshal(cachedCredsContentsRaw, &cachedCredsContents); err != nil { + return ssoCredentials{}, fmt.Errorf("parsing cached sso credentials file \"%s\": %w", cachedCredsFilename, err) + } + if cachedCredsContents.ExpiresAt.Before(p.timeNow()) { + return ssoCredentials{}, fmt.Errorf("sso credentials expired, refresh with AWS CLI") + } + + ssoAccountID := iniProfile.Key("sso_account_id").String() + + portalURL := p.overrideSSOPortalURL + if portalURL == "" { + portalURL = fmt.Sprintf("https://portal.sso.%s.amazonaws.com", cachedCredsContents.Region) + } + req, err := http.NewRequest("GET", fmt.Sprintf("%s/federation/credentials", portalURL), nil) + if err != nil { + return ssoCredentials{}, fmt.Errorf("creating request to get role credentials: %w", err) + } + req.Header.Set("x-amz-sso_bearer_token", cachedCredsContents.AccessToken) + query := req.URL.Query() + query.Add("account_id", ssoAccountID) + query.Add("role_name", ssoRoleName) + req.URL.RawQuery = query.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ssoCredentials{}, fmt.Errorf("making request to get role credentials: %w", err) + } + defer resp.Body.Close() + + var ssoCreds ssoCredentials + if err := json.NewDecoder(resp.Body).Decode(&ssoCreds); err != nil { + return ssoCredentials{}, fmt.Errorf("parsing sso credentials response: %w", err) + } + + return ssoCreds, nil +} + // loadProfiles loads from the file pointed to by shared credentials filename for profile. // The credentials retrieved from the profile will be returned or error. Error will be // returned if it fails to read from the file, or the data is invalid. @@ -149,9 +289,17 @@ func loadProfile(filename, profile string) (*ini.Section, error) { if err != nil { return nil, err } + iniProfile, err := config.GetSection(profile) if err != nil { - return nil, err + // aws allows specifying the profile as [profile myprofile] + if strings.Contains(err.Error(), "does not exist") { + iniProfile, err = config.GetSection(fmt.Sprintf("profile %s", profile)) + } + if err != nil { + return nil, err + } } + return iniProfile, nil } diff --git a/pkg/credentials/file_test.go b/pkg/credentials/file_test.go index fab48dc44..a6d56e163 100644 --- a/pkg/credentials/file_test.go +++ b/pkg/credentials/file_test.go @@ -18,10 +18,15 @@ package credentials import ( + "fmt" + "net/http" + "net/http/httptest" "os" + "path" "path/filepath" "runtime" "testing" + "time" ) func TestFileAWS(t *testing.T) { @@ -147,6 +152,67 @@ func TestFileAWS(t *testing.T) { } } +func TestFileAWSSSO(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "minio-sso-") + if err != nil { + t.Errorf("Creating temp dir: %+v", err) + } + + // the file path is the sso-profile, "main", sha1-ed + os.WriteFile( + path.Join(tmpDir, "b28b7af69320201d1cf206ebf28373980add1451.json"), + []byte(`{"startUrl": "https://testacct.awsapps.com/start", "region": "us-test-2", "accessToken": "my-access-token", "expiresAt": "2020-01-11T00:00:00Z"}`), + 0755, + ) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if urlPath := r.URL.Path; urlPath != "/federation/credentials" { + t.Errorf("Expected path /federation/credentials, got %s", urlPath) + } + + if accountID := r.URL.Query().Get("account_id"); accountID != "123456789" { + t.Errorf("Expected account ID 123456789, got %s", accountID) + } + + if roleName := r.URL.Query().Get("role_name"); roleName != "myrole" { + t.Errorf("Expected role name myrole, got %s", roleName) + } + + if xAuthHeader := r.Header.Get("x-amz-sso_bearer_token"); xAuthHeader != "my-access-token" { + t.Errorf("Expected bearer token my-access-token, got %s", xAuthHeader) + } + + fmt.Fprintln(w, `{"roleCredentials": {"accessKeyId": "accessKey", "secretAccessKey": "secret", "sessionToken": "token", "expiration":1702317362000}}`) + })) + defer ts.Close() + + creds := New(&FileAWSCredentials{ + Filename: "credentials-sso.sample", + Profile: "p1", + + overrideSSOPortalURL: ts.URL, + overrideSSOCacheDir: tmpDir, + timeNow: func() time.Time { return time.Date(2020, time.January, 10, 1, 1, 1, 1, time.UTC) }, + }) + credValues, err := creds.Get() + if err != nil { + t.Fatal(err) + } + + if credValues.AccessKeyID != "accessKey" { + t.Errorf("Expected 'accessKey', got %s'", credValues.AccessKeyID) + } + if credValues.SecretAccessKey != "secret" { + t.Errorf("Expected 'secret', got %s'", credValues.SecretAccessKey) + } + if credValues.SessionToken != "token" { + t.Errorf("Expected 'token', got %s'", credValues.SessionToken) + } + if creds.IsExpired() { + t.Error("Should not be expired") + } +} + func TestFileMinioClient(t *testing.T) { os.Clearenv() From b7d144d8424f0744337b0c61f22304dd3f2340ba Mon Sep 17 00:00:00 2001 From: Josh Chorlton Date: Fri, 15 Dec 2023 20:04:00 +0000 Subject: [PATCH 2/2] appease the lint overlords --- pkg/credentials/file_aws_credentials.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/credentials/file_aws_credentials.go b/pkg/credentials/file_aws_credentials.go index 253b83b2a..4a0c41eec 100644 --- a/pkg/credentials/file_aws_credentials.go +++ b/pkg/credentials/file_aws_credentials.go @@ -34,7 +34,14 @@ import ( ini "gopkg.in/ini.v1" ) +// ErrNoExternalProcessDefined is returned when attempting to get credentials +// from the credential_process config but no credential_process is defined +// in the config provided. var ErrNoExternalProcessDefined = errors.New("config file does not specify credential_process") + +// ErrNoSSOConfig is returned when attempting to get credentials +// from the sso config but no sso configuration is defined +// in the config provided. var ErrNoSSOConfig = errors.New("the specified config does not have sso configurations") // A externalProcessCredentials stores the output of a credential_process