From 6fb22b8e45d2e1cd57d4e5b0ea90b8e8b60d997f Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Thu, 16 May 2024 11:36:57 +0900 Subject: [PATCH 01/13] add configs for tencentcloud --- cmd/saml2aws/main.go | 3 ++- input.go | 1 + pkg/cfg/cfg.go | 4 +++- pkg/flags/flags.go | 5 +++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/saml2aws/main.go b/cmd/saml2aws/main.go index bc0619f5d..f3ab669aa 100644 --- a/cmd/saml2aws/main.go +++ b/cmd/saml2aws/main.go @@ -77,7 +77,8 @@ func main() { app.Flag("browser-autofill", "Configures browser to autofill the username and password. (env: SAML2AWS_BROWSER_AUTOFILL)").Envar("SAML2AWS_BROWSER_AUTOFILL").BoolVar(&commonFlags.BrowserAutoFill) app.Flag("mfa", "The name of the mfa. (env: SAML2AWS_MFA)").Envar("SAML2AWS_MFA").StringVar(&commonFlags.MFA) app.Flag("skip-verify", "Skip verification of server certificate. (env: SAML2AWS_SKIP_VERIFY)").Envar("SAML2AWS_SKIP_VERIFY").Short('s').BoolVar(&commonFlags.SkipVerify) - app.Flag("url", "The URL of the SAML IDP server used to login. (env: SAML2AWS_URL)").Envar("SAML2AWS_URL").StringVar(&commonFlags.URL) + app.Flag("url", "The URL of the AWS SAML IDP server used to login. (env: SAML2AWS_URL)").Envar("SAML2AWS_URL").StringVar(&commonFlags.URL) + app.Flag("tencentcloud-url", "The URL of the TencentCloud SAML IDP server used to login. (env: SAML2AWS_TENCENTCLOUD_URL)").Envar("SAML2AWS_TENCENTCLOUD_URL").StringVar(&commonFlags.TencentCloudURL) app.Flag("username", "The username used to login. (env: SAML2AWS_USERNAME)").Envar("SAML2AWS_USERNAME").StringVar(&commonFlags.Username) app.Flag("password", "The password used to login. (env: SAML2AWS_PASSWORD)").Envar("SAML2AWS_PASSWORD").StringVar(&commonFlags.Password) app.Flag("mfa-token", "The current MFA token (supported in Keycloak, ADFS, GoogleApps). (env: SAML2AWS_MFA_TOKEN)").Envar("SAML2AWS_MFA_TOKEN").StringVar(&commonFlags.MFAToken) diff --git a/input.go b/input.go index f4c3dc00d..7b7b53f87 100644 --- a/input.go +++ b/input.go @@ -40,6 +40,7 @@ func PromptForConfigurationDetails(idpAccount *cfg.IDPAccount) error { idpAccount.Profile = prompter.String("AWS Profile", idpAccount.Profile) idpAccount.URL = prompter.String("URL", idpAccount.URL) + idpAccount.TencentCloudURL = prompter.String("TencentCloud URL (Optional)", idpAccount.TencentCloudURL) idpAccount.Username = prompter.String("Username", idpAccount.Username) switch idpAccount.Provider { diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go index 01644303c..28694c422 100644 --- a/pkg/cfg/cfg.go +++ b/pkg/cfg/cfg.go @@ -37,6 +37,7 @@ type IDPAccount struct { Name string `ini:"name"` AppID string `ini:"app_id"` // used by OneLogin and AzureAD URL string `ini:"url"` + TencentCloudURL string `ini:"tencentcloud_url"` Username string `ini:"username"` Provider string `ini:"provider"` BrowserType string `ini:"browser_type,omitempty"` // used by 'Browser' Provider @@ -89,6 +90,7 @@ func (ia IDPAccount) String() string { return fmt.Sprintf(`account {%s%s%s URL: %s + TencentCloudURL: %s Username: %s Provider: %s MFA: %s @@ -98,7 +100,7 @@ func (ia IDPAccount) String() string { Profile: %s RoleARN: %s Region: %s -}`, appID, policyID, oktaCfg, ia.URL, ia.Username, ia.Provider, ia.MFA, ia.SkipVerify, ia.AmazonWebservicesURN, ia.SessionDuration, ia.Profile, ia.RoleARN, ia.Region) +}`, appID, policyID, oktaCfg, ia.URL, ia.TencentCloudURL, ia.Username, ia.Provider, ia.MFA, ia.SkipVerify, ia.AmazonWebservicesURN, ia.SessionDuration, ia.Profile, ia.RoleARN, ia.Region) } // Validate validate the required / expected fields are set diff --git a/pkg/flags/flags.go b/pkg/flags/flags.go index 0e7607ef7..aa7365b5f 100644 --- a/pkg/flags/flags.go +++ b/pkg/flags/flags.go @@ -19,6 +19,7 @@ type CommonFlags struct { MFAIPAddress string MFAToken string URL string + TencentCloudURL string Username string Password string RoleArn string @@ -64,6 +65,10 @@ func ApplyFlagOverrides(commonFlags *CommonFlags, account *cfg.IDPAccount) { account.URL = commonFlags.URL } + if commonFlags.TencentCloudURL != "" { + account.TencentCloudURL = commonFlags.TencentCloudURL + } + if commonFlags.Username != "" { account.Username = commonFlags.Username } From 3aa5b0cb03b81a1ef994958713eabddfd6802c94 Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Mon, 20 May 2024 22:48:30 +0900 Subject: [PATCH 02/13] change list_role and login to support tc if configured --- aws_account.go | 8 +- aws_account_test.go | 6 +- aws_role.go | 60 --------------- aws_role_test.go | 32 -------- cloud_role.go | 71 +++++++++++++++++ cloud_role_test.go | 54 +++++++++++++ cmd/saml2aws/commands/list_roles.go | 84 ++++++++++++-------- cmd/saml2aws/commands/login.go | 114 +++++++++++++++++----------- cmd/saml2aws/commands/login_test.go | 4 +- input.go | 4 +- pkg/creds/creds.go | 1 + pkg/provider/keycloak/keycloak.go | 113 ++++++++++++++++++++++----- saml.go | 35 +++++---- saml_test.go | 4 +- 14 files changed, 374 insertions(+), 216 deletions(-) delete mode 100644 aws_role.go delete mode 100644 aws_role_test.go create mode 100644 cloud_role.go create mode 100644 cloud_role_test.go diff --git a/aws_account.go b/aws_account.go index ff28c3abf..f30b4d76b 100644 --- a/aws_account.go +++ b/aws_account.go @@ -14,7 +14,7 @@ import ( // AWSAccount holds the AWS account name and roles type AWSAccount struct { Name string - Roles []*AWSRole + Roles []*CloudRole } // ParseAWSAccounts extract the aws accounts from the saml assertion @@ -45,7 +45,7 @@ func ExtractAWSAccounts(data []byte) ([]*AWSAccount, error) { account := new(AWSAccount) account.Name = s.Find("div.saml-account-name").Text() s.Find("label").Each(func(i int, s *goquery.Selection) { - role := new(AWSRole) + role := new(CloudRole) role.Name = s.Text() role.RoleARN, _ = s.Attr("for") account.Roles = append(account.Roles, role) @@ -57,7 +57,7 @@ func ExtractAWSAccounts(data []byte) ([]*AWSAccount, error) { } // AssignPrincipals assign principal from roles -func AssignPrincipals(awsRoles []*AWSRole, awsAccounts []*AWSAccount) { +func AssignPrincipals(awsRoles []*CloudRole, awsAccounts []*AWSAccount) { awsPrincipalARNs := make(map[string]string) for _, awsRole := range awsRoles { @@ -73,7 +73,7 @@ func AssignPrincipals(awsRoles []*AWSRole, awsAccounts []*AWSAccount) { } // LocateRole locate role by name -func LocateRole(awsRoles []*AWSRole, roleName string) (*AWSRole, error) { +func LocateRole(awsRoles []*CloudRole, roleName string) (*CloudRole, error) { for _, awsRole := range awsRoles { if awsRole.RoleARN == roleName { return awsRole, nil diff --git a/aws_account_test.go b/aws_account_test.go index b5c86c43b..b8b04072e 100644 --- a/aws_account_test.go +++ b/aws_account_test.go @@ -36,7 +36,7 @@ func TestExtractAWSAccounts(t *testing.T) { } func TestAssignPrincipals(t *testing.T) { - awsRoles := []*AWSRole{ + awsRoles := []*CloudRole{ { PrincipalARN: "arn:aws:iam::000000000001:saml-provider/test-idp", RoleARN: "arn:aws:iam::000000000001:role/Development", @@ -45,7 +45,7 @@ func TestAssignPrincipals(t *testing.T) { awsAccounts := []*AWSAccount{ { - Roles: []*AWSRole{ + Roles: []*CloudRole{ { RoleARN: "arn:aws:iam::000000000001:role/Development", }, @@ -59,7 +59,7 @@ func TestAssignPrincipals(t *testing.T) { } func TestLocateRole(t *testing.T) { - awsRoles := []*AWSRole{ + awsRoles := []*CloudRole{ { PrincipalARN: "arn:aws:iam::000000000001:saml-provider/test-idp", RoleARN: "arn:aws:iam::000000000001:role/Development", diff --git a/aws_role.go b/aws_role.go deleted file mode 100644 index 60ff2246b..000000000 --- a/aws_role.go +++ /dev/null @@ -1,60 +0,0 @@ -package saml2aws - -import ( - "fmt" - "regexp" - "strings" -) - -// AWSRole aws role attributes -type AWSRole struct { - RoleARN string - PrincipalARN string - Name 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) { - r, _ := regexp.Compile("arn:([^:\n]*):([^:\n]*):([^:\n]*):([^:\n]*):(([^:/\n]*)[:/])?([^:,\n]*)") - tokens := r.FindAllString(role, -1) - - 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 = strings.TrimSpace(token) - } - if strings.Contains(token, ":role") { - awsRole.RoleARN = strings.TrimSpace(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/aws_role_test.go b/aws_role_test.go deleted file mode 100644 index ea9eb665d..000000000 --- a/aws_role_test.go +++ /dev/null @@ -1,32 +0,0 @@ -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/cloud_role.go b/cloud_role.go new file mode 100644 index 000000000..731a17bc4 --- /dev/null +++ b/cloud_role.go @@ -0,0 +1,71 @@ +package saml2aws + +import ( + "fmt" + "log" + "regexp" + "strings" +) + +// CloudRole aws role attributes +type CloudRole struct { + Provider string + RoleARN string + PrincipalARN string + Name string +} + +// ParseCloudRoles parses and splits the roles while also validating the contents +func ParseCloudRoles(roles []string, provider string) ([]*CloudRole, error) { + awsRoles := make([]*CloudRole, len(roles)) + + for i, role := range roles { + awsRole, err := parseRole(role, provider) + if err != nil { + return nil, err + } + + awsRoles[i] = awsRole + } + + return awsRoles, nil +} + +func parseRole(role, provider string) (*CloudRole, error) { + var r *regexp.Regexp + switch provider { + case "AWS": + r, _ = regexp.Compile("arn:([^:\n]*):([^:\n]*):([^:\n]*):([^:\n]*):(([^:/\n]*)[:/])?([^:,\n]*)") + case "TencentCloud": + r, _ = regexp.Compile("qcs::([^:\\n]*):([^:\\n]*):([^:\\n]*):([^:/\\n]*)([/]([^,]*)|:([^,\\n]*))\n") + default: + return nil, fmt.Errorf("Invalid provider: %s", provider) + } + + log.Println("Parsing role: ", role) + tokens := r.FindAllString(role, -1) + if len(tokens) != 2 { + return nil, fmt.Errorf("Invalid role string only %d tokens", len(tokens)) + } + + providerRole := &CloudRole{} + for _, token := range tokens { + if strings.Contains(token, ":saml-provider") { + providerRole.PrincipalARN = strings.TrimSpace(token) + } + if strings.Contains(token, ":role") { + providerRole.RoleARN = strings.TrimSpace(token) + } + } + providerRole.Provider = provider + + if providerRole.PrincipalARN == "" { + return nil, fmt.Errorf("Unable to locate PrincipalARN in: %s", role) + } + + if providerRole.RoleARN == "" { + return nil, fmt.Errorf("Unable to locate RoleARN in: %s", role) + } + + return providerRole, nil +} diff --git a/cloud_role_test.go b/cloud_role_test.go new file mode 100644 index 000000000..8cad9faef --- /dev/null +++ b/cloud_role_test.go @@ -0,0 +1,54 @@ +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 := ParseCloudRoles(roles, "AWS") + + 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 = ParseCloudRoles(roles, "AWS") + + assert.NotNil(t, err) + assert.Nil(t, awsRoles) + + // TencentCloud Roles + roles = []string{ + "qcs::cam::uin/888888888888:roleName/dage,qcs::cam::uin/888888888888:saml-provider/example-provider-idp", + "qcs::cam::uin/888888888888:saml-provider/example-provider-idp,qcs::cam::uin/888888888888:roleName/dage", + } + + tencentcloudRoles, err := ParseCloudRoles(roles, "TencentCloud") + + assert.Nil(t, err) + assert.Len(t, tencentcloudRoles, 2) + + for _, tencentcloudRole := range tencentcloudRoles { + assert.Equal(t, "qcs::cam::uin/888888888888:saml-provider/example-provider-idp", tencentcloudRole.PrincipalARN) + assert.Equal(t, "qcs::cam::uin/888888888888:roleName/dage", tencentcloudRole.RoleARN) + } + + roles = []string{""} + tencentcloudRoles, err = ParseCloudRoles(roles, "TencentCloud") + + assert.NotNil(t, err) + assert.Nil(t, tencentcloudRoles) + +} diff --git a/cmd/saml2aws/commands/list_roles.go b/cmd/saml2aws/commands/list_roles.go index 75c522606..ec63351ec 100644 --- a/cmd/saml2aws/commands/list_roles.go +++ b/cmd/saml2aws/commands/list_roles.go @@ -2,6 +2,7 @@ package commands import ( b64 "encoding/base64" + "encoding/json" "fmt" "log" "os" @@ -89,57 +90,74 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { } } - data, err := b64.StdEncoding.DecodeString(samlAssertion) - if err != nil { - return errors.Wrap(err, "error decoding saml assertion") + samlAssertions := make(map[string]string) + if loginDetails.TencentCloudURL != "" { + // If TencentCloud is configured, unmarshal the SAML assertion for both AWS and TencentCloud + if err = json.Unmarshal([]byte(samlAssertion), &samlAssertions); err != nil { + return errors.Wrap(err, "error unmarshalling saml assertion. (Devsisters custom implementation)") + } + } else { + // Only AWS is configured, proceed with normal saml2aws flow + samlAssertions["AWS"] = samlAssertion } - roles, err := saml2aws.ExtractAwsRoles(data) - if err != nil { - return errors.Wrap(err, "error parsing aws roles") - } + cloudRoles := make([]*saml2aws.CloudRole, 0) + for cloud, assertion := range samlAssertions { + data, err := b64.StdEncoding.DecodeString(assertion) + if err != nil { + return errors.Wrap(err, "error decoding SAML assertion.") + } - if len(roles) == 0 { - log.Println("No roles to assume") - os.Exit(1) - } + roleArns, err := saml2aws.ExtractCloudRoles(data) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("error extracting %v role arns.", cloud)) + } + if len(roleArns) == 0 { + log.Println("No", cloud, "roles to assume.") + continue + } - awsRoles, err := saml2aws.ParseAWSRoles(roles) - if err != nil { - return errors.Wrap(err, "error parsing aws roles") + cloudRoles, err := saml2aws.ParseCloudRoles(roleArns, cloud) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("error parsing %s roles", cloud)) + } + cloudRoles = append(cloudRoles, cloudRoles...) + } + if len(cloudRoles) == 0 { + os.Exit(1) } - if err := listRoles(awsRoles, samlAssertion, loginFlags); err != nil { + if err := listRoles(cloudRoles, samlAssertions); err != nil { return errors.Wrap(err, "Failed to list roles") } return nil } -func listRoles(awsRoles []*saml2aws.AWSRole, samlAssertion string, loginFlags *flags.LoginExecFlags) error { - if len(awsRoles) == 0 { - return errors.New("no roles available") - } +func listRoles(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]string) error { + cloudAccounts := make([]*saml2aws.AWSAccount, 0) + for provider, assertion := range samlAssertions { + data, err := b64.StdEncoding.DecodeString(assertion) + if err != nil { + return errors.Wrap(err, "error decoding saml assertion") + } - samlAssertionData, err := b64.StdEncoding.DecodeString(samlAssertion) - if err != nil { - return errors.Wrap(err, "error decoding saml assertion") - } + aud, err := saml2aws.ExtractDestinationURL(data) + if err != nil { + return errors.Wrap(err, "error parsing destination url") + } - aud, err := saml2aws.ExtractDestinationURL(samlAssertionData) - if err != nil { - return errors.Wrap(err, "error parsing destination url") - } + accounts, err := saml2aws.ParseAWSAccounts(aud, assertion) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("error parsing %v role accounts", provider)) + } - awsAccounts, err := saml2aws.ParseAWSAccounts(aud, samlAssertion) - if err != nil { - return errors.Wrap(err, "error parsing aws role accounts") + saml2aws.AssignPrincipals(cloudRoles, accounts) + cloudAccounts = append(cloudAccounts, accounts...) } - saml2aws.AssignPrincipals(awsRoles, awsAccounts) - log.Println("") - for _, account := range awsAccounts { + for _, account := range cloudAccounts { fmt.Println(account.Name) for _, role := range account.Roles { fmt.Println(role.RoleARN) diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go index e1cc12166..35f281774 100644 --- a/cmd/saml2aws/commands/login.go +++ b/cmd/saml2aws/commands/login.go @@ -75,7 +75,7 @@ func Login(loginFlags *flags.LoginExecFlags) error { os.Exit(1) } - logger.WithField("idpAccount", account).Debug("building provider") + logger.WithField("idpAccount", account).Debug("building samlProvider") provider, err := saml2aws.NewSAMLClient(account) if err != nil { @@ -130,11 +130,23 @@ func Login(loginFlags *flags.LoginExecFlags) error { } } - role, err := selectAwsRole(samlAssertion, account) - if err != nil { - return errors.Wrap(err, "Failed to assume role. Please check whether you are permitted to assume the given role for the AWS service.") + log.Println("SAML assertion:", samlAssertion) + + samlAssertions := make(map[string]string) + if loginDetails.TencentCloudURL != "" { + // If TencentCloud is configured, unmarshal the SAML assertion for both AWS and TencentCloud + if err = json.Unmarshal([]byte(samlAssertion), &samlAssertions); err != nil { + return errors.Wrap(err, "error unmarshalling saml assertion. (Devsisters custom implementation)") + } + } else { + // Only AWS is configured, proceed with normal saml2aws flow + samlAssertions["AWS"] = samlAssertion } + role, err := selectAwsRole(samlAssertions, account) + if err != nil { + return errors.Wrap(err, "Error resolving role.") + } log.Println("Selected role:", role.RoleARN) awsCreds, err := loginToStsUsingRole(account, role, samlAssertion) @@ -192,7 +204,7 @@ func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *flags.LoginExecFla // log.Printf("loginFlags %+v", loginFlags) - loginDetails := &creds.LoginDetails{URL: account.URL, Username: account.Username, MFAToken: loginFlags.CommonFlags.MFAToken, DuoMFAOption: loginFlags.DuoMFAOption} + loginDetails := &creds.LoginDetails{URL: account.URL, TencentCloudURL: account.TencentCloudURL, Username: account.Username, MFAToken: loginFlags.CommonFlags.MFAToken, DuoMFAOption: loginFlags.DuoMFAOption} log.Printf("Using IdP Account %s to access %s %s", loginFlags.CommonFlags.IdpAccount, account.Provider, account.URL) @@ -263,69 +275,79 @@ func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *flags.LoginExecFla return loginDetails, nil } -func selectAwsRole(samlAssertion string, account *cfg.IDPAccount) (*saml2aws.AWSRole, error) { - data, err := b64.StdEncoding.DecodeString(samlAssertion) - if err != nil { - return nil, errors.Wrap(err, "Error decoding SAML assertion.") - } +func selectAwsRole(samlAssertions map[string]string, account *cfg.IDPAccount) (*saml2aws.CloudRole, error) { + cloudRoles := make([]*saml2aws.CloudRole, 0) + for cloud, assertion := range samlAssertions { + data, err := b64.StdEncoding.DecodeString(assertion) + if err != nil { + return nil, errors.Wrap(err, "Error decoding SAML assertion.") + } - roles, err := saml2aws.ExtractAwsRoles(data) - if err != nil { - return nil, errors.Wrap(err, "Error parsing AWS roles.") - } + roleArns, err := saml2aws.ExtractCloudRoles(data) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("Error extracting %v roles arns.", cloud)) + } + if len(roleArns) == 0 { + log.Println("No", cloud, "roles to assume.") + continue + } - if len(roles) == 0 { - log.Println("No roles to assume.") - log.Println("Please check you are permitted to assume roles for the AWS service.") - os.Exit(1) + cloudRoles, err := saml2aws.ParseCloudRoles(roleArns, cloud) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("Error parsing %v roles.", cloud)) + } + cloudRoles = append(cloudRoles, cloudRoles...) } - awsRoles, err := saml2aws.ParseAWSRoles(roles) - if err != nil { - return nil, errors.Wrap(err, "Error parsing AWS roles.") + if len(cloudRoles) == 0 { + log.Println("Please check you are permitted to assume roles for the AWS or TencentCloud service.") + os.Exit(1) } - return resolveRole(awsRoles, samlAssertion, account) + return resolveRole(cloudRoles, samlAssertions, account) } -func resolveRole(awsRoles []*saml2aws.AWSRole, samlAssertion string, account *cfg.IDPAccount) (*saml2aws.AWSRole, error) { - var role = new(saml2aws.AWSRole) - - if len(awsRoles) == 1 { +func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]string, account *cfg.IDPAccount) (role *saml2aws.CloudRole, err error) { + if len(cloudRoles) == 1 { if account.RoleARN != "" { - return saml2aws.LocateRole(awsRoles, account.RoleARN) + return saml2aws.LocateRole(cloudRoles, account.RoleARN) } - return awsRoles[0], nil - } else if len(awsRoles) == 0 { + return cloudRoles[0], nil + } else if len(cloudRoles) == 0 { return nil, errors.New("No roles available.") } - samlAssertionData, err := b64.StdEncoding.DecodeString(samlAssertion) - if err != nil { - return nil, errors.Wrap(err, "Error decoding SAML assertion.") - } + cloudAccounts := make([]*saml2aws.AWSAccount, 0) + for provider, assertion := range samlAssertions { + data, err := b64.StdEncoding.DecodeString(assertion) + if err != nil { + return nil, errors.Wrap(err, "Error decoding SAML assertion.") + } - aud, err := saml2aws.ExtractDestinationURL(samlAssertionData) - if err != nil { - return nil, errors.Wrap(err, "Error parsing destination URL.") - } + aud, err := saml2aws.ExtractDestinationURL(data) + if err != nil { + return nil, errors.Wrap(err, "Error parsing destination URL.") + } - awsAccounts, err := saml2aws.ParseAWSAccounts(aud, samlAssertion) - if err != nil { - return nil, errors.Wrap(err, "Error parsing AWS role accounts.") + accounts, err := saml2aws.ParseAWSAccounts(aud, assertion) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("Error parsing %v role accounts.", provider)) + } + + saml2aws.AssignPrincipals(cloudRoles, accounts) + cloudAccounts = append(cloudAccounts, accounts...) } - if len(awsAccounts) == 0 { + + if len(cloudAccounts) == 0 { return nil, errors.New("No accounts available.") } - saml2aws.AssignPrincipals(awsRoles, awsAccounts) - if account.RoleARN != "" { - return saml2aws.LocateRole(awsRoles, account.RoleARN) + return saml2aws.LocateRole(cloudRoles, account.RoleARN) } for { - role, err = saml2aws.PromptForAWSRoleSelection(awsAccounts) + role, err = saml2aws.PromptForAWSRoleSelection(cloudAccounts) if err == nil { break } @@ -335,7 +357,7 @@ func resolveRole(awsRoles []*saml2aws.AWSRole, samlAssertion string, account *cf return role, nil } -func loginToStsUsingRole(account *cfg.IDPAccount, role *saml2aws.AWSRole, samlAssertion string) (*awsconfig.AWSCredentials, error) { +func loginToStsUsingRole(account *cfg.IDPAccount, role *saml2aws.CloudRole, samlAssertion string) (*awsconfig.AWSCredentials, error) { sess, err := session.NewSession(&aws.Config{ Region: &account.Region, diff --git a/cmd/saml2aws/commands/login_test.go b/cmd/saml2aws/commands/login_test.go index bca0442cf..6d217b4bb 100644 --- a/cmd/saml2aws/commands/login_test.go +++ b/cmd/saml2aws/commands/login_test.go @@ -65,13 +65,13 @@ func TestOktaResolveLoginDetailsWithFlags(t *testing.T) { func TestResolveRoleSingleEntry(t *testing.T) { - adminRole := &saml2aws.AWSRole{ + adminRole := &saml2aws.CloudRole{ Name: "admin", RoleARN: "arn:aws:iam::456456456456:saml-provider/example-idp,arn:aws:iam::456456456456:role/admin", PrincipalARN: "arn:aws:iam::456456456456:role/admin,arn:aws:iam::456456456456:saml-provider/example-idp", } - awsRoles := []*saml2aws.AWSRole{ + awsRoles := []*saml2aws.CloudRole{ adminRole, } diff --git a/input.go b/input.go index 7b7b53f87..f8abbda52 100644 --- a/input.go +++ b/input.go @@ -89,9 +89,9 @@ func PromptForLoginDetails(loginDetails *creds.LoginDetails, provider string) er } // PromptForAWSRoleSelection present a list of roles to the user for selection -func PromptForAWSRoleSelection(accounts []*AWSAccount) (*AWSRole, error) { +func PromptForAWSRoleSelection(accounts []*AWSAccount) (*CloudRole, error) { - roles := map[string]*AWSRole{} + roles := map[string]*CloudRole{} var roleOptions []string for _, account := range accounts { diff --git a/pkg/creds/creds.go b/pkg/creds/creds.go index 5aa697103..4936487fc 100644 --- a/pkg/creds/creds.go +++ b/pkg/creds/creds.go @@ -11,6 +11,7 @@ type LoginDetails struct { MFAToken string DuoMFAOption string URL string + TencentCloudURL string StateToken string // used by Okta OktaSessionCookie string // used by Okta } diff --git a/pkg/provider/keycloak/keycloak.go b/pkg/provider/keycloak/keycloak.go index c0395c3a6..1085bf6ea 100644 --- a/pkg/provider/keycloak/keycloak.go +++ b/pkg/provider/keycloak/keycloak.go @@ -3,6 +3,7 @@ package keycloak import ( "bytes" "encoding/base64" + "encoding/json" "fmt" "io" "log" @@ -57,57 +58,131 @@ func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) return kc.doAuthenticate(&authContext{loginDetails.MFAToken, 0, true}, loginDetails) } +// XXX(changwon): Retrieve "multiple" SAML responses from all configured providers func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.LoginDetails) (string, error) { - authSubmitURL, authForm, err := kc.getLoginForm(loginDetails) + awsAuthSubmitURL, awsAuthSubmitForm, err := kc.getLoginForm(loginDetails) if err != nil { - return "", errors.Wrap(err, "error retrieving login form from idp") + return "", errors.Wrap(err, "error retrieving aws login form from idp") + } + if awsAuthSubmitURL == "" { + return "", errors.Wrap(err, "error retrieving aws login url from idp") } - data, err := kc.postLoginForm(authSubmitURL, authForm) + awsdata, err := kc.postLoginForm(awsAuthSubmitURL, awsAuthSubmitForm) if err != nil { - return "", fmt.Errorf("error submitting login form") - } - if authSubmitURL == "" { - return "", fmt.Errorf("error submitting login form") + return "", errors.Wrap(err, "error submitting aws login form") } - doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) + awsdoc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(awsdata)) if err != nil { return "", errors.Wrap(err, "error parsing document") } - if containsTotpForm(doc) { - totpSubmitURL, err := extractSubmitURL(doc) + if containsTotpForm(awsdoc) { + totpSubmitURL, err := extractSubmitURL(awsdoc) if err != nil { return "", errors.Wrap(err, "unable to locate IDP totp form submit URL") } - doc, err = kc.postTotpForm(authCtx, totpSubmitURL, doc) + awsdoc, err = kc.postTotpForm(authCtx, totpSubmitURL, awsdoc) if err != nil { return "", errors.Wrap(err, "error posting totp form") } - } else if containsWebauthnForm(doc) { - credentialIDs, challenge, rpId, err := extractWebauthnParameters(doc) + } else if containsWebauthnForm(awsdoc) { + credentialIDs, challenge, rpId, err := extractWebauthnParameters(awsdoc) if err != nil { return "", errors.Wrap(err, "could not extract Webauthn parameters") } - webauthnSubmitURL, err := extractSubmitURL(doc) + webauthnSubmitURL, err := extractSubmitURL(awsdoc) if err != nil { return "", errors.Wrap(err, "unable to locate IDP Webauthn form submit URL") } - doc, err = kc.postWebauthnForm(webauthnSubmitURL, credentialIDs, challenge, rpId) + awsdoc, err = kc.postWebauthnForm(webauthnSubmitURL, credentialIDs, challenge, rpId) if err != nil { return "", errors.Wrap(err, "error posting Webauthn form") } } - samlResponse, err := extractSamlResponse(doc) - if err != nil && authCtx.authenticatorIndexValid && passwordValid(doc) { + awsSamlResponse, err := extractSamlResponse(awsdoc) + if err != nil && authCtx.authenticatorIndexValid && passwordValid(awsdoc) { return kc.doAuthenticate(authCtx, loginDetails) } - return samlResponse, err + log.Println("SAML response successfully retrieved for AWS") + log.Println(awsSamlResponse) + + // If configured, retrieve TencentCloud SAML Response with the same authCtx + if loginDetails.TencentCloudURL != "" { + tcAuthSubmitURL, tcAuthSubmitForm, err := kc.getLoginForm(loginDetails) + if err != nil { + return "", errors.Wrap(err, "error retrieving tencentcloud login form from idp") + } + if tcAuthSubmitURL == "" { + return "", errors.Wrap(err, "error retrieving tencentcloud login url from idp") + } + + tcdata, err := kc.postLoginForm(tcAuthSubmitURL, tcAuthSubmitForm) + if err != nil { + return "", errors.Wrap(err, "error submitting tencentcloud login form") + } + + tcdoc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(tcdata)) + if err != nil { + return "", errors.Wrap(err, "error parsing document") + } + + if containsTotpForm(tcdoc) { + totpSubmitURL, err := extractSubmitURL(tcdoc) + if err != nil { + return "", errors.Wrap(err, "unable to locate IDP totp form submit URL") + } + + tcdoc, err = kc.postTotpForm(authCtx, totpSubmitURL, tcdoc) + if err != nil { + return "", errors.Wrap(err, "error posting totp form") + } + } else if containsWebauthnForm(tcdoc) { + credentialIDs, challenge, rpId, err := extractWebauthnParameters(tcdoc) + if err != nil { + return "", errors.Wrap(err, "could not extract Webauthn parameters") + } + + webauthnSubmitURL, err := extractSubmitURL(tcdoc) + if err != nil { + return "", errors.Wrap(err, "unable to locate IDP Webauthn form submit URL") + } + + tcdoc, err = kc.postWebauthnForm(webauthnSubmitURL, credentialIDs, challenge, rpId) + if err != nil { + return "", errors.Wrap(err, "error posting Webauthn form") + } + } + + tcSamlResponse, err := extractSamlResponse(tcdoc) + if err != nil && authCtx.authenticatorIndexValid && passwordValid(tcdoc) { + return kc.doAuthenticate(authCtx, loginDetails) + } + log.Println("SAML response successfully retrieved for TencentCloud") + log.Println(tcSamlResponse) + + if awsSamlResponse == "" && tcSamlResponse == "" { + return "", errors.Wrap(err, "no SAML response retrieved from keycloak") + } + + // Return both AWS and TencentCloud SAML responses + samlResponses := make(map[string]string) + samlResponses["AWS"] = awsSamlResponse + samlResponses["TencentCloud"] = tcSamlResponse + jsonSamlResponses, err := json.Marshal(samlResponses) + if err != nil { + return "", errors.Wrap(err, "error marshalling SAML responses from keycloak") + } + return string(jsonSamlResponses), err + } + + // Return normally + return awsSamlResponse, err } func extractWebauthnParameters(doc *goquery.Document) (credentialIDs []string, challenge string, rpID string, err error) { @@ -210,6 +285,8 @@ func (kc *Client) postTotpForm(authCtx *authContext, totpSubmitURL string, doc * if authCtx.mfaToken == "" { authCtx.mfaToken = prompter.RequestSecurityCode("000000") + } else { + log.Println("MFA token already provided") } doc.Find("input").Each(func(i int, s *goquery.Selection) { diff --git a/saml.go b/saml.go index 282aedd86..392adb07e 100644 --- a/saml.go +++ b/saml.go @@ -2,6 +2,7 @@ package saml2aws import ( "fmt" + "log" "strconv" "time" @@ -52,7 +53,7 @@ func ExtractSessionDuration(data []byte) (int64, error) { // log.Printf("tag: %s", assertionElement.Tag) - //Get the actual assertion attributes + // Get the actual assertion attributes attributeStatement := assertionElement.FindElement(childPath(assertionElement.Space, attributeStatementTag)) if attributeStatement == nil { return 0, ErrMissingElement{Tag: attributeStatementTag} @@ -133,51 +134,57 @@ func ExtractMFATokenExpiryTime(data []byte) (time.Time, error) { return time.Parse(time.RFC3339, ValidUntilString) } -// ExtractAwsRoles given an assertion document extract the aws roles -func ExtractAwsRoles(data []byte) ([]string, error) { - - awsroles := []string{} +// ExtractCloudRoles given an assertion document extract the aws roles +func ExtractCloudRoles(data []byte) ([]string, error) { + cloudroles := []string{} doc := etree.NewDocument() if err := doc.ReadFromBytes(data); err != nil { - return awsroles, err + return cloudroles, err } - // log.Printf("root tag: %s", doc.Root().Tag) + log.Printf("root tag: %s", doc.Root().Tag) assertionElement := doc.FindElement(".//Assertion") if assertionElement == nil { return nil, ErrMissingAssertion } - // log.Printf("tag: %s", assertionElement.Tag) + log.Printf("tag: %s", assertionElement.Tag) - //Get the actual assertion attributes + // Get the actual assertion attributes attributeStatement := assertionElement.FindElement(childPath(assertionElement.Space, attributeStatementTag)) if attributeStatement == nil { return nil, ErrMissingElement{Tag: attributeStatementTag} } - // log.Printf("tag: %s", attributeStatement.Tag) + log.Printf("tag: %s", attributeStatement.Tag) attributes := attributeStatement.FindElements(childPath(assertionElement.Space, attributeTag)) for _, attribute := range attributes { - if attribute.SelectAttrValue("Name", "") != "https://aws.amazon.com/SAML/Attributes/Role" { + if attribute.SelectAttrValue("Name", "") != "https://aws.amazon.com/SAML/Attributes/Role" && attribute.SelectAttrValue("Name", "") != "https://cloud.tencent.com/SAML/Attributes/Role" { continue } + + if attribute.SelectAttrValue("Name", "") == "https://aws.amazon.com/SAML/Attributes/Role" { + log.Printf("found aws assertion") + } else if attribute.SelectAttrValue("Name", "") == "https://cloud.tencent.com/SAML/Attributes/Role" { + log.Printf("found tencent assertion") + } + atributeValues := attribute.FindElements(childPath(assertionElement.Space, attributeValueTag)) for _, attrValue := range atributeValues { - awsroles = append(awsroles, attrValue.Text()) + cloudroles = append(cloudroles, attrValue.Text()) } } - return awsroles, nil + return cloudroles, nil } 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/saml_test.go b/saml_test.go index 38338dcfb..1ed45e75a 100644 --- a/saml_test.go +++ b/saml_test.go @@ -12,7 +12,7 @@ func TestExtractAwsRoles(t *testing.T) { data, err := os.ReadFile("testdata/assertion.xml") assert.Nil(t, err) - roles, err := ExtractAwsRoles(data) + roles, err := ExtractCloudRoles(data) assert.Nil(t, err) assert.Len(t, roles, 2) } @@ -21,7 +21,7 @@ func TestExtractAwsRolesFail(t *testing.T) { data, err := os.ReadFile("testdata/notxml.xml") assert.Nil(t, err) - _, err = ExtractAwsRoles(data) + _, err = ExtractCloudRoles(data) assert.Error(t, err) } From 630962cdc18a76cabad7b4cf0a7a567ae7c19234 Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Tue, 21 May 2024 00:51:24 +0900 Subject: [PATCH 03/13] add typing of cloud providers, fix behavior when getLoginForm is called multiple times --- aws_account.go => cloud_account.go | 52 +++++++++++--- aws_account_test.go => cloud_account_test.go | 2 +- cloud_role.go | 27 +++---- cmd/saml2aws/commands/list_roles.go | 22 +++--- cmd/saml2aws/commands/login.go | 13 ++-- input.go | 2 +- pkg/cloud/types.go | 12 ++++ pkg/provider/keycloak/keycloak.go | 76 +++++++------------- saml.go | 17 +++-- 9 files changed, 126 insertions(+), 97 deletions(-) rename aws_account.go => cloud_account.go (53%) rename aws_account_test.go => cloud_account_test.go (98%) create mode 100644 pkg/cloud/types.go diff --git a/aws_account.go b/cloud_account.go similarity index 53% rename from aws_account.go rename to cloud_account.go index f30b4d76b..cf7e619a1 100644 --- a/aws_account.go +++ b/cloud_account.go @@ -4,21 +4,23 @@ import ( "bytes" "fmt" "io" + "log" "net/http" "net/url" "github.com/PuerkitoBio/goquery" "github.com/pkg/errors" + "github.com/versent/saml2aws/v2/pkg/cloud" ) -// AWSAccount holds the AWS account name and roles -type AWSAccount struct { +// CloudAccount holds the AWS account name and roles +type CloudAccount struct { Name string Roles []*CloudRole } -// ParseAWSAccounts extract the aws accounts from the saml assertion -func ParseAWSAccounts(audience string, samlAssertion string) ([]*AWSAccount, error) { +// ParseCloudAccounts extract the aws accounts from the saml assertion +func ParseCloudAccounts(provider cloud.Provider, audience string, samlAssertion string) ([]*CloudAccount, error) { res, err := http.PostForm(audience, url.Values{"SAMLResponse": {samlAssertion}}) if err != nil { return nil, errors.Wrap(err, "error retrieving AWS login form") @@ -29,20 +31,52 @@ func ParseAWSAccounts(audience string, samlAssertion string) ([]*AWSAccount, err return nil, errors.Wrap(err, "error retrieving AWS login body") } - return ExtractAWSAccounts(data) + switch provider { + case cloud.AWS: + return ExtractAWSAccounts(data) + case cloud.TencentCloud: + return ExtractTencentCloudAccounts(data) + default: + return nil, fmt.Errorf("unsupported cloud provider: %s", provider) + } } // ExtractAWSAccounts extract the accounts from the AWS html page -func ExtractAWSAccounts(data []byte) ([]*AWSAccount, error) { - accounts := []*AWSAccount{} +func ExtractAWSAccounts(data []byte) ([]*CloudAccount, error) { + accounts := []*CloudAccount{} + + log.Println(string(data)) + doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) + if err != nil { + return nil, errors.Wrap(err, "failed to build document from response") + } + + doc.Find("fieldset > div.saml-account").Each(func(i int, s *goquery.Selection) { + account := new(CloudAccount) + account.Name = s.Find("div.saml-account-name").Text() + s.Find("label").Each(func(i int, s *goquery.Selection) { + role := new(CloudRole) + role.Name = s.Text() + role.RoleARN, _ = s.Attr("for") + account.Roles = append(account.Roles, role) + }) + accounts = append(accounts, account) + }) + + return accounts, nil +} + +func ExtractTencentCloudAccounts(data []byte) ([]*CloudAccount, error) { + accounts := []*CloudAccount{} + log.Println(string(data)) doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) if err != nil { return nil, errors.Wrap(err, "failed to build document from response") } doc.Find("fieldset > div.saml-account").Each(func(i int, s *goquery.Selection) { - account := new(AWSAccount) + account := new(CloudAccount) account.Name = s.Find("div.saml-account-name").Text() s.Find("label").Each(func(i int, s *goquery.Selection) { role := new(CloudRole) @@ -57,7 +91,7 @@ func ExtractAWSAccounts(data []byte) ([]*AWSAccount, error) { } // AssignPrincipals assign principal from roles -func AssignPrincipals(awsRoles []*CloudRole, awsAccounts []*AWSAccount) { +func AssignPrincipals(awsRoles []*CloudRole, awsAccounts []*CloudAccount) { awsPrincipalARNs := make(map[string]string) for _, awsRole := range awsRoles { diff --git a/aws_account_test.go b/cloud_account_test.go similarity index 98% rename from aws_account_test.go rename to cloud_account_test.go index b8b04072e..60ba0b250 100644 --- a/aws_account_test.go +++ b/cloud_account_test.go @@ -43,7 +43,7 @@ func TestAssignPrincipals(t *testing.T) { }, } - awsAccounts := []*AWSAccount{ + awsAccounts := []*CloudAccount{ { Roles: []*CloudRole{ { diff --git a/cloud_role.go b/cloud_role.go index 731a17bc4..8a10adcd9 100644 --- a/cloud_role.go +++ b/cloud_role.go @@ -2,25 +2,26 @@ package saml2aws import ( "fmt" - "log" "regexp" "strings" + + "github.com/versent/saml2aws/v2/pkg/cloud" ) // CloudRole aws role attributes type CloudRole struct { - Provider string + Provider cloud.Provider RoleARN string PrincipalARN string Name string } // ParseCloudRoles parses and splits the roles while also validating the contents -func ParseCloudRoles(roles []string, provider string) ([]*CloudRole, error) { +func ParseCloudRoles(roles []string, cp cloud.Provider) ([]*CloudRole, error) { awsRoles := make([]*CloudRole, len(roles)) for i, role := range roles { - awsRole, err := parseRole(role, provider) + awsRole, err := parseRole(role, cp) if err != nil { return nil, err } @@ -31,18 +32,20 @@ func ParseCloudRoles(roles []string, provider string) ([]*CloudRole, error) { return awsRoles, nil } -func parseRole(role, provider string) (*CloudRole, error) { +func parseRole(role string, cp cloud.Provider) (*CloudRole, error) { var r *regexp.Regexp - switch provider { - case "AWS": + switch cp { + case cloud.AWS: r, _ = regexp.Compile("arn:([^:\n]*):([^:\n]*):([^:\n]*):([^:\n]*):(([^:/\n]*)[:/])?([^:,\n]*)") - case "TencentCloud": - r, _ = regexp.Compile("qcs::([^:\\n]*):([^:\\n]*):([^:\\n]*):([^:/\\n]*)([/]([^,]*)|:([^,\\n]*))\n") + case cloud.TencentCloud: + r, _ = regexp.Compile("qcs::([^:]*):([^:]*):([^:]*):([^:/]*)(/[^,]*)?") + default: - return nil, fmt.Errorf("Invalid provider: %s", provider) + return nil, fmt.Errorf("Invalid provider:") } - log.Println("Parsing role: ", role) + // log.Println("Parsing role: ", role) + tokens := r.FindAllString(role, -1) if len(tokens) != 2 { return nil, fmt.Errorf("Invalid role string only %d tokens", len(tokens)) @@ -57,7 +60,7 @@ func parseRole(role, provider string) (*CloudRole, error) { providerRole.RoleARN = strings.TrimSpace(token) } } - providerRole.Provider = provider + providerRole.Provider = cp if providerRole.PrincipalARN == "" { return nil, fmt.Errorf("Unable to locate PrincipalARN in: %s", role) diff --git a/cmd/saml2aws/commands/list_roles.go b/cmd/saml2aws/commands/list_roles.go index ec63351ec..e4baeafe2 100644 --- a/cmd/saml2aws/commands/list_roles.go +++ b/cmd/saml2aws/commands/list_roles.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/versent/saml2aws/v2" "github.com/versent/saml2aws/v2/helper/credentials" + "github.com/versent/saml2aws/v2/pkg/cloud" "github.com/versent/saml2aws/v2/pkg/flags" "github.com/versent/saml2aws/v2/pkg/samlcache" ) @@ -90,7 +91,7 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { } } - samlAssertions := make(map[string]string) + samlAssertions := make(map[cloud.Provider]string) if loginDetails.TencentCloudURL != "" { // If TencentCloud is configured, unmarshal the SAML assertion for both AWS and TencentCloud if err = json.Unmarshal([]byte(samlAssertion), &samlAssertions); err != nil { @@ -98,30 +99,30 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { } } else { // Only AWS is configured, proceed with normal saml2aws flow - samlAssertions["AWS"] = samlAssertion + samlAssertions[cloud.AWS] = samlAssertion } cloudRoles := make([]*saml2aws.CloudRole, 0) for cloud, assertion := range samlAssertions { data, err := b64.StdEncoding.DecodeString(assertion) if err != nil { - return errors.Wrap(err, "error decoding SAML assertion.") + return errors.Wrap(err, "error decoding SAML assertion") } roleArns, err := saml2aws.ExtractCloudRoles(data) if err != nil { - return errors.Wrap(err, fmt.Sprintf("error extracting %v role arns.", cloud)) + return errors.Wrap(err, fmt.Sprintf("error extracting %v role arns", cloud)) } if len(roleArns) == 0 { - log.Println("No", cloud, "roles to assume.") + log.Println("No", cloud, "roles to assume") continue } - cloudRoles, err := saml2aws.ParseCloudRoles(roleArns, cloud) + roles, err := saml2aws.ParseCloudRoles(roleArns, cloud) if err != nil { return errors.Wrap(err, fmt.Sprintf("error parsing %s roles", cloud)) } - cloudRoles = append(cloudRoles, cloudRoles...) + cloudRoles = append(cloudRoles, roles...) } if len(cloudRoles) == 0 { os.Exit(1) @@ -134,8 +135,8 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { return nil } -func listRoles(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]string) error { - cloudAccounts := make([]*saml2aws.AWSAccount, 0) +func listRoles(cloudRoles []*saml2aws.CloudRole, samlAssertions map[cloud.Provider]string) error { + cloudAccounts := make([]*saml2aws.CloudAccount, 0) for provider, assertion := range samlAssertions { data, err := b64.StdEncoding.DecodeString(assertion) if err != nil { @@ -146,8 +147,9 @@ func listRoles(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]strin if err != nil { return errors.Wrap(err, "error parsing destination url") } + log.Println("Audience:", aud) - accounts, err := saml2aws.ParseAWSAccounts(aud, assertion) + accounts, err := saml2aws.ParseCloudAccounts(provider, aud, assertion) if err != nil { return errors.Wrap(err, fmt.Sprintf("error parsing %v role accounts", provider)) } diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go index 35f281774..e33e3a685 100644 --- a/cmd/saml2aws/commands/login.go +++ b/cmd/saml2aws/commands/login.go @@ -18,6 +18,7 @@ import ( "github.com/versent/saml2aws/v2/helper/credentials" "github.com/versent/saml2aws/v2/pkg/awsconfig" "github.com/versent/saml2aws/v2/pkg/cfg" + "github.com/versent/saml2aws/v2/pkg/cloud" "github.com/versent/saml2aws/v2/pkg/creds" "github.com/versent/saml2aws/v2/pkg/flags" "github.com/versent/saml2aws/v2/pkg/samlcache" @@ -132,7 +133,7 @@ func Login(loginFlags *flags.LoginExecFlags) error { log.Println("SAML assertion:", samlAssertion) - samlAssertions := make(map[string]string) + samlAssertions := make(map[cloud.Provider]string) if loginDetails.TencentCloudURL != "" { // If TencentCloud is configured, unmarshal the SAML assertion for both AWS and TencentCloud if err = json.Unmarshal([]byte(samlAssertion), &samlAssertions); err != nil { @@ -140,7 +141,7 @@ func Login(loginFlags *flags.LoginExecFlags) error { } } else { // Only AWS is configured, proceed with normal saml2aws flow - samlAssertions["AWS"] = samlAssertion + samlAssertions[cloud.AWS] = samlAssertion } role, err := selectAwsRole(samlAssertions, account) @@ -275,7 +276,7 @@ func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *flags.LoginExecFla return loginDetails, nil } -func selectAwsRole(samlAssertions map[string]string, account *cfg.IDPAccount) (*saml2aws.CloudRole, error) { +func selectAwsRole(samlAssertions map[cloud.Provider]string, account *cfg.IDPAccount) (*saml2aws.CloudRole, error) { cloudRoles := make([]*saml2aws.CloudRole, 0) for cloud, assertion := range samlAssertions { data, err := b64.StdEncoding.DecodeString(assertion) @@ -307,7 +308,7 @@ func selectAwsRole(samlAssertions map[string]string, account *cfg.IDPAccount) (* return resolveRole(cloudRoles, samlAssertions, account) } -func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]string, account *cfg.IDPAccount) (role *saml2aws.CloudRole, err error) { +func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[cloud.Provider]string, account *cfg.IDPAccount) (role *saml2aws.CloudRole, err error) { if len(cloudRoles) == 1 { if account.RoleARN != "" { return saml2aws.LocateRole(cloudRoles, account.RoleARN) @@ -317,7 +318,7 @@ func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]str return nil, errors.New("No roles available.") } - cloudAccounts := make([]*saml2aws.AWSAccount, 0) + cloudAccounts := make([]*saml2aws.CloudAccount, 0) for provider, assertion := range samlAssertions { data, err := b64.StdEncoding.DecodeString(assertion) if err != nil { @@ -329,7 +330,7 @@ func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[string]str return nil, errors.Wrap(err, "Error parsing destination URL.") } - accounts, err := saml2aws.ParseAWSAccounts(aud, assertion) + accounts, err := saml2aws.ParseCloudAccounts(provider, aud, assertion) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("Error parsing %v role accounts.", provider)) } diff --git a/input.go b/input.go index f8abbda52..53bf76dd7 100644 --- a/input.go +++ b/input.go @@ -89,7 +89,7 @@ func PromptForLoginDetails(loginDetails *creds.LoginDetails, provider string) er } // PromptForAWSRoleSelection present a list of roles to the user for selection -func PromptForAWSRoleSelection(accounts []*AWSAccount) (*CloudRole, error) { +func PromptForAWSRoleSelection(accounts []*CloudAccount) (*CloudRole, error) { roles := map[string]*CloudRole{} var roleOptions []string diff --git a/pkg/cloud/types.go b/pkg/cloud/types.go new file mode 100644 index 000000000..d19b79d3d --- /dev/null +++ b/pkg/cloud/types.go @@ -0,0 +1,12 @@ +package cloud + +type Provider int + +const ( + AWS Provider = iota + TencentCloud +) + +func (p Provider) String() string { + return [...]string{"AWS", "TencentCloud"}[p] +} diff --git a/pkg/provider/keycloak/keycloak.go b/pkg/provider/keycloak/keycloak.go index 1085bf6ea..ded2ea8d5 100644 --- a/pkg/provider/keycloak/keycloak.go +++ b/pkg/provider/keycloak/keycloak.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/versent/saml2aws/v2/pkg/cfg" + "github.com/versent/saml2aws/v2/pkg/cloud" "github.com/versent/saml2aws/v2/pkg/creds" "github.com/versent/saml2aws/v2/pkg/prompter" "github.com/versent/saml2aws/v2/pkg/provider" @@ -58,7 +59,7 @@ func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) return kc.doAuthenticate(&authContext{loginDetails.MFAToken, 0, true}, loginDetails) } -// XXX(changwon): Retrieve "multiple" SAML responses from all configured providers +// NOTE(devsisters): Retrieve "multiple" SAML responses from all configured providers func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.LoginDetails) (string, error) { awsAuthSubmitURL, awsAuthSubmitForm, err := kc.getLoginForm(loginDetails) if err != nil { @@ -68,6 +69,8 @@ func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.Login return "", errors.Wrap(err, "error retrieving aws login url from idp") } + // log.Println(awsAuthSubmitURL) + awsdata, err := kc.postLoginForm(awsAuthSubmitURL, awsAuthSubmitForm) if err != nil { return "", errors.Wrap(err, "error submitting aws login form") @@ -109,71 +112,31 @@ func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.Login if err != nil && authCtx.authenticatorIndexValid && passwordValid(awsdoc) { return kc.doAuthenticate(authCtx, loginDetails) } - log.Println("SAML response successfully retrieved for AWS") - log.Println(awsSamlResponse) + // log.Println("SAML response successfully retrieved for AWS") + // log.Println(awsSamlResponse) // If configured, retrieve TencentCloud SAML Response with the same authCtx if loginDetails.TencentCloudURL != "" { - tcAuthSubmitURL, tcAuthSubmitForm, err := kc.getLoginForm(loginDetails) + tcdoc, err := kc.getAdditionalLoginForm(loginDetails.TencentCloudURL) if err != nil { return "", errors.Wrap(err, "error retrieving tencentcloud login form from idp") } - if tcAuthSubmitURL == "" { - return "", errors.Wrap(err, "error retrieving tencentcloud login url from idp") - } - - tcdata, err := kc.postLoginForm(tcAuthSubmitURL, tcAuthSubmitForm) - if err != nil { - return "", errors.Wrap(err, "error submitting tencentcloud login form") - } - - tcdoc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(tcdata)) - if err != nil { - return "", errors.Wrap(err, "error parsing document") - } - - if containsTotpForm(tcdoc) { - totpSubmitURL, err := extractSubmitURL(tcdoc) - if err != nil { - return "", errors.Wrap(err, "unable to locate IDP totp form submit URL") - } - - tcdoc, err = kc.postTotpForm(authCtx, totpSubmitURL, tcdoc) - if err != nil { - return "", errors.Wrap(err, "error posting totp form") - } - } else if containsWebauthnForm(tcdoc) { - credentialIDs, challenge, rpId, err := extractWebauthnParameters(tcdoc) - if err != nil { - return "", errors.Wrap(err, "could not extract Webauthn parameters") - } - - webauthnSubmitURL, err := extractSubmitURL(tcdoc) - if err != nil { - return "", errors.Wrap(err, "unable to locate IDP Webauthn form submit URL") - } - - tcdoc, err = kc.postWebauthnForm(webauthnSubmitURL, credentialIDs, challenge, rpId) - if err != nil { - return "", errors.Wrap(err, "error posting Webauthn form") - } - } tcSamlResponse, err := extractSamlResponse(tcdoc) if err != nil && authCtx.authenticatorIndexValid && passwordValid(tcdoc) { return kc.doAuthenticate(authCtx, loginDetails) } - log.Println("SAML response successfully retrieved for TencentCloud") - log.Println(tcSamlResponse) + // log.Println("SAML response successfully retrieved for TencentCloud") + // log.Println(tcSamlResponse) if awsSamlResponse == "" && tcSamlResponse == "" { return "", errors.Wrap(err, "no SAML response retrieved from keycloak") } // Return both AWS and TencentCloud SAML responses - samlResponses := make(map[string]string) - samlResponses["AWS"] = awsSamlResponse - samlResponses["TencentCloud"] = tcSamlResponse + samlResponses := make(map[cloud.Provider]string) + samlResponses[cloud.AWS] = awsSamlResponse + samlResponses[cloud.TencentCloud] = tcSamlResponse jsonSamlResponses, err := json.Marshal(samlResponses) if err != nil { return "", errors.Wrap(err, "error marshalling SAML responses from keycloak") @@ -257,6 +220,21 @@ func (kc *Client) getLoginForm(loginDetails *creds.LoginDetails) (string, url.Va return authSubmitURL, authForm, nil } +func (kc *Client) getAdditionalLoginForm(idpUrl string) (*goquery.Document, error) { + + res, err := kc.client.Get(idpUrl) + if err != nil { + return nil, errors.Wrap(err, "error retrieving second form") + } + + doc, err := goquery.NewDocumentFromReader(res.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to build document from response") + } + + return doc, nil +} + func (kc *Client) postLoginForm(authSubmitURL string, authForm url.Values) ([]byte, error) { req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(authForm.Encode())) diff --git a/saml.go b/saml.go index 392adb07e..58f050392 100644 --- a/saml.go +++ b/saml.go @@ -2,7 +2,6 @@ package saml2aws import ( "fmt" - "log" "strconv" "time" @@ -143,14 +142,14 @@ func ExtractCloudRoles(data []byte) ([]string, error) { return cloudroles, err } - log.Printf("root tag: %s", doc.Root().Tag) + // log.Printf("root tag: %s", doc.Root().Tag) assertionElement := doc.FindElement(".//Assertion") if assertionElement == nil { return nil, ErrMissingAssertion } - log.Printf("tag: %s", assertionElement.Tag) + // log.Printf("tag: %s", assertionElement.Tag) // Get the actual assertion attributes attributeStatement := assertionElement.FindElement(childPath(assertionElement.Space, attributeStatementTag)) @@ -158,7 +157,7 @@ func ExtractCloudRoles(data []byte) ([]string, error) { return nil, ErrMissingElement{Tag: attributeStatementTag} } - log.Printf("tag: %s", attributeStatement.Tag) + // log.Printf("tag: %s", attributeStatement.Tag) attributes := attributeStatement.FindElements(childPath(assertionElement.Space, attributeTag)) for _, attribute := range attributes { @@ -166,11 +165,11 @@ func ExtractCloudRoles(data []byte) ([]string, error) { continue } - if attribute.SelectAttrValue("Name", "") == "https://aws.amazon.com/SAML/Attributes/Role" { - log.Printf("found aws assertion") - } else if attribute.SelectAttrValue("Name", "") == "https://cloud.tencent.com/SAML/Attributes/Role" { - log.Printf("found tencent assertion") - } + // if attribute.SelectAttrValue("Name", "") == "https://aws.amazon.com/SAML/Attributes/Role" { + // log.Printf("found aws assertion") + // } else if attribute.SelectAttrValue("Name", "") == "https://cloud.tencent.com/SAML/Attributes/Role" { + // log.Printf("found tencent assertion") + // } atributeValues := attribute.FindElements(childPath(assertionElement.Space, attributeValueTag)) for _, attrValue := range atributeValues { From 39f988cf758b66390ec014cf3ee7c6643cd799f3 Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Tue, 21 May 2024 19:56:26 +0900 Subject: [PATCH 04/13] modify list-role not to request http post to saml auth endpoints --- cloud_account.go | 55 +++++++++-------------------- cmd/saml2aws/commands/list_roles.go | 46 ++++-------------------- go.mod | 2 +- go.sum | 4 +-- 4 files changed, 25 insertions(+), 82 deletions(-) diff --git a/cloud_account.go b/cloud_account.go index cf7e619a1..95d2b2dd2 100644 --- a/cloud_account.go +++ b/cloud_account.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "log" "net/http" "net/url" @@ -21,21 +20,24 @@ type CloudAccount struct { // ParseCloudAccounts extract the aws accounts from the saml assertion func ParseCloudAccounts(provider cloud.Provider, audience string, samlAssertion string) ([]*CloudAccount, error) { - res, err := http.PostForm(audience, url.Values{"SAMLResponse": {samlAssertion}}) - if err != nil { - return nil, errors.Wrap(err, "error retrieving AWS login form") - } - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, errors.Wrap(err, "error retrieving AWS login body") - } switch provider { case cloud.AWS: + res, err := http.PostForm(audience, url.Values{"SAMLResponse": {samlAssertion}}) + if err != nil { + return nil, errors.Wrap(err, "error retrieving cloud SAML login form") + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "error retrieving AWS login body") + } return ExtractAWSAccounts(data) case cloud.TencentCloud: - return ExtractTencentCloudAccounts(data) + return nil, nil default: return nil, fmt.Errorf("unsupported cloud provider: %s", provider) } @@ -43,33 +45,8 @@ func ParseCloudAccounts(provider cloud.Provider, audience string, samlAssertion // ExtractAWSAccounts extract the accounts from the AWS html page func ExtractAWSAccounts(data []byte) ([]*CloudAccount, error) { - accounts := []*CloudAccount{} - - log.Println(string(data)) - doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) - if err != nil { - return nil, errors.Wrap(err, "failed to build document from response") - } - - doc.Find("fieldset > div.saml-account").Each(func(i int, s *goquery.Selection) { - account := new(CloudAccount) - account.Name = s.Find("div.saml-account-name").Text() - s.Find("label").Each(func(i int, s *goquery.Selection) { - role := new(CloudRole) - role.Name = s.Text() - role.RoleARN, _ = s.Attr("for") - account.Roles = append(account.Roles, role) - }) - accounts = append(accounts, account) - }) - - return accounts, nil -} - -func ExtractTencentCloudAccounts(data []byte) ([]*CloudAccount, error) { - accounts := []*CloudAccount{} + accounts := make([]*CloudAccount, 0) - log.Println(string(data)) doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) if err != nil { return nil, errors.Wrap(err, "failed to build document from response") @@ -91,14 +68,14 @@ func ExtractTencentCloudAccounts(data []byte) ([]*CloudAccount, error) { } // AssignPrincipals assign principal from roles -func AssignPrincipals(awsRoles []*CloudRole, awsAccounts []*CloudAccount) { +func AssignPrincipals(awsRoles []*CloudRole, cloudAccounts []*CloudAccount) { awsPrincipalARNs := make(map[string]string) for _, awsRole := range awsRoles { awsPrincipalARNs[awsRole.RoleARN] = awsRole.PrincipalARN } - for _, awsAccount := range awsAccounts { + for _, awsAccount := range cloudAccounts { for _, awsRole := range awsAccount.Roles { awsRole.PrincipalARN = awsPrincipalARNs[awsRole.RoleARN] } diff --git a/cmd/saml2aws/commands/list_roles.go b/cmd/saml2aws/commands/list_roles.go index e4baeafe2..c3a82f2be 100644 --- a/cmd/saml2aws/commands/list_roles.go +++ b/cmd/saml2aws/commands/list_roles.go @@ -114,7 +114,7 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { return errors.Wrap(err, fmt.Sprintf("error extracting %v role arns", cloud)) } if len(roleArns) == 0 { - log.Println("No", cloud, "roles to assume") + // log.Println("No", cloud, "roles to assyyume") continue } @@ -123,48 +123,14 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { return errors.Wrap(err, fmt.Sprintf("error parsing %s roles", cloud)) } cloudRoles = append(cloudRoles, roles...) - } - if len(cloudRoles) == 0 { - os.Exit(1) - } - - if err := listRoles(cloudRoles, samlAssertions); err != nil { - return errors.Wrap(err, "Failed to list roles") - } - - return nil -} - -func listRoles(cloudRoles []*saml2aws.CloudRole, samlAssertions map[cloud.Provider]string) error { - cloudAccounts := make([]*saml2aws.CloudAccount, 0) - for provider, assertion := range samlAssertions { - data, err := b64.StdEncoding.DecodeString(assertion) - if err != nil { - return errors.Wrap(err, "error decoding saml assertion") - } - - aud, err := saml2aws.ExtractDestinationURL(data) - if err != nil { - return errors.Wrap(err, "error parsing destination url") - } - log.Println("Audience:", aud) - accounts, err := saml2aws.ParseCloudAccounts(provider, aud, assertion) - if err != nil { - return errors.Wrap(err, fmt.Sprintf("error parsing %v role accounts", provider)) + for _, role := range roles { + fmt.Println(fmt.Sprintf("%v (%v)", role.RoleARN, cloud)) } - - saml2aws.AssignPrincipals(cloudRoles, accounts) - cloudAccounts = append(cloudAccounts, accounts...) } - - log.Println("") - for _, account := range cloudAccounts { - fmt.Println(account.Name) - for _, role := range account.Roles { - fmt.Println(role.RoleARN) - } - fmt.Println("") + if len(cloudRoles) == 0 { + fmt.Println("No cloud provider roles to assume") + os.Exit(1) } return nil diff --git a/go.mod b/go.mod index 7d738e995..df849f305 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.22.0 // indirect golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect - golang.org/x/sys v0.19.0 // indirect + golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index e40786d01..b71b6dfc2 100644 --- a/go.sum +++ b/go.sum @@ -247,8 +247,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From 7a20f39b45e16c58b69a006686360ac70a20b0da Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Tue, 21 May 2024 23:39:05 +0900 Subject: [PATCH 05/13] change selectAWSRole to support TencentCloud --- cloud_account.go | 42 +++++++++++++++++++++ cloud_role.go | 7 ++++ cmd/saml2aws/commands/list_roles.go | 4 +- cmd/saml2aws/commands/login.go | 57 +++++++++-------------------- input.go | 24 +++++------- 5 files changed, 78 insertions(+), 56 deletions(-) diff --git a/cloud_account.go b/cloud_account.go index 95d2b2dd2..e1e9948c9 100644 --- a/cloud_account.go +++ b/cloud_account.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "log" "net/http" "net/url" @@ -18,6 +19,47 @@ type CloudAccount struct { Roles []*CloudRole } +func AssignAWSAccounts(awsRoles []*CloudRole, samlXml []byte, samlAssertion string) ([]*CloudRole, error) { + roleArnMap := make(map[string]*CloudRole) + for _, role := range awsRoles { + roleArnMap[role.RoleARN] = role + } + + aud, err := ExtractDestinationURL(samlXml) + if err != nil { + return nil, errors.Wrap(err, "Error parsing destination URL.") + } + + res, err := http.PostForm(aud, url.Values{"SAMLResponse": {samlAssertion}}) + if err != nil { + return nil, errors.Wrap(err, "Error retrieving cloud SAML login form.") + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Unexpected status code: %d", res.StatusCode) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "Error retrieving AWS login body.") + } + + doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) + if err != nil { + return nil, errors.Wrap(err, "failed to build document from response") + } + + doc.Find("fieldset > div.saml-account").Each(func(i int, s *goquery.Selection) { + name := s.Find("div.saml-account-name").Text() + s.Find("label").Each(func(i int, s *goquery.Selection) { + arn, _ := s.Attr("for") + roleArnMap[arn].Account = name + log.Println("Marked role", arn, "as belonging to account", name) + }) + }) + + return awsRoles, nil +} + // ParseCloudAccounts extract the aws accounts from the saml assertion func ParseCloudAccounts(provider cloud.Provider, audience string, samlAssertion string) ([]*CloudAccount, error) { diff --git a/cloud_role.go b/cloud_role.go index 8a10adcd9..b1587441b 100644 --- a/cloud_role.go +++ b/cloud_role.go @@ -14,6 +14,7 @@ type CloudRole struct { RoleARN string PrincipalARN string Name string + Account string } // ParseCloudRoles parses and splits the roles while also validating the contents @@ -58,6 +59,12 @@ func parseRole(role string, cp cloud.Provider) (*CloudRole, error) { } if strings.Contains(token, ":role") { providerRole.RoleARN = strings.TrimSpace(token) + if cp == cloud.AWS { + providerRole.Name = strings.Split(token, "/")[1] + } else if cp == cloud.TencentCloud { + providerRole.Name = strings.Split(token, "/")[2] + providerRole.Account = strings.Split(strings.Split(token, "/")[1], ":")[0] + } } } providerRole.Provider = cp diff --git a/cmd/saml2aws/commands/list_roles.go b/cmd/saml2aws/commands/list_roles.go index c3a82f2be..f62ee22c6 100644 --- a/cmd/saml2aws/commands/list_roles.go +++ b/cmd/saml2aws/commands/list_roles.go @@ -125,11 +125,11 @@ func ListRoles(loginFlags *flags.LoginExecFlags) error { cloudRoles = append(cloudRoles, roles...) for _, role := range roles { - fmt.Println(fmt.Sprintf("%v (%v)", role.RoleARN, cloud)) + log.Println(fmt.Sprintf("%v (%v)", role.RoleARN, cloud)) } } if len(cloudRoles) == 0 { - fmt.Println("No cloud provider roles to assume") + log.Println("No cloud provider roles to assume") os.Exit(1) } diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go index e33e3a685..94ce1b2a9 100644 --- a/cmd/saml2aws/commands/login.go +++ b/cmd/saml2aws/commands/login.go @@ -131,7 +131,7 @@ func Login(loginFlags *flags.LoginExecFlags) error { } } - log.Println("SAML assertion:", samlAssertion) + // log.Println("SAML assertion:", samlAssertion) samlAssertions := make(map[cloud.Provider]string) if loginDetails.TencentCloudURL != "" { @@ -278,7 +278,7 @@ func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *flags.LoginExecFla func selectAwsRole(samlAssertions map[cloud.Provider]string, account *cfg.IDPAccount) (*saml2aws.CloudRole, error) { cloudRoles := make([]*saml2aws.CloudRole, 0) - for cloud, assertion := range samlAssertions { + for cloudProvider, assertion := range samlAssertions { data, err := b64.StdEncoding.DecodeString(assertion) if err != nil { return nil, errors.Wrap(err, "Error decoding SAML assertion.") @@ -286,18 +286,26 @@ func selectAwsRole(samlAssertions map[cloud.Provider]string, account *cfg.IDPAcc roleArns, err := saml2aws.ExtractCloudRoles(data) if err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("Error extracting %v roles arns.", cloud)) + return nil, errors.Wrap(err, fmt.Sprintf("Error extracting %v roles arns.", cloudProvider)) } if len(roleArns) == 0 { - log.Println("No", cloud, "roles to assume.") + log.Println("No", cloudProvider, "roles to assume.") continue } - cloudRoles, err := saml2aws.ParseCloudRoles(roleArns, cloud) + roles, err := saml2aws.ParseCloudRoles(roleArns, cloudProvider) if err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("Error parsing %v roles.", cloud)) + return nil, errors.Wrap(err, fmt.Sprintf("Error parsing %v roles.", cloudProvider)) } - cloudRoles = append(cloudRoles, cloudRoles...) + + if cloudProvider == cloud.AWS { + roles, err = saml2aws.AssignAWSAccounts(roles, data, assertion) + if err != nil { + return nil, errors.Wrap(err, "Error assigning AWS accounts to roles.") + } + } + + cloudRoles = append(cloudRoles, roles...) } if len(cloudRoles) == 0 { @@ -305,10 +313,10 @@ func selectAwsRole(samlAssertions map[cloud.Provider]string, account *cfg.IDPAcc os.Exit(1) } - return resolveRole(cloudRoles, samlAssertions, account) + return resolveRole(cloudRoles, account) } -func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[cloud.Provider]string, account *cfg.IDPAccount) (role *saml2aws.CloudRole, err error) { +func resolveRole(cloudRoles []*saml2aws.CloudRole, account *cfg.IDPAccount) (role *saml2aws.CloudRole, err error) { if len(cloudRoles) == 1 { if account.RoleARN != "" { return saml2aws.LocateRole(cloudRoles, account.RoleARN) @@ -318,37 +326,8 @@ func resolveRole(cloudRoles []*saml2aws.CloudRole, samlAssertions map[cloud.Prov return nil, errors.New("No roles available.") } - cloudAccounts := make([]*saml2aws.CloudAccount, 0) - for provider, assertion := range samlAssertions { - data, err := b64.StdEncoding.DecodeString(assertion) - if err != nil { - return nil, errors.Wrap(err, "Error decoding SAML assertion.") - } - - aud, err := saml2aws.ExtractDestinationURL(data) - if err != nil { - return nil, errors.Wrap(err, "Error parsing destination URL.") - } - - accounts, err := saml2aws.ParseCloudAccounts(provider, aud, assertion) - if err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("Error parsing %v role accounts.", provider)) - } - - saml2aws.AssignPrincipals(cloudRoles, accounts) - cloudAccounts = append(cloudAccounts, accounts...) - } - - if len(cloudAccounts) == 0 { - return nil, errors.New("No accounts available.") - } - - if account.RoleARN != "" { - return saml2aws.LocateRole(cloudRoles, account.RoleARN) - } - for { - role, err = saml2aws.PromptForAWSRoleSelection(cloudAccounts) + role, err = saml2aws.PromptForCloudRoleSelection(cloudRoles) if err == nil { break } diff --git a/input.go b/input.go index 53bf76dd7..ba268af14 100644 --- a/input.go +++ b/input.go @@ -3,7 +3,6 @@ package saml2aws import ( "fmt" "log" - "sort" "github.com/pkg/errors" "github.com/versent/saml2aws/v2/pkg/cfg" @@ -88,26 +87,21 @@ func PromptForLoginDetails(loginDetails *creds.LoginDetails, provider string) er return nil } -// PromptForAWSRoleSelection present a list of roles to the user for selection -func PromptForAWSRoleSelection(accounts []*CloudAccount) (*CloudRole, error) { +// PromptForCloudRoleSelections present a list of roles to the user for selection +func PromptForCloudRoleSelection(roles []*CloudRole) (*CloudRole, error) { - roles := map[string]*CloudRole{} - var roleOptions []string - - for _, account := range accounts { - for _, role := range account.Roles { - name := fmt.Sprintf("%s / %s", account.Name, role.Name) - roles[name] = role - roleOptions = append(roleOptions, name) - } + roleMap := make(map[string]*CloudRole) + roleOptions := make([]string, len(roles)) + for i, role := range roles { + name := fmt.Sprintf("%s %s / %s", role.Provider, role.Account, role.Name) + roleOptions[i] = name + roleMap[name] = role } - sort.Strings(roleOptions) - selectedRole, err := prompter.ChooseWithDefault("Please choose the role", roleOptions[0], roleOptions) if err != nil { return nil, errors.Wrap(err, "Role selection failed") } - return roles[selectedRole], nil + return roleMap[selectedRole], nil } From ec3655dc2648d625ec20376cfd6b5b4ac5dfbd35 Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Thu, 23 May 2024 23:03:37 +0900 Subject: [PATCH 06/13] support TencentCloud login --- cloud_account.go | 3 +- cmd/saml2aws/commands/login.go | 219 ++++++++++++++++++++------------- go.mod | 1 + go.sum | 2 + pkg/tcconfig/tcconfig.go | 119 ++++++++++++++++++ 5 files changed, 254 insertions(+), 90 deletions(-) create mode 100644 pkg/tcconfig/tcconfig.go diff --git a/cloud_account.go b/cloud_account.go index e1e9948c9..0a5321982 100644 --- a/cloud_account.go +++ b/cloud_account.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "log" "net/http" "net/url" @@ -53,7 +52,7 @@ func AssignAWSAccounts(awsRoles []*CloudRole, samlXml []byte, samlAssertion stri s.Find("label").Each(func(i int, s *goquery.Selection) { arn, _ := s.Attr("for") roleArnMap[arn].Account = name - log.Println("Marked role", arn, "as belonging to account", name) + // log.Println("Marked role", arn, "as belonging to account", name) }) }) diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go index 94ce1b2a9..a276d3517 100644 --- a/cmd/saml2aws/commands/login.go +++ b/cmd/saml2aws/commands/login.go @@ -7,13 +7,15 @@ import ( "log" "os" "strings" - "time" "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/sirupsen/logrus" + "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/common/profile" + tcsts "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/sts/v20180813" "github.com/versent/saml2aws/v2" "github.com/versent/saml2aws/v2/helper/credentials" "github.com/versent/saml2aws/v2/pkg/awsconfig" @@ -22,6 +24,7 @@ import ( "github.com/versent/saml2aws/v2/pkg/creds" "github.com/versent/saml2aws/v2/pkg/flags" "github.com/versent/saml2aws/v2/pkg/samlcache" + "github.com/versent/saml2aws/v2/pkg/tcconfig" ) // Login login to ADFS @@ -34,46 +37,15 @@ func Login(loginFlags *flags.LoginExecFlags) error { return errors.Wrap(err, "Error building login details.") } - sharedCreds := awsconfig.NewSharedCredentials(account.Profile, account.CredentialsFile) // creates a cacheProvider, only used when --cache is set cacheProvider := &samlcache.SAMLCacheProvider{ Account: account.Name, Filename: account.SAMLCacheFile, } - logger.Debug("Check if creds exist.") - - // this checks if the credentials file has been created yet - exist, err := sharedCreds.CredsExists() - if err != nil { - return errors.Wrap(err, "Error loading credentials.") - } - if !exist { - log.Println("Unable to load credentials. Login required to create them.") - return nil - } - - if !sharedCreds.Expired() && !loginFlags.Force { - logger.Debug("Credentials are not expired. Skipping.") - previousCreds, err := sharedCreds.Load() - if err != nil { - log.Println("Unable to load cached credentials.") - } else { - logger.Debug("Credentials will expire at ", previousCreds.Expires) - } - if loginFlags.CredentialProcess { - err = PrintCredentialProcess(previousCreds) - if err != nil { - return err - } - } - return nil - } - loginDetails, err := resolveLoginDetails(account, loginFlags) if err != nil { - log.Printf("%+v", err) - os.Exit(1) + return err } logger.WithField("idpAccount", account).Debug("building samlProvider") @@ -121,7 +93,7 @@ func Login(loginFlags *flags.LoginExecFlags) error { log.Println("Response did not contain a valid SAML assertion.") log.Println("Please check that your username and password is correct.") log.Println("To see the output follow the instructions in https://github.com/versent/saml2aws#debugging-issues-with-idps") - os.Exit(1) + return errors.New("Response did not contain a valid SAML assertion.") } if !loginFlags.CommonFlags.DisableKeychain { @@ -144,36 +116,49 @@ func Login(loginFlags *flags.LoginExecFlags) error { samlAssertions[cloud.AWS] = samlAssertion } - role, err := selectAwsRole(samlAssertions, account) + role, err := selectCloudRole(samlAssertions, account) if err != nil { return errors.Wrap(err, "Error resolving role.") } log.Println("Selected role:", role.RoleARN) - awsCreds, err := loginToStsUsingRole(account, role, samlAssertion) - if err != nil { - return errors.Wrap(err, "Error logging into AWS role using SAML assertion.") - } - - // print credential process if needed - if loginFlags.CredentialProcess { - err = PrintCredentialProcess(awsCreds) + switch role.Provider { + case cloud.AWS: + creds, err := assumeAwsRoleWithSAML(account, role, samlAssertions[role.Provider]) if err != nil { + return errors.Wrap(err, "Error logging into AWS role using SAML assertion.") + } + cp := awsconfig.NewSharedCredentials(account.Profile, account.CredentialsFile) + if err := cp.Save(creds); err != nil { return err } - } else { - err = saveCredentials(awsCreds, sharedCreds) + + log.Println("Logged in as:", creds.PrincipalARN) + log.Println("") + log.Println("Your new access key pair has been stored in the AWS configuration.") + log.Printf("Note that it will expire at %v", creds.Expires) + if account.Profile != "default" { + log.Println("To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile", account.Profile, "ec2 describe-instances).") + } + case cloud.TencentCloud: + creds, err := assumeTencentRoleWithSAML(account, role, samlAssertions[role.Provider]) if err != nil { + return errors.Wrap(err, "Error logging into TencentCloud role using SAML assertion.") + } + cp := tcconfig.NewSharedCredentials(account.Profile, account.CredentialsFile) + if err := cp.Save(creds); err != nil { return err } - log.Println("Logged in as:", awsCreds.PrincipalARN) + log.Println("Logged in as:", creds.PrincipalARN) log.Println("") - log.Println("Your new access key pair has been stored in the AWS configuration.") - log.Printf("Note that it will expire at %v", awsCreds.Expires) - if sharedCreds.Profile != "default" { - log.Println("To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile", sharedCreds.Profile, "ec2 describe-instances).") + log.Println("Your new secret key pair has been stored in the TencentCloud configuration.") + log.Printf("Note that it will expire at %v", creds.Expires) + if account.Profile != "default" { + log.Println("To use this credential, call the TC CLI with the --profile option (e.g. tccli --profile", account.Profile, "cvm DescribeInstances).") } + default: + return errors.Wrap(err, "Error resolving role (unknown provider).") } return nil @@ -193,8 +178,7 @@ func buildIdpAccount(loginFlags *flags.LoginExecFlags) (*cfg.IDPAccount, error) // update username and hostname if supplied flags.ApplyFlagOverrides(loginFlags.CommonFlags, account) - err = account.Validate() - if err != nil { + if err := account.Validate(); err != nil { return nil, errors.Wrap(err, "Failed to validate account.") } @@ -276,7 +260,7 @@ func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *flags.LoginExecFla return loginDetails, nil } -func selectAwsRole(samlAssertions map[cloud.Provider]string, account *cfg.IDPAccount) (*saml2aws.CloudRole, error) { +func selectCloudRole(samlAssertions map[cloud.Provider]string, account *cfg.IDPAccount) (*saml2aws.CloudRole, error) { cloudRoles := make([]*saml2aws.CloudRole, 0) for cloudProvider, assertion := range samlAssertions { data, err := b64.StdEncoding.DecodeString(assertion) @@ -337,7 +321,7 @@ func resolveRole(cloudRoles []*saml2aws.CloudRole, account *cfg.IDPAccount) (rol return role, nil } -func loginToStsUsingRole(account *cfg.IDPAccount, role *saml2aws.CloudRole, samlAssertion string) (*awsconfig.AWSCredentials, error) { +func assumeAwsRoleWithSAML(account *cfg.IDPAccount, role *saml2aws.CloudRole, samlAssertion string) (*awsconfig.AWSCredentials, error) { sess, err := session.NewSession(&aws.Config{ Region: &account.Region, @@ -373,50 +357,109 @@ func loginToStsUsingRole(account *cfg.IDPAccount, role *saml2aws.CloudRole, saml }, nil } -func saveCredentials(awsCreds *awsconfig.AWSCredentials, sharedCreds *awsconfig.CredentialsProvider) error { - err := sharedCreds.Save(awsCreds) - if err != nil { - return errors.Wrap(err, "Error saving credentials.") - } +func assumeTencentRoleWithSAML(account *cfg.IDPAccount, role *saml2aws.CloudRole, samlAssertion string) (*tcconfig.TCCredentials, error) { - return nil -} + credential := common.NewCredential("", "") -// CredentialsToCredentialProcess -// Returns a Json output that is compatible with the AWS credential_process -// https://github.com/awslabs/awsprocesscreds -func CredentialsToCredentialProcess(awsCreds *awsconfig.AWSCredentials) (string, error) { + clientProfile := profile.NewClientProfile() - type AWSCredentialProcess struct { - Version int - AccessKeyId string - SecretAccessKey string - SessionToken string - Expiration string + client, err := tcsts.NewClient(credential, "", clientProfile) + if err != nil { + log.Fatalf("Failed to create sts client: %v", err) } - - cred_process := AWSCredentialProcess{ - Version: 1, - AccessKeyId: awsCreds.AWSAccessKey, - SecretAccessKey: awsCreds.AWSSecretKey, - SessionToken: awsCreds.AWSSessionToken, - Expiration: awsCreds.Expires.Format(time.RFC3339), + region, ok := convertAWSRegionToTencentCloud(account.Region) + if !ok { + log.Println("Selected region %v is unknown or not available in TencentCloud. Selecting %v in best effort.", account.Region, region) } + client.Init(region) + + log.Println("Requesting TencentCloud credentials using SAML assertion.") - p, err := json.Marshal(cred_process) + samlRequest := tcsts.NewAssumeRoleWithSAMLRequest() + sessionDuration := uint64(account.SessionDuration) + samlRequest.SAMLAssertion = &samlAssertion + samlRequest.PrincipalArn = &role.PrincipalARN + samlRequest.RoleArn = &role.RoleARN + samlRequest.DurationSeconds = &sessionDuration + samlRequest.RoleSessionName = &account.Username + + // log.Println(fmt.Sprintf("tccli sts AssumeRoleWithSAML --PrincipalArn %v --RoleArn %v --SAMLAssertion %v --DurationSeconds %v --RoleSessionName %v", role.PrincipalARN, role.RoleARN, samlAssertion, sessionDuration, account.Username)) + + resp, err := client.AssumeRoleWithSAML(samlRequest) if err != nil { - return "", errors.Wrap(err, "Error while marshalling the credential process.") + return nil, errors.Wrap(err, "Error retrieving STS credentials using SAML.") } - return string(p), nil + return &tcconfig.TCCredentials{ + SecretID: aws.StringValue(resp.Response.Credentials.TmpSecretId), + SecretKey: aws.StringValue(resp.Response.Credentials.TmpSecretKey), + Token: aws.StringValue(resp.Response.Credentials.Token), + Region: account.Region, + Expires: aws.StringValue(resp.Response.Expiration), + PrincipalARN: role.PrincipalARN, + }, nil } -// PrintCredentialProcess Prints a Json output that is compatible with the AWS credential_process -// https://github.com/awslabs/awsprocesscreds -func PrintCredentialProcess(awsCreds *awsconfig.AWSCredentials) error { - jsonData, err := CredentialsToCredentialProcess(awsCreds) - if err == nil { - fmt.Println(jsonData) +// convertAWSRegionToTencentCloud converts AWS regions to TencentCloud regions. Returns the TencentCloud region and a boolean indicating if the region is directly supported in TencentCloud. +func convertAWSRegionToTencentCloud(region string) (string, bool) { + switch region { + case "us-east-1": + return "na-ashburn", true + case "us-east-2": + return "na-toronto", true + case "us-west-1": + return "na-siliconvalley", true + case "us-west-2": + return "na-siliconvalley", false + case "af-south-1": + return "ap-mumbai", false + case "ap-east-1": + return "ap-hongkong", true + case "ap-south-1": + return "ap-mumbai", true + case "ap-south-2": + return "ap-mumbai", false + case "ap-southeast-1": + return "ap-singapore", true + case "ap-southeast-2": + return "ap-jakarta", false + case "ap-southeast-3": + return "ap-jakarta", true + case "ap-southeast-4": + return "ap-jakarta", false + case "ap-northeast-1": + return "ap-tokyo", true + case "ap-northeast-2": + return "ap-seoul", true + case "ap-northeast-3": + return "ap-tokyo", false + case "ca-central-1": + return "na-toronto", false + case "ca-west-1": + return "na-siliconvalley", false + case "eu-central-1": + return "eu-frankfurt", true + case "eu-central-2": + return "eu-frankfurt", false + case "eu-west-1": + return "eu-frankfurt", false + case "eu-west-2": + return "eu-frankfurt", false + case "eu-south-1": + return "eu-frankfurt", false + case "eu-south-2": + return "eu-frankfurt", false + case "eu-north-1": + return "eu-frankfurt", false + case "il-central-1": + return "ap-mumbai", false + case "me-south-1": + return "ap-mumbai", false + case "me-central-1": + return "ap-mumbai", false + case "sa-east-1": + return "sa-saopaulo", true + default: + return "ap-tokyo", false } - return err } diff --git a/go.mod b/go.mod index df849f305..490fd5553 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/mtibben/percent v0.2.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tencentcloud/tencentcloud-sdk-go-intl-en v3.0.961+incompatible // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index b71b6dfc2..b87680ab6 100644 --- a/go.sum +++ b/go.sum @@ -179,6 +179,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tencentcloud/tencentcloud-sdk-go-intl-en v3.0.961+incompatible h1:F0j3fWCiiFZY4/zEzCCPZ0P9xwMub6LH7blNfoC5EWw= +github.com/tencentcloud/tencentcloud-sdk-go-intl-en v3.0.961+incompatible/go.mod h1:72Wo6Gt6F8d8V+njrAmduVoT9QjPwCyXktpqCWr7PUc= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/pkg/tcconfig/tcconfig.go b/pkg/tcconfig/tcconfig.go new file mode 100644 index 000000000..f5a321293 --- /dev/null +++ b/pkg/tcconfig/tcconfig.go @@ -0,0 +1,119 @@ +package tcconfig + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "runtime" + + "github.com/mitchellh/go-homedir" + "github.com/pkg/errors" +) + +var ( + ErrCredentialsHomeNotFound = errors.New("user home directory not found") + ErrCredentialsNotFound = errors.New("tc credentials not found") +) + +type TCCredentials struct { + SecretID string `json:"secretId,omitempty"` + SecretKey string `json:"secretKey,omitempty"` + Token string `json:"token,omitempty"` + Region string `json:"region,omitempty"` + Expires string `json:"x_security_token_expires,omitempty"` + PrincipalARN string `json:"-"` +} + +type CredentialsProvider struct { + Filename string + Profile string +} + +func NewSharedCredentials(profile string, filename string) *CredentialsProvider { + return &CredentialsProvider{ + Filename: filename, + Profile: profile, + } +} + +func (p *CredentialsProvider) Save(creds *TCCredentials) error { + filename, err := p.resolveFilename() + if err != nil { + return err + } + + if _, err = os.Stat(filename); err != nil { + if os.IsNotExist(err) { + dir := filepath.Dir(filename) + if err = os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + } + } + + bytes, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return errors.Wrap(err, "unable to marshal credentials") + } + bytes = append(bytes, '\n') + + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return errors.Wrap(err, "unable to load file") + } + if _, err := file.Write(bytes); err != nil { + return errors.Wrap(err, "unable to write to file") + } + defer file.Close() + + return nil +} + +func (p *CredentialsProvider) resolveFilename() (string, error) { + if p.Filename == "" { + filename, err := p.locateConfigFile() + if err != nil { + return "", err + } + p.Filename = filename + } + + return p.Filename, nil +} + +func (p *CredentialsProvider) locateConfigFile() (string, error) { + filename := os.Getenv("TENCENTCLOUD_CREDENTIALS_FILE") + if filename != "" { + return filename, nil + } + + // Default location for credentials file is ~/.tccli/{profile}.credentials + var name string + var err error + if runtime.GOOS == "windows" { + panic("error locating credentials file on windows: not implemented") + } else { + if name, err = homedir.Expand("~/.tccli/" + p.Profile + ".credential"); err != nil { + return "", ErrCredentialsHomeNotFound + } + log.Println("config file:", name) + } + + if name, err = resolveSymlink(name); err != nil { + return "", errors.Wrap(err, "unable to resolve symlink") + } + + return name, nil +} + +func resolveSymlink(filename string) (string, error) { + sympath, err := filepath.EvalSymlinks(filename) + if os.IsNotExist(err) { + return filename, nil + } + if err != nil { + return "", err + } + return sympath, nil +} From 3e3e9ba00aa8fa7ee7344d78d8468a23342879fd Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Fri, 24 May 2024 00:23:31 +0900 Subject: [PATCH 07/13] remove extraneous print --- pkg/tcconfig/tcconfig.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/tcconfig/tcconfig.go b/pkg/tcconfig/tcconfig.go index f5a321293..db1c6458c 100644 --- a/pkg/tcconfig/tcconfig.go +++ b/pkg/tcconfig/tcconfig.go @@ -2,7 +2,6 @@ package tcconfig import ( "encoding/json" - "log" "os" "path/filepath" "runtime" @@ -97,7 +96,7 @@ func (p *CredentialsProvider) locateConfigFile() (string, error) { if name, err = homedir.Expand("~/.tccli/" + p.Profile + ".credential"); err != nil { return "", ErrCredentialsHomeNotFound } - log.Println("config file:", name) + // log.Println("config file:", name) } if name, err = resolveSymlink(name); err != nil { From 3c0c4a659a4340938496e7255d0e1278c4636a96 Mon Sep 17 00:00:00 2001 From: ChangWon Lee Date: Mon, 27 May 2024 10:29:33 +0900 Subject: [PATCH 08/13] remove extraneous log for MFA --- pkg/provider/keycloak/keycloak.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/provider/keycloak/keycloak.go b/pkg/provider/keycloak/keycloak.go index ded2ea8d5..0e694463a 100644 --- a/pkg/provider/keycloak/keycloak.go +++ b/pkg/provider/keycloak/keycloak.go @@ -263,8 +263,6 @@ func (kc *Client) postTotpForm(authCtx *authContext, totpSubmitURL string, doc * if authCtx.mfaToken == "" { authCtx.mfaToken = prompter.RequestSecurityCode("000000") - } else { - log.Println("MFA token already provided") } doc.Find("input").Each(func(i int, s *goquery.Selection) { From f8adbe198490c084753034b20b7b1d8b3dfada68 Mon Sep 17 00:00:00 2001 From: Sangyu Lee Date: Mon, 20 Jan 2025 15:17:06 +0900 Subject: [PATCH 09/13] add flake --- default.nix | 14 ++++++++++++ flake.lock | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 21 ++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 default.nix create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/default.nix b/default.nix new file mode 100644 index 000000000..96dd3a1a2 --- /dev/null +++ b/default.nix @@ -0,0 +1,14 @@ +{ buildGoModule, ... }: + +buildGoModule { + name = "saml2aws"; + version = "2.36.14-tencent"; + src = ./.; + vendorHash = "sha256-pml6M45IJXfeOiMcPq8K88LxQFR/WmOzQXwGXHGOCew"; + env = { + CGO_ENABLED = "0"; + }; + subPackages = [ + "cmd/saml2aws" + ]; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..2537edd51 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1737062831, + "narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..8f3cf3cbc --- /dev/null +++ b/flake.nix @@ -0,0 +1,21 @@ +{ + description = "saml2aws"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, flake-utils, nixpkgs }: flake-utils.lib.eachDefaultSystem + ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + packages = { + saml2aws = (pkgs.callPackage (import ./.) { }); + }; + } + ); +} From f653169a129d439843899501df0664bb34ddd847 Mon Sep 17 00:00:00 2001 From: Sangyu Lee Date: Mon, 20 Jan 2025 15:31:24 +0900 Subject: [PATCH 10/13] nix fmt --- default.nix | 4 +--- flake.nix | 14 ++++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/default.nix b/default.nix index 96dd3a1a2..7e50a38b4 100644 --- a/default.nix +++ b/default.nix @@ -8,7 +8,5 @@ buildGoModule { env = { CGO_ENABLED = "0"; }; - subPackages = [ - "cmd/saml2aws" - ]; + subPackages = [ "cmd/saml2aws" ]; } diff --git a/flake.nix b/flake.nix index 8f3cf3cbc..3406e75db 100644 --- a/flake.nix +++ b/flake.nix @@ -6,16 +6,22 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, flake-utils, nixpkgs }: flake-utils.lib.eachDefaultSystem - ( + outputs = + { + self, + flake-utils, + nixpkgs, + }: + flake-utils.lib.eachDefaultSystem ( system: let pkgs = import nixpkgs { inherit system; }; in { - packages = { + packages = rec { + default = saml2aws; saml2aws = (pkgs.callPackage (import ./.) { }); }; } - ); + ); } From e03e7747d9786588d6e2312c3fcdb7380d180ab2 Mon Sep 17 00:00:00 2001 From: Sangyu Lee Date: Mon, 20 Jan 2025 15:33:27 +0900 Subject: [PATCH 11/13] add result to gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dab5a8422..d56c08852 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ bin/ # direnv .envrc -.buildtemp \ No newline at end of file +.buildtemp + +# nix build result +result From 0e9f034d1bbe03f4c73e483f88e1dc438542f275 Mon Sep 17 00:00:00 2001 From: Sangyu Lee Date: Mon, 20 Jan 2025 16:14:53 +0900 Subject: [PATCH 12/13] fix errors on pure evaluation mode --- default.nix | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/default.nix b/default.nix index 7e50a38b4..e94aca7dc 100644 --- a/default.nix +++ b/default.nix @@ -1,12 +1,20 @@ -{ buildGoModule, ... }: +{ buildGoModule, stdenv, darwin, ... }: -buildGoModule { +buildGoModule rec { name = "saml2aws"; version = "2.36.14-tencent"; src = ./.; + + buildInputs = [] ++ (if stdenv.hostPlatform.isDarwin then [ + darwin.apple_sdk.frameworks.AppKit + ] else []); + + ldflags = [ + "-s" + "-w" + "-X main.Version=${version}" + ]; + vendorHash = "sha256-pml6M45IJXfeOiMcPq8K88LxQFR/WmOzQXwGXHGOCew"; - env = { - CGO_ENABLED = "0"; - }; subPackages = [ "cmd/saml2aws" ]; } From ad8df0d33b894a3b60644c2acfa6e98717583e21 Mon Sep 17 00:00:00 2001 From: Sangyu Lee Date: Wed, 22 Jan 2025 10:40:40 +0900 Subject: [PATCH 13/13] =?UTF-8?q?roleARN=20parameter=EA=B0=80=20=EC=A3=BC?= =?UTF-8?q?=EC=96=B4=EC=A7=84=20=EA=B2=BD=EC=9A=B0=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?role=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/saml2aws/commands/login.go | 4 ++++ default.nix | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go index a276d3517..4d920175e 100644 --- a/cmd/saml2aws/commands/login.go +++ b/cmd/saml2aws/commands/login.go @@ -310,6 +310,10 @@ func resolveRole(cloudRoles []*saml2aws.CloudRole, account *cfg.IDPAccount) (rol return nil, errors.New("No roles available.") } + if account.RoleARN != "" { + return saml2aws.LocateRole(cloudRoles, account.RoleARN) + } + for { role, err = saml2aws.PromptForCloudRoleSelection(cloudRoles) if err == nil { diff --git a/default.nix b/default.nix index e94aca7dc..e837d5dfc 100644 --- a/default.nix +++ b/default.nix @@ -15,6 +15,6 @@ buildGoModule rec { "-X main.Version=${version}" ]; - vendorHash = "sha256-pml6M45IJXfeOiMcPq8K88LxQFR/WmOzQXwGXHGOCew"; + vendorHash = "sha256-pml6M45IJXfeOiMcPq8K88LxQFR/WmOzQXwGXHGOCew="; subPackages = [ "cmd/saml2aws" ]; }