Skip to content

Commit 214331e

Browse files
authored
Merge pull request #1142 from taraspos/taras/aws-codecommit
auth/aws: AWS CodeCommit IAM authentication
2 parents 215ee0a + 8e83249 commit 214331e

18 files changed

Lines changed: 431 additions & 14 deletions

File tree

auth/aws/provider.go

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
"encoding/base64"
2222
"errors"
2323
"fmt"
24+
"net/http"
25+
"net/url"
2426
"os"
2527
"regexp"
2628
"strings"
@@ -32,14 +34,20 @@ import (
3234
"github.com/aws/aws-sdk-go-v2/service/ecrpublic"
3335
"github.com/aws/aws-sdk-go-v2/service/eks"
3436
"github.com/aws/aws-sdk-go-v2/service/sts"
37+
"github.com/aws/smithy-go/aws-http-auth/credentials"
38+
"github.com/aws/smithy-go/aws-http-auth/sigv4"
39+
v4 "github.com/aws/smithy-go/aws-http-auth/v4"
3540
"github.com/google/go-containerregistry/pkg/authn"
3641
corev1 "k8s.io/api/core/v1"
3742

3843
"github.com/fluxcd/pkg/auth"
3944
)
4045

4146
// ProviderName is the name of the AWS authentication provider.
42-
const ProviderName = "aws"
47+
const (
48+
ProviderName = "aws"
49+
codeCommitCanonicalTimestampFormat = "20060102T150405"
50+
)
4351

4452
// Provider implements the auth.Provider interface for AWS authentication.
4553
type Provider struct{ Implementation }
@@ -396,3 +404,88 @@ func (p Provider) impl() Implementation {
396404
}
397405
return p.Implementation
398406
}
407+
408+
type signerHeaderHostOnly struct{}
409+
410+
func (signerHeaderHostOnly) IsSigned(h string) bool {
411+
return h == "host"
412+
}
413+
414+
// GetRegionFromCodeCommitURL extracts the AWS region from a CodeCommit HTTPS
415+
// git URL (e.g. https://git-codecommit.us-east-1.amazonaws.com/...).
416+
// Returns an error if the URL is nil, not HTTPS, or not a valid CodeCommit URL.
417+
// https://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-git
418+
func GetRegionFromCodeCommitURL(gitURL *url.URL) (string, error) {
419+
if gitURL == nil {
420+
return "", fmt.Errorf("Git URL must be specified for AWS CodeCommit authentication")
421+
}
422+
if !strings.EqualFold(gitURL.Scheme, "https") {
423+
return "", fmt.Errorf("AWS CodeCommit authentication requires an HTTPS Git URL")
424+
}
425+
urlSplit := strings.Split(gitURL.Hostname(), ".")
426+
if len(urlSplit) < 4 ||
427+
!(strings.HasPrefix(gitURL.Hostname(), "git-codecommit.") || strings.HasPrefix(gitURL.Hostname(), "git-codecommit-fips.")) ||
428+
!(strings.HasSuffix(gitURL.Hostname(), ".amazonaws.com") || strings.HasSuffix(gitURL.Hostname(), ".amazonaws.com.cn")) {
429+
return "", fmt.Errorf("invalid AWS CodeCommit Git URL: %s", gitURL.Host)
430+
}
431+
return urlSplit[1], nil
432+
}
433+
434+
// NewCodeCommitGitToken returns HTTPS Git credentials for AWS CodeCommit.
435+
func (Provider) NewCodeCommitGitCredentials(_ context.Context, accessTokens []auth.Token, opts ...auth.Option) (string, string, error) {
436+
var o auth.Options
437+
o.Apply(opts...)
438+
439+
gitURL := o.GitURL
440+
region, err := GetRegionFromCodeCommitURL(gitURL)
441+
if err != nil {
442+
return "", "", err
443+
}
444+
if len(accessTokens) == 0 {
445+
return "", "", fmt.Errorf("AWS access token is required for region %q", region)
446+
}
447+
448+
creds, ok := accessTokens[0].(*Credentials)
449+
if !ok {
450+
return "", "", fmt.Errorf("failed to cast token to AWS token: %T", accessTokens[0])
451+
}
452+
453+
req, err := http.NewRequest("GIT", gitURL.String(), nil)
454+
if err != nil {
455+
return "", "", fmt.Errorf("failed to build CodeCommit signing request: %w", err)
456+
}
457+
req.Host = gitURL.Host
458+
459+
signingTime := time.Now().UTC()
460+
461+
signer := sigv4.New(func(o *v4.SignerOptions) {
462+
o.HeaderRules = signerHeaderHostOnly{}
463+
o.DisableUnsignedPayloadSentinel = true
464+
o.CanonicalTimeFormat = codeCommitCanonicalTimestampFormat
465+
})
466+
signInput := &sigv4.SignRequestInput{
467+
Request: req,
468+
Service: "codecommit",
469+
Region: region,
470+
Credentials: credentials.Credentials{
471+
AccessKeyID: *creds.AccessKeyId,
472+
SecretAccessKey: *creds.SecretAccessKey,
473+
SessionToken: *creds.SessionToken,
474+
Expires: *creds.Expiration,
475+
},
476+
Time: signingTime,
477+
}
478+
479+
if err := signer.SignRequest(signInput); err != nil {
480+
return "", "", fmt.Errorf("failed to sign request: %w", err)
481+
}
482+
483+
authHeader := req.Header.Get("Authorization")
484+
sigStart := strings.Index(authHeader, "Signature=")
485+
signature := authHeader[sigStart+10:]
486+
487+
username := strings.Join([]string{*creds.AccessKeyId, *creds.SessionToken}, "%")
488+
password := signingTime.Format(codeCommitCanonicalTimestampFormat) + "Z" + signature
489+
490+
return username, password, nil
491+
}

