Skip to content

Commit c69e541

Browse files
committed
auth/aws: AWS CodeCommit IAM authentification
Signed-off-by: Taras Postument <taras.postument@gmail.com>
1 parent 07d627d commit c69e541

8 files changed

Lines changed: 240 additions & 2 deletions

File tree

auth/aws/provider.go

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/base64"
2222
"errors"
2323
"fmt"
24+
"net/http"
2425
"os"
2526
"regexp"
2627
"strings"
@@ -32,14 +33,20 @@ import (
3233
"github.com/aws/aws-sdk-go-v2/service/ecrpublic"
3334
"github.com/aws/aws-sdk-go-v2/service/eks"
3435
"github.com/aws/aws-sdk-go-v2/service/sts"
36+
"github.com/aws/smithy-go/aws-http-auth/credentials"
37+
"github.com/aws/smithy-go/aws-http-auth/sigv4"
38+
v4 "github.com/aws/smithy-go/aws-http-auth/v4"
3539
"github.com/google/go-containerregistry/pkg/authn"
3640
corev1 "k8s.io/api/core/v1"
3741

3842
"github.com/fluxcd/pkg/auth"
3943
)
4044

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

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

auth/aws/provider_test.go

Lines changed: 107 additions & 0 deletions
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) {
@@ -536,3 +537,109 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
536537

537538
g.Expect(o.STSRegion).To(Equal("us-west-2"))
538539
}
540+
541+
func TestProvider_NewCodeCommitGitCredentials(t *testing.T) {
542+
invalidToken := &generic.Token{Token: "invalid", ExpiresAt: time.Now().Add(time.Hour)}
543+
proxyUrl := url.URL{Scheme: "http", Host: "proxy.example.com"}
544+
awsRegion := "us-east-1"
545+
for _, tt := range []struct {
546+
name string
547+
gitURL string
548+
getAccessToken bool
549+
accessTokens []auth.Token
550+
expectedUsername string
551+
err string
552+
}{
553+
{
554+
name: "valid CodeCommit URL",
555+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
556+
getAccessToken: true,
557+
expectedUsername: "access-key-id%session-token",
558+
},
559+
{
560+
name: "valid CodeCommit FIPS URL",
561+
gitURL: "https://git-codecommit-fips.us-east-1.amazonaws.com/v1/repos/test-repo",
562+
getAccessToken: true,
563+
expectedUsername: "access-key-id%session-token",
564+
},
565+
{
566+
name: "valid CodeCommit China URL",
567+
gitURL: "https://git-codecommit.cn-north-1.amazonaws.com.cn/v1/repos/test-repo",
568+
getAccessToken: true,
569+
expectedUsername: "access-key-id%session-token",
570+
},
571+
{
572+
name: "missing Git URL",
573+
getAccessToken: true,
574+
err: "Git URL must be specified for AWS CodeCommit authentication",
575+
},
576+
{
577+
name: "non HTTPS URL",
578+
gitURL: "http://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
579+
getAccessToken: true,
580+
err: "AWS CodeCommit authentication requires an HTTPS Git URL",
581+
},
582+
{
583+
name: "invalid CodeCommit URL",
584+
gitURL: "https://github.com/org/repo",
585+
getAccessToken: true,
586+
err: "invalid AWS CodeCommit Git URL: github.com",
587+
},
588+
{
589+
name: "missing access token",
590+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
591+
getAccessToken: false,
592+
accessTokens: []auth.Token{},
593+
err: `AWS access token is required for region "us-east-1"`,
594+
},
595+
{
596+
name: "invalid access token type",
597+
gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo",
598+
getAccessToken: false,
599+
accessTokens: []auth.Token{invalidToken},
600+
err: "failed to cast token to AWS token: *generic.Token",
601+
},
602+
} {
603+
t.Run(tt.name, func(t *testing.T) {
604+
g := NewWithT(t)
605+
606+
impl := &mockImplementation{
607+
t: t,
608+
argRegion: awsRegion,
609+
argProxyURL: &proxyUrl,
610+
returnCreds: awssdk.Credentials{AccessKeyID: "access-key-id", SecretAccessKey: "secret-access-key", SessionToken: "session-token"},
611+
}
612+
613+
opts := []auth.Option{}
614+
if tt.gitURL != "" {
615+
gitURL, err := url.Parse(tt.gitURL)
616+
g.Expect(err).NotTo(HaveOccurred())
617+
opts = append(opts, auth.WithGitURL(*gitURL))
618+
}
619+
620+
provider := aws.Provider{Implementation: impl}
621+
accessTokens := tt.accessTokens
622+
if tt.getAccessToken {
623+
accessToken, err := auth.GetAccessToken(context.Background(), provider,
624+
auth.WithSTSRegion(awsRegion),
625+
auth.WithProxyURL(proxyUrl),
626+
)
627+
g.Expect(err).NotTo(HaveOccurred())
628+
accessTokens = []auth.Token{accessToken}
629+
}
630+
631+
username, password, err := provider.NewCodeCommitGitCredentials(context.Background(), accessTokens, opts...)
632+
633+
if tt.err == "" {
634+
g.Expect(err).NotTo(HaveOccurred())
635+
g.Expect(username).To(Equal(tt.expectedUsername))
636+
g.Expect(password).To(MatchRegexp(`^[0-9]{8}T[0-9]{6}Z[0-9a-f]{64}$`))
637+
} else {
638+
g.Expect(err).To(HaveOccurred())
639+
g.Expect(err.Error()).To(Equal(tt.err))
640+
g.Expect(username).To(BeEmpty())
641+
g.Expect(password).To(BeEmpty())
642+
}
643+
})
644+
}
645+
}

