commit 653521ed9fef17e6b7e53c6b2eb56e76f9844aa8 Author: Jeremy Stott Date: Thu Apr 11 00:08:13 2019 +1200 aws-oidc working with google and onelogin openid connect. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d13c6d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +aws-oidc diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd2540a --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# aws-oidc + +Assume roles in AWS using an OpenID Connect Identity provider. + +It outputs temporary AWS credentials in a JSON format that can be consumed by the credentials_process setting in ~/.aws/config. + +https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes + +OneLogin 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 {} + +Google Example: + + 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 {} + +For some reason, even when using PKCE, google need a client_secret for applications not registered as Android, iOS or Chrome. + +## Configure AWS Config + +~/.aws/config + + [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 {} + +Now you can use the AWS cli as normal, and specify the profile: + + $ aws --profile oidc sts get-caller-identity + { + "UserId": "AROAJUTXNWXGCAEILMXTY:50904038", + "Account": "892845094662", + "Arn": "arn:aws:sts::892845094662:assumed-role/onelogin-test-oidc/50904038" + } diff --git a/aws-oidc.go b/aws-oidc.go new file mode 100644 index 0000000..9ae4184 --- /dev/null +++ b/aws-oidc.go @@ -0,0 +1,46 @@ +package main + +import ( + "log" + "os" + + "github.com/stoggi/aws-oidc/cli" + "gopkg.in/alecthomas/kingpin.v2" +) + +// Version is provided at compile time +var Version = "dev" +var labelText chan string + +func main() { + run(os.Args[1:], os.Exit) +} + +func run(args []string, exit func(int)) { + + f, err := os.OpenFile(GetLogPath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("error opening file: %v", err) + } + defer f.Close() + + log.SetOutput(f) + log.Println("Starting...") + + // Default configuration, values are overridden by command line options. + config := cli.GlobalConfig{} + + app := kingpin.New( + "aws-oidc", + "Assume roles in AWS using an OIDC identity provider", + ) + + app.Writer(os.Stdout) + app.Version(Version) + app.Terminate(exit) + + cli.ConfigureGlobal(app, &config) + cli.ConfigureExec(app, &config) + + kingpin.MustParse(app.Parse(args)) +} diff --git a/cli/exec.go b/cli/exec.go new file mode 100644 index 0000000..905bdf2 --- /dev/null +++ b/cli/exec.go @@ -0,0 +1,126 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "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" + + kingpin "gopkg.in/alecthomas/kingpin.v2" +) + +type ExecConfig struct { + RoleArn string + Duration int64 + ProviderURL string + ClientID string + ClientSecret string + PKCE bool + Nonce bool + ReAuth bool + AgentCommant []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"` +} + +func ConfigureExec(app *kingpin.Application, config *GlobalConfig) { + + execConfig := ExecConfig{} + + cmd := app.Command("exec", "Execute a command with temporary AWS credentials") + + cmd.Default() + + cmd.Flag("role_arn", "The AWS role you want to assume"). + Required(). + StringVar(&execConfig.RoleArn) + + 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.AgentCommant) + + cmd.Action(func(c *kingpin.ParseContext) error { + ExecCommand(app, config, &execConfig) + return nil + }) +} + +func ExecCommand(app *kingpin.Application, config *GlobalConfig, execConfig *ExecConfig) { + + providerConfig := &provider.ProviderConfig{ + ClientID: execConfig.ClientID, + ClientSecret: execConfig.ClientSecret, + ProviderURL: execConfig.ProviderURL, + PKCE: execConfig.PKCE, + Nonce: execConfig.Nonce, + ReAuth: execConfig.ReAuth, + AgentCommand: execConfig.AgentCommant, + } + + authResult, err := provider.Authenticate(providerConfig) + app.FatalIfError(err, "Error authenticating to identity provider: %v", err) + + svc := sts.New(session.New()) + input := &sts.AssumeRoleWithWebIdentityInput{ + DurationSeconds: aws.Int64(execConfig.Duration), + RoleArn: aws.String(execConfig.RoleArn), + RoleSessionName: aws.String(authResult.Token.Subject), + WebIdentityToken: aws.String(authResult.JWT), + } + + assumeRoleResult, err := svc.AssumeRoleWithWebIdentity(input) + app.FatalIfError(err, "Unable to assume role: %v", 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 { + app.Fatalf("Error creating credential json") + } + fmt.Printf(string(json)) +} diff --git a/cli/global.go b/cli/global.go new file mode 100644 index 0000000..c64eb3d --- /dev/null +++ b/cli/global.go @@ -0,0 +1,41 @@ +package cli + +import ( + "github.com/99designs/aws-vault/vault" + "github.com/99designs/keyring" + 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 + + AwsConfig *vault.Config + 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"). + StringVar(&config.Region) + + app.PreAction(func(c *kingpin.ParseContext) (err error) { + + // Attempt to open the aws-vault keychain + keychain, err := keyring.Open(keyring.Config{ + KeychainName: "aws-vault", + ServiceName: "aws-vault", + AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, + }) + kingpin.FatalIfError(err, "Could not open aws-vault keychain") + + //config.AwsConfig = awsConfig + config.Keyring = &keychain + + return nil + }) + +} diff --git a/config_darwin.go b/config_darwin.go new file mode 100644 index 0000000..13a55c7 --- /dev/null +++ b/config_darwin.go @@ -0,0 +1,26 @@ +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 "" +} + +// 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/go.mod b/go.mod new file mode 100644 index 0000000..4562c3b --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +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/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect + github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect + github.com/aws/aws-sdk-go v1.19.11 + github.com/coreos/go-oidc v2.0.0+incompatible + github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a // indirect + github.com/go-ini/ini v1.42.0 // indirect + github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // 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/square/go-jose.v2 v2.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c5b1303 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +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/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/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/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/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +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/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/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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= +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-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/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/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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/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/provider/provider.go b/provider/provider.go new file mode 100644 index 0000000..dbb84dd --- /dev/null +++ b/provider/provider.go @@ -0,0 +1,185 @@ +package provider + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "net" + "net/http" + "os/exec" + "strings" + + oidc "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 + ReAuth bool + AgentCommand []string +} + +type Result struct { + JWT string + Token *oidc.IDToken +} + +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"` +} + +func Authenticate(p *ProviderConfig) (Result, error) { + ctx := context.Background() + resultChannel := make(chan Result, 0) + errorChannel := make(chan error, 0) + + provider, err := oidc.NewProvider(ctx, p.ProviderURL) + if err != nil { + return Result{"", nil}, err + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return Result{"", nil}, err + } + 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"}, + } + + stateData := make([]byte, 32) + if _, err = rand.Read(stateData); err != nil { + return Result{"", nil}, err + } + state := base64.URLEncoding.EncodeToString(stateData) + + codeData := make([]byte, 32) + if _, err = rand.Read(codeData); err != nil { + return Result{"", nil}, 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) + _, err = rand.Read(nonceData) + nonce := base64.URLEncoding.EncodeToString(nonceData) + + authCodeOptions := []oauth2.AuthCodeOption{} + 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)) + } + + 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) + }) + + http.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 <- Result{rawIDToken, idToken} + }) + + // Filter the commands, and replace "{}" with our callback url + c := p.AgentCommand[:0] + for _, arg := range p.AgentCommand { + if arg == "{}" { + c = append(c, baseURL) + } else { + c = append(c, arg) + } + } + cmd := exec.Command(c[0], c[1:]...) + cmd.Run() + + server := &http.Server{} + go func() { + server.Serve(listener) + }() + + select { + case err := <-errorChannel: + server.Shutdown(ctx) + return Result{}, err + case res := <-resultChannel: + server.Shutdown(ctx) + return res, nil + } +}