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

Add new package auth for various authentication scenarios #664

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion .github/workflows/integration-gcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./oci/tests/integration
working-directory: ./auth/registry/tests/integration
steps:
- name: Checkout
uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0
Expand Down
76 changes: 76 additions & 0 deletions auth/aws/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
Copyright 2023 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package aws

import (
"context"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
)

// Provider is an authentication provider for AWS.
type Provider struct {
optFns []func(*config.LoadOptions) error
config *aws.Config
}

// ProviderOptFunc enables specifying options for the provider.
type ProviderOptFunc func(*Provider)

// NewProvider returns a new authentication provider for AWS.
func NewProvider(opts ...ProviderOptFunc) *Provider {
p := &Provider{}
for _, opt := range opts {
opt(p)
}
return p
}

// WithRegion configures the AWS region.
func WithRegion(region string) ProviderOptFunc {
return func(p *Provider) {
p.optFns = append(p.optFns, config.WithRegion(region))
}
}

// WithOptFns configures the AWS config with the provided load options.
func WithOptFns(optFns []func(*config.LoadOptions) error) ProviderOptFunc {
return func(p *Provider) {
p.optFns = append(p.optFns, optFns...)
}
}

// WithConfig specifies the custom AWS config to use.
func WithConfig(config aws.Config) ProviderOptFunc {
return func(p *Provider) {
p.config = &config
}
}

// GetConfig returns the default config constructed using any options that the
// provider was configured with. If OIDC/IRSA has been configured for the EKS
// cluster, then the config object will also be configured with the necessary
// credentials. The returned config object can be used to fetch tokens to access
// particular AWS services.
func (p *Provider) GetConfig(ctx context.Context) (aws.Config, error) {
if p.config != nil {
return *p.config, nil
}
cfg, err := config.LoadDefaultConfig(ctx, p.optFns...)
return cfg, err
}
100 changes: 100 additions & 0 deletions auth/aws/ecr_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
Copyright 2023 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package aws

import (
"context"
"encoding/base64"
"errors"
"fmt"
"regexp"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/google/go-containerregistry/pkg/authn"
)

var registryPartRe = regexp.MustCompile(`([0-9+]*).dkr.ecr.([^/.]*)\.(amazonaws\.com[.cn]*)`)

// ParseRegistry returns the AWS account ID and region and `true` if
// the image registry/repository is hosted in AWS's Elastic Container Registry,
// otherwise empty strings and `false`.
func ParseRegistry(registry string) (accountId, awsEcrRegion string, ok bool) {
registryParts := registryPartRe.FindAllStringSubmatch(registry, -1)
if len(registryParts) < 1 || len(registryParts[0]) < 3 {
return "", "", false
}
return registryParts[0][1], registryParts[0][2], true
}

// GetECRAuthConfig returns an AuthConfig that contains the credentials
// required to authenticate against ECR to access the provided image.
func (p *Provider) GetECRAuthConfig(ctx context.Context, image string) (authn.AuthConfig, time.Duration, error) {
var authConfig authn.AuthConfig
var expiresIn time.Duration

_, awsEcrRegion, ok := ParseRegistry(image)
if !ok {
return authConfig, expiresIn, errors.New("failed to parse AWS ECR image, invalid ECR image")
}
p.optFns = append(p.optFns, config.WithRegion(awsEcrRegion))

cfg, err := p.GetConfig(ctx)
if err != nil {
return authConfig, expiresIn, err
}

ecrService := ecr.NewFromConfig(cfg)
// NOTE: ecr.GetAuthorizationTokenInput has deprecated RegistryIds. Hence,
// pass nil input.
ecrToken, err := ecrService.GetAuthorizationToken(ctx, nil)
if err != nil {
return authConfig, expiresIn, err
}

// Validate the authorization data.
if len(ecrToken.AuthorizationData) == 0 {
return authConfig, expiresIn, errors.New("no authorization data")
}
authData := ecrToken.AuthorizationData[0]
if authData.AuthorizationToken == nil {
return authConfig, expiresIn, fmt.Errorf("no authorization token")
}
token, err := base64.StdEncoding.DecodeString(*authData.AuthorizationToken)
if err != nil {
return authConfig, expiresIn, err
}

tokenSplit := strings.Split(string(token), ":")
// Validate the tokens.
if len(tokenSplit) != 2 {
return authConfig, expiresIn, fmt.Errorf("invalid authorization token, expected the token to have two parts separated by ':', got %d parts", len(tokenSplit))
}

authConfig = authn.AuthConfig{
Username: tokenSplit[0],
Password: tokenSplit[1],
}
if authData.ExpiresAt == nil {
return authConfig, expiresIn, fmt.Errorf("no expiration time")
}
expiresIn = authData.ExpiresAt.Sub(time.Now())

return authConfig, expiresIn, nil
}
178 changes: 178 additions & 0 deletions auth/aws/ecr_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
Copyright 2023 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package aws

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/google/go-containerregistry/pkg/authn"
. "github.com/onsi/gomega"
)

