Repository: Versent/saml2aws
Pull request #2 (2016-09-13 13:03:42)

Added login command and now save last adfs hostname.


Patch:
diff --git a/Makefile b/Makefile
index 53702d3..cb291a9 100644
--- a/Makefile
+++ b/Makefile
@@ -20,6 +20,6 @@ release: build
 	gh-release create versent/$(NAME) $(VERSION) $(shell git rev-parse --abbrev-ref HEAD)
 
 test:
-	go test -v $(shell glide novendor)
+	go test -cover -v $(shell glide novendor)
 
 .PHONY: build test release packages
diff --git a/adfs.go b/adfs.go
index 79b80db..60118a6 100644
--- a/adfs.go
+++ b/adfs.go
@@ -19,12 +19,11 @@ import (
 
 // ADFSClient wrapper around ADFS enabling authentication and retrieval of assertions
 type ADFSClient struct {
-	client      *http.Client
-	endpointURL string
+	client *http.Client
 }
 
 // NewADFSClient create a new ADFS client
-func NewADFSClient(endpointURL string, skipVerify bool) (*ADFSClient, error) {
+func NewADFSClient(skipVerify bool) (*ADFSClient, error) {
 
 	tr := &http.Transport{
 		TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify},
@@ -42,18 +41,19 @@ func NewADFSClient(endpointURL string, skipVerify bool) (*ADFSClient, error) {
 	client := &http.Client{Transport: tr, Jar: jar}
 
 	return &ADFSClient{
-		client:      client,
-		endpointURL: endpointURL,
+		client: client,
 	}, nil
 }
 
 // Authenticate authenticate to ADFS and return the data from the body of the SAML assertion.
-func (ac *ADFSClient) Authenticate(creds *LoginCreds) (string, error) {
+func (ac *ADFSClient) Authenticate(loginDetails *LoginDetails) (string, error) {
 	var authSubmitURL string
 	var samlAssertion string
 	authForm := url.Values{}
 
-	res, err := ac.client.Get(ac.endpointURL)
+	adfsURL := fmt.Sprintf("https://%s/adfs/ls/IdpInitiatedSignOn.aspx?loginToRp=urn:amazon:webservices", loginDetails.Hostname)
+
+	res, err := ac.client.Get(adfsURL)
 	if err != nil {
 		return samlAssertion, errors.Wrap(err, "error retieving form")
 	}
@@ -64,7 +64,7 @@ func (ac *ADFSClient) Authenticate(creds *LoginCreds) (string, error) {
 	}
 
 	doc.Find("input").Each(func(i int, s *goquery.Selection) {
-		updateFormData(authForm, s, creds)
+		updateFormData(authForm, s, loginDetails)
 	})
 
 	//spew.Dump(authForm)
@@ -124,7 +124,7 @@ func (ac *ADFSClient) Authenticate(creds *LoginCreds) (string, error) {
 	return samlAssertion, nil
 }
 
-func updateFormData(authForm url.Values, s *goquery.Selection, user *LoginCreds) {
+func updateFormData(authForm url.Values, s *goquery.Selection, user *LoginDetails) {
 	name, ok := s.Attr("name")
 	//	log.Printf("name = %s ok = %v", name, ok)
 	if !ok {
diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go
new file mode 100644
index 0000000..f22168e
--- /dev/null
+++ b/cmd/saml2aws/commands/login.go
@@ -0,0 +1,121 @@
+package commands
+
+import (
+	"encoding/base64"
+	"fmt"
+	"os"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/sts"
+	"github.com/pkg/errors"
+	"github.com/versent/saml2aws"
+)
+
+func Login(profile string, skipVerify bool) error {
+
+	sharedCreds := saml2aws.NewSharedCredentials(profile)
+
+	err := sharedCreds.Exists()
+	if err != nil {
+		return errors.Wrap(err, "error loading aws credentials file")
+	}
+
+	config := saml2aws.NewConfigLoader("adfs")
+
+	username, err := config.LoadUsername()
+	if err != nil {
+		return errors.Wrap(err, "error loading config file")
+	}
+
+	hostname, err := config.LoadHostname()
+	if err != nil {
+		return errors.Wrap(err, "error loading config file")
+	}
+
+	loginDetails, err := saml2aws.PromptForLoginDetails(username, hostname)
+	if err != nil {
+		return errors.Wrap(err, "error accepting password")
+	}
+
+	fmt.Printf("ADFS https://%s\n", loginDetails.Hostname)
+
+	fmt.Println("Authenticating to ADFS...")
+
+	adfs, err := saml2aws.NewADFSClient(skipVerify)
+	if err != nil {
+		return errors.Wrap(err, "error building adfs client")
+	}
+
+	samlAssertion, err := adfs.Authenticate(loginDetails)
+	if err != nil {
+		return errors.Wrap(err, "error authenticating to adfs")
+
+	}
+
+	if samlAssertion == "" {
+		fmt.Println("Response did not contain a valid SAML assertion")
+		fmt.Println("Please check your username and password is correct")
+		os.Exit(1)
+	}
+
+	data, err := base64.StdEncoding.DecodeString(samlAssertion)
+	if err != nil {
+		return errors.Wrap(err, "error decoding saml assertion")
+	}
+
+	roles, err := saml2aws.ExtractAwsRoles(data)
+	if err != nil {
+		return errors.Wrap(err, "error parsing aws roles")
+	}
+
+	if len(roles) == 0 {
+		fmt.Println("No roles to assume")
+		fmt.Println("Please check you are permitted to assume roles for the AWS service")
+		os.Exit(1)
+	}
+
+	role, err := saml2aws.PromptForAWSRoleSelection(roles)
+	if err != nil {
+		return errors.Wrap(err, "error selecting role")
+	}
+
+	fmt.Println("Selected role:", role.RoleARN)
+
+	sess, err := session.NewSession()
+	if err != nil {
+		return errors.Wrap(err, "failed to create session")
+	}
+
+	svc := sts.New(sess)
+
+	params := &sts.AssumeRoleWithSAMLInput{
+		PrincipalArn:  aws.String(role.PrincipalARN), // Required
+		RoleArn:       aws.String(role.RoleARN),      // Required
+		SAMLAssertion: aws.String(samlAssertion),     // Required
+	}
+
+	fmt.Println("Requesting AWS credentials using SAML assertion")
+
+	resp, err := svc.AssumeRoleWithSAML(params)
+	if err != nil {
+		return errors.Wrap(err, "error retieving sts credentials using SAML")
+	}
+
+	fmt.Println("Saving credentials")
+
+	err = sharedCreds.Save(aws.StringValue(resp.Credentials.AccessKeyId), aws.StringValue(resp.Credentials.SecretAccessKey), aws.StringValue(resp.Credentials.SessionToken))
+	if err != nil {
+		return errors.Wrap(err, "error saving credentials")
+	}
+
+	fmt.Println("")
+	fmt.Println("Your new access key pair has been stored in the AWS configuration")
+	fmt.Printf("Note that it will expire at %v\n", resp.Credentials.Expiration.Local())
+	fmt.Println("To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile", profile, "ec2 describe-instances).")
+
+	config.SaveUsername(loginDetails.Username)
+	config.SaveHostname(loginDetails.Hostname)
+
+	return nil
+}
diff --git a/cmd/saml2aws/main.go b/cmd/saml2aws/main.go
index 89829a2..61d1ff1 100644
--- a/cmd/saml2aws/main.go
+++ b/cmd/saml2aws/main.go
@@ -1,23 +1,22 @@
 package main
 
 import (
-	"encoding/base64"
-	"fmt"
 	"log"
 	"os"
 
 	"github.com/alecthomas/kingpin"
-	"github.com/aws/aws-sdk-go/aws"
-	"github.com/aws/aws-sdk-go/aws/session"
-	"github.com/aws/aws-sdk-go/service/sts"
-	"github.com/versent/saml2aws"
+	"github.com/versent/saml2aws/cmd/saml2aws/commands"
 )
 
 var (
+	app = kingpin.New("saml2aws", "A command line tool to help with SAML access to the AWS token service.")
+
 	// /verbose      = kingpin.Flag("verbose", "Verbose mode.").Short('v').Bool()
-	skipVerify   = kingpin.Flag("skip-verify", "Skip verification of server certificate.").Short('s').Bool()
-	profileName  = kingpin.Flag("profile", "The AWS profile to save the temporary credentials").Short('p').Default("saml").String()
-	adfsHostname = kingpin.Arg("hostname", "Hostname of the ADFS service").Required().String()
+
+	cmdLogin = app.Command("login", "Login to a SAML 2.0 IDP and convert the SAML assertion to an STS token.")
+
+	skipVerify  = cmdLogin.Flag("skip-verify", "Skip verification of server certificate.").Short('s').Bool()
+	profileName = cmdLogin.Flag("profile", "The AWS profile to save the temporary credentials").Short('p').Default("saml").String()
 
 	// Version app version
 	Version = "1.0.0"
@@ -26,105 +25,17 @@ var (
 func main() {
 	log.SetFlags(log.Lshortfile)
 
-	kingpin.Version(Version)
-	kingpin.Parse()
-
-	sharedCreds := saml2aws.NewSharedCredentials(*profileName)
-
-	err := sharedCreds.Exists()
-	if err != nil {
-		log.Fatalf("error loading aws credentials file: %v", err)
-	}
-
-	config := saml2aws.NewConfig("adfs")
-
-	username, err := config.LoadUsername()
-	if err != nil {
-		log.Fatalf("error loading config file: %v", err)
-	}
-
-	adfsURL := fmt.Sprintf("https://%s/adfs/ls/IdpInitiatedSignOn.aspx?loginToRp=urn:amazon:webservices", *adfsHostname)
+	app.Version(Version)
+	command := kingpin.MustParse(app.Parse(os.Args[1:]))
 
-	fmt.Printf("ADFS https://%s\n", *adfsHostname)
+	var err error
 
-	user, err := saml2aws.PromptForLoginCreds(username)
-	if err != nil {
-		log.Fatalf("error accepting password: %v", err)
-	}
-
-	fmt.Println("Authenticating to ADFS...")
-
-	adfs, err := saml2aws.NewADFSClient(adfsURL, *skipVerify)
-	if err != nil {
-		log.Fatalf("error building adfs client: %v", err)
-	}
-
-	samlAssertion, err := adfs.Authenticate(user)
-	if err != nil {
-		log.Fatalf("error authenticating to adfs: %v", err)
-	}
-
-	if samlAssertion == "" {
-		fmt.Println("Response did not contain a valid SAML assertion")
-		fmt.Println("Please check your username and password is correct")
-		os.Exit(1)
+	switch command {
+	case cmdLogin.FullCommand():
+		err = commands.Login(*profileName, *skipVerify)
 	}
 
-	data, err := base64.StdEncoding.DecodeString(samlAssertion)
 	if err != nil {
-		log.Fatalf("error decoding saml assertion: %v", err)
+		log.Fatal(err)
 	}
-
-	roles, err := saml2aws.ExtractAwsRoles(data)
-	if err != nil {
-		log.Fatalf("error parsing aws roles: %v", err)
-	}
-
-	if len(roles) == 0 {
-		fmt.Println("No roles to assume")
-		fmt.Println("Please check you are permitted to assume roles for the AWS service")
-		os.Exit(1)
-	}
-
-	role, err := saml2aws.PromptForAWSRoleSelection(roles)
-	if err != nil {
-		log.Fatalf("error selecting role: %v", err)
-	}
-
-	fmt.Println("Selected role:", role.RoleARN)
-
-	sess, err := session.NewSession()
-	if err != nil {
-		fmt.Println("failed to create session,", err)
-		return
-	}
-
-	svc := sts.New(sess)
-
-	params := &sts.AssumeRoleWithSAMLInput{
-		PrincipalArn:  aws.String(role.PrincipalARN), // Required
-		RoleArn:       aws.String(role.RoleARN),      // Required
-		SAMLAssertion: aws.String(samlAssertion),     // Required
-	}
-
-	fmt.Println("Requesting AWS credentials using SAML assertion")
-
-	resp, err := svc.AssumeRoleWithSAML(params)
-	if err != nil {
-		log.Fatalf("error retieving sts credentials using SAML: %v", err)
-	}
-
-	fmt.Println("Saving credentials")
-
-	err = sharedCreds.Save(aws.StringValue(resp.Credentials.AccessKeyId), aws.StringValue(resp.Credentials.SecretAccessKey), aws.StringValue(resp.Credentials.SessionToken))
-	if err != nil {
-		log.Fatalf("error saving credentials: %v", err)
-	}
-
-	fmt.Println("")
-	fmt.Println("Your new access key pair has been stored in the AWS configuration")
-	fmt.Printf("Note that it will expire at %v\n", resp.Credentials.Expiration.Local())
-	fmt.Println("To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile", *profileName, "ec2 describe-instances).")
-
-	config.SaveUsername(user.Username)
 }
diff --git a/config.go b/config.go
index 267a362..bf35445 100644
--- a/config.go
+++ b/config.go
@@ -17,70 +17,93 @@ var (
 	ErrConfigFileNotFound = errors.New("aws credentials file not found")
 )
 
-// Config loads config options
-type Config struct {
+// ConfigLoader loads config options
+type ConfigLoader struct {
 	Filename string
 	Profile  string
 }
 
-// NewConfig helper to create the config
-func NewConfig(profile string) *Config {
-	return &Config{
+// NewConfigLoader helper to create the config
+func NewConfigLoader(profile string) *ConfigLoader {
+	return &ConfigLoader{
 		Profile: profile,
 	}
 }
 
-// Exists verify that the credentials file exists
-func (p *Config) exists() (bool, error) {
+// ensureConfigExists verify that the config file exists
+func (p *ConfigLoader) ensureConfigExists() error {
 	filename, err := p.filename()
 	if err != nil {
-		return false, err
+		return err
 	}
 
 	if _, err := os.Stat(filename); err != nil {
 		if os.IsNotExist(err) {
-			return false, nil
+
+			// create an base config file
+			err = ioutil.WriteFile(filename, []byte("["+p.Profile+"]"), 0666)
+			if err != nil {
+				return err
+			}
+
 		}
-		return false, err
+		return err
 	}
 
-	return true, nil
+	return nil
 }
 
 // SaveUsername persist the username
-func (p *Config) SaveUsername(username string) error {
+func (p *ConfigLoader) SaveUsername(username string) error {
 	filename, err := p.filename()
 	if err != nil {
 		return err
 	}
 
-	return saveConfig(filename, p.Profile, username)
+	return saveConfig(filename, p.Profile, "username", username)
 }
 
-// LoadUsername persist the username
-func (p *Config) LoadUsername() (string, error) {
+// LoadUsername load the username
+func (p *ConfigLoader) LoadUsername() (string, error) {
 	filename, err := p.filename()
 	if err != nil {
 		return "", err
 	}
 
-	exists, err := p.exists()
+	err = p.ensureConfigExists()
 	if err != nil {
 		return "", err
 	}
 
-	if !exists {
-		// create an base config file
-		err = ioutil.WriteFile(filename, []byte("["+p.Profile+"]"), 0666)
+	return loadConfig(filename, p.Profile, "username")
+}
+
+// SaveHostname persist the hostname
+func (p *ConfigLoader) SaveHostname(hostname string) error {
+	filename, err := p.filename()
+	if err != nil {
+		return err
 	}
+
+	return saveConfig(filename, p.Profile, "hostname", hostname)
+}
+
+// LoadHostname load the hostname
+func (p *ConfigLoader) LoadHostname() (string, error) {
+	filename, err := p.filename()
+	if err != nil {
+		return "", err
+	}
+
+	err = p.ensureConfigExists()
 	if err != nil {
 		return "", err
 	}
 
-	return loadConfig(filename, p.Profile)
+	return loadConfig(filename, p.Profile, "hostname")
 }
 
-func (p *Config) filename() (string, error) {
+func (p *ConfigLoader) filename() (string, error) {
 	if p.Filename == "" {
 		if p.Filename = os.Getenv("AWS2SAML_CONFIG_FILE"); p.Filename != "" {
 			return p.Filename, nil
@@ -100,7 +123,7 @@ func (p *Config) filename() (string, error) {
 	return p.Filename, nil
 }
 
-func loadConfig(filename, profile string) (string, error) {
+func loadConfig(filename, profile, field string) (string, error) {
 	config, err := ini.Load(filename)
 	if err != nil {
 		return "", err
@@ -110,11 +133,11 @@ func loadConfig(filename, profile string) (string, error) {
 		return "", err
 	}
 
-	return iniProfile.Key("username").String(), nil
+	return iniProfile.Key(field).String(), nil
 
 }
 
-func saveConfig(filename, profile, username string) error {
+func saveConfig(filename, profile, field, value string) error {
 	config, err := ini.Load(filename)
 	if err != nil {
 		return err
@@ -124,7 +147,7 @@ func saveConfig(filename, profile, username string) error {
 		return err
 	}
 
-	_, err = iniProfile.NewKey("username", username)
+	_, err = iniProfile.NewKey(field, value)
 	if err != nil {
 		return err
 	}
diff --git a/input.go b/input.go
index 95045d8..92d9b39 100644
--- a/input.go
+++ b/input.go
@@ -10,30 +10,27 @@ import (
 	"github.com/segmentio/go-prompt"
 )
 
-var (
-	// ErrMissingUsername returned when the user fails to enter a username
-	ErrMissingUsername = fmt.Errorf("Missing or invalid username entered")
-)
-
-// PromptForLoginCreds prompt the user to present their username and password
-func PromptForLoginCreds(username string) (*LoginCreds, error) {
-
-	var usernameEntered string
-
-	// do while
-	for ok := true; ok; ok = strings.TrimSpace(username) == "" && strings.TrimSpace(usernameEntered) == "" {
-		usernameEntered = prompt.String("Username [%s]", username)
-	}
+// LoginDetails used to authenticate to ADFS
+type LoginDetails struct {
+	Username string
+	Password string
+	Hostname string
+}
 
-	if usernameEntered == "" {
-		usernameEntered = username
-	}
+// PromptForLoginDetails prompt the user to present their username, password and hostname
+func PromptForLoginDetails(username, hostname string) (*LoginDetails, error) {
 
+	hostname = promptFor("Hostname [%s]", hostname)
+	username = promptFor("Username [%s]", username)
 	password := prompt.PasswordMasked("Password")
 
 	fmt.Println("")
 
-	return &LoginCreds{strings.TrimSpace(usernameEntered), strings.TrimSpace(password)}, nil
+	return &LoginDetails{
+		Username: strings.TrimSpace(username),
+		Password: strings.TrimSpace(password),
+		Hostname: strings.TrimSpace(hostname),
+	}, nil
 }
 
 // PromptForAWSRoleSelection present a list of roles to the user for selection
@@ -72,3 +69,18 @@ func PromptForAWSRoleSelection(roles []string) (*AWSRole, error) {
 
 	return &AWSRole{tok[1], tok[0]}, nil
 }
+
+func promptFor(promptString, defaultValue string) string {
+	var val string
+
+	// do while
+	for ok := true; ok; ok = strings.TrimSpace(defaultValue) == "" && strings.TrimSpace(val) == "" {
+		val = prompt.String(promptString, defaultValue)
+	}
+
+	if val == "" {
+		val = defaultValue
+	}
+
+	return val
+}
diff --git a/saml2aws.go b/saml2aws.go
index 451385e..3dd862a 100644
--- a/saml2aws.go
+++ b/saml2aws.go
@@ -1,11 +1,5 @@
 package saml2aws
 
-// LoginCreds credentials used to authenticate to ADFS
-type LoginCreds struct {
-	Username string
-	Password string
-}
-
 // AWSRole aws role attributes
 type AWSRole struct {
 	RoleARN      string


Test patch:
diff --git a/config_test.go b/config_test.go
index 7c0c1e4..712b35c 100644
--- a/config_test.go
+++ b/config_test.go
@@ -9,7 +9,7 @@ import (
 
 func TestUpdateConfig(t *testing.T) {
 
-	config := &Config{".aws2saml.config", "adfs"}
+	config := &ConfigLoader{".aws2saml.config", "adfs"}
 
 	username, err := config.LoadUsername()
 	assert.Nil(t, err)
@@ -24,3 +24,20 @@ func TestUpdateConfig(t *testing.T) {
 
 	os.Remove(".aws2saml.config")
 }
+
+func TestUpdateHostnameConfig(t *testing.T) {
+	config := &ConfigLoader{".aws2saml.config", "adfs"}
+
+	hostname, err := config.LoadHostname()
+	assert.Nil(t, err)
+	assert.Equal(t, "", hostname)
+
+	err = config.SaveHostname("id.example.com")
+	assert.Nil(t, err)
+
+	hostname, err = config.LoadHostname()
+	assert.Nil(t, err)
+	assert.Equal(t, "id.example.com", hostname)
+
+	os.Remove(".aws2saml.config")
+}