refactor: move ecr authorization token action to domain

This commit is contained in:
joshsiv-mulga
2026-10-01 15:38:50 +10:00
parent 54bdee40f4
commit a2f6005d29
3 changed files with 107 additions and 20 deletions
@@ -0,0 +1,41 @@
package awsapi
import (
"encoding/base64"
"errors"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecr"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// AuthorizationTokenIssuer is the narrow credential capability the ECR AWS
// action needs. ECR auth owns the concrete ES256 issuer; the AWS adapter owns
// its AWS JSON response projection.
type AuthorizationTokenIssuer interface {
Mint(ecrauth.Principal) (token string, expiresAt time.Time, err error)
}
// GetAuthorizationToken mints an ECR registry token for the canonical
// SigV4-authenticated principal. The returned token is base64("AWS:<jwt>"),
// which Docker replays as Basic auth to the ECR /v2 registry endpoint.
func GetAuthorizationToken(issuer AuthorizationTokenIssuer, endpoint RepositoryEndpoint, principal ecrauth.Principal) (*ecr.GetAuthorizationTokenOutput, error) {
if issuer == nil {
return nil, errors.New(awserrors.ErrorNotImplemented)
}
token, expiresAt, err := issuer.Mint(principal)
if err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
authToken := base64.StdEncoding.EncodeToString([]byte("AWS:" + token))
return &ecr.GetAuthorizationTokenOutput{
AuthorizationData: []*ecr.AuthorizationData{{
AuthorizationToken: aws.String(authToken),
ProxyEndpoint: aws.String("https://" + endpoint.RegistryURIHost(principal.AccountID)),
ExpiresAt: aws.Time(expiresAt),
}},
}, nil
}
@@ -0,0 +1,58 @@
package awsapi
import (
"encoding/base64"
"errors"
"testing"
"time"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeAuthorizationTokenIssuer struct {
got ecrauth.Principal
token string
expiresAt time.Time
err error
}
func (i *fakeAuthorizationTokenIssuer) Mint(principal ecrauth.Principal) (string, time.Time, error) {
i.got = principal
return i.token, i.expiresAt, i.err
}
func TestGetAuthorizationToken_ProjectsMintedCredential(t *testing.T) {
expiresAt := time.Date(2026, 10, 2, 3, 4, 5, 0, time.UTC)
issuer := &fakeAuthorizationTokenIssuer{token: "signed-token", expiresAt: expiresAt}
principal := ecrauth.Principal{
AccountID: "123456789012", ARN: "arn:aws:iam::123456789012:user/dev",
Type: "user", AccessKeyID: "AKIAEXAMPLE",
}
out, err := GetAuthorizationToken(issuer, RepositoryEndpoint{
Region: "ap-southeast-2", ServicesDomain: "spinifex.test", RegistryPort: "9999",
}, principal)
require.NoError(t, err)
require.Len(t, out.AuthorizationData, 1)
data := out.AuthorizationData[0]
assert.Equal(t, principal, issuer.got)
assert.Equal(t, "https://123456789012.dkr.ecr.ap-southeast-2.spinifex.test:9999", *data.ProxyEndpoint)
assert.Equal(t, expiresAt, *data.ExpiresAt)
decoded, err := base64.StdEncoding.DecodeString(*data.AuthorizationToken)
require.NoError(t, err)
assert.Equal(t, "AWS:signed-token", string(decoded))
}
func TestGetAuthorizationToken_RefusesUnavailableIssuer(t *testing.T) {
principal := ecrauth.Principal{AccountID: "123456789012"}
_, err := GetAuthorizationToken(nil, RepositoryEndpoint{}, principal)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, awserrors.ValidErrorCodeFromError(err))
_, err = GetAuthorizationToken(&fakeAuthorizationTokenIssuer{err: errors.New("signer unavailable")}, RepositoryEndpoint{}, principal)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, awserrors.ValidErrorCodeFromError(err))
}
+8 -20
View File
@@ -1,22 +1,19 @@
package gateway
import (
"encoding/base64"
"errors"
"log/slog"
"net/http"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecr"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// handleGetAuthorizationToken mints a self-contained ES256 ECR token for the
// SigV4-authenticated caller and returns it in the AWS GetAuthorizationToken
// shape. The token is base64("AWS:<jwt>"); docker stores it and replays it as
// Basic auth against the /v2 registry, where the auth bridge verifies it.
// handleGetAuthorizationToken adapts an authenticated gateway identity into
// the canonical ECR credential principal. The AWS token response is owned by
// the ECR action adapter, while canonical IAM/STS ARN construction remains a
// generic gateway identity concern.
func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *http.Request) error {
if gw.ECRTokenIssuer == nil {
return errors.New(awserrors.ErrorNotImplemented)
@@ -42,26 +39,17 @@ func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *h
return errors.New(awserrors.ErrorServerInternal)
}
token, expiresAt, err := gw.ECRTokenIssuer.Mint(ecrauth.Principal{
output, err := awsapi.GetAuthorizationToken(gw.ECRTokenIssuer, gw.ecrRepositoryEndpoint(), ecrauth.Principal{
AccountID: accountID,
ARN: callerARN,
Type: principalType,
AccessKeyID: accessKey,
})
if err != nil {
slog.Error("GetAuthorizationToken: mint failed", "err", err)
return errors.New(awserrors.ErrorServerInternal)
slog.Error("GetAuthorizationToken: action failed", "err", err)
return err
}
authToken := base64.StdEncoding.EncodeToString([]byte("AWS:" + token))
proxyEndpoint := "https://" + gw.ecrRepositoryEndpoint().RegistryURIHost(accountID)
awsapi.WriteJSONResponse(w, &ecr.GetAuthorizationTokenOutput{
AuthorizationData: []*ecr.AuthorizationData{{
AuthorizationToken: aws.String(authToken),
ProxyEndpoint: aws.String(proxyEndpoint),
ExpiresAt: aws.Time(expiresAt),
}},
})
awsapi.WriteJSONResponse(w, output)
return nil
}