const (
testValidECRImage = "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1"
)

func TestParseRegistry(t *testing.T) {
tests := []struct {
registry string
wantAccountID string
wantRegion string
wantOK bool
}{
{
registry: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1",
wantAccountID: "012345678901",
wantRegion: "us-east-1",
wantOK: true,
},
{
registry: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo",
wantAccountID: "012345678901",
wantRegion: "us-east-1",
wantOK: true,
},
{
registry: "012345678901.dkr.ecr.us-east-1.amazonaws.com",
wantAccountID: "012345678901",
wantRegion: "us-east-1",
wantOK: true,
},
{
registry: "gcr.io/foo/bar:baz",
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.registry, func(t *testing.T) {
g := NewWithT(t)

accId, region, ok := ParseRegistry(tt.registry)
g.Expect(ok).To(Equal(tt.wantOK), "unexpected OK")
g.Expect(accId).To(Equal(tt.wantAccountID), "unexpected account IDs")
g.Expect(region).To(Equal(tt.wantRegion), "unexpected regions")
})
}
}

func TestProvider_GetECRAuthConfig(t *testing.T) {
expiresAt := time.Now().Add(time.Hour)
tests := []struct {
name string
responseBody []byte
statusCode int
wantErr bool
wantAuthConfig authn.AuthConfig
}{
{
// NOTE: The authorizationToken is base64 encoded.
name: "success",
responseBody: []byte(`{
"authorizationData": [
{
"authorizationToken": "c29tZS1rZXk6c29tZS1zZWNyZXQ=",
"expiresAt": <expiresAt>
}
]
}`),
statusCode: http.StatusOK,
wantAuthConfig: authn.AuthConfig{
Username: "some-key",
Password: "some-secret",
},
},
{
name: "fail",
statusCode: http.StatusInternalServerError,
wantErr: true,
},
{
name: "invalid token",
responseBody: []byte(`{
"authorizationData": [
{
"authorizationToken": "c29tZS10b2tlbg=="
}
]
}`),
statusCode: http.StatusOK,
wantErr: true,
},
{
name: "invalid data",
responseBody: []byte(`{
"authorizationData": [
{
"foo": "bar"
}
]
}`),
statusCode: http.StatusOK,
wantErr: true,
},
{
name: "invalid response",
responseBody: []byte(`{}`),
statusCode: http.StatusOK,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)

handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(strings.ReplaceAll(
string(tt.responseBody), "<expiresAt>", fmt.Sprint(expiresAt.Unix())),
))
}
srv := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(func() {
srv.Close()
})

cfg := aws.NewConfig()
cfg.EndpointResolverWithOptions = aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{URL: srv.URL}, nil
})
cfg.Credentials = credentials.NewStaticCredentialsProvider("x", "y", "z")

provider := NewProvider(WithConfig(*cfg))
auth, expiry, err := provider.GetECRAuthConfig(context.TODO(), "0123.dkr.ecr.us-east-1.amazonaws.com/foo:v1")
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
} else {
g.Expect(err).ToNot(HaveOccurred())
if tt.statusCode == http.StatusOK {
g.Expect(auth).To(Equal(tt.wantAuthConfig))
g.Expect(time.Now().UTC().Add(expiry)).To(BeTemporally("~", expiresAt, time.Second))
}
}
})
}
}