diff --git a/README.md b/README.md index c17df41..f50b754 100644 --- a/README.md +++ b/README.md @@ -6,58 +6,69 @@ It outputs temporary AWS credentials in a JSON format that can be consumed by th https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes -OneLogin Example: +Example: - aws-oidc exec \ - --role_arn="arn:aws:iam::892845094662:role/onelogin-test-oidc" \ - --provider_url=https://openid-connect.onelogin.com/oidc \ - --client_id=97a61160-3c09-0137-8c69-0a1c3f4fd822144813 \ - --pkce \ - --nonce \ - -- open -b com.google.chrome -n --args --profile-directory=Default {} + aws-oidc auth \ + --role_arn="arn:aws:iam::892845094662:role/onelogin-test-oidc" \ + --duration=3600 \ + --provider_url=https://openid-connect.onelogin.com/oidc \ + --client_id=97a61160-3c09-0137-8c69-0a1c3f4fd822144813 \ + --agent=open -Google Example: +All the provider arguments can be specified in a TOML configuration file: - aws-oidc exec \ - --role_arn="arn:aws:iam::892845094662:role/web-identity-lanbda" \ - --provider_url=https://accounts.google.com \ - --client_id=430784603061-osbtei3s71l0bj6d8oegto0itefjmiq6.apps.googleusercontent.com \ - --client_secret=... \ - --pkce \ - --nonce \ - -- open -b com.google.chrome -n --args --profile-directory=Default {} + region = "ap-southeast-2" -For some reason, even when using PKCE, google need a client_secret for applications not registered as Android, iOS or Chrome. + [[AuthProvider]] + name = "onelogin" + role_arn = "arn:aws:iam::012345678901:role/role-name" + duration = 900 + provider_url = "https://openid-connect.onelogin.com/oidc" + client_id = "ef061080-43aa-0137-62f3-066d8813aeb888900" + agent = ["open", "-b", "com.google.chrome", "-n", "--args", "--profile-directory=Default", "{}"] + + [[AuthProvider]] + name = "google" + role_arn = "arn:aws:iam::012345678901:role/role-name" + duration = 900 + provider_url = "https://accounts.google.com" + client_id = "430784603061-osbtei3s71l0bj6d8oegto0itefjmiq6.apps.googleusercontent.com" + agent = ["open", "-b", "com.google.chrome", "-n", "--args", "--profile-directory=Profile 1", "{}"] + +This configuration file should be located in **~/.aws-oidc/config** ## Configure AWS Config -~/.aws/config +Add the profiles for each role you want to assume to **~/.aws/config**. Specify the provider name from the configuration file, and override any default settings: - [profile oidc] - credential_process = aws-oidc exec --role_arn=arn:aws:iam::892845094662:role/onelogin-test-oidc --provider_url=https://openid-connect.onelogin.com/oidc --client_id=97a61160-3c09-0137-8c69-0a1c3f4fd822144813 --pkce --nonce -- open -b com.google.chrome -n --args --profile-directory=Default {} + [profile engineer] + credential_process = aws-oidc auth onelogin --role_arn=arn:aws:iam::892845094662:role/onelogin-test-oidc --duration 7200 Now you can use the AWS cli as normal, and specify the profile: - $ aws --profile oidc sts get-caller-identity + $ aws --profile engineer sts get-caller-identity { "UserId": "AROAJUTXNWXGCAEILMXTY:50904038", "Account": "892845094662", "Arn": "arn:aws:sts::892845094662:assumed-role/onelogin-test-oidc/50904038" } -## AWS Cognito +## Run other commands with credentials - ./aws-oidc exec \ - --provider_url=https://cognito-idp.us-west-2.amazonaws.com/us-west-2_eBYNmnpS9 \ - --client_id=70kdnvprlqf1daspkn0iikdngv \ - --pkce \ - --nonce \ - --no-reauth \ - -- open -b com.google.chrome -n --args --profile-directory=Default {} +Most AWS SDK's should be able to pick up the profile parameter, and suppor the `credentials_process` setting in your **~/.aws/config** file. If not, you can run an arbitary command with the temporary credentials with `exec`: + + aws-oidc exec engineer -- ./path/to/command with arguments + +This will use the profiles defined in **~/.aws/config** to assume the role with `aws-oidc` and then set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables for the new process. ## Find roles that an oidc client could assume - aws-vault exec test-privileged-admin -- aws iam list-roles --query < y { + return x + } + return y +} diff --git a/cli/exec.go b/cli/exec.go index 5c23e65..d1523e3 100644 --- a/cli/exec.go +++ b/cli/exec.go @@ -1,164 +1,140 @@ package cli import ( - "encoding/json" - "fmt" - - "github.com/99designs/keyring" - "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/stoggi/aws-oidc/provider" - "golang.org/x/oauth2" + "log" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" "gopkg.in/alecthomas/kingpin.v2" ) +// ExecConfig stores the parameters needed for an exec command type ExecConfig struct { - RoleArn string - Duration int64 - ProviderURL string - ClientID string - ClientSecret string - PKCE bool - Nonce bool - ReAuth bool - AgentCommand []string -} - -// json metadata for AWS credential process. Ref: https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes -type AwsCredentialHelperData struct { - Version int `json:"Version"` - AccessKeyID string `json:"AccessKeyId"` - SecretAccessKey string `json:"SecretAccessKey"` - SessionToken string `json:"SessionToken"` - Expiration string `json:"Expiration,omitempty"` -} - -type LambdaPayload struct { - Role string `json:"role"` - Token string `json:"token"` + Profile string + Command string + Args []string + Signals chan os.Signal } +// ConfigureExec configures the exec command with arguments and flags func ConfigureExec(app *kingpin.Application, config *GlobalConfig) { execConfig := ExecConfig{} - cmd := app.Command("exec", "Execute a command with temporary AWS credentials") + cmd := app.Command("exec", "Retrieve temporary credentials and set them as environment variables") - cmd.Default() + cmd.Arg("profile", "Name of the profile"). + StringVar(&config.Profile) - cmd.Flag("role_arn", "The AWS role you want to assume"). - Required(). - StringVar(&execConfig.RoleArn) + cmd.Arg("cmd", "Command to execute"). + Default(os.Getenv("SHELL")). + StringVar(&execConfig.Command) - cmd.Flag("duration", "The duration to assume the role for in seconds"). - Default("3600"). - Int64Var(&execConfig.Duration) - - cmd.Flag("provider_url", "The OpenID Connect Provider URL"). - Required(). - StringVar(&execConfig.ProviderURL) - - cmd.Flag("client_id", "The OpenID Connect Client ID"). - Required(). - StringVar(&execConfig.ClientID) - - cmd.Flag("client_secret", "The OpenID Connect Client Secret"). - Default(""). - StringVar(&execConfig.ClientSecret) - - cmd.Flag("pkce", "Use PKCE in the OIDC code flow"). - Default("true"). - BoolVar(&execConfig.PKCE) - - cmd.Flag("nonce", "Require a nonce included and verified in the token"). - Default("true"). - BoolVar(&execConfig.Nonce) - - cmd.Flag("reauth", "Require reauthentication by the identity provider"). - Default("false"). - BoolVar(&execConfig.ReAuth) - - cmd.Arg("agent", "The executable and arguments of the local browser to use"). - Default("open", "{}"). - StringsVar(&execConfig.AgentCommand) + cmd.Arg("args", "Command arguments"). + StringsVar(&execConfig.Args) cmd.Action(func(c *kingpin.ParseContext) error { + execConfig.Signals = make(chan os.Signal) ExecCommand(app, config, &execConfig) return nil }) } +// ExecCommand retrieves temporary credentials and sets them as environment variables func ExecCommand(app *kingpin.Application, config *GlobalConfig, execConfig *ExecConfig) { - p := &provider.ProviderConfig{ - ClientID: execConfig.ClientID, - ClientSecret: execConfig.ClientSecret, - ProviderURL: execConfig.ProviderURL, - PKCE: execConfig.PKCE, - Nonce: execConfig.Nonce, - ReAuth: execConfig.ReAuth, - AgentCommand: execConfig.AgentCommand, + if os.Getenv("AWS_OIDC") != "" { + app.Fatalf("aws-vault sessions should be nested with care, unset $AWS_OIDC to force") + return } - oauth2Token := provider.OAuth2Token{} - item, err := (*config.Keyring).Get(execConfig.ClientID) - if err != keyring.ErrKeyNotFound { - if err := json.Unmarshal(item.Data, &oauth2Token); err != nil { - // Log this error only, because we can attempt to recover by getting a new token - app.Errorf("Unable to unmarshal OAuth2Token from keychain: %v", err) + val, err := config.Session.Config.Credentials.Get() + if err != nil { + app.Fatalf("Unable to get credentials for profile: %s", config.Profile) + } + + env := environ(os.Environ()) + env.Set("AWS_OIDC", config.Profile) + + env.Unset("AWS_ACCESS_KEY_ID") + env.Unset("AWS_SECRET_ACCESS_KEY") + env.Unset("AWS_CREDENTIAL_FILE") + env.Unset("AWS_DEFAULT_PROFILE") + env.Unset("AWS_PROFILE") + + if config.Region != "" { + log.Printf("Setting subprocess env: AWS_DEFAULT_REGION=%s, AWS_REGION=%s", config.Region, config.Region) + env.Set("AWS_DEFAULT_REGION", config.Region) + env.Set("AWS_REGION", config.Region) + } + + log.Println("Setting subprocess env: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY") + env.Set("AWS_ACCESS_KEY_ID", val.AccessKeyID) + env.Set("AWS_SECRET_ACCESS_KEY", val.SecretAccessKey) + + if val.SessionToken != "" { + log.Println("Setting subprocess env: AWS_SESSION_TOKEN, AWS_SECURITY_TOKEN") + env.Set("AWS_SESSION_TOKEN", val.SessionToken) + env.Set("AWS_SECURITY_TOKEN", val.SessionToken) + } + + cmd := exec.Command(execConfig.Command, execConfig.Args...) + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + signal.Notify(execConfig.Signals, os.Interrupt, os.Kill) + + if err := cmd.Start(); err != nil { + app.Fatalf("%v", err) + } + // wait for the command to finish + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + close(waitCh) + }() + + for { + select { + case sig := <-execConfig.Signals: + if err = cmd.Process.Signal(sig); err != nil { + app.Errorf("%v", err) + break + } + case err := <-waitCh: + var waitStatus syscall.WaitStatus + if exitError, ok := err.(*exec.ExitError); ok { + waitStatus = exitError.Sys().(syscall.WaitStatus) + os.Exit(waitStatus.ExitStatus()) + } + if err != nil { + app.Fatalf("%v", err) + } + return } } - - err = p.Authenticate(&oauth2Token) - app.FatalIfError(err, "Error authenticating with identity provider") - - AWSCredentialsJSON, err := assumeRoleWithWebIdentity(execConfig, oauth2Token.IDToken) - app.FatalIfError(err, "Error assume role with web identity") - - json, err := json.Marshal(&oauth2Token) - app.FatalIfError(err, "Error marshalling OAuth2 token") - err = (*config.Keyring).Set(keyring.Item{ - Key: execConfig.ClientID, - Data: json, - Label: fmt.Sprintf("OAuth2 token for %s", execConfig.RoleArn), - Description: "OIDC OAuth2 Token", - }) - app.FatalIfError(err, "Error storing OAuth2 Token in keychain") - - fmt.Printf(AWSCredentialsJSON) } -func assumeRoleWithWebIdentity(execConfig *ExecConfig, idToken string) (string, error) { +// environ is a slice of strings representing the environment, in the form "key=value". +type environ []string - svc := sts.New(session.New()) - - input := &sts.AssumeRoleWithWebIdentityInput{ - DurationSeconds: aws.Int64(execConfig.Duration), - RoleArn: aws.String(execConfig.RoleArn), - RoleSessionName: aws.String("aws-oidc"), - WebIdentityToken: aws.String(idToken), +// Unset an environment variable by key +func (e *environ) Unset(key string) { + for i := range *e { + if strings.HasPrefix((*e)[i], key+"=") { + (*e)[i] = (*e)[len(*e)-1] + *e = (*e)[:len(*e)-1] + break + } } - - assumeRoleResult, err := svc.AssumeRoleWithWebIdentity(input) - if err != nil { - return "", err - } - - expiry := *assumeRoleResult.Credentials.Expiration - credentialData := AwsCredentialHelperData{ - Version: 1, - AccessKeyID: *assumeRoleResult.Credentials.AccessKeyId, - SecretAccessKey: *assumeRoleResult.Credentials.SecretAccessKey, - SessionToken: *assumeRoleResult.Credentials.SessionToken, - Expiration: expiry.Format("2006-01-02T15:04:05Z"), - } - - json, err := json.Marshal(&credentialData) - if err != nil { - return "", err - } - - return string(json), nil +} + +// Set adds an environment variable, replacing any existing ones of the same key +func (e *environ) Set(key, val string) { + e.Unset(key) + *e = append(*e, key+"="+val) } diff --git a/cli/global.go b/cli/global.go index 8b868fe..e3f9aab 100644 --- a/cli/global.go +++ b/cli/global.go @@ -1,41 +1,54 @@ package cli import ( - "github.com/99designs/aws-vault/vault" "github.com/99designs/keyring" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" kingpin "gopkg.in/alecthomas/kingpin.v2" ) // GlobalConfig used for defaults and command line arguments type GlobalConfig struct { //Region in AWS used by KMSAuth and BLESS - Region string + Region string + Profile string + AuthProvider []AuthConfig - AwsConfig *vault.Config - Keyring *keyring.Keyring + Session *session.Session + Keyring *keyring.Keyring } // ConfigureGlobal application arguments and flags func ConfigureGlobal(app *kingpin.Application, config *GlobalConfig) { app.Flag("region", "The region in AWS"). - Default("ap-southeast-2"). + Default(config.Region). + Envar("AWS_REGION"). StringVar(&config.Region) + app.Flag("profile", "The profile to use as defined in the AWS config file"). + Default(config.Profile). + Envar("AWS_PROFILE"). + StringVar(&config.Profile) + app.PreAction(func(c *kingpin.ParseContext) (err error) { // Attempt to open the aws-vault keychain keychain, err := keyring.Open(keyring.Config{ - KeychainName: "aws-oidc", - ServiceName: "aws-oidc", - AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, + KeychainName: "aws-oidc", + ServiceName: "aws-oidc", + AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, KeychainTrustApplication: true, }) kingpin.FatalIfError(err, "Could not open aws-vault keychain") - - //config.AwsConfig = awsConfig config.Keyring = &keychain + config.Session = session.Must(session.NewSessionWithOptions(session.Options{ + Config: aws.Config{Region: aws.String(config.Region)}, + Profile: config.Profile, + SharedConfigState: session.SharedConfigEnable, + })) + return nil }) diff --git a/cli/list.go b/cli/list.go index 6c5ede0..2fb97fd 100644 --- a/cli/list.go +++ b/cli/list.go @@ -5,16 +5,18 @@ import ( "fmt" "net/url" - "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/iam" jmespath "github.com/jmespath/go-jmespath" "gopkg.in/alecthomas/kingpin.v2" ) +// ListConfig stores the parameters needed for a List command type ListConfig struct { ClientID string + Claim string } +// ConfigureList configures the list command with arguments and flags func ConfigureList(app *kingpin.Application, config *GlobalConfig) { listConfig := ListConfig{} @@ -25,15 +27,20 @@ func ConfigureList(app *kingpin.Application, config *GlobalConfig) { Required(). StringVar(&listConfig.ClientID) + cmd.Flag("claim", "The claim used in the IAM policies, prrovider:claim"). + Required(). + StringVar(&listConfig.Claim) + cmd.Action(func(c *kingpin.ParseContext) error { ListCommand(app, config, &listConfig) return nil }) } +// ListCommand retrieves the list of AWS roles that have trust policues that accept a given client_id func ListCommand(app *kingpin.Application, config *GlobalConfig, listConfig *ListConfig) { - svc := iam.New(session.New()) + svc := iam.New(config.Session) input := &iam.ListRolesInput{} listRoleResult, err := svc.ListRoles(input) @@ -47,9 +54,13 @@ func ListCommand(app *kingpin.Application, config *GlobalConfig, listConfig *Lis var d interface{} err = json.Unmarshal([]byte(decodedValue), &d) app.FatalIfError(err, "Unable to unmarshall AssumeRolePolicyDocument") - v, err := jmespath.Search("contains(Statement[].Condition.StringEquals.\"openid-connect.onelogin.com/oidc:aud\", `abc`)", d) - app.FatalIfError(err, "Unable to parse AssumeRolePolicyDocument") - fmt.Println(v) + query := fmt.Sprintf("contains(Statement[].Condition.StringEquals.\"%s\", '%s')", listConfig.Claim, listConfig.ClientID) + containsClientID, err := jmespath.Search(query, d) + app.FatalIfError(err, "Unable to parse AssumeRolePolicyDocument") + if containsClientID.(bool) { + fmt.Println(*role.RoleName) + fmt.Println(*role.Arn) + } } } diff --git a/config_darwin.go b/config_darwin.go index 13a55c7..a4830e3 100644 --- a/config_darwin.go +++ b/config_darwin.go @@ -20,7 +20,12 @@ func execDir() string { return "" } +// GetConfigFilePath returns the path of the configuration file +func GetConfigFilePath() string { + return filepath.Join(homeDir(), ".aws-oidc/config") +} + // GetLogPath returns the path that should be used to store logs func GetLogPath() string { - return filepath.Join(homeDir(), "./Library/Logs/aws-oidc.log") + return filepath.Join(homeDir(), "Library/Logs/aws-oidc.log") } diff --git a/go.mod b/go.mod index ee9463f..989aff9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stoggi/aws-oidc require ( github.com/99designs/aws-vault v4.5.1+incompatible github.com/99designs/keyring v0.0.0-20190110203331-82da6802f65f + github.com/BurntSushi/toml v0.3.1 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect github.com/aulanov/go.dbus v0.0.0-20150729231527-25c3068a42a0 // indirect diff --git a/go.sum b/go.sum index b6064c2..4b122c0 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/99designs/aws-vault v4.5.1+incompatible h1:VjWncFWraO5K5HTRo34YMq2Mkp github.com/99designs/aws-vault v4.5.1+incompatible/go.mod h1:BKt7gBiUkiAOh7TP/c36gMpRJkIk5F8hStyQoWwC/Rw= github.com/99designs/keyring v0.0.0-20190110203331-82da6802f65f h1:WXiWWJrYCaOaYimBAXlRdRJ7qOisrYyMLYnCvvhHVms= github.com/99designs/keyring v0.0.0-20190110203331-82da6802f65f/go.mod h1:aKt8W/yd91/xHY6ixZAJZ2vYbhr3pP8DcrvuGSGNPJk= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= diff --git a/provider/provider.go b/provider/provider.go index 83e9910..b7dbde6 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -24,7 +24,6 @@ type ProviderConfig struct { ProviderURL string PKCE bool Nonce bool - ReAuth bool AgentCommand []string } @@ -115,12 +114,10 @@ func (p *ProviderConfig) Authenticate(t *OAuth2Token) error { } if t != nil { - if err := t.Refresh(&config); err == nil { - log.Println("Refreshed token successfully") + if err := t.Refresh(&config); err != nil { return nil - } else { - log.Println(err) } + log.Println(err) } stateData := make([]byte, 32) @@ -159,10 +156,6 @@ func (p *ProviderConfig) Authenticate(t *OAuth2Token) error { authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("nonce", nonce)) } - if p.ReAuth { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("acr_values", "onelogin:nist:level:1:re-auth")) - } - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { url := config.AuthCodeURL(state, authCodeOptions...) http.Redirect(w, r, url, http.StatusFound) @@ -211,13 +204,18 @@ func (p *ProviderConfig) Authenticate(t *OAuth2Token) error { // Filter the commands, and replace "{}" with our callback url c := p.AgentCommand[:0] + replacedURL := false for _, arg := range p.AgentCommand { if arg == "{}" { c = append(c, baseURL) + replacedURL = true } else { c = append(c, arg) } } + if !replacedURL { + c = append(c, baseURL) + } cmd := exec.Command(c[0], c[1:]...) cmd.Run()