diff --git a/go.mod b/go.mod index 83a5c63..2c4c7eb 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,11 @@ module github.com/stoggi/sshrimp -go 1.13 +go 1.14 replace github.com/b-b3rn4rd/gocfn => github.com/stoggi/gocfn v0.0.0-20200214083946-6202cea979b9 +replace github.com/stoggi/aws-oidc => ./internal/aws-oidc + require ( cloud.google.com/go v0.38.0 github.com/AlecAivazis/survey/v2 v2.0.5 diff --git a/internal/aws-oidc/.gitignore b/internal/aws-oidc/.gitignore new file mode 100644 index 0000000..7d13c6d --- /dev/null +++ b/internal/aws-oidc/.gitignore @@ -0,0 +1 @@ +aws-oidc diff --git a/internal/aws-oidc/LICENSE b/internal/aws-oidc/LICENSE new file mode 100644 index 0000000..8087e75 --- /dev/null +++ b/internal/aws-oidc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Jeremy Stott + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/aws-oidc/README.md b/internal/aws-oidc/README.md new file mode 100644 index 0000000..b1a14f6 --- /dev/null +++ b/internal/aws-oidc/README.md @@ -0,0 +1,101 @@ +# aws-oidc + +Assume roles in AWS using an OpenID Connect identity provider. + +![example](docs/aws-oidc-lambda.gif) + +It is intended to be used as a `credentials_process` in ~/.aws/config that outputs temporary AWS credentials in a JSON format. + +https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes + +## Getting Started + +Add the following to **~/.aws/config**: + + [profile default] + region = us-east-1 + credential_process = /Users/jeremy/projects/aws-oidc/aws-oidc auth google + +And configure aws-oidc by creating **~/.aws-oidc/config** and setting the `role_arn` and `client_id`: + + region = "us-east-1" + + [[AuthProvider]] + name = "google" + role_arn = "arn:aws:iam::0123456789012:role/your-role-name" + duration = 900 + provider_url = "https://accounts.google.com" + client_id = "YOUR_CLIENT_ID" + client_secret = "YOUR_CLIENT_SECRET" # only specify this if your OIDC provider requires it even when using PKCE + agent = ["open", "-b", "com.google.chrome"] + +Then you can assume the role using the AWS cli: + + aws sts get-caller-identity + +Most AWS SDK implementations should be able to use the `credential_process` configuration, including: + +* aws-sdk-go +* aws-cli +* boto3 + +## Sign into the AWS Console + +Use the `login` command to exchange the temporary credentials with an [AWS Console login URL](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html) + + aws-oidc login + +![example](docs/aws-oidc-console-login.gif) + +## Open Chrome with a particular profile + +Open `chrome://version/` in the Chrome profile you want to authenticate in, and make a note of the last part of the profile path. + +Update the `agent` option with the path in your **~/.aws-oidc/config** file: + + agent = ["open", "-b", "com.google.chrome", "-n", "--args", "--profile-directory=Profile 1", "{}"] + +## Configure More Roles + +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 engineer] + credential_process = aws-oidc auth onelogin --role_arn=arn:aws:iam::0123456789012:role/your-role-name --duration 7200 + +Make sure each authentication provider exists in **~/.aws-oidc/config**. You can also override any of the configured settings here on the command line. + +To make use of this new role, simply specify the `profile` in your AWS SDK: + + aws --profile engineer sts get-caller-identity + +## Run other commands with AWS credentials + +Most AWS SDK's should be able to pick up the profile parameter, and support 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 by calling `aws-oidc auth` and then set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` environment variables for the new process. + +## Find roles that an oidc client could assume + +Use the `list` command to find roles that your claim and client_id can assume: + + aws-oidc list --claim="accounts.google.com:aud" --client_id="CLIENT_ID" + +Example using only the AWS CLI: + + aws iam list-roles --query < y { + return x + } + return y +} diff --git a/internal/aws-oidc/cli/exec.go b/internal/aws-oidc/cli/exec.go new file mode 100644 index 0000000..d1523e3 --- /dev/null +++ b/internal/aws-oidc/cli/exec.go @@ -0,0 +1,140 @@ +package cli + +import ( + "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 { + 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", "Retrieve temporary credentials and set them as environment variables") + + cmd.Arg("profile", "Name of the profile"). + StringVar(&config.Profile) + + cmd.Arg("cmd", "Command to execute"). + Default(os.Getenv("SHELL")). + StringVar(&execConfig.Command) + + 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) { + + if os.Getenv("AWS_OIDC") != "" { + app.Fatalf("aws-vault sessions should be nested with care, unset $AWS_OIDC to force") + return + } + + 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 + } + } +} + +// environ is a slice of strings representing the environment, in the form "key=value". +type environ []string + +// 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 + } + } +} + +// 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/internal/aws-oidc/cli/global.go b/internal/aws-oidc/cli/global.go new file mode 100644 index 0000000..e3f9aab --- /dev/null +++ b/internal/aws-oidc/cli/global.go @@ -0,0 +1,55 @@ +package cli + +import ( + "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 + Profile string + AuthProvider []AuthConfig + + 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(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}, + KeychainTrustApplication: true, + }) + kingpin.FatalIfError(err, "Could not open aws-vault keychain") + 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/internal/aws-oidc/cli/list.go b/internal/aws-oidc/cli/list.go new file mode 100644 index 0000000..2fb97fd --- /dev/null +++ b/internal/aws-oidc/cli/list.go @@ -0,0 +1,66 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/url" + + "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{} + + cmd := app.Command("list", "List roles that a ClientID can assume") + + cmd.Flag("client_id", "The OpenID Connect Client ID"). + 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(config.Session) + + input := &iam.ListRolesInput{} + listRoleResult, err := svc.ListRoles(input) + app.FatalIfError(err, "Unable to list roles") + + for _, role := range listRoleResult.Roles { + + decodedValue, err := url.QueryUnescape(*role.AssumeRolePolicyDocument) + app.FatalIfError(err, "Unable to urldecode document") + + var d interface{} + err = json.Unmarshal([]byte(decodedValue), &d) + app.FatalIfError(err, "Unable to unmarshall AssumeRolePolicyDocument") + + 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/internal/aws-oidc/cli/login.go b/internal/aws-oidc/cli/login.go new file mode 100644 index 0000000..090e280 --- /dev/null +++ b/internal/aws-oidc/cli/login.go @@ -0,0 +1,97 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "gopkg.in/alecthomas/kingpin.v2" +) + +// LoginConfig stores the parameters needed for an login command +type LoginConfig struct { + Profile string +} + +type signinSession struct { + SessionID string `json:"sessionId"` + SessionKey string `json:"sessionKey"` + SessionToken string `json:"sessionToken"` +} + +type signinToken struct { + SigninToken string +} + +// ConfigureLogin configures the login command with arguments and flags +func ConfigureLogin(app *kingpin.Application, config *GlobalConfig) { + + loginConfig := LoginConfig{} + + cmd := app.Command("login", "Login to the AWS console for a given profile") + + cmd.Arg("profile", "Name of the profile"). + StringVar(&config.Profile) + + cmd.Action(func(c *kingpin.ParseContext) error { + LoginCommand(app, config, &loginConfig) + return nil + }) +} + +// LoginCommand exchanges temporary credentials for an AWS Console signin url +// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html +func LoginCommand(app *kingpin.Application, config *GlobalConfig, loginConfig *LoginConfig) { + + // Retrieve credentials from current session. This will try and get credentials + // using aws-oidc itself if configured in ~/.aws/config. + val, err := config.Session.Config.Credentials.Get() + if err != nil { + app.Fatalf("Unable to get credentials for profile: %s", config.Profile) + } + + credentialData := signinSession{ + SessionID: val.AccessKeyID, + SessionKey: val.SecretAccessKey, + SessionToken: val.SessionToken, + } + credentialJSON, err := json.Marshal(&credentialData) + if err != nil { + app.Fatalf("Unable to marshal credentials for profile: %s", config.Profile) + } + + // Create the federation URL to exchange access keys for a session token + tokenURL, _ := url.Parse("https://signin.aws.amazon.com/federation") + tokenQuery := url.Values{} + tokenQuery.Set("Action", "getSigninToken") + tokenQuery.Set("Session", string(credentialJSON)) + tokenURL.RawQuery = tokenQuery.Encode() + + var client = &http.Client{ + Timeout: time.Second * 60, + } + resp, err := client.Get(tokenURL.String()) + if err != nil { + app.Fatalf("Unable to get signin token for profile: %s", config.Profile) + } else if resp.StatusCode != 200 { + app.Fatalf("GetSigninToken returned %d instead of 200 for profile: %s", resp.StatusCode, config.Profile) + } + defer resp.Body.Close() + + token := signinToken{} + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + app.Fatalf("Unable to decode GetSigninToken response for profile: %s", config.Profile) + } + + // Create the federation URL to exchange the session token for a login URL + loginURL, _ := url.Parse("https://signin.aws.amazon.com/federation") + loginQuery := url.Values{} + loginQuery.Set("Action", "login") + loginQuery.Set("Destination", "https://console.aws.amazon.com/") + loginQuery.Set("SigninToken", token.SigninToken) + loginURL.RawQuery = loginQuery.Encode() + + fmt.Println(loginURL) +} diff --git a/internal/aws-oidc/config_darwin.go b/internal/aws-oidc/config_darwin.go new file mode 100644 index 0000000..a4830e3 --- /dev/null +++ b/internal/aws-oidc/config_darwin.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "os/user" + "path/filepath" +) + +func homeDir() string { + if currentUser, err := user.Current(); err == nil { + return currentUser.HomeDir + } + return "" +} + +func execDir() string { + if currentExecutable, err := os.Executable(); err == nil { + return filepath.Dir(currentExecutable) + } + 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") +} diff --git a/internal/aws-oidc/docs/aws-oidc-console-login.gif b/internal/aws-oidc/docs/aws-oidc-console-login.gif new file mode 100644 index 0000000..2c9d568 Binary files /dev/null and b/internal/aws-oidc/docs/aws-oidc-console-login.gif differ diff --git a/internal/aws-oidc/docs/aws-oidc-lambda.gif b/internal/aws-oidc/docs/aws-oidc-lambda.gif new file mode 100644 index 0000000..2c747f6 Binary files /dev/null and b/internal/aws-oidc/docs/aws-oidc-lambda.gif differ diff --git a/internal/aws-oidc/go.mod b/internal/aws-oidc/go.mod new file mode 100644 index 0000000..989aff9 --- /dev/null +++ b/internal/aws-oidc/go.mod @@ -0,0 +1,30 @@ +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 + github.com/aws/aws-sdk-go v1.19.11 + github.com/coreos/go-oidc v2.0.0+incompatible + github.com/danieljoos/wincred v1.0.1 // indirect + github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a // indirect + github.com/go-ini/ini v1.42.0 // indirect + github.com/godbus/dbus v4.1.0+incompatible // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af + github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect + github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect + github.com/stretchr/testify v1.3.0 // indirect + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 + golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a + gopkg.in/alecthomas/kingpin.v2 v2.2.6 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/ini.v1 v1.42.0 // indirect + gopkg.in/square/go-jose.v2 v2.3.0 // indirect +) diff --git a/internal/aws-oidc/go.sum b/internal/aws-oidc/go.sum new file mode 100644 index 0000000..4b122c0 --- /dev/null +++ b/internal/aws-oidc/go.sum @@ -0,0 +1,84 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/99designs/aws-vault v4.5.1+incompatible h1:VjWncFWraO5K5HTRo34YMq2MkpKYphZy5luMSe76pkg= +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= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aulanov/go.dbus v0.0.0-20150729231527-25c3068a42a0 h1:EEDvbomAQ+MFWqJ9FM6RXyJTkc4lckyWsbc5CGQkG1Y= +github.com/aulanov/go.dbus v0.0.0-20150729231527-25c3068a42a0/go.mod h1:VHvUx+4lTCaJ8zUnEXF4cWEc9c8lnDt4PGLwlZ+3yaM= +github.com/aws/aws-sdk-go v1.19.11 h1:tqaTGER6Byw3QvsjGW0p018U2UOqaJPeJuzoaF7jjoQ= +github.com/aws/aws-sdk-go v1.19.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/coreos/go-oidc v2.0.0+incompatible h1:+RStIopZ8wooMx+Vs5Bt8zMXxV1ABl5LbakNExNmZIg= +github.com/coreos/go-oidc v2.0.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/danieljoos/wincred v1.0.1 h1:fcRTaj17zzROVqni2FiToKUVg3MmJ4NtMSGCySPIr/g= +github.com/danieljoos/wincred v1.0.1/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3EJbmjhLdK9U= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a h1:mq+R6XEM6lJX5VlLyZIrUSP8tSuJp82xTK89hvBwJbU= +github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= +github.com/go-ini/ini v1.42.0 h1:TWr1wGj35+UiWHlBA8er89seFXxzwFn11spilrrj+38= +github.com/go-ini/ini v1.42.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= +github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6 h1:hfM5TYph19rQBp3oOg4SVckf4ZmYrycciBJCWmxOcIE= +github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6/go.mod h1:JJNrCn9otv/2QP4D7SMJBgaleKpOf66PnW6F5WGNRIc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a h1:pa8hGb/2YqsZKovtsgrwcDH1RZhVbTKCjLp47XpqCDs= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a h1:tImsplftrFpALCYumobsd0K86vlAs/eXGFms2txfJfA= +golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk= +gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/square/go-jose.v2 v2.3.0 h1:nLzhkFyl5bkblqYBoiWJUt5JkWOzmiaBtCxdJAqJd3U= +gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/internal/aws-oidc/provider/provider.go b/internal/aws-oidc/provider/provider.go new file mode 100644 index 0000000..8dbff39 --- /dev/null +++ b/internal/aws-oidc/provider/provider.go @@ -0,0 +1,253 @@ +package provider + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "log" + "net" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/coreos/go-oidc" + + "golang.org/x/net/context" + "golang.org/x/oauth2" +) + +type ProviderConfig struct { + ClientID string + ClientSecret string + ProviderURL string + PKCE bool + Nonce bool + AgentCommand []string +} + +type Result struct { + JWT string + Token *oidc.IDToken + Claims *TokenClaims +} + +type TokenClaims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + Subject string `json:"sub"` + Picture string `json:"picture"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Groups []string `json:"groups"` +} + +type OAuth2Token struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` + IDToken string `json:"id_token,omitempty"` +} + +func refresh(config oauth2.Config, t *OAuth2Token) error { + ctx := context.Background() + + tokenSourceToken := oauth2.Token{ + AccessToken: t.AccessToken, + TokenType: t.TokenType, + RefreshToken: t.RefreshToken, + Expiry: t.Expiry, + } + ts := config.TokenSource(ctx, tokenSourceToken.WithExtra(map[string]interface{}{ + "id_token": t.IDToken, + })) + + res, err := ts.Token() + if err != nil { + return err + } + idtoken, ok := res.Extra("id_token").(string) + if !ok { + return errors.New("can't extract id_token") + } + t.AccessToken = res.AccessToken + t.RefreshToken = res.RefreshToken + t.Expiry = res.Expiry + t.TokenType = res.TokenType + t.IDToken = idtoken + + return nil +} + +func (p ProviderConfig) Authenticate(t *OAuth2Token) error { + ctx := context.Background() + resultChannel := make(chan *oauth2.Token) + errorChannel := make(chan error) + Mux := http.NewServeMux() + server := &http.Server{ + Handler: Mux, + } + + provider, err := oidc.NewProvider(ctx, p.ProviderURL) + if err != nil { + return err + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + defer listener.Close() + baseURL := "http://" + listener.Addr().String() + redirectURL := baseURL + "/auth/callback" + + oidcConfig := &oidc.Config{ + ClientID: p.ClientID, + SupportedSigningAlgs: []string{"RS256"}, + } + verifier := provider.Verifier(oidcConfig) + + config := oauth2.Config{ + ClientID: p.ClientID, + ClientSecret: p.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: redirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + if t != nil { + if err := refresh(config, t); err == nil { + return nil + } + log.Println(err) + } + + stateData := make([]byte, 32) + if _, err = rand.Read(stateData); err != nil { + return err + } + state := base64.URLEncoding.EncodeToString(stateData) + + codeData := make([]byte, 32) + if _, err = rand.Read(codeData); err != nil { + return err + } + codeVerifier := base64.StdEncoding.EncodeToString(codeData) + codeDigest := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.URLEncoding.EncodeToString(codeDigest[:]) + codeChallengeEncoded := strings.Replace(codeChallenge, "=", "", -1) + + nonceData := make([]byte, 32) + _, _ = rand.Read(nonceData) + nonce := base64.URLEncoding.EncodeToString(nonceData) + + var authCodeOptions []oauth2.AuthCodeOption + var tokenCodeOptions []oauth2.AuthCodeOption + + if p.PKCE { + authCodeOptions = append(authCodeOptions, + oauth2.SetAuthURLParam("code_challenge", codeChallengeEncoded), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + tokenCodeOptions = append(tokenCodeOptions, + oauth2.SetAuthURLParam("code_verifier", codeVerifier), + ) + } + + if p.Nonce { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("nonce", nonce)) + } + + Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + url := config.AuthCodeURL(state, authCodeOptions...) + http.Redirect(w, r, url, http.StatusFound) + }) + + Mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + http.Error(w, "state did not match", http.StatusBadRequest) + errorChannel <- errors.New("state did not match") + return + } + + oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"), tokenCodeOptions...) + if err != nil { + http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) + errorChannel <- errors.New("failed to exchange token: " + err.Error()) + return + } + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError) + errorChannel <- errors.New("no id_token field in oauth2 token") + return + } + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError) + errorChannel <- errors.New("failed to verify ID Token: " + err.Error()) + return + } + if p.Nonce && idToken.Nonce != nonce { + http.Error(w, "Failed to verify Nonce", http.StatusInternalServerError) + errorChannel <- errors.New("failed to verify Nonce") + return + } + + var claims = new(TokenClaims) + if err := idToken.Claims(&claims); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + errorChannel <- errors.New("failed to verify Claims: " + err.Error()) + return + } + w.Write([]byte("Signed in successfully, return to cli app")) + resultChannel <- oauth2Token + }) + + // Filter the commands, and replace "{}" with our callback url + c := make([]string, 0, len(p.AgentCommand)) + 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) + } + + //TODO Drop privileges + cmd := exec.Command(c[0], c[1:]...) + cmd.Start() + cmd.Process.Release() + + go func() { + server.Serve(listener) + }() + + select { + case err := <-errorChannel: + server.Shutdown(ctx) + return err + case res := <-resultChannel: + server.Shutdown(ctx) + IDToken, ok := res.Extra("id_token").(string) + if !ok { + return errors.New("can't extract id_token") + } + t.AccessToken = res.AccessToken + t.RefreshToken = res.RefreshToken + t.Expiry = res.Expiry + t.TokenType = res.TokenType + t.IDToken = IDToken + return nil + case <-time.After(2 * time.Minute): + server.Shutdown(ctx) + return errors.New("no oauth2 flow callback received within last 2 minutes, exiting") + } +}