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 c59231a4..c3e14664 100644 --- a/cli/get.go +++ b/cli/get.go @@ -1,22 +1,34 @@ package main import ( + "context" "fmt" + "net" + "net/http" "os" "time" "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" + "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 ( - 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 ( @@ -35,12 +47,10 @@ 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\".") 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.") } @@ -58,6 +68,7 @@ func resolveApplicationInfo(cfg *Config, bypassCache bool, nameOrID string) (*Ac if bypassCache { return &Account{ID: nameOrID, Name: nameOrID}, true } + return cfg.FindAccount(nameOrID) } @@ -82,7 +93,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) @@ -108,11 +118,28 @@ 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") return nil } + roleName = account.MostRecentRole } @@ -131,26 +158,46 @@ 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 + if ttl == 1 && config.TTL != 0 { + ttl = config.TTL } + region, _ := cmd.Flags().GetString(FlagRegion) + var provider SAMLAssertionProvider + if cloudType == cloudAws { + provider = OktaAWSSAMLProvider{ + OIDCDomain: oidcDomain, + ClientID: clientID, + 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. + // 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 + } - tok, err := ExchangeAccessTokenForWebSSOToken(cmd.Context(), client, oauthCfg, config.Tokens, account.ID) - if err != nil { - cmd.PrintErrf("error exchanging token: %s\n", err) - return nil + provider = OktaTencentCloudSAMLProvider{ + Href: account.Href, + Region: region, + TTL: ttl, + } } - assertion, err := ExchangeWebSSOTokenForSAMLAssertion(cmd.Context(), client, oidcDomain, tok) + assertionBytes, err := provider.FetchSAMLAssertion(cmd.Context()) if err != nil { - cmd.PrintErrf("failed to fetch SAML assertion: %s\n", err) + cmd.PrintErrf("could not fetch SAML assertion: %s\n", err) return nil } - assertionStr := string(assertion) - samlResponse, err := ParseBase64EncodedSAMLResponse(assertionStr) + samlResponse, err := ParseBase64EncodedSAMLResponse(string(assertionBytes)) if err != nil { cmd.PrintErrf("could not parse assertion: %s\n", err) return nil @@ -162,36 +209,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 - } - - if cloudType == 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: &assertionStr, - }) - - 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, - } - } else { - 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 { @@ -201,6 +222,126 @@ 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) + ExchangeAssertionForCredentials(ctx context.Context, response SAMLResponse, rp RoleProviderPair) (CloudCredentials, error) +} + +type OktaAWSSAMLProvider struct { + OIDCDomain string + ClientID string + 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) { + 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 + Region string + TTL uint +} + +func (p OktaTencentCloudSAMLProvider) FetchSAMLAssertion(ctx context.Context) ([]byte, error) { + handler := SAMLCallbackHandler{ + AssertionChannel: make(chan []byte, 1), + } + + // 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) + } + + 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() + + if err := browser.OpenURL(p.Href); err != nil { + return nil, fmt.Errorf("failed to open web browser to URL %s: %w", p.Href, err) + } + + 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) { + 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 { switch outputType { case outputTypeEnvironmentVariable: 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..b361d741 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -19,6 +19,25 @@ import ( "golang.org/x/oauth2" ) +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. const stateBufSize = 43 @@ -38,15 +57,21 @@ 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) } 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 @@ -65,10 +90,15 @@ 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), } @@ -153,11 +183,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), @@ -205,11 +233,19 @@ 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} + } } -// 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/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/saml.go b/cli/saml.go index 575e980e..656baca6 100644 --- a/cli/saml.go +++ b/cli/saml.go @@ -1,22 +1,66 @@ 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 string + inner *types.Assertion +} + +func (r *SAMLResponse) AddAttribute(name, value string) { + if r.inner == nil { + r.inner = &types.Assertion{} + } + + 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 { + vals := r.GetAttributeValues(name) + if len(vals) > 0 { + return vals[0] + } else { + return "" + } +} + +func (r SAMLResponse) GetAttributeValues(name string) []string { + 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 { RoleARN string ProviderARN string } -const ( - awsFlag = 0 - tencentFlag = 1 -) - -func ListSAMLRoles(response *saml.Response) []string { +func ListSAMLRoles(response *SAMLResponse) []string { if response == nil { return nil } @@ -39,7 +83,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 } @@ -97,6 +141,46 @@ func getARN(value string) RoleProviderPair { return p } -func ParseBase64EncodedSAMLResponse(xml string) (*saml.Response, error) { - return saml.ParseEncodedResponse(xml) +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 := ParseEncodedResponse(xml) + if err != nil { + return nil, nil + } + return &SAMLResponse{original: xml, inner: res}, nil +} + +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() + + // TODO: This can panic if the body doesn't contain SAMLResponse + h.AssertionChannel <- []byte(v["SAMLResponse"][0]) } 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) 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 -} 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) +} 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= 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 { diff --git a/internal/api/serverless_functions.go b/internal/api/serverless_functions.go index a999c790..ace08b2d 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 ( + ApplicationTypeAWS ApplicationType = "aws" + ApplicationTypeTencent ApplicationType = "tencent" +) + 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 ApplicationTypeAWS, true + } + + if strings.Contains(strings.ToLower(app.AppName), "tencent") { + return ApplicationTypeTencent, true + } + + return "", false +}