diff --git a/.github/workflows/publish-gh.yml b/.github/workflows/publish-gh.yml new file mode 100644 index 0000000..03d02ca --- /dev/null +++ b/.github/workflows/publish-gh.yml @@ -0,0 +1,50 @@ +name: Deploy Jekyll with GitHub Pages dependencies preinstalled + +on: + # Runs on pushes targeting the default branch + push: + branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Pages + uses: actions/configure-pages@v3 + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./ + destination: ./_site + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/Makefile b/Makefile index 2f55ede..955cf4c 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ tag: tagbuildrelease: tag cross-build release -show_coverage: +show_coverage: test go tool cover -html=.coverage/out .PHONY: deps diff --git a/README.md b/README.md index 554e69f..6a71bf1 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,39 @@ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=dnitsch_aws-cli-auth&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=dnitsch_aws-cli-auth) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=dnitsch_aws-cli-auth&metric=coverage)](https://sonarcloud.io/summary/new_code?id=dnitsch_aws-cli-auth) -# aws-cli-auth +# AWS CLI AUTH +CLI tool for retrieving AWS temporary credentials using a variety of methods. -CLI tool for retrieving AWS temporary credentials using SAML providers. +**Supports**: -Firstly, this package currently deals with SAML only, however if you have an OIDC IdP provider set up to AWS you can use this [package](https://github.com/openstandia/aws-cli-oidc) and likewise this [package](https://github.com/Versent/saml2aws) for standard SAML only AWS integrations - standard meaning. +- Any IdP Provider SAML provider via WebUI +- AWS Portal direct account => role selection +- Role chaining for every credential exchange type +- web_identity_token file with role chaining + +This tool deals with IdP logins via SAML, both into an AWS account directly or via AWS SSO Portal + +--- +> **NOTE**: [aws cli](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/sso/login.html) now supports a login via a session into a single AWS portal, it works in a similar fashion except this tool does not store the refreshToken on the device and is meant to be used with `credential_process` +--- + +> If you have an OIDC IdP provider set up to AWS you can use this [aws-cli-oidc](https://github.com/openstandia/aws-cli-oidc) and likewise this [saml2aws](https://github.com/Versent/saml2aws) for standard SAML only AWS integrations - standard meaning that your IdP has a standard and flow and a supports programatic MFA submission. If, however, you need to support a non standard user journeys enforced by your IdP i.e. a sub company selection within your organization login portal, or a selection screen for different MFA providers - PingID or RSA HardToken etc.... you cannot reliably automate the flow or it would have to be too specific. -As such this approach uses [go-rod](https://github.com/go-rod/rod) library to uniformly allow the user to complete any and all auth steps and selections in a managed browser session up to the point of where the SAMLResponse were to be sent to AWS ACS service `https://signin.aws.amazon.com/saml`. Capturing this via hijack request and posting to AWS STS service to exchange this for the temporary credentials. +As such this approach uses [go-rod](https://github.com/go-rod/rod) library to uniformly allow the user to complete any and all auth steps and selections in a managed browser session up to the point of where the SAMLResponse is to be sent to AWS ACS service `https://signin.aws.amazon.com/saml`. + +Capturing this via hijack request and posting to AWS STS service to exchange this for the temporary credentials. The advantage of using SAML is that real users can gain access to the AWS Console UI or programatically and audited as the same person in cloudtrail. By default the tool creates the session name - which can be audited including the persons username from the localhost. +## [Installation](./docs/install.md) + +## [Usage](./docs/usage.md) + ## Known Issues - Even though a datadir is created to store the chromium session data it is advised to still open settings and save the username/password manually the first time you are presented with the login screen. @@ -30,193 +48,6 @@ By default the tool creates the session name - which can be audited including th - Prior to `v0.8.0` you might be need to manually kill the `aws-cli-auth` process manually from your OS's process manager. -## Install - -MacOS - -```bash -curl -L https://github.com/dnitsch/aws-cli-auth/releases/latest/download/aws-cli-auth-darwin -o aws-cli-auth -chmod +x aws-cli-auth -sudo mv aws-cli-auth /usr/local/bin -``` - -Linux - -```bash -curl -L https://github.com/dnitsch/aws-cli-auth/releases/latest/download/aws-cli-auth-linux -o aws-cli-auth -chmod +x aws-cli-auth -sudo mv aws-cli-auth /usr/local/bin -``` - -Windows - -```posh -iwr -Uri "https://github.com/dnitsch/aws-cli-auth/releases/latest/download/aws-cli-auth-windows.exe" -OutFile "aws-cli-auth" -``` - -### Versioned - -Download a specific version from [Releases page](https://github.com/dnitsch/aws-cli-auth/releases) - -example for MacOS - -```bash -curl -L https://github.com/dnitsch/aws-cli-auth/releases/download/v0.6.2/aws-cli-auth-darwin -o aws-cli-auth -chmod +x aws-cli-auth -sudo mv aws-cli-auth /usr/local/bin -``` - -## Usage - -``` -CLI tool for retrieving AWS temporary credentials using SAML providers, or specified method of retrieval - i.e. force AWS_WEB_IDENTITY. -Useful in situations like CI jobs or containers where multiple env vars might be present. -Stores them under the $HOME/.aws/credentials file under a specified path or returns the crednetial_process payload for use in config - -Usage: - aws-cli-auth [command] - -Available Commands: - aws-cli-auth Clears any stored credentials in the OS secret store - completion Generate the autocompletion script for the specified shell - help Help about any command - saml Get AWS credentials and out to stdout - specific Initiates a specific crednetial provider [WEB_ID] - -Flags: - --cfg-section string config section name in the yaml config file - -h, --help help for aws-cli-auth - -r, --role string Set the role you want to assume when SAML or OIDC process completes - -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section - -Use "aws-cli-auth [command] --help" for more information about a command. -``` - -### SAML - -``` -Get AWS credentials and out to stdout through your SAML provider authentication. - -Usage: - aws-cli-auth saml [flags] - -Flags: - -a, --acsurl string Override the default ACS Url, used for checkin the post of the SAMLResponse (default "https://signin.aws.amazon.com/saml") - -h, --help help for saml - -d, --max-duration int Override default max session duration, in seconds, of the role session [900-43200] (default 900) - --principal string Principal Arn of the SAML IdP in AWS - -p, --provider string Saml Entity StartSSO Url - -Global Flags: - --cfg-section string config section name in the yaml config file - -r, --role string Set the role you want to assume when SAML or OIDC process completes - -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process -``` - -Example: - -```bash -aws-cli-auth saml --cfg-section nonprod_saml_admin -p "https://your-idp.com/idp/foo?PARTNER=urn:amazon:webservices" --principal "arn:aws:iam::XXXXXXXXXX:saml-provider/IDP_ENTITY_ID" -r "arn:aws:iam::XXXXXXXXXX:role/Developer" -d 3600 -s -``` - -The PartnerId in most IdPs is usually `urn:amazon:webservices` - but you can change this for anything you stored it as. - -If successful will store the creds under the specified config section in credentials profile as per below example - -```ini -[default] -aws_access_key_id = XXXXX -aws_secret_access_key = YYYYYYYYY - -[another_profile] -aws_access_key_id = XXXXX -aws_secret_access_key = YYYYYYYYY - -[nonprod_saml_admin] -aws_access_key_id = XXXXXX -aws_secret_access_key = YYYYYYYYY -aws_session_token = ZZZZZZZZZZZZZZZZZZZZ -``` - -To give it a quick test. - -```bash -aws sts get-caller-identity --profile=nonprod_saml_admin -``` - -### AWS Credential Process - -[Sourcing credentials with an external process](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html) describes how to integrate aws-cli with external tool. -You can use `aws-cli-auth` as the external process. Add the following lines to your `.aws/config` file. - -``` -[profile test_nonprod] -region = eu-west-1 -credential_process=aws-cli-auth saml -p https://your-idp.com/idp/foo?PARTNER=urn:amazon:webservices --principal arn:aws:iam::XXXXXXXXXX:saml-provider/IDP_ENTITY_ID -r arn:aws:iam::XXXXXXXXXX:role/Developer -d 3600 -``` - -Optionally you can still use it as a source profile provided your base role allows AssumeRole on that resource - -``` -[profile elevated_from_test_nonprod] -role_arn = arn:aws:iam::XXXXXXXXXX:role/ElevatedRole -source_profile = test_nonprod -region = eu-west-1 -output = json -``` - -Notice the missing `-s` | `--store-profile` flag - -### Use in CI - -Often times in CI you may have multiple credential provider methods enabled for various flows - this method lets you specify the exact credential provider to use without removing environment variables. - -``` -Initiates a specific crednetial provider [WEB_ID] as opposed to relying on the defaultCredentialChain provider. -This is useful in CI situations where various authentication forms maybe present from AWS_ACCESS_KEY as env vars to metadata of the node. -Returns the same JSON object as the call to the AWS cli for any of the sts AssumeRole* commands - -Usage: - aws-cli-auth specific [flags] - -Flags: - -h, --help help for specific - -m, --method string Runs a specific credentialProvider as opposed to rel (default "WEB_ID") - -Global Flags: - --cfg-section string config section name in the yaml config file - -r, --role string Set the role you want to assume when SAML or OIDC process completes - -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section -``` - -```bash -AWS_ROLE_ARN=arn:aws:iam::XXXX:role/some-role-in-k8s-service-account AWS_WEB_IDENTITY_TOKEN_FILE=/var/token aws-cli-auth specific | jq . -``` - -Above is the same as this: - -```bash -AWS_ROLE_ARN=arn:aws:iam::XXXX:role/some-role-in-k8s-service-account AWS_WEB_IDENTITY_TOKEN_FILE=/var/token aws-cli-auth specific -m WEB_ID | jq . -``` - -### Clear - -``` -Clears any stored credentials in the OS secret store - -Usage: - aws-cli-auth clear-cache [flags] - -Flags: - -f, --force If aws-cli-auth exited improprely in a previous run there is a chance that there could be hanging processes left over - this will clean them up forcefully - -h, --help help for clear-cache - -Global Flags: - --cfg-section string config section name in the yaml config file - -r, --role string Set the role you want to assume when SAML or OIDC process completes - -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section -``` - ## Licence WFTPL diff --git a/cmd/clear.go b/cmd/clear.go index 841e4dd..89d5b57 100644 --- a/cmd/clear.go +++ b/cmd/clear.go @@ -28,7 +28,9 @@ func clear(cmd *cobra.Command, args []string) error { web := web.New(web.NewWebConf(datadir)) - secretStore, err := credentialexchange.NewSecretStore("", fmt.Sprintf("%s-%s", credentialexchange.SELF_NAME, credentialexchange.RoleKeyConverter("")), os.TempDir()+"/aws-clie-auth-lock") + secretStore, err := credentialexchange.NewSecretStore("", + fmt.Sprintf("%s-%s", credentialexchange.SELF_NAME, credentialexchange.RoleKeyConverter("")), + os.TempDir(), "") if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index d78d5c5..4956ceb 100755 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,8 @@ var ( cfgSectionName string storeInProfile bool killHangingProcess bool + role string + roleChain []string verbose bool rootCmd = &cobra.Command{ Use: "aws-cli-auth", @@ -38,6 +40,7 @@ func Execute(ctx context.Context) { func init() { rootCmd.PersistentFlags().StringVarP(&role, "role", "r", "", "Set the role you want to assume when SAML or OIDC process completes") + rootCmd.PersistentFlags().StringSliceVarP(&roleChain, "role-chain", "", []string{}, "If specified it will assume the roles from the base credentials, in order they are specified in") rootCmd.PersistentFlags().StringVarP(&cfgSectionName, "cfg-section", "", "", "config section name in the yaml config file") rootCmd.PersistentFlags().BoolVarP(&storeInProfile, "store-profile", "s", false, "By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output") diff --git a/cmd/saml.go b/cmd/saml.go index 40a0146..01d6565 100755 --- a/cmd/saml.go +++ b/cmd/saml.go @@ -4,7 +4,9 @@ import ( "errors" "fmt" "os" + "os/user" "path" + "strings" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -19,14 +21,18 @@ var ( ) var ( - providerUrl string - principalArn string - acsUrl string - role string - datadir string - duration int - reloadBeforeTime int - samlCmd = &cobra.Command{ + providerUrl string + principalArn string + acsUrl string + isSso bool + ssoRegion string + ssoRole string + ssoUserEndpoint string + ssoFedCredEndpoint string + datadir string + duration int + reloadBeforeTime int + samlCmd = &cobra.Command{ Use: "saml ", Short: "Get AWS credentials and out to stdout", Long: `Get AWS credentials and out to stdout through your SAML provider authentication.`, @@ -45,35 +51,59 @@ func init() { samlCmd.PersistentFlags().StringVarP(&providerUrl, "provider", "p", "", "Saml Entity StartSSO Url") samlCmd.MarkPersistentFlagRequired("provider") samlCmd.PersistentFlags().StringVarP(&principalArn, "principal", "", "", "Principal Arn of the SAML IdP in AWS") - samlCmd.MarkPersistentFlagRequired("principal") samlCmd.PersistentFlags().StringVarP(&acsUrl, "acsurl", "a", "https://signin.aws.amazon.com/saml", "Override the default ACS Url, used for checkin the post of the SAMLResponse") + samlCmd.PersistentFlags().StringVarP(&ssoUserEndpoint, "sso-user-endpoint", "", "https://portal.sso.%s.amazonaws.com/user", "UserEndpoint in a go style fmt.Sprintf string with a region placeholder") + samlCmd.PersistentFlags().StringVarP(&ssoRole, "sso-role", "", "", "Sso Role name must be in this format - 12345678910:PowerUser") + samlCmd.PersistentFlags().StringVarP(&ssoFedCredEndpoint, "sso-fed-endpoint", "", "https://portal.sso.%s.amazonaws.com/federation/credentials/", "FederationCredEndpoint in a go style fmt.Sprintf string with a region placeholder") + samlCmd.PersistentFlags().StringVarP(&ssoRegion, "sso-region", "", "eu-west-1", "If using SSO, you must set the region") samlCmd.PersistentFlags().IntVarP(&duration, "max-duration", "d", 900, "Override default max session duration, in seconds, of the role session [900-43200]") - samlCmd.MarkPersistentFlagRequired("max-duration") + samlCmd.PersistentFlags().BoolVarP(&isSso, "is-sso", "", false, `Enables the new AWS User portal login. +If this flag is specified the --sso-role must also be specified.`) samlCmd.PersistentFlags().IntVarP(&reloadBeforeTime, "reload-before", "", 0, "Triggers a credentials refresh before the specified max-duration. Value provided in seconds. Should be less than the max-duration of the session") rootCmd.AddCommand(samlCmd) } func getSaml(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + user, err := user.Current() + if err != nil { + return err + } - conf := credentialexchange.SamlConfig{ + conf := credentialexchange.CredentialConfig{ ProviderUrl: providerUrl, PrincipalArn: principalArn, Duration: duration, AcsUrl: acsUrl, + IsSso: isSso, + SsoRegion: ssoRegion, + SsoRole: ssoRole, BaseConfig: credentialexchange.BaseConfig{ StoreInProfile: storeInProfile, Role: role, + RoleChain: credentialexchange.InsertRoleIntoChain(role, roleChain), + Username: user.Username, CfgSectionName: cfgSectionName, DoKillHangingProcess: killHangingProcess, ReloadBeforeTime: reloadBeforeTime, }, } + if isSso { + sr := strings.Split(ssoRole, ":") + if len(sr) > 2 { + return fmt.Errorf("incorrectly formatted role for AWS SSO - must only be ACCOUNT:ROLE_NAME") + } + conf.SsoUserEndpoint = fmt.Sprintf("https://portal.sso.%s.amazonaws.com/user", conf.SsoRegion) + conf.SsoCredFedEndpoint = fmt.Sprintf("https://portal.sso.%s.amazonaws.com/federation/credentials/", conf.SsoRegion) + fmt.Sprintf("?account_id=%s&role_name=%s&debug=true", sr[0], sr[1]) + } + datadir := path.Join(credentialexchange.HomeDir(), fmt.Sprintf(".%s-data", credentialexchange.SELF_NAME)) os.MkdirAll(datadir, 0755) - secretStore, err := credentialexchange.NewSecretStore(conf.BaseConfig.Role, fmt.Sprintf("%s-%s", credentialexchange.SELF_NAME, credentialexchange.RoleKeyConverter(conf.BaseConfig.Role)), os.TempDir()+"/aws-clie-auth-lock") + secretStore, err := credentialexchange.NewSecretStore(conf.BaseConfig.Role, + fmt.Sprintf("%s-%s", credentialexchange.SELF_NAME, credentialexchange.RoleKeyConverter(conf.BaseConfig.Role)), + os.TempDir(), user.Username) if err != nil { return err } @@ -84,7 +114,7 @@ func getSaml(cmd *cobra.Command, args []string) error { } svc := sts.NewFromConfig(cfg) - return cmdutils.GetSamlCreds(ctx, svc, secretStore, conf, web.NewWebConf(datadir)) + return cmdutils.GetCredsWebUI(ctx, svc, secretStore, conf, web.NewWebConf(datadir)) } func samlInitConfig() { diff --git a/cmd/specific.go b/cmd/specific.go index 2d77842..8d4691d 100644 --- a/cmd/specific.go +++ b/cmd/specific.go @@ -54,19 +54,20 @@ func specific(cmd *cobra.Command, args []string) error { return fmt.Errorf("unsupported Method: %s", method) } } - config := credentialexchange.SamlConfig{BaseConfig: credentialexchange.BaseConfig{StoreInProfile: storeInProfile}} - // IF role is provided it can be assumed from the WEB_ID credentials - // - if role != "" { - awsCreds, err = credentialexchange.AssumeRoleWithCreds(ctx, awsCreds, svc, user.Name, role) - if err != nil { - return err - } + config := credentialexchange.CredentialConfig{ + BaseConfig: credentialexchange.BaseConfig{ + StoreInProfile: storeInProfile, + Username: user.Username, + Role: role, + RoleChain: credentialexchange.InsertRoleIntoChain(role, roleChain), + }, } - if err := credentialexchange.SetCredentials(awsCreds, config); err != nil { + awsCreds, err = credentialexchange.AssumeRoleInChain(ctx, awsCreds, svc, config.BaseConfig.Username, config.BaseConfig.RoleChain) + if err != nil { return err } - return nil + + return credentialexchange.SetCredentials(awsCreds, config) } diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..6d7e5d5 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,35 @@ +# Install + +MacOS + +```bash +curl -L https://github.com/dnitsch/aws-cli-auth/releases/latest/download/aws-cli-auth-darwin -o aws-cli-auth +chmod +x aws-cli-auth +sudo mv aws-cli-auth /usr/local/bin +``` + +Linux + +```bash +curl -L https://github.com/dnitsch/aws-cli-auth/releases/latest/download/aws-cli-auth-linux -o aws-cli-auth +chmod +x aws-cli-auth +sudo mv aws-cli-auth /usr/local/bin +``` + +Windows + +```posh +iwr -Uri "https://github.com/dnitsch/aws-cli-auth/releases/latest/download/aws-cli-auth-windows.exe" -OutFile "aws-cli-auth" +``` + +## Versioned + +Download a specific version from [Releases page](https://github.com/dnitsch/aws-cli-auth/releases) + +example for MacOS + +```bash +curl -L https://github.com/dnitsch/aws-cli-auth/releases/download/v0.11.0/aws-cli-auth-darwin -o aws-cli-auth +chmod +x aws-cli-auth +sudo mv aws-cli-auth /usr/local/bin +``` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..8780a52 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,164 @@ + +# Usage + +``` +CLI tool for retrieving AWS temporary credentials using SAML providers, or specified method of retrieval - i.e. force AWS_WEB_IDENTITY. +Useful in situations like CI jobs or containers where multiple env vars might be present. +Stores them under the $HOME/.aws/credentials file under a specified path or returns the crednetial_process payload for use in config + +Usage: + aws-cli-auth [command] + +Available Commands: + aws-cli-auth Clears any stored credentials in the OS secret store + completion Generate the autocompletion script for the specified shell + help Help about any command + saml Get AWS credentials and out to stdout + specific Initiates a specific crednetial provider [WEB_ID] + +Flags: + --cfg-section string config section name in the yaml config file + -h, --help help for aws-cli-auth + -r, --role string Set the role you want to assume when SAML or OIDC process completes + -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section + +Use "aws-cli-auth [command] --help" for more information about a command. +``` + +## SAML + +``` +Get AWS credentials and out to stdout through your SAML provider authentication. + +Usage: + aws-cli-auth saml [flags] + +Flags: + -a, --acsurl string Override the default ACS Url, used for checkin the post of the SAMLResponse (default "https://signin.aws.amazon.com/saml") + -h, --help help for saml + -d, --max-duration int Override default max session duration, in seconds, of the role session [900-43200] (default 900) + --principal string Principal Arn of the SAML IdP in AWS + -p, --provider string Saml Entity StartSSO Url + +Global Flags: + --cfg-section string config section name in the yaml config file + -r, --role string Set the role you want to assume when SAML or OIDC process completes + -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process +``` + +Example: + +```bash +aws-cli-auth saml --cfg-section nonprod_saml_admin -p "https://your-idp.com/idp/foo?PARTNER=urn:amazon:webservices" --principal "arn:aws:iam::XXXXXXXXXX:saml-provider/IDP_ENTITY_ID" -r "arn:aws:iam::XXXXXXXXXX:role/Developer" -d 3600 -s +``` + +The PartnerId in most IdPs is usually `urn:amazon:webservices` - but you can change this for anything you stored it as. + +If successful will store the creds under the specified config section in credentials profile as per below example + +```ini +[default] +aws_access_key_id = XXXXX +aws_secret_access_key = YYYYYYYYY + +[another_profile] +aws_access_key_id = XXXXX +aws_secret_access_key = YYYYYYYYY + +[nonprod_saml_admin] +aws_access_key_id = XXXXXX +aws_secret_access_key = YYYYYYYYY +aws_session_token = ZZZZZZZZZZZZZZZZZZZZ +``` + +To give it a quick test. + +```bash +aws sts get-caller-identity --profile=nonprod_saml_admin +``` + +## AWS SSO Portal + +**NOW** Includes support for AWS User Portal, largely remains the same with a few exceptions/additions: + +- `-r|--role` + needs to be in the following format `12345678901:AdministratorAccess` +- `--sso-region` + region of your SSO setup +- `--is-sso` + flag to set the flow over AWS SSO + +Sample: `aws-cli-auth saml --cfg-section test_sso_portal -p https://your_idp.com/app_id -r 12345678901:AdministratorAccess --sso-region ap-north-1 -d 3600 --is-sso --reload-before 60` + +## AWS Credential Process + +[Sourcing credentials with an external process](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html) describes how to integrate aws-cli with external tool. +You can use `aws-cli-auth` as the external process. Add the following lines to your `.aws/config` file. + +``` +[profile test_nonprod] +region = eu-west-1 +credential_process=aws-cli-auth saml -p https://your-idp.com/idp/foo?PARTNER=urn:amazon:webservices --principal arn:aws:iam::XXXXXXXXXX:saml-provider/IDP_ENTITY_ID -r arn:aws:iam::XXXXXXXXXX:role/Developer -d 3600 +``` + +Optionally you can still use it as a source profile provided your base role allows AssumeRole on that resource + +``` +[profile elevated_from_test_nonprod] +role_arn = arn:aws:iam::XXXXXXXXXX:role/ElevatedRole +source_profile = test_nonprod +region = eu-west-1 +output = json +``` + +Notice the missing `-s` | `--store-profile` flag + +## Use in CI + +Often times in CI you may have multiple credential provider methods enabled for various flows - this method lets you specify the exact credential provider to use without removing environment variables. + +``` +Initiates a specific crednetial provider [WEB_ID] as opposed to relying on the defaultCredentialChain provider. +This is useful in CI situations where various authentication forms maybe present from AWS_ACCESS_KEY as env vars to metadata of the node. +Returns the same JSON object as the call to the AWS cli for any of the sts AssumeRole* commands + +Usage: + aws-cli-auth specific [flags] + +Flags: + -h, --help help for specific + -m, --method string Runs a specific credentialProvider as opposed to rel (default "WEB_ID") + +Global Flags: + --cfg-section string config section name in the yaml config file + -r, --role string Set the role you want to assume when SAML or OIDC process completes + -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section +``` + +```bash +AWS_ROLE_ARN=arn:aws:iam::XXXX:role/some-role-in-k8s-service-account AWS_WEB_IDENTITY_TOKEN_FILE=/var/token aws-cli-auth specific | jq . +``` + +Above is the same as this: + +```bash +AWS_ROLE_ARN=arn:aws:iam::XXXX:role/some-role-in-k8s-service-account AWS_WEB_IDENTITY_TOKEN_FILE=/var/token aws-cli-auth specific -m WEB_ID | jq . +``` + +## Clear + +``` +Clears any stored credentials in the OS secret store + +Usage: + aws-cli-auth clear-cache [flags] + +Flags: + -f, --force If aws-cli-auth exited improprely in a previous run there is a chance that there could be hanging processes left over - this will clean them up forcefully + -h, --help help for clear-cache + +Global Flags: + --cfg-section string config section name in the yaml config file + -r, --role string Set the role you want to assume when SAML or OIDC process completes + -s, --store-profile By default the credentials are returned to stdout to be used by the credential_process. Set this flag to instead store the credentials under a named profile section +``` diff --git a/internal/cmdutils/cmdutils.go b/internal/cmdutils/cmdutils.go index 1bc5dc5..7db1a97 100644 --- a/internal/cmdutils/cmdutils.go +++ b/internal/cmdutils/cmdutils.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os/user" "github.com/dnitsch/aws-cli-auth/internal/credentialexchange" "github.com/dnitsch/aws-cli-auth/internal/web" @@ -22,10 +21,9 @@ type SecretStorageImpl interface { SaveAWSCredential(cred *credentialexchange.AWSCredentials) error } -// GetSamlCreds -func GetSamlCreds(ctx context.Context, svc credentialexchange.AuthSamlApi, secretStore SecretStorageImpl, conf credentialexchange.SamlConfig, webConfig *web.WebConfig) error { +// GetCredsWebUI +func GetCredsWebUI(ctx context.Context, svc credentialexchange.AuthSamlApi, secretStore SecretStorageImpl, conf credentialexchange.CredentialConfig, webConfig *web.WebConfig) error { if conf.BaseConfig.CfgSectionName == "" && conf.BaseConfig.StoreInProfile { - // Debug("Config-Section name must be provided if store-profile is enabled") return fmt.Errorf("Config-Section name must be provided if store-profile is enabled %w", ErrMissingArg) } @@ -41,21 +39,31 @@ func GetSamlCreds(ctx context.Context, svc credentialexchange.AuthSamlApi, secre } if !credsValid { - return refreshCreds(ctx, conf, secretStore, svc, webConfig) + if conf.IsSso { + return refreshAwsSsoCreds(ctx, conf, secretStore, svc, webConfig) + } + return refreshSamlCreds(ctx, conf, secretStore, svc, webConfig) } return credentialexchange.SetCredentials(storedCreds, conf) } -func refreshCreds(ctx context.Context, conf credentialexchange.SamlConfig, secretStore SecretStorageImpl, svc credentialexchange.AuthSamlApi, webConfig *web.WebConfig) error { - +func refreshAwsSsoCreds(ctx context.Context, conf credentialexchange.CredentialConfig, secretStore SecretStorageImpl, svc credentialexchange.AuthSamlApi, webConfig *web.WebConfig) error { webBrowser := web.New(webConfig) - - samlResp, err := webBrowser.GetSamlLogin(conf) + capturedCreds, err := webBrowser.GetSSOCredentials(conf) if err != nil { return err } - user, err := user.Current() + awsCreds := &credentialexchange.AWSCredentials{} + awsCreds.FromRoleCredString(capturedCreds) + return completeCredProcess(ctx, secretStore, svc, awsCreds, conf) +} + +func refreshSamlCreds(ctx context.Context, conf credentialexchange.CredentialConfig, secretStore SecretStorageImpl, svc credentialexchange.AuthSamlApi, webConfig *web.WebConfig) error { + + webBrowser := web.New(webConfig) + + samlResp, err := webBrowser.GetSamlLogin(conf) if err != nil { return err } @@ -63,17 +71,26 @@ func refreshCreds(ctx context.Context, conf credentialexchange.SamlConfig, secre roleObj := credentialexchange.AWSRole{ RoleARN: conf.BaseConfig.Role, PrincipalARN: conf.PrincipalArn, - Name: credentialexchange.SessionName(user.Username, credentialexchange.SELF_NAME), + Name: credentialexchange.SessionName(conf.BaseConfig.Username, credentialexchange.SELF_NAME), Duration: conf.Duration, } awsCreds, err := credentialexchange.LoginStsSaml(ctx, samlResp, roleObj, svc) if err != nil { return err } + return completeCredProcess(ctx, secretStore, svc, awsCreds, conf) +} - awsCreds.Version = 1 - if err := secretStore.SaveAWSCredential(awsCreds); err != nil { +func completeCredProcess(ctx context.Context, secretStore SecretStorageImpl, svc credentialexchange.AuthSamlApi, awsCreds *credentialexchange.AWSCredentials, conf credentialexchange.CredentialConfig) error { + creds, err := credentialexchange.AssumeRoleInChain(ctx, awsCreds, svc, conf.BaseConfig.Username, conf.BaseConfig.RoleChain) + if err != nil { return err } - return credentialexchange.SetCredentials(awsCreds, conf) + creds.Version = 1 + + if err := secretStore.SaveAWSCredential(creds); err != nil { + return err + } + + return credentialexchange.SetCredentials(creds, conf) } diff --git a/internal/cmdutils/cmdutils_test.go b/internal/cmdutils/cmdutils_test.go index 39406e4..f9538b9 100644 --- a/internal/cmdutils/cmdutils_test.go +++ b/internal/cmdutils/cmdutils_test.go @@ -56,8 +56,6 @@ func AwsMockHandler(t *testing.T, mux *http.ServeMux) http.Handler { c6104cbe-af31-11e0-8154-cbc7ccf896c7 `)) - // w.Write([]byte(`{"Credentials":{"AccessKeyId":"AWSGFDDFSDESFRFRE123112","Expiration":"1239792839344","SecretAccessKey":"SDFSDJHWUJFWE322342323WEFFDWEF@£423rERVedfvvr342","SessionToken":"fdsdf23r4234werfedsfvfvee43g5r354grtrtv"}}`)) - // } }) return mux } @@ -134,8 +132,8 @@ func IdpHandler(t *testing.T, addAwsMock bool) http.Handler { return mux } -func testConfig() credentialexchange.SamlConfig { - return credentialexchange.SamlConfig{ +func testConfig() credentialexchange.CredentialConfig { + return credentialexchange.CredentialConfig{ BaseConfig: credentialexchange.BaseConfig{ Role: "arn:aws:iam::1122223334:role/some-role", StoreInProfile: false, @@ -149,6 +147,7 @@ func testConfig() credentialexchange.SamlConfig { type mockAuthApi struct { assumeRoleWSaml func(ctx context.Context, params *sts.AssumeRoleWithSAMLInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleWithSAMLOutput, error) getCallId func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + assume func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) } func (m *mockAuthApi) AssumeRoleWithSAML(ctx context.Context, params *sts.AssumeRoleWithSAMLInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleWithSAMLOutput, error) { @@ -159,6 +158,10 @@ func (m *mockAuthApi) GetCallerIdentity(ctx context.Context, params *sts.GetCall return m.getCallId(ctx, params, optFns...) } +func (m *mockAuthApi) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return m.assume(ctx, params, optFns...) +} + type mockSecretApi struct { mCred func() (*credentialexchange.AWSCredentials, error) mclear func() error @@ -184,7 +187,7 @@ func (s *mockSecretApi) SaveAWSCredential(cred *credentialexchange.AWSCredential func Test_GetSamlCreds_With(t *testing.T) { ttests := map[string]struct { - config func(t *testing.T) credentialexchange.SamlConfig + config func(t *testing.T) credentialexchange.CredentialConfig handler func(t *testing.T, awsMock bool) http.Handler authApi func(t *testing.T) credentialexchange.AuthSamlApi secretStore func(t *testing.T) cmdutils.SecretStorageImpl @@ -192,7 +195,7 @@ func Test_GetSamlCreds_With(t *testing.T) { errTyp error }{ "correct config and extracted creds but not valid anymore": { - config: func(t *testing.T) credentialexchange.SamlConfig { + config: func(t *testing.T) credentialexchange.CredentialConfig { return testConfig() }, handler: IdpHandler, @@ -228,7 +231,20 @@ func Test_GetSamlCreds_With(t *testing.T) { UserId: aws.String("some-user-id"), }, nil } - + m.assume = func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return &sts.AssumeRoleOutput{ + AssumedRoleUser: &types.AssumedRoleUser{ + AssumedRoleId: aws.String("some-role"), + Arn: aws.String("arn"), + }, + Credentials: &types.Credentials{ + AccessKeyId: aws.String("123213"), + SecretAccessKey: aws.String("32798hewf"), + SessionToken: aws.String("49hefusdSOM_LONG_TOKEN_HERE"), + Expiration: aws.Time(time.Now().Local().Add(time.Minute * time.Duration(5))), + }, + }, nil + } return m }, secretStore: func(t *testing.T) cmdutils.SecretStorageImpl { @@ -251,7 +267,7 @@ func Test_GetSamlCreds_With(t *testing.T) { errTyp: nil, }, "correct config and extracted creds an IsValid": { - config: func(t *testing.T) credentialexchange.SamlConfig { + config: func(t *testing.T) credentialexchange.CredentialConfig { conf := testConfig() conf.BaseConfig.ReloadBeforeTime = 60 return conf @@ -312,7 +328,7 @@ func Test_GetSamlCreds_With(t *testing.T) { errTyp: nil, }, "mising config section name and --store-in-profile set": { - config: func(t *testing.T) credentialexchange.SamlConfig { + config: func(t *testing.T) credentialexchange.CredentialConfig { tc := testConfig() tc.BaseConfig.CfgSectionName = "" tc.BaseConfig.StoreInProfile = true @@ -329,7 +345,7 @@ func Test_GetSamlCreds_With(t *testing.T) { errTyp: cmdutils.ErrMissingArg, }, "failure on unable to retrieve existing credential": { - config: func(t *testing.T) credentialexchange.SamlConfig { + config: func(t *testing.T) credentialexchange.CredentialConfig { tc := testConfig() tc.BaseConfig.CfgSectionName = "" tc.BaseConfig.StoreInProfile = false @@ -350,7 +366,7 @@ func Test_GetSamlCreds_With(t *testing.T) { errTyp: credentialexchange.ErrUnableToLoadAWSCred, }, "fails on isValid": { - config: func(t *testing.T) credentialexchange.SamlConfig { + config: func(t *testing.T) credentialexchange.CredentialConfig { tc := testConfig() tc.BaseConfig.CfgSectionName = "" tc.BaseConfig.StoreInProfile = false @@ -398,7 +414,138 @@ func Test_GetSamlCreds_With(t *testing.T) { ss := tt.secretStore(t) - err := cmdutils.GetSamlCreds( + err := cmdutils.GetCredsWebUI( + context.TODO(), tt.authApi(t), ss, conf, + web.NewWebConf(tempDir).WithHeadless().WithTimeout(10)) + + if tt.expectErr { + if err == nil { + t.Errorf("got , wanted %s", tt.errTyp) + return + } + if !errors.Is(err, tt.errTyp) { + t.Errorf("got %s, wanted %s", err, tt.errTyp) + return + } + } + + if err != nil && !tt.expectErr { + t.Errorf("got %s, wanted ", err) + } + }) + } +} + +func mockSsoHandler(t *testing.T) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Server", "Server") + w.Header().Set("X-Amzn-Requestid", "9363fdebc232c348b71c8ba5b59f9a34") + w.Write([]byte(``)) + }) + mux.HandleFunc("/fed-endpoint", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`{"roleCredentials":{"accessKeyId":"asdas","secretAccessKey":"sa/08asc62pun9a","sessionToken":"somtoken//////////YO4Dm0aJYq4K2rQ9V0B6yJMsKpkc5fo+iUT6nI99cZWmGFE","expiration":1698943755000}}`)) + }) + mux.HandleFunc("/idp-onload", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(` + + +
+ + + `)) + }) + return mux +} + +func Test_Get_SSO_Creds_with(t *testing.T) { + ttests := map[string]struct { + config func(t *testing.T) credentialexchange.CredentialConfig + handler func(t *testing.T) http.Handler + authApi func(t *testing.T) credentialexchange.AuthSamlApi + secretStore func(t *testing.T) cmdutils.SecretStorageImpl + expectErr bool + errTyp error + }{ + "correct outcome": { + config: func(t *testing.T) credentialexchange.CredentialConfig { + return testConfig() + }, + handler: mockSsoHandler, + authApi: func(t *testing.T) credentialexchange.AuthSamlApi { + m := &mockAuthApi{} + m.assumeRoleWSaml = func(ctx context.Context, params *sts.AssumeRoleWithSAMLInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleWithSAMLOutput, error) { + return &sts.AssumeRoleWithSAMLOutput{}, nil + } + + m.getCallId = func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + // t.Error() + return &sts.GetCallerIdentityOutput{}, nil + } + m.assume = func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return &sts.AssumeRoleOutput{ + AssumedRoleUser: &types.AssumedRoleUser{ + AssumedRoleId: aws.String("some-role"), + Arn: aws.String("arn"), + }, + Credentials: &types.Credentials{ + AccessKeyId: aws.String("123213"), + SecretAccessKey: aws.String("32798hewf"), + SessionToken: aws.String("49hefusdSOM_LONG_TOKEN_HERE"), + Expiration: aws.Time(time.Now().Local().Add(time.Minute * time.Duration(5))), + }, + }, nil + } + return m + }, + secretStore: func(t *testing.T) cmdutils.SecretStorageImpl { + ss := &mockSecretApi{} + ss.mCred = func() (*credentialexchange.AWSCredentials, error) { + return &credentialexchange.AWSCredentials{ + Version: 1, + AWSAccessKey: "3212321", + AWSSecretKey: "23fsd2332", + AWSSessionToken: "LONG_TOKEN", + Expires: time.Now().Local().Add(time.Minute * time.Duration(-1)), + }, nil + } + ss.mSave = func(cred *credentialexchange.AWSCredentials) error { + return nil + } + return ss + }, + expectErr: false, + errTyp: nil, + }, + } + for name, tt := range ttests { + t.Run(name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler(t)) + defer ts.Close() + conf := tt.config(t) + conf.IsSso = true + conf.SsoUserEndpoint = fmt.Sprintf("%s/user", ts.URL) + conf.SsoCredFedEndpoint = fmt.Sprintf("%s/fed-endpoint", ts.URL) + conf.ProviderUrl = fmt.Sprintf("%s/idp-onload", ts.URL) + conf.AcsUrl = fmt.Sprintf("%s/saml", ts.URL) + conf.BaseConfig = credentialexchange.BaseConfig{} + + tempDir, _ := os.MkdirTemp(os.TempDir(), "saml-sso-tester") + + defer func() { + os.RemoveAll(tempDir) + }() + + ss := tt.secretStore(t) + + err := cmdutils.GetCredsWebUI( context.TODO(), tt.authApi(t), ss, conf, web.NewWebConf(tempDir).WithHeadless().WithTimeout(10)) diff --git a/internal/credentialexchange/config.go b/internal/credentialexchange/config.go index 78cb54e..efa75d7 100644 --- a/internal/credentialexchange/config.go +++ b/internal/credentialexchange/config.go @@ -9,16 +9,23 @@ const ( type BaseConfig struct { Role string + RoleChain []string + Username string CfgSectionName string StoreInProfile bool DoKillHangingProcess bool ReloadBeforeTime int } -type SamlConfig struct { - BaseConfig BaseConfig - ProviderUrl string - PrincipalArn string - AcsUrl string - Duration int +type CredentialConfig struct { + BaseConfig BaseConfig + ProviderUrl string + PrincipalArn string + AcsUrl string + Duration int + IsSso bool + SsoRegion string + SsoRole string + SsoUserEndpoint string + SsoCredFedEndpoint string } diff --git a/internal/credentialexchange/credentialexchange.go b/internal/credentialexchange/credentialexchange.go index 1794809..8ce9579 100755 --- a/internal/credentialexchange/credentialexchange.go +++ b/internal/credentialexchange/credentialexchange.go @@ -2,6 +2,7 @@ package credentialexchange import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -17,6 +18,7 @@ var ( ErrUnableSessionCreate = errors.New("unable to create a sesion") ErrTokenExpired = errors.New("token expired") ErrMissingEnvVar = errors.New("missing env var") + ErrUnmarshalCred = errors.New("unable to unmarshal credential from string") ) // AWSRole aws role attributes @@ -36,9 +38,32 @@ type AWSCredentials struct { Expires time.Time `json:"Expiration"` } +func (a *AWSCredentials) FromRoleCredString(cred string) (*AWSCredentials, error) { + // RoleCreds can be encapsulated in this function + // never used outside of this scope for now + type RoleCreds struct { + RoleCreds struct { + AccessKey string `json:"accessKeyId"` + SecretKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration int64 `json:"expiration"` + } `json:"roleCredentials"` + } + rc := &RoleCreds{} + if err := json.Unmarshal([]byte(cred), rc); err != nil { + return nil, fmt.Errorf("%s, %w", err, ErrUnmarshalCred) + } + a.AWSAccessKey = rc.RoleCreds.AccessKey + a.AWSSecretKey = rc.RoleCreds.SecretKey + a.AWSSessionToken = rc.RoleCreds.SessionToken + a.Expires = time.UnixMilli(rc.RoleCreds.Expiration) + return a, nil +} + type AuthSamlApi interface { AssumeRoleWithSAML(ctx context.Context, params *sts.AssumeRoleWithSAMLInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleWithSAMLOutput, error) GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) } // LoginStsSaml exchanges saml response for STS creds @@ -135,15 +160,11 @@ func LoginAwsWebToken(ctx context.Context, username string, svc authWebTokenApi) }, nil } -type authAssumeRoleCredsApi interface { - AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) -} - // AssumeRoleWithCreds uses existing creds retrieved from anywhere // to pass to a credential provider and assume a specific role // // Most common use case is role chaining an WeBId role to a specific one -func AssumeRoleWithCreds(ctx context.Context, currentCreds *AWSCredentials, svc authAssumeRoleCredsApi, username, role string) (*AWSCredentials, error) { +func assumeRoleWithCreds(ctx context.Context, currentCreds *AWSCredentials, svc AuthSamlApi, username, role string) (*AWSCredentials, error) { input := &sts.AssumeRoleInput{ RoleArn: &role, @@ -166,3 +187,15 @@ func AssumeRoleWithCreds(ctx context.Context, currentCreds *AWSCredentials, svc Expires: roleCreds.Credentials.Expiration.Local(), }, nil } + +// AssumeRoleInChain loops over all the roles provided +func AssumeRoleInChain(ctx context.Context, baseCreds *AWSCredentials, svc AuthSamlApi, username string, roles []string) (*AWSCredentials, error) { + for _, r := range roles { + c, err := assumeRoleWithCreds(ctx, baseCreds, svc, username, r) + if err != nil { + return nil, err + } + baseCreds = c + } + return baseCreds, nil +} diff --git a/internal/credentialexchange/credentialexchange_test.go b/internal/credentialexchange/credentialexchange_test.go index 3ac3481..f7f3e45 100644 --- a/internal/credentialexchange/credentialexchange_test.go +++ b/internal/credentialexchange/credentialexchange_test.go @@ -19,6 +19,7 @@ import ( type mockAuthApi struct { assumeRoleWSaml func(ctx context.Context, params *sts.AssumeRoleWithSAMLInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleWithSAMLOutput, error) getCallId func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + assume func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) } func (m *mockAuthApi) AssumeRoleWithSAML(ctx context.Context, params *sts.AssumeRoleWithSAMLInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleWithSAMLOutput, error) { @@ -29,6 +30,10 @@ func (m *mockAuthApi) GetCallerIdentity(ctx context.Context, params *sts.GetCall return m.getCallId(ctx, params, optFns...) } +func (m *mockAuthApi) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return m.assume(ctx, params, optFns...) +} + var mockSuccessAwsCreds = &types.Credentials{ AccessKeyId: aws.String("123"), SecretAccessKey: aws.String("456"), @@ -405,24 +410,16 @@ func Test_LoginAwsWebToken_with(t *testing.T) { } } -type mockAssumeRole struct { - assume func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) -} - -func (m *mockAssumeRole) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - return m.assume(ctx, params, optFns...) -} - func Test_AssumeSpecifiedCreds_with(t *testing.T) { ttests := map[string]struct { - srv func(t *testing.T) *mockAssumeRole + srv func(t *testing.T) *mockAuthApi currCred *credentialexchange.AWSCredentials expectErr bool errTyp error }{ "successfully passed in creds from somewhere": { - srv: func(t *testing.T) *mockAssumeRole { - m := &mockAssumeRole{} + srv: func(t *testing.T) *mockAuthApi { + m := &mockAuthApi{} m.assume = func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { return &sts.AssumeRoleOutput{ AssumedRoleUser: &types.AssumedRoleUser{Arn: aws.String("somearn")}, @@ -433,8 +430,8 @@ func Test_AssumeSpecifiedCreds_with(t *testing.T) { }, }, "error on calling AssumeRole API": { - srv: func(t *testing.T) *mockAssumeRole { - m := &mockAssumeRole{} + srv: func(t *testing.T) *mockAuthApi { + m := &mockAuthApi{} m.assume = func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { return nil, fmt.Errorf("some error") } @@ -446,7 +443,7 @@ func Test_AssumeSpecifiedCreds_with(t *testing.T) { } for name, tt := range ttests { t.Run(name, func(t *testing.T) { - got, err := credentialexchange.AssumeRoleWithCreds(context.TODO(), tt.currCred, tt.srv(t), "foo", "barrole") + got, err := credentialexchange.AssumeRoleInChain(context.TODO(), tt.currCred, tt.srv(t), "foo", []string{"barrole"}) if tt.expectErr { if err == nil { @@ -468,3 +465,53 @@ func Test_AssumeSpecifiedCreds_with(t *testing.T) { }) } } + +func Test_GetCredentialFromRoleCreds(t *testing.T) { + ttests := map[string]struct { + input string + expect *credentialexchange.AWSCredentials + expectErr bool + errTyp error + }{ + "succeeds with escaped format": { + "{\"roleCredentials\":{\"accessKeyId\":\"asdas\",\"secretAccessKey\":\"sa/08asc62pun9a\",\"sessionToken\":\"somtoken//////////YO4Dm0aJYq4K2rQ9V0B6yJMsKpkc5fo+iUT6nI99cZWmGFE\",\"expiration\":1698943755000}}", + &credentialexchange.AWSCredentials{ + AWSAccessKey: "asdas", + }, + false, nil, + }, + "succeeds with unescaped format": { + `{"roleCredentials":{"accessKeyId":"asdas","secretAccessKey":"sa/08asc62pun9a","sessionToken":"somtoken//////////YO4Dm0aJYq4K2rQ9V0B6yJMsKpkc5fo+iUT6nI99cZWmGFE","expiration":1698943755000}}`, + &credentialexchange.AWSCredentials{ + AWSAccessKey: "asdas", + }, + false, nil, + }, + } + for name, tt := range ttests { + t.Run(name, func(t *testing.T) { + a := &credentialexchange.AWSCredentials{} + got, err := a.FromRoleCredString(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("got , wanted %s", tt.errTyp) + } + if !errors.Is(err, tt.errTyp) { + t.Errorf("got %s, wanted %s", err, tt.errTyp) + } + return + } + + if err != nil { + t.Fatalf("got %s, wanted ", err) + } + + if err != nil { + t.Fatal(err) + } + if got.AWSAccessKey != tt.expect.AWSAccessKey { + t.Fatal("not matched") + } + }) + } +} diff --git a/internal/credentialexchange/doc.go b/internal/credentialexchange/doc.go index 0640b7f..75dcd2e 100644 --- a/internal/credentialexchange/doc.go +++ b/internal/credentialexchange/doc.go @@ -5,5 +5,5 @@ // Currently supports SAML as posted by an IdP to an ACS endpoint in AWS // AWS_WEB_IDENTITY_TOKEN_FILE and optionally can specify the exact role to choose, // -// if the TOKEN corresponds to the `chained role`. +// if the TOKEN corresponds to the `chained role`. package credentialexchange diff --git a/internal/credentialexchange/helper.go b/internal/credentialexchange/helper.go index c5ab8c3..a5de349 100644 --- a/internal/credentialexchange/helper.go +++ b/internal/credentialexchange/helper.go @@ -7,7 +7,6 @@ import ( "log" "os" "path" - "strings" "time" ini "gopkg.in/ini.v1" @@ -40,7 +39,18 @@ func SessionName(username, selfName string) string { return fmt.Sprintf("%s-%s", username, selfName) } -func SetCredentials(creds *AWSCredentials, config SamlConfig) error { +func InsertRoleIntoChain(role string, roleChain []string) []string { + // IF role is provided it can be assumed from the WEB_ID credentials + // this is to maintain the old implementation + if role != "" { + // could use this experimental slice Insert + // https://pkg.go.dev/golang.org/x/exp/slices#Insert + roleChain = append(roleChain[:0], append([]string{role}, roleChain[0:]...)...) + } + return roleChain +} + +func SetCredentials(creds *AWSCredentials, config CredentialConfig) error { if config.BaseConfig.StoreInProfile { if err := storeCredentialsInProfile(*creds, config.BaseConfig.CfgSectionName); err != nil { return err @@ -51,16 +61,16 @@ func SetCredentials(creds *AWSCredentials, config SamlConfig) error { } func storeCredentialsInProfile(creds AWSCredentials, configSection string) error { - var awsConfPath string + basePath := path.Join(HomeDir(), ".aws") + awsConfPath := path.Join(basePath, "credentials") + + if _, err := os.Stat(basePath); os.IsNotExist(err) { + os.Mkdir(basePath, 0755) + os.WriteFile(awsConfPath, []byte(``), 0755) + } if overriddenpath, exists := os.LookupEnv("AWS_SHARED_CREDENTIALS_FILE"); exists { awsConfPath = overriddenpath - } else { - awsCredsPath := path.Join(HomeDir(), ".aws", "credentials") - if _, err := os.Stat(awsCredsPath); os.IsNotExist(err) { - os.Mkdir(awsCredsPath, 0655) - } - awsConfPath = awsCredsPath } cfg, err := ini.Load(awsConfPath) @@ -128,15 +138,3 @@ func WriteIniSection(role string) error { return nil } - -func GetAllIniSections() ([]string, error) { - sections := []string{} - cfg, err := ini.Load(ConfigIniFile("")) - if err != nil { - return nil, err - } - for _, v := range cfg.Section(INI_CONF_SECTION).ChildSections() { - sections = append(sections, strings.Replace(v.Name(), fmt.Sprintf("%s.", INI_CONF_SECTION), "", -1)) - } - return sections, nil -} diff --git a/internal/credentialexchange/helper_test.go b/internal/credentialexchange/helper_test.go index 236b22a..c1967b8 100644 --- a/internal/credentialexchange/helper_test.go +++ b/internal/credentialexchange/helper_test.go @@ -3,6 +3,7 @@ package credentialexchange_test import ( "fmt" "os" + "path" "strings" "testing" "time" @@ -100,3 +101,141 @@ func Test_HomeDirOverwritten(t *testing.T) { }) } } + +func Test_InsertIntoRoleSlice_with(t *testing.T) { + ttests := map[string]struct { + role string + roleChain []string + expect []string + }{ + "chain empty and role specified": { + "role", []string{}, []string{"role"}, + }, + "chain set and role empty": { + "", []string{"rolec1"}, []string{"rolec1"}, + }, + "both set and role is always first in list": { + "role", []string{"rolec1"}, []string{"role", "rolec1"}, + }, + } + for name, tt := range ttests { + t.Run(name, func(t *testing.T) { + if got := credentialexchange.InsertRoleIntoChain(tt.role, tt.roleChain); len(got) != len(tt.expect) { + t.Errorf("expected: %v, got: %v", tt.expect, got) + } + }) + } +} + +func Test_SetCredentials_with(t *testing.T) { + ttests := map[string]struct { + setup func() func() + conf credentialexchange.CredentialConfig + cred func() *credentialexchange.AWSCredentials + expectErr bool + }{ + "write to creds file": { + setup: func() func() { + tempDir, _ := os.MkdirTemp(os.TempDir(), "set-creds-tester") + os.Setenv("HOME", tempDir) + return func() { + os.Clearenv() + os.RemoveAll(tempDir) + } + }, + cred: func() *credentialexchange.AWSCredentials { + return mockSuccessCreds + }, + conf: credentialexchange.CredentialConfig{ + BaseConfig: credentialexchange.BaseConfig{ + StoreInProfile: true, + CfgSectionName: "test-section", + }, + }, + }, + "write to stdout": { + setup: func() func() { + tempDir, _ := os.MkdirTemp(os.TempDir(), "set-creds-tester") + os.Setenv("HOME", tempDir) + return func() { + os.Clearenv() + os.RemoveAll(tempDir) + } + }, + cred: func() *credentialexchange.AWSCredentials { + return mockSuccessCreds + }, + conf: credentialexchange.CredentialConfig{ + BaseConfig: credentialexchange.BaseConfig{ + StoreInProfile: false, + CfgSectionName: "test-section", + }, + }, + }, + "write using AWS_CREDENTIALS_FILE": { + setup: func() func() { + tempDir, _ := os.MkdirTemp(os.TempDir(), "set-creds-tester") + os.Setenv("HOME", tempDir) + os.WriteFile(path.Join(tempDir, "creds"), []byte(``), 0777) + os.Setenv("AWS_SHARED_CREDENTIALS_FILE", path.Join(tempDir, "creds")) + return func() { + os.Clearenv() + os.RemoveAll(tempDir) + } + }, + cred: func() *credentialexchange.AWSCredentials { + return mockSuccessCreds + }, + conf: credentialexchange.CredentialConfig{ + BaseConfig: credentialexchange.BaseConfig{ + StoreInProfile: true, + CfgSectionName: "test-section", + }, + }, + }, + // "fail on marshal to stdout": { + // setup: func() func() { + // tempDir, _ := os.MkdirTemp(os.TempDir(), "set-creds-tester") + // os.Setenv("HOME", tempDir) + // os.WriteFile(path.Join(tempDir, "creds"), []byte(``), 0777) + // os.Setenv("AWS_SHARED_CREDENTIALS_FILE", path.Join(tempDir, "creds")) + // return func() { + // os.Clearenv() + // os.RemoveAll(tempDir) + // } + // }, + // cred: func() *credentialexchange.AWSCredentials { + // var x interface{} + // x = &credentialexchange.AWSCredentials{} + // cred := &credentialexchange.AWSCredentials{ + // PrincipalARN: x, + // } + // return cred + // }, + // conf: credentialexchange.CredentialConfig{ + // BaseConfig: credentialexchange.BaseConfig{ + // StoreInProfile: true, + // CfgSectionName: "test-section", + // }, + // }, + // expectErr: true, + // }, + } + for name, tt := range ttests { + t.Run(name, func(t *testing.T) { + cleanUp := tt.setup() + + defer cleanUp() + + err := credentialexchange.SetCredentials(tt.cred(), tt.conf) + if tt.expectErr && err == nil { + t.Error("got , wanted non nil") + return + } + + if err != nil { + t.Errorf("got %s, wanted ", err) + } + }) + } +} diff --git a/internal/credentialexchange/secret.go b/internal/credentialexchange/secret.go index 99de9ff..e3a24dd 100644 --- a/internal/credentialexchange/secret.go +++ b/internal/credentialexchange/secret.go @@ -5,22 +5,23 @@ import ( "errors" "fmt" "os" - "os/user" "strings" "time" "github.com/werf/lockgate" "github.com/werf/lockgate/pkg/file_locker" "github.com/zalando/go-keyring" + ini "gopkg.in/ini.v1" ) var ( - ErrUnableToLoadAWSCred = errors.New("unable to laod AWS credential") - ErrCannotLockDir = errors.New("unable to create lock dir") - ErrUnableToRetrieveSections = errors.New("unable to retrieve sections") - ErrUnableToLoadDueToLock = errors.New("cannot load secret due to lock error") - ErrUnableToAcquireLock = errors.New("cannot acquire lock") - ErrUnmarshallingSecret = errors.New("cannot unmarshal secret") + ErrUnableToLoadAWSCred = errors.New("unable to laod AWS credential") + ErrCannotLockDir = errors.New("unable to create lock dir") + ErrUnableToRetrieveSections = errors.New("unable to retrieve sections") + ErrUnableToLoadDueToLock = errors.New("cannot load secret due to lock error") + ErrUnableToAcquireLock = errors.New("cannot acquire lock") + ErrUnmarshallingSecret = errors.New("cannot unmarshal secret") + ErrFailedToClearSecretStorage = errors.New("failed to clear secret storage on OS") ) // AWSRole aws role attributes @@ -67,17 +68,13 @@ func (k *keyRingImpl) Delete(service, user string) error { return keyring.Delete(service, user) } -func NewSecretStore(roleArn, namer, lockDir string) (*SecretStore, error) { +func NewSecretStore(roleArn, namer, baseDir, username string) (*SecretStore, error) { + lockDir := baseDir + "/aws-clie-auth-lock" locker, err := file_locker.NewFileLocker(lockDir) if err != nil { return nil, fmt.Errorf("cannot setup lock dir: %s", lockDir) } - user, err := user.Current() - if err != nil { - return nil, fmt.Errorf("cannot setup lock dir: %s", lockDir) - } - return &SecretStore{ lockDir: lockDir, locker: locker, @@ -85,7 +82,7 @@ func NewSecretStore(roleArn, namer, lockDir string) (*SecretStore, error) { lockResource: namer, secretService: namer, roleArn: roleArn, - secretUser: user.Name, + secretUser: username, }, nil } @@ -179,14 +176,22 @@ func (s *SecretStore) Clear() error { // ClearAll loops through all the sections in the INI file // deletes them from the keychain implementation on the OS func (s *SecretStore) ClearAll() error { - secretServices, err := GetAllIniSections() + srvSections := []string{} + cfg, err := ini.Load(ConfigIniFile("")) if err != nil { return fmt.Errorf("unable to get sections from ini: %s, %w", err, ErrUnableToRetrieveSections) } - for _, v := range secretServices { - s.keyring.Delete(fmt.Sprintf("%s-%s", SELF_NAME, v), s.secretUser) + for _, v := range cfg.Section(INI_CONF_SECTION).ChildSections() { + srvSections = append(srvSections, strings.Replace(v.Name(), fmt.Sprintf("%s.", INI_CONF_SECTION), "", -1)) } + + for _, v := range srvSections { + if err := s.keyring.Delete(fmt.Sprintf("%s-%s", SELF_NAME, v), s.secretUser); err != nil { + return fmt.Errorf("%s, %w", err, ErrFailedToClearSecretStorage) + } + } + return nil } diff --git a/internal/credentialexchange/secret_test.go b/internal/credentialexchange/secret_test.go index 54fface..f064225 100644 --- a/internal/credentialexchange/secret_test.go +++ b/internal/credentialexchange/secret_test.go @@ -141,7 +141,7 @@ name = "arn:aws:iam::111122342343:role/DevAdmin" os.RemoveAll(tmpDir) }() - crde, err := credentialexchange.NewSecretStore("roleArn", "namer", "lockDir") + crde, err := credentialexchange.NewSecretStore("roleArn", "namer", tmpDir, "test-user") if err != nil { t.Fail() } @@ -219,7 +219,7 @@ name = "arn:aws:iam::111122342343:role/DevAdmin" os.RemoveAll(tmpDir) }() - crde, errInit := credentialexchange.NewSecretStore("roleArn", "namer", "lockDir") + crde, errInit := credentialexchange.NewSecretStore("roleArn", "namer", tmpDir, "test-user") if errInit != nil { t.Fatal(errInit) @@ -247,3 +247,85 @@ name = "arn:aws:iam::111122342343:role/DevAdmin" }) } } + +func Test_ClearAll_with(t *testing.T) { + ttests := map[string]struct { + keyring func(t *testing.T) keyring.Keyring + locker func(t *testing.T) lockgate.Locker + errTyp error + expectErr bool + }{ + "correct input": { + keyring: func(t *testing.T) keyring.Keyring { + k := &mockKeyRing{} + k.delete = func(service, user string) error { + return nil + } + return k + }, + locker: func(t *testing.T) lockgate.Locker { + l := &mockLocker{} + return l + }, + errTyp: nil, + expectErr: false, + }, + "keyring delete error": { + keyring: func(t *testing.T) keyring.Keyring { + k := &mockKeyRing{} + k.delete = func(service, user string) error { + return fmt.Errorf("someerror") + } + return k + }, + locker: func(t *testing.T) lockgate.Locker { + l := &mockLocker{} + return l + }, + errTyp: credentialexchange.ErrFailedToClearSecretStorage, + expectErr: true, + }, + } + for name, tt := range ttests { + t.Run(name, func(t *testing.T) { + tmpDir, _ := os.MkdirTemp(os.TempDir(), "saml-cred-test") + iniFile := path.Join(tmpDir, fmt.Sprintf(".%s.ini", credentialexchange.SELF_NAME)) + os.WriteFile(iniFile, []byte(` +[role] +[role.someotherRole] +name = "arn:aws:iam::111122342343:role/DevAdmin" +`), 0777) + os.Setenv("HOME", tmpDir) + defer func() { + os.Clearenv() + os.RemoveAll(tmpDir) + }() + + crde, errInit := credentialexchange.NewSecretStore("roleArn", "namer", tmpDir, "test-user") + + if errInit != nil { + t.Fatal(errInit) + return + } + + crde.WithKeyring(tt.keyring(t)).WithLocker(tt.locker(t)) + + err := crde.ClearAll() + + if tt.expectErr { + if err == nil { + t.Errorf("got , wanted %s", tt.errTyp) + } + if !errors.Is(err, tt.errTyp) { + t.Errorf("got %s, wanted %s", err, tt.errTyp) + } + return + } + + if err != nil { + t.Errorf("got %s, wanted ", err) + } + + }) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 1b0ee53..54fed36 100755 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -3,6 +3,7 @@ package web import ( "errors" "fmt" + "net/http" nurl "net/url" "os" "strings" @@ -73,7 +74,7 @@ func New(conf *WebConfig) *Web { } // GetSamlLogin performs a saml login for a given -func (web *Web) GetSamlLogin(conf credentialexchange.SamlConfig) (string, error) { +func (web *Web) GetSamlLogin(conf credentialexchange.CredentialConfig) (string, error) { // do not clean up userdata defer web.browser.MustClose() @@ -86,7 +87,6 @@ func (web *Web) GetSamlLogin(conf credentialexchange.SamlConfig) (string, error) capturedSaml := make(chan string) router.MustAdd(fmt.Sprintf("%s*", conf.AcsUrl), func(ctx *rod.Hijack) { - // TODO: support both REDIRECT AND POST if ctx.Request.Method() == "POST" || ctx.Request.Method() == "GET" { cp := ctx.Request.Body() capturedSaml <- cp @@ -111,6 +111,55 @@ func (web *Web) GetSamlLogin(conf credentialexchange.SamlConfig) (string, error) } } +// GetSSOCredentials +func (web *Web) GetSSOCredentials(conf credentialexchange.CredentialConfig) (string, error) { + + defer web.browser.MustClose() + + web.browser.MustPage(conf.ProviderUrl) + router := web.browser.HijackRequests() + defer router.MustStop() + + capturedCreds, loadedUserInfo := make(chan string), make(chan bool) + + router.MustAdd(conf.SsoUserEndpoint, func(ctx *rod.Hijack) { + ctx.MustLoadResponse() + if ctx.Request.Method() == "GET" { + ctx.Response.SetHeader( + "Content-Type", "text/html; charset=utf-8", + "Content-Location", conf.SsoCredFedEndpoint, + "Location", conf.SsoCredFedEndpoint) + ctx.Response.Payload().ResponseCode = http.StatusMovedPermanently + loadedUserInfo <- true + } + }) + + router.MustAdd(conf.SsoCredFedEndpoint, func(ctx *rod.Hijack) { + _ = ctx.LoadResponse(http.DefaultClient, true) + if ctx.Request.Method() == "GET" { + cp := ctx.Response.Body() + capturedCreds <- cp + } + }) + + go router.Run() + + // forever loop wait for either a successfully + // extracted Creds + // + // Timesout after a specified timeout - default 120s + for { + select { + case <-loadedUserInfo: + // empty case to ensure user endpoint sets correct clientside cookies + case creds := <-capturedCreds: + return creds, nil + case <-time.After(time.Duration(web.conf.timeout*1000) * time.Millisecond): + return "", fmt.Errorf("%w", ErrTimedOut) + } + } +} + func (web *Web) ClearCache() error { errs := []error{} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 0aadb59..1bd7bdc 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -84,7 +84,7 @@ SAMLResponse=dsicisud99u2ubf92e9euhre&RelayState= func Test_WebUI_with_succesful_saml(t *testing.T) { ts := httptest.NewServer(mockIdpHandler(t)) defer ts.Close() - conf := credentialexchange.SamlConfig{BaseConfig: credentialexchange.BaseConfig{}} + conf := credentialexchange.CredentialConfig{BaseConfig: credentialexchange.BaseConfig{}} conf.AcsUrl = fmt.Sprintf("%s/saml", ts.URL) conf.ProviderUrl = fmt.Sprintf("%s/idp-onload", ts.URL) @@ -104,12 +104,10 @@ func Test_WebUI_with_succesful_saml(t *testing.T) { } } -// 2023-10-27T09:54:59+01:00 - func Test_WebUI_timeout_and_return_error(t *testing.T) { ts := httptest.NewServer(mockIdpHandler(t)) defer ts.Close() - conf := credentialexchange.SamlConfig{BaseConfig: credentialexchange.BaseConfig{}} + conf := credentialexchange.CredentialConfig{BaseConfig: credentialexchange.BaseConfig{}} conf.AcsUrl = fmt.Sprintf("%s/saml", ts.URL) conf.ProviderUrl = fmt.Sprintf("%s/idp-onload", ts.URL) @@ -130,7 +128,7 @@ func Test_WebUI_timeout_and_return_error(t *testing.T) { func Test_ClearCache(t *testing.T) { ts := httptest.NewServer(mockIdpHandler(t)) defer ts.Close() - conf := credentialexchange.SamlConfig{BaseConfig: credentialexchange.BaseConfig{}} + conf := credentialexchange.CredentialConfig{BaseConfig: credentialexchange.BaseConfig{}} conf.AcsUrl = fmt.Sprintf("%s/unknown", ts.URL) conf.ProviderUrl = fmt.Sprintf("%s/idp-onload", ts.URL) @@ -147,3 +145,86 @@ func Test_ClearCache(t *testing.T) { } } + +func mockSsoHandler(t *testing.T) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Server", "Server") + w.Header().Set("X-Amzn-Requestid", "9363fdebc232c348b71c8ba5b59f9a34") + w.Write([]byte(``)) + }) + mux.HandleFunc("/fed-endpoint", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`{"roleCredentials":{"accessKeyId":"asdas","secretAccessKey":"sa/08asc62pun9a","sessionToken":"somtoken//////////YO4Dm0aJYq4K2rQ9V0B6yJMsKpkc5fo+iUT6nI99cZWmGFE","expiration":1698943755000}}`)) + }) + mux.HandleFunc("/idp-onload", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(` + + +
+ + + `)) + }) + return mux +} + +func Test_WebUI_with_succesful_ssoLogin(t *testing.T) { + ts := httptest.NewServer(mockSsoHandler(t)) + defer ts.Close() + conf := credentialexchange.CredentialConfig{ + IsSso: true, + SsoUserEndpoint: fmt.Sprintf("%s/user", ts.URL), + SsoCredFedEndpoint: fmt.Sprintf("%s/fed-endpoint", ts.URL), + ProviderUrl: fmt.Sprintf("%s/idp-onload", ts.URL), + AcsUrl: fmt.Sprintf("%s/saml", ts.URL), + BaseConfig: credentialexchange.BaseConfig{}, + } + + tempDir, _ := os.MkdirTemp(os.TempDir(), "web-sso-tester") + + defer func() { + os.RemoveAll(tempDir) + }() + + webUi := web.New(web.NewWebConf(tempDir).WithHeadless().WithTimeout(10)) + creds, err := webUi.GetSSOCredentials(conf) + if err != nil { + t.Errorf("expected err to be got: %s", err) + } + if creds != `{"roleCredentials":{"accessKeyId":"asdas","secretAccessKey":"sa/08asc62pun9a","sessionToken":"somtoken//////////YO4Dm0aJYq4K2rQ9V0B6yJMsKpkc5fo+iUT6nI99cZWmGFE","expiration":1698943755000}}` { + t.Errorf("incorrect saml returned\n expected \"dsicisud99u2ubf92e9euhre\", got: %s", creds) + } +} + +func Test_WebUI_with_timeout_ssoLogin(t *testing.T) { + ts := httptest.NewServer(mockSsoHandler(t)) + defer ts.Close() + conf := credentialexchange.CredentialConfig{ + IsSso: true, + SsoUserEndpoint: fmt.Sprintf("%s/user", ts.URL), + SsoCredFedEndpoint: fmt.Sprintf("%s/fed-endpoint", ts.URL), + ProviderUrl: fmt.Sprintf("%s/idp-onload", ts.URL), + AcsUrl: fmt.Sprintf("%s/saml", ts.URL), + BaseConfig: credentialexchange.BaseConfig{}, + } + + tempDir, _ := os.MkdirTemp(os.TempDir(), "web-sso-tester") + + defer func() { + os.RemoveAll(tempDir) + }() + + webUi := web.New(web.NewWebConf(tempDir).WithHeadless().WithTimeout(0)) + _, err := webUi.GetSSOCredentials(conf) + + if !errors.Is(err, web.ErrTimedOut) { + t.Errorf("incorrect error returned\n expected: %s, got: %s", web.ErrTimedOut, err) + } +}