Skip to content
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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pkg/credentials/credentials-sso.sample
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
211 changes: 183 additions & 28 deletions pkg/credentials/file_aws_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@
package credentials

import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
Expand All @@ -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
Expand All @@ -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.
//
Expand All @@ -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
Expand All @@ -68,6 +114,8 @@ func NewFileAWSCredentials(filename, profile string) *Credentials {
return New(&FileAWSCredentials{
Filename: filename,
Profile: profile,

timeNow: time.Now,
})
}

Expand Down Expand Up @@ -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(),
Expand All @@ -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.
Expand All @@ -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))
}
if err != nil {
return nil, err
}
}

return iniProfile, nil
}
66 changes: 66 additions & 0 deletions pkg/credentials/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()

Expand Down