Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tencent Cloud support #99

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2e2278f
Provide an error if --oidc-domain is invalid
punmechanic Oct 6, 2023
8d50e1f
Alter Lambda function to return type of application
punmechanic Sep 23, 2023
feb4d46
Make the search for Tencent applications case-insensitive
punmechanic Sep 25, 2023
d718e3a
Add a test API server
punmechanic Sep 25, 2023
e2ffc94
Return well-defined errors from token exchange
punmechanic Sep 25, 2023
a6cf69e
use cloudType to infer the type of application
punmechanic Sep 25, 2023
ad88026
only print an error if an error occurred
punmechanic Sep 25, 2023
29fc4ba
Return the cloud type and add a branch for Tencent
punmechanic Sep 25, 2023
5ecf6d1
Spin up a web browser for SAML
punmechanic Sep 25, 2023
91b3493
Add SAML Listener skeleton
punmechanic Sep 27, 2023
fadfc2a
Open server in a Goroutine
punmechanic Oct 3, 2023
7669900
Get the Redirect URL from the Oauth configuration
punmechanic Oct 3, 2023
afa4206
Pass SAML assertion to the channel
punmechanic Oct 3, 2023
c40912e
Add SAMLProvider interface
punmechanic Oct 3, 2023
d5ed558
Add wrapper around SAML library
punmechanic Oct 3, 2023
220d02a
Remove unused consts
punmechanic Oct 3, 2023
4fb782b
Remove deprecated SAML library
punmechanic Oct 3, 2023
c062fa7
Move browser opening functionality to browser_darwin.go
punmechanic Oct 3, 2023
51cb17d
use the stored copy of the assertion
punmechanic Oct 3, 2023
d98e264
Add Linux OpenBrowser function
punmechanic Oct 3, 2023
6d9907f
Use new SAMLProvider type for encapsulating exchange
punmechanic Oct 3, 2023
7acfaa7
Use the browser package
punmechanic Oct 4, 2023
cc1f1ee
Implement ExchangeAssertionForCredentials
punmechanic Oct 6, 2023
1a536ae
Wait for confirmation of open port before listening
punmechanic Oct 6, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
14 changes: 9 additions & 5 deletions cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"strings"

"github.com/riotgames/key-conjurer/internal/api"
"golang.org/x/oauth2"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
241 changes: 191 additions & 50 deletions cli/get.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 <time-remaining> 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.")
}

Expand All @@ -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)
}

Expand All @@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can now infer the cloud type from the application in the cache. Giving the user the option to specify this doesn't make much sense - They'll almost certainly get it wrong. As such, this is removed.

It'll be removed from switch.go as well.

oidcDomain, _ := cmd.Flags().GetString(FlagOIDCDomain)
clientID, _ := cmd.Flags().GetString(FlagClientID)
awsCliPath, _ := cmd.Flags().GetString(FlagAWSCLIPath)
Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"errors"
"fmt"
"os"

Expand Down Expand Up @@ -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
}

Expand Down
Loading