-
Notifications
You must be signed in to change notification settings - Fork 664
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add support for sso auth #1911
base: master
Are you sure you want to change the base?
add support for sso auth #1911
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,16 @@ 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 | ||
type externalProcessCredentials struct { | ||
Version int | ||
|
@@ -38,6 +53,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 +94,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 +114,8 @@ func NewFileAWSCredentials(filename, profile string) *Credentials { | |
return New(&FileAWSCredentials{ | ||
Filename: filename, | ||
Profile: profile, | ||
|
||
timeNow: time.Now, | ||
}) | ||
} | ||
|
||
|
@@ -98,40 +146,39 @@ 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. | ||
secret := iniProfile.Key("aws_secret_access_key") | ||
// 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 +188,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 +296,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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Maybe this shouldn't over-write the original error if it fails... |
||
} | ||
if err != nil { | ||
return nil, err | ||
} | ||
} | ||
|
||
return iniProfile, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"}`), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the "startURL" used for a call? Just making sure that we don't have tests making external calls. |
||
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() | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please describe what is happening - it seems to be some interaction with external files/tools I don't really understand.