auth/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ 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+
// TODO: update to the tagged version that includes
25+
// https://github.com/aws/smithy-go/pull/628
26+
github.com/aws/smithy-go/aws-http-auth v1.1.2-0.20260302195807-5bb6ea94670a
2427
github.com/coreos/go-oidc/v3 v3.17.0
2528
github.com/fluxcd/pkg/apis/meta v1.25.0
2629
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.2-0.20260302195807-5bb6ea94670a h1:sN7kaGyTnpaIf0Ta59oeMjH59BYbyI+GvAs0wJgbLus=
60+
github.com/aws/smithy-go/aws-http-auth v1.1.2-0.20260302195807-5bb6ea94670a/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: 17 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,22 @@ 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+
token, err := auth.GetAccessToken(ctx, provider, awsOpts...)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
username, password, err := provider.NewCodeCommitGitCredentials(ctx, []auth.Token{token}, awsOpts...)
59+
if err != nil {
60+
return nil, err
61+
}
62+
return &GitCredentials{
63+
Username: username,
64+
Password: password,
65+
}, nil
4966
default:
5067
return nil, fmt.Errorf("provider '%s' does not support Git credentials", providerName)
5168
}

auth/utils/git_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ package utils_test
1818

1919
import (
2020
"context"
21+
"fmt"
22+
"net/url"
2123
"testing"
2224
"time"
2325

2426
. "github.com/onsi/gomega"
2527

28+
"github.com/fluxcd/pkg/auth"
2629
authutils "github.com/fluxcd/pkg/auth/utils"
2730
)
2831

@@ -44,4 +47,17 @@ func TestGetGitCredentials(t *testing.T) {
4447
g.Expect(err.Error()).To(Equal("provider 'unknown' does not support Git credentials"))
4548
g.Expect(p).To(BeNil())
4649
})
50+
51+
t.Run("aws", func(t *testing.T) {
52+
g := NewWithT(t)
53+
region := "us-east-1"
54+
t.Setenv("AWS_REGION", region)
55+
u, err := url.Parse(fmt.Sprintf("https://git-codecommit.%s.amazonaws.com/v1/repos/repo-name", region))
56+
g.Expect(err).ToNot(HaveOccurred())
57+
opts := []auth.Option{auth.WithGitURL(*u)}
58+
p, err := authutils.GetGitCredentials(context.Background(), "aws", opts...)
59+
g.Expect(err).To(HaveOccurred())
60+
g.Expect(err.Error()).To(ContainSubstring("failed to create provider access token"))
61+
g.Expect(p).To(BeNil())
62+
})
4763
}

tests/integration/testapp/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ func checkGit(ctx context.Context) {
238238
panic(err)
239239
}
240240
if !*gitSSH {
241-
creds, err := authutils.GetGitCredentials(ctx, *provider, authOpts...)
241+
creds, err := authutils.GetGitCredentials(ctx, *provider, append(authOpts, auth.WithGitURL(*u))...)
242242
if err != nil {
243243
panic(err)
244244
}

0 commit comments

Comments
 (0)