Repository: Versent/saml2aws
Pull request #9 (2016-09-25 10:27:20)

Add support for ping federation SAML provider
This includes:
* Better validation of aws roles, including catering for switched order.
* New command line option to override the default ADFS provider.
* Added a provider interface and options struct.
* Code tidy up and more validation.
* Added some build instructions to the `README.md`.


Patch:
diff --git a/.gitignore b/.gitignore
index 9af9966..020f460 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
 dist
 build
 packages
-release
\ No newline at end of file
+release
+*_string.go
diff --git a/Makefile b/Makefile
index adee064..4b1ca95 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,8 @@ ARCH=$(shell uname -m)
 VERSION=1.2.0
 ITERATION := 1
 
+default: build
+
 deps:
 	glide install
 
@@ -22,4 +24,4 @@ release: build
 test:
 	go test -cover -v $(shell glide novendor)
 
-.PHONY: build test release packages
+.PHONY: default deps build test release packages
diff --git a/README.md b/README.md
index a594419..a43eb37 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # saml2aws
 
-CLI tool which enables you to login and retrieve [AWS](https://aws.amazon.com/) temporary credentials using SAML with [ADFS 3.x](https://msdn.microsoft.com/en-us/library/bb897402.aspx).
+CLI tool which enables you to login and retrieve [AWS](https://aws.amazon.com/) temporary credentials using SAML with [ADFS 3.x](https://msdn.microsoft.com/en-us/library/bb897402.aspx) or [PingFederate](https://www.pingidentity.com/en/products/pingfederate.html) Identity Providers.
 
 This is based on python code from [
 How to Implement a General Solution for Federated API/CLI Access Using SAML 2.0](https://blogs.aws.amazon.com/security/post/TxU0AVUS9J00FP/How-to-Implement-a-General-Solution-for-Federated-API-CLI-Access-Using-SAML-2-0).
@@ -8,14 +8,16 @@ How to Implement a General Solution for Federated API/CLI Access Using SAML 2.0]
 The process goes something like this:
 
 * Prompt user for credentials
-* Log in to ADFS using form based authentication
+* Log in to Identity Provider using form based authentication
 * Build a SAML assertion containing AWS roles
 * Exchange the role and SAML assertion with [AWS STS service](https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html) to get a temporary set of credentials
 * Save these creds to an aws profile named "saml"
 
 # Requirements
 
-* ADFS 3.x 
+* Identity Provider
+  * ADFS 3.x
+  * PingFederate + PingId
 * AWS SAML Provider configured
 
 # Usage
@@ -29,6 +31,7 @@ Flags:
       --help            Show context-sensitive help (also try --help-long and --help-man).
   -p, --profile="saml"  The AWS profile to save the temporary credentials
   -s, --skip-verify     Skip verification of server certificate.
+  -i, --provider="ADFS" The type of SAML IDP provider.
       --version         Show application version.
 
 Commands:
@@ -43,11 +46,10 @@ Commands:
   exec [<command>...]
     Exec the supplied command with env vars from STS token.
 
-
-
 ```
+saml2aws will default to using ADFS as the Identity Provider. To use PingFederate change the provider flag to Ping eg `--provider=Ping`
 
-# install
+# Install
 
 If your on OSX you can install saml2aws using homebrew!
 
@@ -134,7 +136,29 @@ localhost                  : ok=2    changed=0    unreachable=0    failed=0
 
 ```
 
-# environment vars
+# Building
+
+To build this software on osx clone to the repo to `$GOPATH/src/github.com/versent/saml2aws` and ensure you have `$GOPATH/bin` in your `$PATH`.
+
+If you don't have glide installed you can install it using [homebrew](http://brew.sh/).
+
+```
+brew install glide
+```
+
+Then to build the software just run.
+
+```
+make
+```
+
+To release run.
+
+```
+make release
+```
+
+# Environment vars
 
 The exec sub command will export the following environment variables.
 
diff --git a/aws_role.go b/aws_role.go
new file mode 100644
index 0000000..fe9f282
--- /dev/null
+++ b/aws_role.go
@@ -0,0 +1,57 @@
+package saml2aws
+
+import (
+	"fmt"
+	"strings"
+)
+
+// AWSRole aws role attributes
+type AWSRole struct {
+	RoleARN      string
+	PrincipalARN string
+}
+
+// ParseAWSRoles parses and splits the roles while also validating the contents
+func ParseAWSRoles(roles []string) ([]*AWSRole, error) {
+	awsRoles := make([]*AWSRole, len(roles))
+
+	for i, role := range roles {
+		awsRole, err := parseRole(role)
+		if err != nil {
+			return nil, err
+		}
+
+		awsRoles[i] = awsRole
+	}
+
+	return awsRoles, nil
+}
+
+func parseRole(role string) (*AWSRole, error) {
+	tokens := strings.Split(role, ",")
+
+	if len(tokens) != 2 {
+		return nil, fmt.Errorf("Invalid role string only %d tokens", len(tokens))
+	}
+
+	awsRole := &AWSRole{}
+
+	for _, token := range tokens {
+		if strings.Contains(token, ":saml-provider") {
+			awsRole.PrincipalARN = token
+		}
+		if strings.Contains(token, ":role") {
+			awsRole.RoleARN = token
+		}
+	}
+
+	if awsRole.PrincipalARN == "" {
+		return nil, fmt.Errorf("Unable to locate PrincipalARN in: %s", role)
+	}
+
+	if awsRole.RoleARN == "" {
+		return nil, fmt.Errorf("Unable to locate RoleARN in: %s", role)
+	}
+
+	return awsRole, nil
+}
diff --git a/cmd/saml2aws/commands/exec.go b/cmd/saml2aws/commands/exec.go
index 94c2376..c75b74e 100644
--- a/cmd/saml2aws/commands/exec.go
+++ b/cmd/saml2aws/commands/exec.go
@@ -15,7 +15,7 @@ import (
 )
 
 // Exec execute the supplied command after seeding the environment
-func Exec(profile string, skipVerify bool, cmdline []string) error {
+func Exec(profile string, providerName string, skipVerify bool, cmdline []string) error {
 
 	if len(cmdline) < 1 {
 		return fmt.Errorf("Command to execute required.")
@@ -27,7 +27,7 @@ func Exec(profile string, skipVerify bool, cmdline []string) error {
 	}
 
 	if !ok {
-		err = Login(profile, skipVerify)
+		err = Login(profile, providerName, skipVerify)
 	}
 	if err != nil {
 		return errors.Wrap(err, "error logging in")
diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go
index 9f51f7d..4c43e19 100644
--- a/cmd/saml2aws/commands/login.go
+++ b/cmd/saml2aws/commands/login.go
@@ -13,7 +13,7 @@ import (
 )
 
 // Login login to ADFS
-func Login(profile string, skipVerify bool) error {
+func Login(profile, providerName string, skipVerify bool) error {
 
 	config := saml2aws.NewConfigLoader("adfs")
 
@@ -32,16 +32,18 @@ func Login(profile string, skipVerify bool) error {
 		return errors.Wrap(err, "error accepting password")
 	}
 
-	fmt.Printf("ADFS https://%s\n", loginDetails.Hostname)
+	fmt.Printf("%s https://%s\n", providerName, loginDetails.Hostname)
 
-	fmt.Println("Authenticating to ADFS...")
+	fmt.Printf("Authenticating to %s...\n", providerName)
 
-	adfs, err := saml2aws.NewADFSClient(skipVerify)
+	opts := &saml2aws.SAMLOptions{Provider: providerName, SkipVerify: skipVerify}
+
+	provider, err := saml2aws.NewSAMLClient(opts)
 	if err != nil {
 		return errors.Wrap(err, "error building adfs client")
 	}
 
-	samlAssertion, err := adfs.Authenticate(loginDetails)
+	samlAssertion, err := provider.Authenticate(loginDetails)
 	if err != nil {
 		return errors.Wrap(err, "error authenticating to adfs")
 
@@ -69,7 +71,12 @@ func Login(profile string, skipVerify bool) error {
 		os.Exit(1)
 	}
 
-	role, err := saml2aws.PromptForAWSRoleSelection(roles)
+	awsRoles, err := saml2aws.ParseAWSRoles(roles)
+	if err != nil {
+		return errors.Wrap(err, "error parsing aws roles")
+	}
+
+	role, err := saml2aws.PromptForAWSRoleSelection(awsRoles)
 	if err != nil {
 		return errors.Wrap(err, "error selecting role")
 	}
@@ -111,6 +118,7 @@ func Login(profile string, skipVerify bool) error {
 	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).")
 
+	fmt.Println("Saving config:", config.Filename)
 	config.SaveUsername(loginDetails.Username)
 	config.SaveHostname(loginDetails.Hostname)
 
diff --git a/cmd/saml2aws/main.go b/cmd/saml2aws/main.go
index 835ca01..cf1676e 100644
--- a/cmd/saml2aws/main.go
+++ b/cmd/saml2aws/main.go
@@ -12,8 +12,9 @@ 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()
-	profileName = app.Flag("profile", "The AWS profile to save the temporary credentials").Short('p').Default("saml").String()
-	skipVerify  = app.Flag("skip-verify", "Skip verification of server certificate.").Short('s').Bool()
+	profileName  = app.Flag("profile", "The AWS profile to save the temporary credentials").Short('p').Default("saml").String()
+	skipVerify   = app.Flag("skip-verify", "Skip verification of server certificate.").Short('s').Bool()
+	providerName = app.Flag("provider", "The type of SAML IDP provider.").Short('i').Default("ADFS").Enum("ADFS", "Ping")
 
 	cmdLogin = app.Command("login", "Login to a SAML 2.0 IDP and convert the SAML assertion to an STS token.")
 
@@ -56,9 +57,9 @@ func main() {
 
 	switch command {
 	case cmdLogin.FullCommand():
-		err = commands.Login(*profileName, *skipVerify)
+		err = commands.Login(*profileName, *providerName, *skipVerify)
 	case cmdExec.FullCommand():
-		err = commands.Exec(*profileName, *skipVerify, *cmdLine)
+		err = commands.Exec(*profileName, *providerName, *skipVerify, *cmdLine)
 	}
 
 	if err != nil {
diff --git a/config.go b/config.go
index a6e9f63..062ff2a 100644
--- a/config.go
+++ b/config.go
@@ -103,6 +103,36 @@ func (p *ConfigLoader) LoadHostname() (string, error) {
 	return loadConfig(filename, p.Profile, "hostname")
 }
 
+// SaveProvider persist the provider
+func (p *ConfigLoader) SaveProvider(provider string) error {
+	filename, err := p.filename()
+	if err != nil {
+		return err
+	}
+
+	return saveConfig(filename, p.Profile, "provider", provider)
+}
+
+// LoadProvider load the provider
+func (p *ConfigLoader) LoadProvider(defaultValue string) (string, error) {
+
+	if defaultValue != "" {
+		return defaultValue, nil
+	}
+
+	filename, err := p.filename()
+	if err != nil {
+		return "", err
+	}
+
+	err = p.ensureConfigExists()
+	if err != nil {
+		return "", err
+	}
+
+	return loadConfig(filename, p.Profile, "provider")
+}
+
 func (p *ConfigLoader) filename() (string, error) {
 	if p.Filename == "" {
 		if p.Filename = os.Getenv("AWS2SAML_CONFIG_FILE"); p.Filename != "" {
diff --git a/input.go b/input.go
index 92d9b39..910401c 100644
--- a/input.go
+++ b/input.go
@@ -34,12 +34,12 @@ func PromptForLoginDetails(username, hostname string) (*LoginDetails, error) {
 }
 
 // PromptForAWSRoleSelection present a list of roles to the user for selection
-func PromptForAWSRoleSelection(roles []string) (*AWSRole, error) {
+func PromptForAWSRoleSelection(roles []*AWSRole) (*AWSRole, error) {
 
 	if len(roles) == 1 {
-		tok := strings.Split(roles[0], ",")
-
-		return &AWSRole{tok[0], tok[1]}, nil
+		//tok := strings.Split(roles[0], ",")
+		// &AWSRole{tok[0], tok[1]}
+		return roles[0], nil
 	}
 
 	reader := bufio.NewReader(os.Stdin)
@@ -47,7 +47,7 @@ func PromptForAWSRoleSelection(roles []string) (*AWSRole, error) {
 	fmt.Println("Please choose the role you would like to assume: ")
 
 	for i, role := range roles {
-		fmt.Println("[", i, "]: ", strings.Split(role, ",")[1])
+		fmt.Println("[", i, "]: ", role.RoleARN)
 	}
 
 	fmt.Print("Selection: ")
@@ -63,11 +63,11 @@ func PromptForAWSRoleSelection(roles []string) (*AWSRole, error) {
 		return nil, fmt.Errorf("You selected an invalid role index")
 	}
 
-	selectedRole := roles[v]
+	//selectedRole :=
 
-	tok := strings.Split(selectedRole, ",")
+	//tok := strings.Split(selectedRole, ",")
 
-	return &AWSRole{tok[1], tok[0]}, nil
+	return roles[v], nil
 }
 
 func promptFor(promptString, defaultValue string) string {
diff --git a/pingfed.go b/pingfed.go
new file mode 100644
index 0000000..e03b60e
--- /dev/null
+++ b/pingfed.go
@@ -0,0 +1,268 @@
+package saml2aws
+
+import (
+	"bytes"
+	"crypto/tls"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/pkg/errors"
+
+	"golang.org/x/net/publicsuffix"
+)
+
+// PingFedClient wrapper around PingFed + PingId enabling authentication and retrieval of assertions
+type PingFedClient struct {
+	client *http.Client
+}
+
+// NewPingFedClient create a new PingFed client
+func NewPingFedClient(skipVerify bool) (*PingFedClient, error) {
+
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify},
+	}
+
+	options := &cookiejar.Options{
+		PublicSuffixList: publicsuffix.List,
+	}
+
+	jar, err := cookiejar.New(options)
+	if err != nil {
+		return nil, err
+	}
+
+	client := &http.Client{Transport: tr, Jar: jar}
+	//disable default behaviour to follow redirects as we use this to detect mfa
+	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+		return errors.New("Redirect")
+	}
+
+	return &PingFedClient{
+		client: client,
+	}, nil
+}
+
+// Authenticate Authenticate to PingFed and return the data from the body of the SAML assertion.
+func (ac *PingFedClient) Authenticate(loginDetails *LoginDetails) (string, error) {
+	var authSubmitURL string
+	var samlAssertion string
+	mfaRequired := false
+
+	authForm := url.Values{}
+
+	pingFedURL := fmt.Sprintf("https://%s/idp/startSSO.ping?PartnerSpId=urn:amazon:webservices", loginDetails.Hostname)
+
+	res, err := ac.client.Get(pingFedURL)
+	if err != nil {
+		return samlAssertion, errors.Wrap(err, "error retieving form")
+	}
+
+	doc, err := goquery.NewDocumentFromResponse(res)
+	if err != nil {
+		return samlAssertion, errors.Wrap(err, "failed to build document from response")
+	}
+
+	doc.Find("input").Each(func(i int, s *goquery.Selection) {
+		updateLoginFormData(authForm, s, loginDetails)
+	})
+
+	//spew.Dump(authForm)
+
+	doc.Find("form").Each(func(i int, s *goquery.Selection) {
+		action, ok := s.Attr("action")
+		if !ok {
+			return
+		}
+		authSubmitURL = action
+	})
+
+	if authSubmitURL == "" {
+		return samlAssertion, fmt.Errorf("unable to locate IDP authentication form submit URL")
+	}
+
+	authSubmitURL = fmt.Sprintf("https://%s%s", loginDetails.Hostname, authSubmitURL)
+
+	//log.Printf("id authentication url: %s", authSubmitURL)
+
+	req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(authForm.Encode()))
+	if err != nil {
+		return samlAssertion, errors.Wrap(err, "error building authentication request")
+	}
+
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+	res, err = ac.client.Do(req)
+	if err != nil {
+		//check for redirect, this indicates PingOne MFA being used
+		if res.StatusCode == 302 {
+			mfaRequired = true
+		} else {
+			return samlAssertion, errors.Wrap(err, "error retieving login form")
+		}
+	}
+	//debug(httputil.DumpResponse(res, true))
+
+	//process mfa
+	if mfaRequired {
+
+		mfaURL, err := res.Location()
+		//spew.Dump(mfaURL)
+
+		//follow redirect
+		res, err = ac.client.Get(mfaURL.String())
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error retieving form")
+		}
+		//debug(httputil.DumpResponse(res, true))
+
+		//extract form action and jwt token
+		form, actionURL, err := extractFormData(res)
+
+		//request mfa auth via PingId (device swipe)
+		req, err := http.NewRequest("POST", actionURL, strings.NewReader(form.Encode()))
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error building mfa authentication request")
+		}
+
+		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+		res, err = ac.client.Do(req)
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error retieving mfa response")
+		}
+		//debug(httputil.DumpResponse(res, true))
+
+		//extract form action and csrf token
+		form, actionURL, err = extractFormData(res)
+
+		//contine mfa auth with csrf token
+		req, err = http.NewRequest("POST", actionURL, strings.NewReader(form.Encode()))
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error building authentication request")
+		}
+
+		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+		res, err = ac.client.Do(req)
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error polling mfa device")
+		}
+		//debug(httputil.DumpResponse(res, true))
+
+		//extract form action and jwt token
+		form, actionURL, err = extractFormData(res)
+
+		//pass PingId auth back to pingfed
+		req, err = http.NewRequest("POST", actionURL, strings.NewReader(form.Encode()))
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error building authentication request")
+		}
+
+		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+		res, err = ac.client.Do(req)
+		if err != nil {
+			return samlAssertion, errors.Wrap(err, "error authenticating mfa")
+		}
+		//debug(httputil.DumpResponse(res, true))
+
+	}
+	//log.Printf("res code = %v status = %s", res.StatusCode, res.Status)
+
+	//debug(httputil.DumpResponse(res, true))
+	data, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		return samlAssertion, errors.Wrap(err, "error retieving body")
+	}
+
+	doc, err = goquery.NewDocumentFromReader(bytes.NewBuffer(data))
+	if err != nil {
+		return samlAssertion, errors.Wrap(err, "error parsing document")
+	}
+
+	doc.Find("input").Each(func(i int, s *goquery.Selection) {
+		name, ok := s.Attr("name")
+		if !ok {
+			log.Fatalf("unable to locate IDP authentication form submit URL")
+		}
+		if name == "SAMLResponse" {
+			val, ok := s.Attr("value")
+			if !ok {
+				log.Fatalf("unable to locate saml assertion value")
+			}
+			samlAssertion = val
+		}
+	})
+
+	return samlAssertion, nil
+}
+
+func updateLoginFormData(authForm url.Values, s *goquery.Selection, user *LoginDetails) {
+	name, ok := s.Attr("name")
+	//	log.Printf("name = %s ok = %v", name, ok)
+	if !ok {
+		return
+	}
+	lname := strings.ToLower(name)
+	if strings.Contains(lname, "pf.username") {
+		authForm.Add(name, user.Username)
+	} else if strings.Contains(lname, "pf.pass") {
+		authForm.Add(name, user.Password)
+	} else {
+		// pass through any hidden fields
+		val, ok := s.Attr("value")
+		if !ok {
+			return
+		}
+		authForm.Add(name, val)
+	}
+}
+
+func extractFormData(res *http.Response) (url.Values, string, error) {
+	formData := url.Values{}
+	var actionURL string
+
+	doc, err := goquery.NewDocumentFromResponse(res)
+	if err != nil {
+		return formData, actionURL, errors.Wrap(err, "failed to build document from response")
+	}
+
+	//get action url
+	doc.Find("form").Each(func(i int, s *goquery.Selection) {
+		action, ok := s.Attr("action")
+		if !ok {
+			return
+		}
+		actionURL = action
+	})
+
+	//spew.Dump(actionURL)
+
+	// exxtract form data to passthrough
+	doc.Find("input").Each(func(i int, s *goquery.Selection) {
+		name, ok := s.Attr("name")
+		if !ok {
+			return
+		}
+		val, ok := s.Attr("value")
+		if !ok {
+			return
+		}
+		formData.Add(name, val)
+	})
+
+	return formData, actionURL, nil
+}
+
+func debug(data []byte, err error) {
+	if err == nil {
+		fmt.Printf("%s\n\n", data)
+	} else {
+		log.Fatalf("%s\n\n", err)
+	}
+}
diff --git a/saml.go b/saml.go
index a455e61..e766c30 100644
--- a/saml.go
+++ b/saml.go
@@ -2,7 +2,6 @@ package saml2aws
 
 import (
 	"fmt"
-	"log"
 
 	"github.com/beevik/etree"
 )
@@ -78,6 +77,6 @@ func childPath(space, tag string) string {
 	if space == "" {
 		return "./" + tag
 	}
-	log.Printf("query = %s", "./"+space+":"+tag)
+	//log.Printf("query = %s", "./"+space+":"+tag)
 	return "./" + space + ":" + tag
 }
diff --git a/saml2aws.go b/saml2aws.go
index 3dd862a..c5b2366 100644
--- a/saml2aws.go
+++ b/saml2aws.go
@@ -1,7 +1,32 @@
 package saml2aws
 
-// AWSRole aws role attributes
-type AWSRole struct {
-	RoleARN      string
-	PrincipalARN string
+import "fmt"
+
+type Provider int
+
+const (
+	ADFS Provider = iota
+	Ping
+)
+
+// SAMLClient client interface
+type SAMLClient interface {
+	Authenticate(loginDetails *LoginDetails) (string, error)
+}
+
+type SAMLOptions struct {
+	SkipVerify bool
+	Provider   string
+}
+
+// NewSAMLClient create a new SAML client
+func NewSAMLClient(opts *SAMLOptions) (SAMLClient, error) {
+	switch opts.Provider {
+	case "ADFS":
+		return NewADFSClient(opts.SkipVerify)
+	case "Ping":
+		return NewPingFedClient(opts.SkipVerify)
+	default:
+		return nil, fmt.Errorf("Invalid provider: %v", opts.Provider)
+	}
 }


Test patch:
diff --git a/aws_role_test.go b/aws_role_test.go
new file mode 100644
index 0000000..ea9eb66
--- /dev/null
+++ b/aws_role_test.go
@@ -0,0 +1,32 @@
+package saml2aws
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParseRoles(t *testing.T) {
+
+	roles := []string{
+		"arn:aws:iam::456456456456:saml-provider/example-idp,arn:aws:iam::456456456456:role/admin",
+		"arn:aws:iam::456456456456:role/admin,arn:aws:iam::456456456456:saml-provider/example-idp",
+	}
+
+	awsRoles, err := ParseAWSRoles(roles)
+
+	assert.Nil(t, err)
+	assert.Len(t, awsRoles, 2)
+
+	for _, awsRole := range awsRoles {
+		assert.Equal(t, "arn:aws:iam::456456456456:saml-provider/example-idp", awsRole.PrincipalARN)
+		assert.Equal(t, "arn:aws:iam::456456456456:role/admin", awsRole.RoleARN)
+	}
+
+	roles = []string{""}
+	awsRoles, err = ParseAWSRoles(roles)
+
+	assert.NotNil(t, err)
+	assert.Nil(t, awsRoles)
+
+}
diff --git a/testdata/assertion_pingfed.xml b/testdata/assertion_pingfed.xml
new file mode 100644
index 0000000..fd80227
--- /dev/null
+++ b/testdata/assertion_pingfed.xml
@@ -0,0 +1,110 @@
+<Response Version="2.0" ID="XXX" IssueInstant="2016-09-23T23:29:25.308Z" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
+	<Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
+		id:example:com:au:saml2
+	</Issuer>
+	<Status>
+		<StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
+	</Status>
+	<Assertion ID="n1yLpXwUNeNB6UuM0f5qOrRGxKX" IssueInstant="2016-09-23T23:29:25.315Z" Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
+		<Issuer>
+			id:example:com:au:saml2
+		</Issuer>
+		<Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+			<SignedInfo>
+				<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
+				<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
+				<Reference URI="#n1yLpXwUNeNB6UuM0f5qOrRGxKX">
+					<Transforms>
+						<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
+						<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
+					</Transforms>
+					<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
+					<DigestValue>
+						xxx
+					</DigestValue>
+				</Reference>
+			</SignedInfo>
+			<SignatureValue>
+				xxx
+			</SignatureValue>
+			<KeyInfo>
+				<X509Data>
+					<X509Certificate>
+						xxx
+					</X509Certificate>
+				</X509Data>
+			</KeyInfo>
+		</Signature>
+		<Subject>
+			<NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">
+				evan.mclean@example.com.au
+			</NameID>
+			<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+				<SubjectConfirmationData Recipient="https://signin.aws.amazon.com/saml" NotOnOrAfter="2016-09-23T23:34:25.315Z" />
+			</SubjectConfirmation>
+		</Subject>
+		<Conditions NotBefore="2016-09-23T23:24:25.315Z" NotOnOrAfter="2016-09-23T23:34:25.315Z">
+			<AudienceRestriction>
+				<Audience>
+					urn:amazon:webservices
+				</Audience>
+			</AudienceRestriction>
+		</Conditions>
+		<AuthnStatement SessionIndex="n1yLpXwUNeNB6UuM0f5qOrRGxKX" AuthnInstant="2016-09-23T23:29:25.315Z">
+			<AuthnContext>
+				<AuthnContextClassRef>
+					urn:oasis:names:tc:SAML:2.0:ac:classes:Telephony
+				</AuthnContextClassRef>
+			</AuthnContext>
+		</AuthnStatement>
+		<AttributeStatement>
+			<Attribute Name="http://schemas.xmlsoap.org/claims/CommonName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					Evan McLean
+				</AttributeValue>
+			</Attribute>
+			<Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					evan.mclean
+				</AttributeValue>
+			</Attribute>
+			<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					Evan
+				</AttributeValue>
+			</Attribute>
+			<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					evan.mclean
+				</AttributeValue>
+			</Attribute>
+			<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					McLean
+				</AttributeValue>
+			</Attribute>
+			<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					evan.mclean@example.com.au
+				</AttributeValue>
+			</Attribute>
+			<Attribute Name="https://aws.amazon.com/SAML/Attributes/Role" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					arn:aws:iam::456456456456:role/admin,arn:aws:iam::456456456456:saml-provider/example-idp
+				</AttributeValue>
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					arn:aws:iam::456456456456:role/developer,arn:aws:iam::456456456456:saml-provider/example-idp
+				</AttributeValue>
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					arn:aws:iam::789789789789:role/admin,arn:aws:iam::789789789789:saml-provider/example-idp
+				</AttributeValue>
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					arn:aws:iam::123123123123:role/developer,arn:aws:iam::123123123123:saml-provider/example-idp
+				</AttributeValue>
+				<AttributeValue xsi:type="xs:string" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+					arn:aws:iam::123123123123:role/admin,arn:aws:iam::123123123123:saml-provider/example-idp
+				</AttributeValue>
+			</Attribute>
+		</AttributeStatement>
+	</Assertion>
+</Response>