From 2e2278f2484dcb6f3185101fe5add8adae690187 Mon Sep 17 00:00:00 2001 From: Dan <2939173+trinitroglycerin@users.noreply.github.com> Date: Thu, 5 Oct 2023 23:17:51 -0700 Subject: [PATCH 01/24] Provide an error if --oidc-domain is invalid --- cli/login.go | 6 ++++++ cli/oauth2.go | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cli/login.go b/cli/login.go index 92c9683a..516b2c1f 100644 --- a/cli/login.go +++ b/cli/login.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" @@ -57,6 +58,11 @@ var loginCmd = &cobra.Command{ token, err := Login(cmd.Context(), oidcDomain, clientID, outputMode) if err != nil { + if errors.Is(err, ErrInvalidDomain) { + cmd.PrintErrf("The provided domain %q is not a valid domain. Please correct the domain and try again by passing it to --%s.\n", oidcDomain, FlagOIDCDomain) + return nil + } + return err } diff --git a/cli/oauth2.go b/cli/oauth2.go index dee77baa..245328d7 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -19,6 +19,8 @@ import ( "golang.org/x/oauth2" ) +var ErrInvalidDomain = errors.New("invalid domain") + // stateBufSize is the size of the buffer used to generate the state parameter. // 43 is a magic number - It generates states that are not too short or long for Okta's validation. const stateBufSize = 43 @@ -38,7 +40,12 @@ func NewHTTPClient() *http.Client { } func DiscoverOAuth2Config(ctx context.Context, domain, clientID string) (*oauth2.Config, error) { - provider, err := oidc.NewProvider(ctx, domain) + uri, err := url.Parse(domain) + if domain == "" || err != nil { + return nil, ErrInvalidDomain + } + + provider, err := oidc.NewProvider(ctx, uri.String()) if err != nil { return nil, fmt.Errorf("couldn't discover OIDC configuration for %s: %w", domain, err) } From 8d50e1fd43abd90acdb2147ab775d438d7fdc795 Mon Sep 17 00:00:00 2001 From: Dan <2939173+trinitroglycerin@users.noreply.github.com> Date: Fri, 22 Sep 2023 18:00:47 -0700 Subject: [PATCH 02/24] Alter Lambda function to return type of application --- internal/api/serverless_functions.go | 42 +++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/internal/api/serverless_functions.go b/internal/api/serverless_functions.go index a999c790..813ac8b2 100644 --- a/internal/api/serverless_functions.go +++ b/internal/api/serverless_functions.go @@ -10,9 +10,18 @@ import ( "golang.org/x/exp/slog" ) +type ApplicationType string + +var ( + ApplicationTypeOIDC ApplicationType = "oidc" + ApplicationTypeSAML ApplicationType = "saml" +) + type Application struct { - ID string `json:"@id"` - Name string `json:"name"` + ID string `json:"@id"` + Name string `json:"name"` + Type ApplicationType `json:"type"` + Href string `json:"href"` } type OktaService interface { @@ -57,12 +66,19 @@ func ServeUserApplications(okta OktaService) http.Handler { var accounts []Application for _, app := range applications { - if app.AppName == "amazon_aws" || strings.Contains(app.AppName, "tencent") { - accounts = append(accounts, Application{ - ID: app.AppInstanceId, - Name: app.Label, - }) + typ, ok := deriveApplicationType(app) + if !ok { + continue + } + + app := Application{ + ID: app.AppInstanceId, + Name: app.Label, + Type: typ, + Href: app.LinkUrl, } + + accounts = append(accounts, app) } requestAttrs = append(requestAttrs, slog.Int("application_count", len(accounts))) @@ -70,3 +86,15 @@ func ServeUserApplications(okta OktaService) http.Handler { ServeJSON(w, accounts) }) } + +func deriveApplicationType(app *okta.AppLink) (ApplicationType, bool) { + if app.AppName == "amazon_aws" { + return ApplicationTypeOIDC, true + } + + if strings.Contains(app.AppName, "tencent") { + return ApplicationTypeSAML, true + } + + return "", false +} From feb4d46f895c523483f854444220a4c80a1f52c8 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 09:33:35 -0700 Subject: [PATCH 03/24] Make the search for Tencent applications case-insensitive --- internal/api/serverless_functions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/serverless_functions.go b/internal/api/serverless_functions.go index 813ac8b2..3b71d6e7 100644 --- a/internal/api/serverless_functions.go +++ b/internal/api/serverless_functions.go @@ -92,7 +92,7 @@ func deriveApplicationType(app *okta.AppLink) (ApplicationType, bool) { return ApplicationTypeOIDC, true } - if strings.Contains(app.AppName, "tencent") { + if strings.Contains(strings.ToLower(app.AppName), "tencent") { return ApplicationTypeSAML, true } From d718e3a0f50fbbf701a56f36a1f2d03f4fa6b727 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 10:52:51 -0700 Subject: [PATCH 04/24] Add a test API server --- cmd/testserver/main.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 cmd/testserver/main.go diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go new file mode 100644 index 00000000..a9db6da3 --- /dev/null +++ b/cmd/testserver/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + "net/http" + "net/url" + "os" + + "github.com/riotgames/key-conjurer/internal/api" +) + +func main() { + url, err := url.Parse(os.Getenv("OIDC_DOMAIN")) + if err != nil { + log.Fatalf("failed to parse OIDC_DOMAIN as URL: %s", url) + } + + okta := api.NewOktaService(url, os.Getenv("OKTA_TOKEN")) + http.Handle("/v2/applications", api.ServeUserApplications(okta)) + http.ListenAndServe(":8080", nil) +} From e2ffc940d44cddb0de278c435e1e6906c74a3271 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 11:27:08 -0700 Subject: [PATCH 05/24] Return well-defined errors from token exchange --- cli/oauth2.go | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/cli/oauth2.go b/cli/oauth2.go index 245328d7..56579fd8 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -19,7 +19,24 @@ import ( "golang.org/x/oauth2" ) -var ErrInvalidDomain = errors.New("invalid domain") +var ( + ErrInvalidDomain = errors.New("invalid domain") + // ErrTokenExchangeNotSupported indicates that token exchange is not supported for the given application. + // + // This most commonly occurs when attempting to use token exchange on non-AWS applications with Okta. + // Okta currently (2023-09-25) only supports the web sso grant type for AWS applications. + ErrTokenExchangeNotSupported = errors.New("token exchange not supported") +) + +// ErrOktaErrorResponse is returned when Okta returns a non-200 response that is not covered by other well-defined errors. +type ErrOktaErrorResponse struct { + StatusCode int + Response *http.Response +} + +func (e ErrOktaErrorResponse) Error() string { + return fmt.Sprintf("bad response code: %d", e.StatusCode) +} // stateBufSize is the size of the buffer used to generate the state parameter. // 43 is a magic number - It generates states that are not too short or long for Okta's validation. @@ -212,10 +229,21 @@ func ExchangeAccessTokenForWebSSOToken(ctx context.Context, client *http.Client, return nil, err } - var tok oauth2.Token - return &tok, json.NewDecoder(resp.Body).Decode(&tok) + switch resp.StatusCode { + case http.StatusOK: + var tok oauth2.Token + return &tok, json.NewDecoder(resp.Body).Decode(&tok) + case http.StatusBadRequest: + // Unsupported application - This application probably hasn't been configured to support token exchange. + // In other words, it's probably not an AWS application. + return nil, ErrTokenExchangeNotSupported + default: + return nil, ErrOktaErrorResponse{resp.StatusCode, resp} + } } +var () + // TODO: This is actually an Okta-specific API func ExchangeWebSSOTokenForSAMLAssertion(ctx context.Context, client *http.Client, issuer string, token *oauth2.Token) ([]byte, error) { if client == nil { From a6cf69eb67a772b54ffc3cf4f830c894359d8fe9 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 12:10:40 -0700 Subject: [PATCH 06/24] use cloudType to infer the type of application --- cli/get.go | 46 +++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/cli/get.go b/cli/get.go index c59231a4..dc0cc4c6 100644 --- a/cli/get.go +++ b/cli/get.go @@ -58,6 +58,7 @@ func resolveApplicationInfo(cfg *Config, bypassCache bool, nameOrID string) (*Ac if bypassCache { return &Account{ID: nameOrID, Name: nameOrID}, true } + return cfg.FindAccount(nameOrID) } @@ -113,6 +114,7 @@ A role must be specified when using this command through the --role flag. You ma cmd.PrintErrln("You must specify the --role flag with this command") return nil } + roleName = account.MostRecentRole } @@ -131,25 +133,34 @@ A role must be specified when using this command through the --role flag. You ma return echoCredentials(args[0], args[0], credentials, outputType, shellType, awsCliPath, tencentCliPath) } - oauthCfg, err := DiscoverOAuth2Config(cmd.Context(), oidcDomain, clientID) - if err != nil { - cmd.PrintErrf("could not discover oauth2 config: %s\n", err) - return nil - } + var assertionBytes []byte + switch cloudType { + case cloudAws: + oauthCfg, err := DiscoverOAuth2Config(cmd.Context(), oidcDomain, clientID) + if err != nil { + cmd.PrintErrf("could not discover oauth2 config: %s\n", err) + return nil + } - tok, err := ExchangeAccessTokenForWebSSOToken(cmd.Context(), client, oauthCfg, config.Tokens, account.ID) - if err != nil { - cmd.PrintErrf("error exchanging token: %s\n", err) - return nil - } + tok, err := ExchangeAccessTokenForWebSSOToken(cmd.Context(), client, oauthCfg, config.Tokens, account.ID) + if err != nil { + cmd.PrintErrf("error exchanging token: %s\n", err) + return nil + } - assertion, err := ExchangeWebSSOTokenForSAMLAssertion(cmd.Context(), client, oidcDomain, tok) - if err != nil { - cmd.PrintErrf("failed to fetch SAML assertion: %s\n", err) - return nil + assertionBytes, err = ExchangeWebSSOTokenForSAMLAssertion(cmd.Context(), client, oidcDomain, tok) + if err != nil { + cmd.PrintErrf("failed to fetch SAML assertion: %s\n", err) + return nil + } + case cloudTencent: + // Tencent applications aren't supported by the Okta API, so we can't use the same flow as AWS. + // Instead, we can construct a URL to initiate logging into the application that has been pre-configured to support KeyConjurer. + // This URL will redirect back to a web server known ahead of time with a SAML assertion which we can then exchange for credentials. + panic("not yet implemented") } - assertionStr := string(assertion) + assertionStr := string(assertionBytes) samlResponse, err := ParseBase64EncodedSAMLResponse(assertionStr) if err != nil { cmd.PrintErrf("could not parse assertion: %s\n", err) @@ -166,7 +177,8 @@ A role must be specified when using this command through the --role flag. You ma ttl = config.TTL } - if cloudType == cloudAws { + switch cloudType { + case cloudAws: region, _ := cmd.Flags().GetString(FlagRegion) session, _ := session.NewSession(&aws.Config{Region: aws.String(region)}) stsClient := sts.New(session) @@ -190,7 +202,7 @@ A role must be specified when using this command through the --role flag. You ma SessionToken: *resp.Credentials.SessionToken, credentialsType: cloudType, } - } else { + default: panic("not yet implemented") } From ad88026aa3f645f8a7dd8e9946f8c1450b14cd60 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 12:16:37 -0700 Subject: [PATCH 07/24] only print an error if an error occurred --- internal/api/json.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/api/json.go b/internal/api/json.go index aedcfff7..dcc48f66 100644 --- a/internal/api/json.go +++ b/internal/api/json.go @@ -18,7 +18,9 @@ func ServeJSON[T any](w http.ResponseWriter, data T) { // Nothing we can do to respond to the error message here either w.Header().Set("Content-Type", "application/json") _, err = w.Write(buf) - slog.Error("could not write JSON to the client: %s", err) + if err != nil { + slog.Error("could not write JSON to the client: %s", err) + } } type JSONError struct { From 29fc4ba2e41b47191d19d9a75b099ad854c6a94b Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 12:27:45 -0700 Subject: [PATCH 08/24] Return the cloud type and add a branch for Tencent --- cli/accounts.go | 2 ++ cli/config.go | 14 +++++++++----- cli/get.go | 26 ++++++++++++++++++++++++-- cli/oauth2.go | 3 --- internal/api/serverless_functions.go | 8 ++++---- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/cli/accounts.go b/cli/accounts.go index 1b25610d..51a61509 100644 --- a/cli/accounts.go +++ b/cli/accounts.go @@ -112,6 +112,8 @@ func refreshAccounts(ctx context.Context, serverAddr *url.URL, tok *oauth2.Token ID: app.ID, Name: app.Name, Alias: generateDefaultAlias(app.Name), + Href: app.Href, + Type: app.Type, } } diff --git a/cli/config.go b/cli/config.go index 57fcc5a3..9233c251 100644 --- a/cli/config.go +++ b/cli/config.go @@ -12,6 +12,7 @@ import ( "strings" + "github.com/riotgames/key-conjurer/internal/api" "golang.org/x/oauth2" ) @@ -24,10 +25,12 @@ type TokenSet struct { } type Account struct { - ID string `json:"id"` - Name string `json:"name"` - Alias string `json:"alias"` - MostRecentRole string `json:"most_recent_role"` + ID string `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + MostRecentRole string `json:"most_recent_role"` + Href string `json:"href"` + Type api.ApplicationType `json:"type"` } func (a *Account) NormalizeName() string { @@ -164,8 +167,9 @@ func (a *accountSet) ReplaceWith(other []Account) { clone := acc // Preserve the alias if the account ID is the same and it already exists if entry, ok := a.accounts[acc.ID]; ok { - // The name is the only thing that might change. entry.Name = acc.Name + entry.Href = acc.Href + entry.Type = acc.Type } else { a.accounts[acc.ID] = &clone } diff --git a/cli/get.go b/cli/get.go index dc0cc4c6..81854cf6 100644 --- a/cli/get.go +++ b/cli/get.go @@ -8,6 +8,7 @@ import ( "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/riotgames/key-conjurer/internal/api" "github.com/spf13/cobra" ) @@ -40,7 +41,6 @@ func init() { getCmd.Flags().String(FlagShellType, shellTypeInfer, "If output type is env, determines which format to output credentials in - by default, the format is inferred based on the execution environment. WSL users may wish to overwrite this to `bash`") getCmd.Flags().String(FlagAWSCLIPath, "~/.aws/", "Path for directory used by the aws-cli tool. Default is \"~/.aws\".") getCmd.Flags().String(FlagTencentCLIPath, "~/.tencent/", "Path for directory used by the tencent-cli tool. Default is \"~/.tencent\".") - getCmd.Flags().String(FlagCloudType, "aws", "Choose a cloud vendor. Default is aws. Can choose aws or tencent") getCmd.Flags().Bool(FlagBypassCache, false, "Do not check the cache for accounts and send the application ID as-is to Okta. This is useful if you have an ID you know is an Okta application ID and it is not stored in your local account cache.") } @@ -83,7 +83,6 @@ A role must be specified when using this command through the --role flag. You ma outputType, _ := cmd.Flags().GetString(FlagOutputType) shellType, _ := cmd.Flags().GetString(FlagShellType) roleName, _ := cmd.Flags().GetString(FlagRoleName) - cloudType, _ := cmd.Flags().GetString(FlagCloudType) oidcDomain, _ := cmd.Flags().GetString(FlagOIDCDomain) clientID, _ := cmd.Flags().GetString(FlagClientID) awsCliPath, _ := cmd.Flags().GetString(FlagAWSCLIPath) @@ -109,6 +108,22 @@ A role must be specified when using this command through the --role flag. You ma return nil } + cloudType := cloudAws + if account.Type == api.ApplicationTypeTencent { + if account.Href == "" { + cmd.PrintErrf( + "The application %q is a Tencent application, but it does not have a URL configured. Please run %s again. If this error persists, confirm that %q is a Tencent application", + account.Name, + "keyconjurer login", + account.Name, + ) + + return nil + } + + cloudType = cloudTencent + } + if roleName == "" { if account.MostRecentRole == "" { cmd.PrintErrln("You must specify the --role flag with this command") @@ -157,6 +172,13 @@ A role must be specified when using this command through the --role flag. You ma // Tencent applications aren't supported by the Okta API, so we can't use the same flow as AWS. // Instead, we can construct a URL to initiate logging into the application that has been pre-configured to support KeyConjurer. // This URL will redirect back to a web server known ahead of time with a SAML assertion which we can then exchange for credentials. + // + // Because we need to know the URL of the application, --bypass-cache can't be used. + if bypassCache { + cmd.PrintErrf("cannot use --%s with Tencent applications\n", FlagBypassCache) + return nil + } + panic("not yet implemented") } diff --git a/cli/oauth2.go b/cli/oauth2.go index 56579fd8..8de886d7 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -242,9 +242,6 @@ func ExchangeAccessTokenForWebSSOToken(ctx context.Context, client *http.Client, } } -var () - -// TODO: This is actually an Okta-specific API func ExchangeWebSSOTokenForSAMLAssertion(ctx context.Context, client *http.Client, issuer string, token *oauth2.Token) ([]byte, error) { if client == nil { client = http.DefaultClient diff --git a/internal/api/serverless_functions.go b/internal/api/serverless_functions.go index 3b71d6e7..ace08b2d 100644 --- a/internal/api/serverless_functions.go +++ b/internal/api/serverless_functions.go @@ -13,8 +13,8 @@ import ( type ApplicationType string var ( - ApplicationTypeOIDC ApplicationType = "oidc" - ApplicationTypeSAML ApplicationType = "saml" + ApplicationTypeAWS ApplicationType = "aws" + ApplicationTypeTencent ApplicationType = "tencent" ) type Application struct { @@ -89,11 +89,11 @@ func ServeUserApplications(okta OktaService) http.Handler { func deriveApplicationType(app *okta.AppLink) (ApplicationType, bool) { if app.AppName == "amazon_aws" { - return ApplicationTypeOIDC, true + return ApplicationTypeAWS, true } if strings.Contains(strings.ToLower(app.AppName), "tencent") { - return ApplicationTypeSAML, true + return ApplicationTypeTencent, true } return "", false From 5ecf6d17e8ce18b372df3d90788d64c75a5d6d47 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Mon, 25 Sep 2023 12:43:05 -0700 Subject: [PATCH 09/24] Spin up a web browser for SAML --- cli/get.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cli/get.go b/cli/get.go index 81854cf6..f3f20b07 100644 --- a/cli/get.go +++ b/cli/get.go @@ -1,8 +1,10 @@ package main import ( + "context" "fmt" "os" + "os/exec" "time" "github.com/aws/aws-sdk-go/aws" @@ -179,7 +181,17 @@ A role must be specified when using this command through the --role flag. You ma return nil } + // TODO: Spin up a web server that listens for a SAML callback. + + // TODO: This only works for OSX. + proc := exec.CommandContext(context.Background(), "open", account.Href) + if err := proc.Run(); err != nil { + cmd.PrintErrf("failed to open %s: %s", account.Href, err) + return nil + } + panic("not yet implemented") + return nil } assertionStr := string(assertionBytes) From 91b3493692333be4f698f28b9b1bcd6c210a71d0 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Wed, 27 Sep 2023 11:04:39 -0700 Subject: [PATCH 10/24] Add SAML Listener skeleton --- cli/get.go | 25 +++++++++++++++++++++---- cli/saml.go | 7 +++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cli/get.go b/cli/get.go index f3f20b07..0b3142b3 100644 --- a/cli/get.go +++ b/cli/get.go @@ -2,7 +2,10 @@ package main import ( "context" + "errors" "fmt" + "log" + "net/http" "os" "os/exec" "time" @@ -181,11 +184,19 @@ A role must be specified when using this command through the --role flag. You ma return nil } - // TODO: Spin up a web server that listens for a SAML callback. + // TODO: We should only open the browser if we're able to start the server, + // and this branch should only terminate once the server is closed. + server := http.Server{ + Addr: "127.0.0.1:57468", + Handler: SAMLCallbackHandler{}, + } + + err := server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Panicln(err) + } - // TODO: This only works for OSX. - proc := exec.CommandContext(context.Background(), "open", account.Href) - if err := proc.Run(); err != nil { + if err = OpenBrowser(account.Href); err != nil { cmd.PrintErrf("failed to open %s: %s", account.Href, err) return nil } @@ -264,3 +275,9 @@ func echoCredentials(id, name string, credentials CloudCredentials, outputType, return fmt.Errorf("%s is an invalid output type", outputType) } } + +func OpenBrowser(url string) error { + // TODO: This only works for OSX. + proc := exec.CommandContext(context.Background(), "open", url) + return proc.Run() +} diff --git a/cli/saml.go b/cli/saml.go index 575e980e..c9f4e11a 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -1,6 +1,7 @@ package main import ( + "net/http" "strings" "github.com/RobotsAndPencils/go-saml" @@ -100,3 +101,9 @@ func getARN(value string) RoleProviderPair { func ParseBase64EncodedSAMLResponse(xml string) (*saml.Response, error) { return saml.ParseEncodedResponse(xml) } + +type SAMLCallbackHandler struct{} + +func (SAMLCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // TODO: Implement +} From fadfc2a07427da940a618f685f55b6d87a43fcf7 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 13:02:16 -0700 Subject: [PATCH 11/24] Open server in a Goroutine --- cli/get.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/cli/get.go b/cli/get.go index 0b3142b3..01045ee6 100644 --- a/cli/get.go +++ b/cli/get.go @@ -191,16 +191,22 @@ A role must be specified when using this command through the --role flag. You ma Handler: SAMLCallbackHandler{}, } - err := server.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Panicln(err) - } - - if err = OpenBrowser(account.Href); err != nil { + done := make(chan struct{}) + go func() { + err := server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Panicln(err) + } + done <- struct{}{} + }() + + if err := OpenBrowser(account.Href); err != nil { cmd.PrintErrf("failed to open %s: %s", account.Href, err) return nil } + <-done + panic("not yet implemented") return nil } From 76699008b802f40a09b822b353a19fdabdf011e0 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 13:05:00 -0700 Subject: [PATCH 12/24] Get the Redirect URL from the Oauth configuration --- cli/oauth2.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/oauth2.go b/cli/oauth2.go index 8de886d7..60d59a4f 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -68,9 +68,10 @@ func DiscoverOAuth2Config(ctx context.Context, domain, clientID string) (*oauth2 } cfg := oauth2.Config{ - ClientID: clientID, - Endpoint: provider.Endpoint(), - Scopes: []string{"openid", "profile", "okta.apps.read", "okta.apps.sso"}, + ClientID: clientID, + Endpoint: provider.Endpoint(), + Scopes: []string{"openid", "profile", "okta.apps.read", "okta.apps.sso"}, + RedirectURL: "http://localhost:57468", } return &cfg, nil @@ -89,10 +90,16 @@ type OAuth2Listener struct { callbackCh chan OAuth2CallbackInfo } -func NewOAuth2Listener() OAuth2Listener { +func NewOAuth2Listener(bindAddr string) OAuth2Listener { + // If the user gave us a fully formed URL, strip the scheme off + if bindAddr[:4] == "http" { + slashIdx := strings.LastIndexByte(bindAddr, '/') + bindAddr = bindAddr[slashIdx+1:] + } + return OAuth2Listener{ // 5RIOT on a phone pad - Addr: ":57468", + Addr: bindAddr, errCh: make(chan error), callbackCh: make(chan OAuth2CallbackInfo), } @@ -177,11 +184,9 @@ func GenerateState() (string, error) { rand.Read(stateBuf) return base64.URLEncoding.EncodeToString([]byte(stateBuf)), nil } - func RedirectionFlow(ctx context.Context, oauthCfg *oauth2.Config, state, codeChallenge, codeVerifier string, outputMode LoginOutputMode) (*oauth2.Token, error) { - listener := NewOAuth2Listener() + listener := NewOAuth2Listener(oauthCfg.RedirectURL) go listener.Listen(ctx) - oauthCfg.RedirectURL = "http://localhost:57468" url := oauthCfg.AuthCodeURL(state, oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("code_challenge", codeChallenge), From afa4206c4361bf5a8a34f0565796b17105bf645b Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 13:28:43 -0700 Subject: [PATCH 13/24] Pass SAML assertion to the channel --- cli/get.go | 16 +++++++++------- cli/saml.go | 25 ++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/cli/get.go b/cli/get.go index 01045ee6..787767e5 100644 --- a/cli/get.go +++ b/cli/get.go @@ -186,29 +186,31 @@ A role must be specified when using this command through the --role flag. You ma // TODO: We should only open the browser if we're able to start the server, // and this branch should only terminate once the server is closed. + handler := SAMLCallbackHandler{ + AssertionChannel: make(chan []byte, 1), + } + server := http.Server{ Addr: "127.0.0.1:57468", - Handler: SAMLCallbackHandler{}, + Handler: &handler, } - done := make(chan struct{}) go func() { err := server.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { + // TODO: Don't panic - instead, pass error to caller log.Panicln(err) } - done <- struct{}{} }() + // TODO: We should probably wait a second before opening the browser to see if the http server is going to throw an error + // If it throws an error, we should bail if err := OpenBrowser(account.Href); err != nil { cmd.PrintErrf("failed to open %s: %s", account.Href, err) return nil } - <-done - - panic("not yet implemented") - return nil + assertionBytes = <-handler.AssertionChannel } assertionStr := string(assertionBytes) diff --git a/cli/saml.go b/cli/saml.go index c9f4e11a..68aa49f2 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -1,7 +1,10 @@ package main import ( + "io" + "log" "net/http" + "net/url" "strings" "github.com/RobotsAndPencils/go-saml" @@ -102,8 +105,24 @@ func ParseBase64EncodedSAMLResponse(xml string) (*saml.Response, error) { return saml.ParseEncodedResponse(xml) } -type SAMLCallbackHandler struct{} +type SAMLCallbackHandler struct { + AssertionChannel chan []byte +} + +func (h SAMLCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // TODO: Handle panics gracefully + assertionBytes, err := io.ReadAll(r.Body) + if err != nil { + log.Fatalf("Failed to read request body: %s", err) + } + + // A correctly formed body will be a url-encoded param response, with the SAMLResponse being base64 encoded. + v, err := url.ParseQuery(string(assertionBytes)) + if err != nil { + log.Fatalf("Incorrectly formatted request body: %s", err) + } + r.Body.Close() -func (SAMLCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // TODO: Implement + // TODO: This can panic if the body doesn't contain SAMLResponse + h.AssertionChannel <- []byte(v["SAMLResponse"][0]) } From c40912e83e1c8db23bd6c03625ffe1bd2a545b4b Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 13:56:57 -0700 Subject: [PATCH 14/24] Add SAMLProvider interface --- cli/get.go | 125 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 45 deletions(-) diff --git a/cli/get.go b/cli/get.go index 787767e5..e94e3532 100644 --- a/cli/get.go +++ b/cli/get.go @@ -153,27 +153,16 @@ A role must be specified when using this command through the --role flag. You ma return echoCredentials(args[0], args[0], credentials, outputType, shellType, awsCliPath, tencentCliPath) } - var assertionBytes []byte - switch cloudType { - case cloudAws: - oauthCfg, err := DiscoverOAuth2Config(cmd.Context(), oidcDomain, clientID) - if err != nil { - cmd.PrintErrf("could not discover oauth2 config: %s\n", err) - return nil - } - - tok, err := ExchangeAccessTokenForWebSSOToken(cmd.Context(), client, oauthCfg, config.Tokens, account.ID) - if err != nil { - cmd.PrintErrf("error exchanging token: %s\n", err) - return nil - } - - assertionBytes, err = ExchangeWebSSOTokenForSAMLAssertion(cmd.Context(), client, oidcDomain, tok) - if err != nil { - cmd.PrintErrf("failed to fetch SAML assertion: %s\n", err) - return nil + var provider SAMLAssertionProvider + if cloudType == cloudAws { + provider = OktaAWSSAMLProvider{ + OIDCDomain: oidcDomain, + ClientID: clientID, + Tokens: config.Tokens, + AccountID: account.ID, + Client: client, } - case cloudTencent: + } else if cloudType == cloudTencent { // Tencent applications aren't supported by the Okta API, so we can't use the same flow as AWS. // Instead, we can construct a URL to initiate logging into the application that has been pre-configured to support KeyConjurer. // This URL will redirect back to a web server known ahead of time with a SAML assertion which we can then exchange for credentials. @@ -184,33 +173,15 @@ A role must be specified when using this command through the --role flag. You ma return nil } - // TODO: We should only open the browser if we're able to start the server, - // and this branch should only terminate once the server is closed. - handler := SAMLCallbackHandler{ - AssertionChannel: make(chan []byte, 1), - } - - server := http.Server{ - Addr: "127.0.0.1:57468", - Handler: &handler, - } - - go func() { - err := server.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - // TODO: Don't panic - instead, pass error to caller - log.Panicln(err) - } - }() - - // TODO: We should probably wait a second before opening the browser to see if the http server is going to throw an error - // If it throws an error, we should bail - if err := OpenBrowser(account.Href); err != nil { - cmd.PrintErrf("failed to open %s: %s", account.Href, err) - return nil + provider = OktaTencentCloudSAMLProvider{ + Href: account.Href, } + } - assertionBytes = <-handler.AssertionChannel + assertionBytes, err := provider.FetchSAMLAssertion(cmd.Context()) + if err != nil { + cmd.PrintErrf("could not fetch SAML assertion: %s\n", err) + return nil } assertionStr := string(assertionBytes) @@ -266,6 +237,70 @@ A role must be specified when using this command through the --role flag. You ma return echoCredentials(args[0], args[0], credentials, outputType, shellType, awsCliPath, tencentCliPath) }} +type SAMLAssertionProvider interface { + FetchSAMLAssertion(ctx context.Context) ([]byte, error) +} + +type OktaAWSSAMLProvider struct { + OIDCDomain string + ClientID string + Tokens *TokenSet + AccountID string + Client *http.Client +} + +func (r OktaAWSSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, error) { + oauthCfg, err := DiscoverOAuth2Config(ctx, r.OIDCDomain, r.ClientID) + if err != nil { + return nil, fmt.Errorf("could not discover OAuth2 configuration: %w", err) + } + + tok, err := ExchangeAccessTokenForWebSSOToken(ctx, r.Client, oauthCfg, r.Tokens, r.AccountID) + if err != nil { + return nil, fmt.Errorf("could not exchange access token for web sso token: %w", err) + } + + assertion, err := ExchangeWebSSOTokenForSAMLAssertion(ctx, r.Client, r.OIDCDomain, tok) + if err != nil { + return nil, fmt.Errorf("could not exchange web sso token for saml assertion: %w", err) + } + + return assertion, nil +} + +type OktaTencentCloudSAMLProvider struct { + Href string +} + +func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, error) { + // TODO: We should only open the browser if we're able to start the server, + // and this branch should only terminate once the server is closed. + handler := SAMLCallbackHandler{ + AssertionChannel: make(chan []byte, 1), + } + + server := http.Server{ + Addr: "127.0.0.1:57468", + Handler: &handler, + } + + go func() { + err := server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + // TODO: Don't panic - instead, pass error to caller + log.Panicln(err) + } + }() + + // TODO: We should probably wait a second before opening the browser to see if the http server is going to throw an error + // If it throws an error, we should bail + if err := OpenBrowser(p.Href); err != nil { + return nil, fmt.Errorf("failed to open web browser to URL %s: %w", p.Href, err) + } + + return <-handler.AssertionChannel, nil +} + func echoCredentials(id, name string, credentials CloudCredentials, outputType, shellType, awsCliPath, tencentCliPath string) error { switch outputType { case outputTypeEnvironmentVariable: From d5ed558cd3277f7c14215b6dbf0319afd4433d3f Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:05:30 -0700 Subject: [PATCH 15/24] Add wrapper around SAML library This will enable us to remove the old deprecated one --- cli/saml.go | 33 +++++++++++++++++++++++++++++---- cli/saml_test.go | 3 +-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/cli/saml.go b/cli/saml.go index 68aa49f2..899458b4 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -10,6 +10,27 @@ import ( "github.com/RobotsAndPencils/go-saml" ) +type SAMLResponse struct { + original []byte + inner *saml.Response +} + +func (r *SAMLResponse) AddAttribute(name, value string) { + if r.inner == nil { + r.inner = &saml.Response{} + } + + r.inner.AddAttribute(name, value) +} + +func (r SAMLResponse) GetAttribute(name string) string { + return r.inner.GetAttribute(name) +} + +func (r SAMLResponse) GetAttributeValues(name string) []string { + return r.inner.GetAttributeValues(name) +} + type RoleProviderPair struct { RoleARN string ProviderARN string @@ -20,7 +41,7 @@ const ( tencentFlag = 1 ) -func ListSAMLRoles(response *saml.Response) []string { +func ListSAMLRoles(response *SAMLResponse) []string { if response == nil { return nil } @@ -43,7 +64,7 @@ func ListSAMLRoles(response *saml.Response) []string { return names } -func FindRoleInSAML(roleName string, response *saml.Response) (RoleProviderPair, bool) { +func FindRoleInSAML(roleName string, response *SAMLResponse) (RoleProviderPair, bool) { if response == nil { return RoleProviderPair{}, false } @@ -101,8 +122,12 @@ func getARN(value string) RoleProviderPair { return p } -func ParseBase64EncodedSAMLResponse(xml string) (*saml.Response, error) { - return saml.ParseEncodedResponse(xml) +func ParseBase64EncodedSAMLResponse(xml string) (*SAMLResponse, error) { + res, err := saml.ParseEncodedResponse(xml) + if err != nil { + return nil, nil + } + return &SAMLResponse{original: []byte(xml), inner: res}, nil } type SAMLCallbackHandler struct { diff --git a/cli/saml_test.go b/cli/saml_test.go index 904b8f9a..2c5cabfb 100644 --- a/cli/saml_test.go +++ b/cli/saml_test.go @@ -3,12 +3,11 @@ package main import ( "testing" - "github.com/RobotsAndPencils/go-saml" "github.com/stretchr/testify/require" ) func TestAwsFindRoleDoesntBreakIfYouHaveMultipleRoles(t *testing.T) { - var resp saml.Response + var resp SAMLResponse resp.AddAttribute("https://aws.amazon.com/SAML/Attributes/Role", "arn:cloud:iam::1234:saml-provider/Okta,arn:cloud:iam::1234:role/Admin") resp.AddAttribute("https://aws.amazon.com/SAML/Attributes/Role", "arn:cloud:iam::1234:saml-provider/Okta,arn:cloud:iam::1234:role/Power") pair, err := FindRoleInSAML("Power", &resp) From 220d02a44c13ca829aff15a9294664be041f279e Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:07:32 -0700 Subject: [PATCH 16/24] Remove unused consts --- cli/saml.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/saml.go b/cli/saml.go index 899458b4..dcd59700 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -36,11 +36,6 @@ type RoleProviderPair struct { ProviderARN string } -const ( - awsFlag = 0 - tencentFlag = 1 -) - func ListSAMLRoles(response *SAMLResponse) []string { if response == nil { return nil From 4fb782b01bfc0e5b1b6c41b198bd97b19f1a34b8 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:21:42 -0700 Subject: [PATCH 17/24] Remove deprecated SAML library --- cli/oauth2.go | 1 - cli/saml.go | 52 ++++++++++++++++++++++++++++++++++++++++++++------- go.mod | 9 +++------ go.sum | 30 ++++++++++++++++++----------- 4 files changed, 67 insertions(+), 25 deletions(-) diff --git a/cli/oauth2.go b/cli/oauth2.go index 60d59a4f..b361d741 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -98,7 +98,6 @@ func NewOAuth2Listener(bindAddr string) OAuth2Listener { } return OAuth2Listener{ - // 5RIOT on a phone pad Addr: bindAddr, errCh: make(chan error), callbackCh: make(chan OAuth2CallbackInfo), diff --git a/cli/saml.go b/cli/saml.go index dcd59700..3d8e47ff 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -1,34 +1,58 @@ package main import ( + "encoding/base64" + "encoding/xml" "io" "log" "net/http" "net/url" "strings" - "github.com/RobotsAndPencils/go-saml" + "github.com/russellhaering/gosaml2/types" ) type SAMLResponse struct { original []byte - inner *saml.Response + inner *types.Assertion } func (r *SAMLResponse) AddAttribute(name, value string) { if r.inner == nil { - r.inner = &saml.Response{} + r.inner = &types.Assertion{} } - r.inner.AddAttribute(name, value) + if r.inner.AttributeStatement == nil { + r.inner.AttributeStatement = &types.AttributeStatement{} + } + + val := types.AttributeValue{Type: "xs:string", Value: value} + r.inner.AttributeStatement.Attributes = append(r.inner.AttributeStatement.Attributes, types.Attribute{ + Name: name, + Values: []types.AttributeValue{val}, + }) } func (r SAMLResponse) GetAttribute(name string) string { - return r.inner.GetAttribute(name) + vals := r.GetAttributeValues(name) + if len(vals) > 0 { + return vals[0] + } else { + return "" + } } func (r SAMLResponse) GetAttributeValues(name string) []string { - return r.inner.GetAttributeValues(name) + var vals []string + for _, attr := range r.inner.AttributeStatement.Attributes { + if attr.Name == name { + for _, v := range attr.Values { + vals = append(vals, v.Value) + } + } + } + + return vals } type RoleProviderPair struct { @@ -117,8 +141,22 @@ func getARN(value string) RoleProviderPair { return p } +func ParseEncodedResponse(b64ResponseXML string) (*types.Assertion, error) { + var response types.Assertion + bytesXML, err := base64.StdEncoding.DecodeString(b64ResponseXML) + if err != nil { + return nil, err + } + + err = xml.Unmarshal(bytesXML, &response) + if err != nil { + return nil, err + } + return &response, nil +} + func ParseBase64EncodedSAMLResponse(xml string) (*SAMLResponse, error) { - res, err := saml.ParseEncodedResponse(xml) + res, err := ParseEncodedResponse(xml) if err != nil { return nil, nil } diff --git a/go.mod b/go.mod index 88573377..e893fed8 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,6 @@ module github.com/riotgames/key-conjurer require ( - github.com/RobotsAndPencils/go-saml v0.0.0-20170520135329-fb13cb52a46b github.com/aws/aws-lambda-go v1.19.1 github.com/aws/aws-sdk-go v1.34.19 github.com/coreos/go-oidc v2.2.1+incompatible @@ -12,6 +11,7 @@ require ( github.com/okta/okta-sdk-golang/v2 v2.2.1 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/riotgames/vault-go-client v0.0.3 + github.com/russellhaering/gosaml2 v0.9.1 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.1 @@ -24,6 +24,7 @@ require ( ) require ( + github.com/beevik/etree v1.1.0 // indirect github.com/cenkalti/backoff/v4 v4.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.5.2 // indirect @@ -38,16 +39,13 @@ require ( github.com/hashicorp/vault/sdk v0.1.13 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mitchellh/mapstructure v1.3.3 // indirect - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect - github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627 // indirect github.com/pierrec/lz4 v2.5.2+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pquerna/cachecontrol v0.2.0 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect @@ -56,7 +54,6 @@ require ( golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.0 // indirect - gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/ini.v1 v1.42.0 // indirect gopkg.in/square/go-jose.v2 v2.5.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index f6af4ac3..d54b793a 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/RiotGames/vault-go-client v0.0.1/go.mod h1:BlrJKlckkGp2bif3OR7zbLNBS6nxAyiyrUadGf/Bbb0= -github.com/RobotsAndPencils/go-saml v0.0.0-20170520135329-fb13cb52a46b h1:EgJ6N2S0h1WfFIjU5/VVHWbMSVYXAluop97Qxpr/lfQ= -github.com/RobotsAndPencils/go-saml v0.0.0-20170520135329-fb13cb52a46b/go.mod h1:3SAoF0F5EbcOuBD5WT9nYkbIJieBS84cUQXADbXeBsU= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-lambda-go v1.19.1 h1:5iUHbIZ2sG6Yq/J1IN3sWm3+vAB1CWwhI21NffLNuNI= @@ -10,6 +8,8 @@ github.com/aws/aws-lambda-go v1.19.1/go.mod h1:jJmlefzPfGnckuHdXX7/80O3BvUUi12XO github.com/aws/aws-sdk-go v1.34.10/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.34.19 h1:x3MMvAJ1nfWviixEduchBSs65DgY5Y2pA2/NAcxVGOo= github.com/aws/aws-sdk-go v1.34.19/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/cenkalti/backoff/v4 v4.1.0 h1:c8LkOFQTzuO0WBM/ae5HdGQuZPfPxp7lqBRwQRm4fSc= github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= @@ -91,21 +91,23 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 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/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -121,10 +123,6 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= -github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/okta/okta-sdk-golang/v2 v2.2.1 h1:o6IqNfn2U8RKVOqkFS21/vHzDzXDOyqNO6dlW61Cj4I= github.com/okta/okta-sdk-golang/v2 v2.2.1/go.mod h1:ZGx20R+s/DQV3L8fxFN2OrQEIe2hBpwM7/443wLyQpc= @@ -136,6 +134,7 @@ github.com/pierrec/lz4 v2.5.2+incompatible h1:WCjObylUIOlKy/+7Abdn34TLIkXiA4UWUM github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -144,6 +143,13 @@ github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEn github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/riotgames/vault-go-client v0.0.3 h1:O0dpk80kHwaZJzEVSaTpwH6FflshaRmfO/yNzcqX7AM= github.com/riotgames/vault-go-client v0.0.3/go.mod h1:ygm6j/P9ITn9vAKiaWd+1SbvUac4EOPigHzb+hmX57k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= +github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -249,8 +255,9 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 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.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= @@ -261,6 +268,7 @@ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From c062fa705fefafb1bde664980a07881f2540841e Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:23:14 -0700 Subject: [PATCH 18/24] Move browser opening functionality to browser_darwin.go --- cli/browser_darwin.go | 11 +++++++++++ cli/get.go | 7 ------- 2 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 cli/browser_darwin.go diff --git a/cli/browser_darwin.go b/cli/browser_darwin.go new file mode 100644 index 00000000..a8176719 --- /dev/null +++ b/cli/browser_darwin.go @@ -0,0 +1,11 @@ +package main + +import ( + "context" + "os/exec" +) + +func OpenBrowser(url string) error { + proc := exec.CommandContext(context.Background(), "open", url) + return proc.Run() +} diff --git a/cli/get.go b/cli/get.go index e94e3532..027f1fee 100644 --- a/cli/get.go +++ b/cli/get.go @@ -7,7 +7,6 @@ import ( "log" "net/http" "os" - "os/exec" "time" "github.com/aws/aws-sdk-go/aws" @@ -318,9 +317,3 @@ func echoCredentials(id, name string, credentials CloudCredentials, outputType, return fmt.Errorf("%s is an invalid output type", outputType) } } - -func OpenBrowser(url string) error { - // TODO: This only works for OSX. - proc := exec.CommandContext(context.Background(), "open", url) - return proc.Run() -} From 51cb17d5b4fbc3cd88571df07f627af2d66cd69e Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:25:25 -0700 Subject: [PATCH 19/24] use the stored copy of the assertion --- cli/get.go | 5 ++--- cli/saml.go | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cli/get.go b/cli/get.go index 027f1fee..dfd181cf 100644 --- a/cli/get.go +++ b/cli/get.go @@ -183,8 +183,7 @@ A role must be specified when using this command through the --role flag. You ma return nil } - assertionStr := string(assertionBytes) - samlResponse, err := ParseBase64EncodedSAMLResponse(assertionStr) + samlResponse, err := ParseBase64EncodedSAMLResponse(string(assertionBytes)) if err != nil { cmd.PrintErrf("could not parse assertion: %s\n", err) return nil @@ -210,7 +209,7 @@ A role must be specified when using this command through the --role flag. You ma DurationSeconds: &timeoutInSeconds, PrincipalArn: &pair.ProviderARN, RoleArn: &pair.RoleARN, - SAMLAssertion: &assertionStr, + SAMLAssertion: &samlResponse.original, }) if err != nil { diff --git a/cli/saml.go b/cli/saml.go index 3d8e47ff..656baca6 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -13,7 +13,7 @@ import ( ) type SAMLResponse struct { - original []byte + original string inner *types.Assertion } @@ -160,7 +160,7 @@ func ParseBase64EncodedSAMLResponse(xml string) (*SAMLResponse, error) { if err != nil { return nil, nil } - return &SAMLResponse{original: []byte(xml), inner: res}, nil + return &SAMLResponse{original: xml, inner: res}, nil } type SAMLCallbackHandler struct { From d98e26413aaefc171ff5b582f5b18c79b503de2a Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:27:03 -0700 Subject: [PATCH 20/24] Add Linux OpenBrowser function Otherwise our pipeline breaks --- cli/browser_linux.go | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 cli/browser_linux.go diff --git a/cli/browser_linux.go b/cli/browser_linux.go new file mode 100644 index 00000000..f95456c5 --- /dev/null +++ b/cli/browser_linux.go @@ -0,0 +1,11 @@ +package main + +import ( + "context" + "os/exec" +) + +func OpenBrowser(url string) error { + proc := exec.CommandContext(context.Background(), "xdg-open", url) + return proc.Run() +} From 6d9907f52ba559a293dab334963bac8793cdd071 Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Tue, 3 Oct 2023 14:36:51 -0700 Subject: [PATCH 21/24] Use new SAMLProvider type for encapsulating exchange --- cli/get.go | 80 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/cli/get.go b/cli/get.go index dfd181cf..71870f0a 100644 --- a/cli/get.go +++ b/cli/get.go @@ -152,6 +152,10 @@ A role must be specified when using this command through the --role flag. You ma return echoCredentials(args[0], args[0], credentials, outputType, shellType, awsCliPath, tencentCliPath) } + if ttl == 1 && config.TTL != 0 { + ttl = config.TTL + } + region, _ := cmd.Flags().GetString(FlagRegion) var provider SAMLAssertionProvider if cloudType == cloudAws { provider = OktaAWSSAMLProvider{ @@ -160,6 +164,8 @@ A role must be specified when using this command through the --role flag. You ma Tokens: config.Tokens, AccountID: account.ID, Client: client, + Region: region, + TTL: ttl, } } else if cloudType == cloudTencent { // Tencent applications aren't supported by the Okta API, so we can't use the same flow as AWS. @@ -173,7 +179,9 @@ A role must be specified when using this command through the --role flag. You ma } provider = OktaTencentCloudSAMLProvider{ - Href: account.Href, + Href: account.Href, + Region: region, + TTL: ttl, } } @@ -195,37 +203,10 @@ A role must be specified when using this command through the --role flag. You ma return nil } - if ttl == 1 && config.TTL != 0 { - ttl = config.TTL - } - - switch cloudType { - case cloudAws: - region, _ := cmd.Flags().GetString(FlagRegion) - session, _ := session.NewSession(&aws.Config{Region: aws.String(region)}) - stsClient := sts.New(session) - timeoutInSeconds := int64(3600 * ttl) - resp, err := stsClient.AssumeRoleWithSAMLWithContext(ctx, &sts.AssumeRoleWithSAMLInput{ - DurationSeconds: &timeoutInSeconds, - PrincipalArn: &pair.ProviderARN, - RoleArn: &pair.RoleARN, - SAMLAssertion: &samlResponse.original, - }) - - if err != nil { - cmd.PrintErrf("failed to exchange credentials: %s", err) - return nil - } - - credentials = CloudCredentials{ - AccessKeyID: *resp.Credentials.AccessKeyId, - Expiration: resp.Credentials.Expiration.Format(time.RFC3339), - SecretAccessKey: *resp.Credentials.SecretAccessKey, - SessionToken: *resp.Credentials.SessionToken, - credentialsType: cloudType, - } - default: - panic("not yet implemented") + credentials, err = provider.ExchangeAssertionForCredentials(ctx, *samlResponse, pair) + if err != nil { + cmd.PrintErrf("failed to exchange SAML assertion for credentials: %s", err) + return nil } if account != nil { @@ -237,6 +218,7 @@ A role must be specified when using this command through the --role flag. You ma type SAMLAssertionProvider interface { FetchSAMLAssertion(ctx context.Context) ([]byte, error) + ExchangeAssertionForCredentials(ctx context.Context, response SAMLResponse, rp RoleProviderPair) (CloudCredentials, error) } type OktaAWSSAMLProvider struct { @@ -245,6 +227,32 @@ type OktaAWSSAMLProvider struct { Tokens *TokenSet AccountID string Client *http.Client + TTL uint + Region string +} + +func (r OktaAWSSAMLProvider) ExchangeAssertionForCredentials(ctx context.Context, response SAMLResponse, rp RoleProviderPair) (CloudCredentials, error) { + session, _ := session.NewSession(&aws.Config{Region: aws.String(r.Region)}) + stsClient := sts.New(session) + timeoutInSeconds := int64(3600 * r.TTL) + resp, err := stsClient.AssumeRoleWithSAMLWithContext(ctx, &sts.AssumeRoleWithSAMLInput{ + DurationSeconds: &timeoutInSeconds, + PrincipalArn: &rp.ProviderARN, + RoleArn: &rp.RoleARN, + SAMLAssertion: &response.original, + }) + + if err != nil { + return CloudCredentials{}, err + } + + return CloudCredentials{ + AccessKeyID: *resp.Credentials.AccessKeyId, + Expiration: resp.Credentials.Expiration.Format(time.RFC3339), + SecretAccessKey: *resp.Credentials.SecretAccessKey, + SessionToken: *resp.Credentials.SessionToken, + credentialsType: cloudAws, + }, nil } func (r OktaAWSSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, error) { @@ -267,7 +275,9 @@ func (r OktaAWSSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, er } type OktaTencentCloudSAMLProvider struct { - Href string + Href string + Region string + TTL uint } func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, error) { @@ -299,6 +309,10 @@ func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([ return <-handler.AssertionChannel, nil } +func (p OktaTencentCloudSAMLProvider) ExchangeAssertionForCredentials(ctx context.Context, response SAMLResponse, rp RoleProviderPair) (CloudCredentials, error) { + panic("not yet implemented") +} + func echoCredentials(id, name string, credentials CloudCredentials, outputType, shellType, awsCliPath, tencentCliPath string) error { switch outputType { case outputTypeEnvironmentVariable: From 7acfaa7a5ebfbadcba0d74f6cb685db310694cdb Mon Sep 17 00:00:00 2001 From: Dan Pantry Date: Wed, 4 Oct 2023 13:22:08 -0700 Subject: [PATCH 22/24] Use the browser package --- cli/browser_darwin.go | 11 --- cli/browser_linux.go | 11 --- cli/get.go | 18 ++-- cli/root.go | 1 - cli/switch.go | 190 ------------------------------------------ 5 files changed, 11 insertions(+), 220 deletions(-) delete mode 100644 cli/browser_darwin.go delete mode 100644 cli/browser_linux.go delete mode 100644 cli/switch.go diff --git a/cli/browser_darwin.go b/cli/browser_darwin.go deleted file mode 100644 index a8176719..00000000 --- a/cli/browser_darwin.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "context" - "os/exec" -) - -func OpenBrowser(url string) error { - proc := exec.CommandContext(context.Background(), "open", url) - return proc.Run() -} diff --git a/cli/browser_linux.go b/cli/browser_linux.go deleted file mode 100644 index f95456c5..00000000 --- a/cli/browser_linux.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "context" - "os/exec" -) - -func OpenBrowser(url string) error { - proc := exec.CommandContext(context.Background(), "xdg-open", url) - return proc.Run() -} diff --git a/cli/get.go b/cli/get.go index 71870f0a..b224b3ea 100644 --- a/cli/get.go +++ b/cli/get.go @@ -12,16 +12,21 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/sts" + "github.com/pkg/browser" "github.com/riotgames/key-conjurer/internal/api" "github.com/spf13/cobra" ) var ( - FlagRegion = "region" - FlagRoleName = "role" - FlagTimeRemaining = "time-remaining" - FlagTimeToLive = "ttl" - FlagBypassCache = "bypass-cache" + FlagRegion = "region" + FlagRoleName = "role" + FlagTimeRemaining = "time-remaining" + FlagTimeToLive = "ttl" + FlagBypassCache = "bypass-cache" + FlagOutputType = "out" + FlagShellType = "shell" + FlagAWSCLIPath = "awscli" + FlagTencentCLIPath = "tencentcli" ) var ( @@ -40,7 +45,6 @@ func init() { getCmd.Flags().Uint(FlagTimeToLive, 1, "The key timeout in hours from 1 to 8.") getCmd.Flags().UintP(FlagTimeRemaining, "t", DefaultTimeRemaining, "Request new keys if there are no keys in the environment or the current keys expire within minutes. Defaults to 60.") getCmd.Flags().StringP(FlagRoleName, "r", "", "The name of the role to assume.") - getCmd.Flags().String(FlagRoleSessionName, "KeyConjurer-AssumeRole", "the name of the role session name that will show up in CloudTrail logs") getCmd.Flags().StringP(FlagOutputType, "o", outputTypeEnvironmentVariable, "Format to save new credentials in. Supported outputs: env, awscli,tencentcli") getCmd.Flags().String(FlagShellType, shellTypeInfer, "If output type is env, determines which format to output credentials in - by default, the format is inferred based on the execution environment. WSL users may wish to overwrite this to `bash`") getCmd.Flags().String(FlagAWSCLIPath, "~/.aws/", "Path for directory used by the aws-cli tool. Default is \"~/.aws\".") @@ -302,7 +306,7 @@ func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([ // TODO: We should probably wait a second before opening the browser to see if the http server is going to throw an error // If it throws an error, we should bail - if err := OpenBrowser(p.Href); err != nil { + if err := browser.OpenURL(p.Href); err != nil { return nil, fmt.Errorf("failed to open web browser to URL %s: %w", p.Href, err) } diff --git a/cli/root.go b/cli/root.go index 11520603..a54b39a5 100644 --- a/cli/root.go +++ b/cli/root.go @@ -33,7 +33,6 @@ func init() { rootCmd.AddCommand(getCmd) rootCmd.AddCommand(setCmd) rootCmd.AddCommand(upgradeCmd) - rootCmd.AddCommand(&switchCmd) rootCmd.AddCommand(&aliasCmd) rootCmd.AddCommand(&unaliasCmd) rootCmd.AddCommand(&rolesCmd) diff --git a/cli/switch.go b/cli/switch.go deleted file mode 100644 index 7e261111..00000000 --- a/cli/switch.go +++ /dev/null @@ -1,190 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/arn" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sts" - "github.com/riotgames/key-conjurer/internal/tencent" - "github.com/spf13/cobra" -) - -var ( - FlagRoleSessionName = "role-session-name" - FlagOutputType = "out" - FlagShellType = "shell" - FlagAWSCLIPath = "awscli" - FlagTencentCLIPath = "tencentcli" - FlagCloudType = "cloud" -) - -func init() { - switchCmd.Flags().String(FlagRoleSessionName, "KeyConjurer-AssumeRole", "the name of the role session name that will show up in CloudTrail logs") - switchCmd.Flags().StringP(FlagOutputType, "o", outputTypeEnvironmentVariable, "Format to save new credentials in. Supported outputs: env, awscli,tencentcli") - switchCmd.Flags().String(FlagShellType, shellTypeInfer, "If output type is env, determines which format to output credentials in - by default, the format is inferred based on the execution environment. WSL users may wish to overwrite this to `bash`") - switchCmd.Flags().String(FlagAWSCLIPath, "~/.aws/", "Path for directory used by the aws-cli tool. Default is \"~/.aws\".") - switchCmd.Flags().String(FlagTencentCLIPath, "~/.tencent/", "Path for directory used by the tencent-cli tool. Default is \"~/.tencent\".") - switchCmd.Flags().String(FlagCloudType, "aws", "Choose a cloud vendor. Default is aws. Can choose aws or tencent") -} - -var switchCmd = cobra.Command{ - Use: "switch ", - Short: "Switch from the current Cloud (AWS or Tencent) account into the one with the given Account ID.", - Long: `Attempt to AssumeRole into the given Cloud (AWS or Tencent) with the current credentials. You only need to use this if you are a power user or network engineer with access to many accounts. - -This is used when a "bastion" account exists which users initially authenticate into and then pivot from that account into other accounts. - -This command will fail if you do not have active Cloud credentials. -`, - Example: "keyconjurer switch 123456798", - Args: cobra.ExactArgs(1), - Aliases: []string{"switch-account"}, - RunE: func(cmd *cobra.Command, args []string) error { - outputType, _ := cmd.Flags().GetString(FlagOutputType) - shellType, _ := cmd.Flags().GetString(FlagShellType) - cloudType, _ := cmd.Flags().GetString(FlagCloudType) - awsCliPath, _ := cmd.Flags().GetString(FlagAWSCLIPath) - if !isMemberOfSlice(permittedOutputTypes, outputType) { - return invalidValueError(outputType, permittedOutputTypes) - } - - if !isMemberOfSlice(permittedShellTypes, shellType) { - return invalidValueError(shellType, permittedShellTypes) - } - - // We could read the environment variable for the assumed role ARN, but it might be expired which isn't very useful to the user. - var err error - var creds CloudCredentials - sessionName, _ := cmd.Flags().GetString(FlagRoleSessionName) - switch strings.ToLower(cloudType) { - case cloudAws: - creds, err = getAWSCredentials(args[0], sessionName) - case cloudTencent: - creds, err = getTencentCredentials(args[0], sessionName) - } - - if err != nil { - // If this failed, either there was a network error or the user is not authorized to assume into this role - // This can happen if the user is not authenticated using the Bastion instance. - return err - } - - switch outputType { - case outputTypeEnvironmentVariable: - creds.WriteFormat(os.Stdout, shellType) - return nil - case outputTypeAWSCredentialsFile: - acc := Account{ID: args[0], Name: args[0]} - newCliEntry := NewCloudCliEntry(creds, &acc) - return SaveCloudCredentialInCLI(awsCliPath, newCliEntry) - default: - return fmt.Errorf("%s is an invalid output type", outputType) - } - }, -} - -func getTencentCredentials(accountID, roleSessionName string) (creds CloudCredentials, err error) { - region := os.Getenv("TENCENT_REGION") - stsClient, err := tencent.NewSTSClient(region) - if err != nil { - return - } - - response, err := stsClient.GetCallerIdentity() - if err != nil { - return - } - - arn := response.Response.Arn - roleID := "" - if (*arn) != "" { - arns := strings.Split(*arn, ":") - if len(arns) >= 5 && len(strings.Split(arns[4], "/")) >= 2 { - roleID = strings.Split(arns[4], "/")[1] - } - } - if roleID == "" { - err = fmt.Errorf("roleID is null") - return - } - - camClient, err := tencent.NewCAMClient(region) - if err != nil { - return - } - roleName, err := camClient.GetRoleName(roleID) - if err != nil { - return - } - resp, err := stsClient.AssumeRole(fmt.Sprintf("qcs::cam::uin/%s:roleName/%s", accountID, roleName), roleSessionName) - if err != nil { - return - } - - creds = CloudCredentials{ - AccountID: accountID, - AccessKeyID: *resp.Response.Credentials.TmpSecretId, - SecretAccessKey: *resp.Response.Credentials.TmpSecretKey, - SessionToken: *resp.Response.Credentials.Token, - Expiration: *resp.Response.Expiration, - credentialsType: cloudTencent, - } - - return creds, nil -} - -func getAWSCredentials(accountID, roleSessionName string) (creds CloudCredentials, err error) { - ctx := context.Background() - sess, err := session.NewSession(aws.NewConfig()) - if err != nil { - return - } - - c := sts.New(sess) - response, err := c.GetCallerIdentityWithContext(ctx, &sts.GetCallerIdentityInput{}) - if err != nil { - return - } - - // We need to modify this to change the last section to be role/GL-SuperAdmin - id, err := arn.Parse(*response.Arn) - if err != nil { - return - } - - parts := strings.Split(id.Resource, "/") - arn := arn.ARN{ - AccountID: accountID, - Partition: "aws", - Service: "iam", - Resource: fmt.Sprintf("role/%s", parts[1]), - Region: id.Region, - } - - roleARN := arn.String() - resp, err := c.AssumeRoleWithContext(ctx, &sts.AssumeRoleInput{ - RoleArn: &roleARN, - RoleSessionName: &roleSessionName, - }) - - if err != nil { - return - } - - creds = CloudCredentials{ - AccountID: accountID, - AccessKeyID: *resp.Credentials.AccessKeyId, - SecretAccessKey: *resp.Credentials.SecretAccessKey, - SessionToken: *resp.Credentials.SessionToken, - Expiration: resp.Credentials.Expiration.Format(time.RFC3339), - credentialsType: cloudAws, - } - - return creds, nil -} From cc1f1eebf9d493d3e88a6e333380a291ac7642f9 Mon Sep 17 00:00:00 2001 From: Dan <2939173+trinitroglycerin@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:21:06 -0700 Subject: [PATCH 23/24] Implement ExchangeAssertionForCredentials --- cli/get.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cli/get.go b/cli/get.go index b224b3ea..97bbc71f 100644 --- a/cli/get.go +++ b/cli/get.go @@ -15,6 +15,9 @@ import ( "github.com/pkg/browser" "github.com/riotgames/key-conjurer/internal/api" "github.com/spf13/cobra" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tencentsts "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sts/v20180813" ) var ( @@ -310,11 +313,38 @@ func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([ return nil, fmt.Errorf("failed to open web browser to URL %s: %w", p.Href, err) } - return <-handler.AssertionChannel, nil + select { + case assert := <-handler.AssertionChannel: + return assert, nil + case <-ctx.Done(): + return nil, ctx.Err() + } } func (p OktaTencentCloudSAMLProvider) ExchangeAssertionForCredentials(ctx context.Context, response SAMLResponse, rp RoleProviderPair) (CloudCredentials, error) { - panic("not yet implemented") + cpf := profile.NewClientProfile() + cpf.HttpProfile.Endpoint = "sts.tencentcloudapi.com" + client, _ := tencentsts.NewClient(&common.Credential{}, p.Region, cpf) + + timeoutInSeconds := int64(3600 * p.TTL) + req := tencentsts.NewAssumeRoleWithSAMLRequest() + req.RoleSessionName = common.StringPtr(fmt.Sprintf("riot-keyConjurer-%s", rp.RoleARN)) + req.DurationSeconds = common.Uint64Ptr(uint64(timeoutInSeconds)) + req.PrincipalArn = &rp.ProviderARN + req.RoleArn = &rp.RoleARN + req.SAMLAssertion = &response.original + resp, err := client.AssumeRoleWithSAMLWithContext(ctx, req) + if err != nil { + return CloudCredentials{}, err + } + + credentials := resp.Response.Credentials + return CloudCredentials{ + AccessKeyID: *credentials.TmpSecretId, + SecretAccessKey: *credentials.TmpSecretKey, + SessionToken: *credentials.Token, + Expiration: *resp.Response.Expiration, + }, nil } func echoCredentials(id, name string, credentials CloudCredentials, outputType, shellType, awsCliPath, tencentCliPath string) error { From 1a536ae3605e3a89c5ebb210ab9e666876ccc184 Mon Sep 17 00:00:00 2001 From: Dan <2939173+trinitroglycerin@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:55:11 -0700 Subject: [PATCH 24/24] Wait for confirmation of open port before listening --- cli/get.go | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/cli/get.go b/cli/get.go index 97bbc71f..c3e14664 100644 --- a/cli/get.go +++ b/cli/get.go @@ -2,9 +2,8 @@ package main import ( "context" - "errors" "fmt" - "log" + "net" "net/http" "os" "time" @@ -288,27 +287,23 @@ type OktaTencentCloudSAMLProvider struct { } func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, error) { - // TODO: We should only open the browser if we're able to start the server, - // and this branch should only terminate once the server is closed. handler := SAMLCallbackHandler{ AssertionChannel: make(chan []byte, 1), } - server := http.Server{ - Addr: "127.0.0.1:57468", - Handler: &handler, + // Listening and serving are done separately, so that we can confirm the port is available before launching a browser. + // Listening attempts to reserves the port, but doesn't block. + addr := "127.0.0.1:57468" + sock, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to listen on %s: %w", addr, err) } - go func() { - err := server.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - // TODO: Don't panic - instead, pass error to caller - log.Panicln(err) - } - }() + server := http.Server{Handler: &handler} + go server.Serve(sock) + // The socket becomes owned by the server so we don't need to close it. + defer server.Close() - // TODO: We should probably wait a second before opening the browser to see if the http server is going to throw an error - // If it throws an error, we should bail if err := browser.OpenURL(p.Href); err != nil { return nil, fmt.Errorf("failed to open web browser to URL %s: %w", p.Href, err) }