Skip to content

Commit

Permalink
add support for sso auth
Browse files Browse the repository at this point in the history
  • Loading branch information
jchorl committed Dec 11, 2023
1 parent 6bc93a8 commit 04dbc61
Show file tree
Hide file tree
Showing 3 changed files with 250 additions and 28 deletions.
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
204 changes: 176 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,9 @@ import (
ini "gopkg.in/ini.v1"
)

var ErrNoExternalProcessDefined = errors.New("config file does not specify credential_process")

Check failure on line 37 in pkg/credentials/file_aws_credentials.go

View workflow job for this annotation

GitHub Actions / Test on Go 1.19.x and ubuntu-latest

exported: exported var ErrNoExternalProcessDefined should have comment or be unexported (revive)

Check failure on line 37 in pkg/credentials/file_aws_credentials.go

View workflow job for this annotation

GitHub Actions / Test on Go 1.21.x and ubuntu-latest

exported: exported var ErrNoExternalProcessDefined should have comment or be unexported (revive)
var ErrNoSSOConfig = errors.New("the specified config does not have sso configurations")

Check failure on line 38 in pkg/credentials/file_aws_credentials.go

View workflow job for this annotation

GitHub Actions / Test on Go 1.19.x and ubuntu-latest

exported: exported var ErrNoSSOConfig should have comment or be unexported (revive)

Check failure on line 38 in pkg/credentials/file_aws_credentials.go

View workflow job for this annotation

GitHub Actions / Test on Go 1.21.x and ubuntu-latest

exported: exported var ErrNoSSOConfig should have comment or be unexported (revive)

// A externalProcessCredentials stores the output of a credential_process
type externalProcessCredentials struct {
Version int
Expand All @@ -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.
//
Expand All @@ -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
Expand All @@ -68,6 +107,8 @@ func NewFileAWSCredentials(filename, profile string) *Credentials {
return New(&FileAWSCredentials{
Filename: filename,
Profile: profile,

timeNow: time.Now,
})
}

Expand Down Expand Up @@ -98,40 +139,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 +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.
Expand All @@ -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
}
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

0 comments on commit 04dbc61

Please sign in to comment.