auth/aws/provider_test.go

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232

3333
"github.com/fluxcd/pkg/auth"
3434
"github.com/fluxcd/pkg/auth/aws"
35+
"github.com/fluxcd/pkg/auth/generic"
3536
)
3637

3738
func TestProvider_NewControllerToken(t *testing.T) {
@@ -82,7 +83,7 @@ func TestProvider_NewControllerToken(t *testing.T) {
8283
}
8384

8485
provider := aws.Provider{Implementation: impl}
85-
token, err := provider.NewControllerToken(context.Background(), opts...)
86+
token, err := provider.NewControllerToken(t.Context(), opts...)
8687

8788
if tt.err == "" {
8889
g.Expect(err).NotTo(HaveOccurred())
@@ -536,3 +537,166 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
536537

537538
g.Expect(o.STSRegion).To(Equal("us-west-2"))
538539
}
540+
541+
func TestGetRegionFromCodeCommitURL(t *testing.T) {
542+
for _, tt := range []struct {
543+
name string
544+
gitURL string
545+
expectedRegion string
546+
err string
547+
}{
548+
{
549+
name: "valid CodeCommit URL",
550+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
551+
expectedRegion: "us-east-1",
552+
},
553+
{
554+
name: "valid CodeCommit FIPS URL",
555+
gitURL: "https://git-codecommit-fips.us-west-2.amazonaws.com/v1/repos/test-repo",
556+
expectedRegion: "us-west-2",
557+
},
558+
{
559+
name: "valid CodeCommit China URL",
560+
gitURL: "https://git-codecommit.cn-north-1.amazonaws.com.cn/v1/repos/test-repo",
561+
expectedRegion: "cn-north-1",
562+
},
563+
{
564+
name: "nil URL",
565+
err: "Git URL must be specified for AWS CodeCommit authentication",
566+
},
567+
{
568+
name: "non-HTTPS URL",
569+
gitURL: "http://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
570+
err: "AWS CodeCommit authentication requires an HTTPS Git URL",
571+
},
572+
{
573+
name: "invalid CodeCommit URL",
574+
gitURL: "https://github.com/org/repo",
575+
err: "invalid AWS CodeCommit Git URL: github.com",
576+
},
577+
} {
578+
t.Run(tt.name, func(t *testing.T) {
579+
g := NewWithT(t)
580+
var parsedURL *url.URL
581+
if tt.gitURL != "" {
582+
var err error
583+
parsedURL, err = url.Parse(tt.gitURL)
584+
g.Expect(err).NotTo(HaveOccurred())
585+
}
586+
region, err := aws.GetRegionFromCodeCommitURL(parsedURL)
587+
if tt.err != "" {
588+
g.Expect(err).To(HaveOccurred())
589+
g.Expect(err.Error()).To(Equal(tt.err))
590+
} else {
591+
g.Expect(err).NotTo(HaveOccurred())
592+
g.Expect(region).To(Equal(tt.expectedRegion))
593+
}
594+
})
595+
}
596+
}
597+
598+
func TestProvider_NewCodeCommitGitCredentials(t *testing.T) {
599+
invalidToken := &generic.Token{Token: "invalid", ExpiresAt: time.Now().Add(time.Hour)}
600+
proxyUrl := url.URL{Scheme: "http", Host: "proxy.example.com"}
601+
awsRegion := "us-east-1"
602+
for _, tt := range []struct {
603+
name string
604+
gitURL string
605+
getAccessToken bool
606+
accessTokens []auth.Token
607+
expectedUsername string
608+
err string
609+
}{
610+
{
611+
name: "valid CodeCommit URL",
612+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
613+
getAccessToken: true,
614+
expectedUsername: "access-key-id%session-token",
615+
},
616+
{
617+
name: "valid CodeCommit FIPS URL",
618+
gitURL: "https://git-codecommit-fips.us-east-1.amazonaws.com/v1/repos/test-repo",
619+
getAccessToken: true,
620+
expectedUsername: "access-key-id%session-token",
621+
},
622+
{
623+
name: "valid CodeCommit China URL",
624+
gitURL: "https://git-codecommit.cn-north-1.amazonaws.com.cn/v1/repos/test-repo",
625+
getAccessToken: true,
626+
expectedUsername: "access-key-id%session-token",
627+
},
628+
{
629+
name: "missing Git URL",
630+
getAccessToken: true,
631+
err: "Git URL must be specified for AWS CodeCommit authentication",
632+
},
633+
{
634+
name: "non HTTPS URL",
635+
gitURL: "http://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
636+
getAccessToken: true,
637+
err: "AWS CodeCommit authentication requires an HTTPS Git URL",
638+
},
639+
{
640+
name: "invalid CodeCommit URL",
641+
gitURL: "https://github.com/org/repo",
642+
getAccessToken: true,
643+
err: "invalid AWS CodeCommit Git URL: github.com",
644+
},
645+
{
646+
name: "missing access token",
647+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
648+
getAccessToken: false,
649+
accessTokens: []auth.Token{},
650+
err: `AWS access token is required for region "us-east-1"`,
651+
},
652+
{
653+
name: "invalid access token type",
654+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
655+
getAccessToken: false,
656+
accessTokens: []auth.Token{invalidToken},
657+
err: "failed to cast token to AWS token: *generic.Token",
658+
},
659+
} {
660+
t.Run(tt.name, func(t *testing.T) {
661+
g := NewWithT(t)
662+
663+
impl := &mockImplementation{
664+
t: t,
665+
argRegion: awsRegion,
666+
argProxyURL: &proxyUrl,
667+
returnCreds: awssdk.Credentials{AccessKeyID: "access-key-id", SecretAccessKey: "secret-access-key", SessionToken: "session-token"},
668+
}
669+
670+
opts := []auth.Option{}
671+
if tt.gitURL != "" {
672+
gitURL, err := url.Parse(tt.gitURL)
673+
g.Expect(err).NotTo(HaveOccurred())
674+
opts = append(opts, auth.WithGitURL(*gitURL))
675+
}
676+
677+
provider := aws.Provider{Implementation: impl}
678+
accessTokens := tt.accessTokens
679+
if tt.getAccessToken {
680+
accessToken, err := auth.GetAccessToken(t.Context(), provider,
681+
auth.WithSTSRegion(awsRegion),
682+
auth.WithProxyURL(proxyUrl),
683+
)
684+
g.Expect(err).NotTo(HaveOccurred())
685+
accessTokens = []auth.Token{accessToken}
686+
}
687+
688+
username, password, err := provider.NewCodeCommitGitCredentials(t.Context(), accessTokens, opts...)
689+
690+
if tt.err == "" {
691+
g.Expect(err).NotTo(HaveOccurred())
692+
g.Expect(username).To(Equal(tt.expectedUsername))
693+
g.Expect(password).To(MatchRegexp(`^[0-9]{8}T[0-9]{6}Z[0-9a-f]{64}$`))
694+
} else {
695+
g.Expect(err).To(HaveOccurred())
696+
g.Expect(err.Error()).To(Equal(tt.err))
697+
g.Expect(username).To(BeEmpty())
698+
g.Expect(password).To(BeEmpty())
699+
}
700+
})
701+
}
702+
}

auth/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require (
2121
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.9
2222
github.com/aws/aws-sdk-go-v2/service/eks v1.77.0
2323
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6
24+
github.com/aws/smithy-go/aws-http-auth v1.1.3
2425
github.com/coreos/go-oidc/v3 v3.17.0
2526
github.com/fluxcd/pkg/apis/meta v1.26.0
2627
github.com/fluxcd/pkg/cache v0.13.0

auth/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
5656
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
5757
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
5858
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
59+
github.com/aws/smithy-go/aws-http-auth v1.1.3 h1:8/T7/2n8x+x9sIAmi5h5mDKS8v7/u2GEpF6T6RrGMrc=
60+
github.com/aws/smithy-go/aws-http-auth v1.1.3/go.mod h1:KL46VTjVK9De3jurMqDLBkXCP9vrAvD03zQrmyzyrQ0=
5961
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
6062
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
6163
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

auth/options.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Options struct {
4242
STSRegion string
4343
STSEndpoint string
4444
ProxyURL *url.URL
45+
GitURL *url.URL
4546
CAData string
4647
ClusterResource string
4748
ClusterAddress string
@@ -122,6 +123,13 @@ func WithProxyURL(proxyURL url.URL) Option {
122123
}
123124
}
124125

126+
// WithGitURL sets the Git repository URL used by Git credential providers.
127+
func WithGitURL(gitURL url.URL) Option {
128+
return func(o *Options) {
129+
o.GitURL = &gitURL
130+
}
131+
}
132+
125133
// WithCAData sets the CA data for credentials that require a CA,
126134
// e.g. for Kubernetes REST config.
127135
func WithCAData(caData string) Option {

auth/utils/git.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"slices"
2323

2424
"github.com/fluxcd/pkg/auth"
25+
"github.com/fluxcd/pkg/auth/aws"
2526
"github.com/fluxcd/pkg/auth/azure"
2627
)
2728

@@ -46,6 +47,34 @@ func GetGitCredentials(ctx context.Context, providerName string, opts ...auth.Op
4647
return &GitCredentials{
4748
BearerToken: token.(*azure.Token).Token,
4849
}, nil
50+
case aws.ProviderName:
51+
provider := aws.Provider{}
52+
awsOpts := slices.Clone(opts)
53+
54+
// Extract the region from the CodeCommit URL and inject it as STSRegion
55+
// before calling GetAccessToken. This will ensure that it's possible to call AWS SDK
56+
// even without AWS_REGION environment variable.
57+
var o auth.Options
58+
o.Apply(awsOpts...)
59+
if o.STSRegion == "" && o.GitURL != nil {
60+
if region, err := aws.GetRegionFromCodeCommitURL(o.GitURL); err == nil {
61+
awsOpts = append(awsOpts, auth.WithSTSRegion(region))
62+
}
63+
}
64+
65+
token, err := auth.GetAccessToken(ctx, provider, awsOpts...)
66+
if err != nil {
67+
return nil, err
68+
}
69+
70+
username, password, err := provider.NewCodeCommitGitCredentials(ctx, []auth.Token{token}, awsOpts...)
71+
if err != nil {
72+
return nil, err
73+
}
74+
return &GitCredentials{
75+
Username: username,
76+
Password: password,
77+
}, nil
4978
default:
5079
return nil, fmt.Errorf("provider '%s' does not support Git credentials", providerName)
5180
}

0 commit comments

Comments
 (0)