From bd77ea8ee1f5df45514febf945dd593da2ed323c Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Fri, 19 Jun 2026 10:32:32 +1000 Subject: [PATCH] feat: ECR OCI /v2/token endpoint + Bearer challenge (#396) Add the Docker Registry v2 token endpoint (GET /v2/token) and switch the /v2 401 challenge from Basic to a Bearer realm pointing at it. The endpoint verifies the caller's existing AWS: credential and re-mints a fresh full-TTL token, providing the token-refresh path for OCI clients (docker, crane, skopeo). The auth bridge still accepts Basic AWS: directly. --- spinifex/gateway/ecr.go | 21 ++- spinifex/gateway/ecr_token.go | 95 ++++++++++++++ spinifex/gateway/ecr_token_test.go | 137 ++++++++++++++++++++ spinifex/gateway/ecrauth_middleware.go | 15 ++- spinifex/gateway/ecrauth_middleware_test.go | 7 +- 5 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 spinifex/gateway/ecr_token.go create mode 100644 spinifex/gateway/ecr_token_test.go diff --git a/spinifex/gateway/ecr.go b/spinifex/gateway/ecr.go index 7b04283d6..7d32f73b8 100644 --- a/spinifex/gateway/ecr.go +++ b/spinifex/gateway/ecr.go @@ -20,12 +20,19 @@ import ( func (gw *GatewayConfig) mountOCIRegistry(r chi.Router) { r.Route("/v2", func(v2 chi.Router) { v2.Use(gw.hostMatch) - v2.Use(gw.ecrAuthBridge) - v2.Get("/", gateway_ecr.APIVersion) - if gw.ECRRegistry != nil { - v2.HandleFunc("/*", gw.ECRRegistry.ServeHTTP) - } else { - v2.HandleFunc("/*", gateway_ecr.NotImplemented) - } + + // The token endpoint authenticates the presented credential in-band and + // issues a Bearer token, so it mounts outside the bearer auth bridge. + v2.Get("/token", gw.handleECRToken) + + v2.Group(func(reg chi.Router) { + reg.Use(gw.ecrAuthBridge) + reg.Get("/", gateway_ecr.APIVersion) + if gw.ECRRegistry != nil { + reg.HandleFunc("/*", gw.ECRRegistry.ServeHTTP) + } else { + reg.HandleFunc("/*", gateway_ecr.NotImplemented) + } + }) }) } diff --git a/spinifex/gateway/ecr_token.go b/spinifex/gateway/ecr_token.go new file mode 100644 index 000000000..84574629f --- /dev/null +++ b/spinifex/gateway/ecr_token.go @@ -0,0 +1,95 @@ +package gateway + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + gateway_ecr "github.com/mulgadc/spinifex/spinifex/gateway/ecr" + gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth" +) + +// ociTokenResponse is the Docker Registry v2 token-endpoint body. token and +// access_token carry the same JWT for compatibility across client versions. +type ociTokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt string `json:"issued_at"` +} + +// handleECRToken implements the OCI Bearer token endpoint advertised by the +// /v2 401 challenge. The caller presents its existing credential (Basic +// AWS: from GetAuthorizationToken, or Bearer ); this verifies it, +// enforces the registry-host account match, and re-mints a fresh full-TTL +// token. Re-minting on each call is the token-refresh path: a client near +// expiry obtains a new token without re-running SigV4. +// +// The endpoint authenticates the presented credential itself, so it mounts +// outside the bearer auth bridge. A missing or invalid credential returns 401 +// without a Bearer realm to avoid a challenge loop. +func (gw *GatewayConfig) handleECRToken(w http.ResponseWriter, r *http.Request) { + if gw.ECRTokenVerifier == nil || gw.ECRTokenIssuer == nil { + gateway_ecr.WriteError(w, http.StatusNotImplemented, "UNSUPPORTED", "token endpoint not configured") + return + } + + authz := r.Header.Values("Authorization") + if len(authz) > 1 { + gateway_ecr.WriteError(w, http.StatusBadRequest, "UNAUTHORIZED", "multiple Authorization headers") + return + } + raw := "" + if len(authz) == 1 { + raw = authz[0] + } + token, ok := extractECRToken(raw) + if !ok { + gateway_ecr.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") + return + } + + claims, err := gw.ECRTokenVerifier.Verify(token) + if err != nil { + slog.Debug("ECR token endpoint: verify failed", "err", err) + gateway_ecr.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials") + return + } + + // Cross-account guard mirrors the auth bridge: a token is account-scoped and + // must match the account in the registry host it is presented against. + if target, _ := r.Context().Value(ctxTargetAccount).(string); target != "" && target != claims.AccountID { + gateway_ecr.WriteError(w, http.StatusForbidden, "DENIED", "token account does not match registry host") + return + } + + fresh, expiresAt, err := gw.ECRTokenIssuer.Mint(gateway_ecrauth.Principal{ + AccountID: claims.AccountID, + ARN: claims.Subject, + Type: claims.PrincipalType, + AccessKeyID: claims.AccessKeyID, + }) + if err != nil { + slog.Error("ECR token endpoint: mint failed", "err", err) + gateway_ecr.WriteError(w, http.StatusInternalServerError, "SERVER_ERROR", "token mint failed") + return + } + + body, err := json.Marshal(ociTokenResponse{ + Token: fresh, + AccessToken: fresh, + ExpiresIn: int(time.Until(expiresAt).Seconds()), + IssuedAt: time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + slog.Error("ECR token endpoint: marshal failed", "err", err) + gateway_ecr.WriteError(w, http.StatusInternalServerError, "SERVER_ERROR", "response encode failed") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + slog.Error("ECR token endpoint: write failed", "err", err) + } +} diff --git a/spinifex/gateway/ecr_token_test.go b/spinifex/gateway/ecr_token_test.go new file mode 100644 index 000000000..dc584d73b --- /dev/null +++ b/spinifex/gateway/ecr_token_test.go @@ -0,0 +1,137 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func decodeTokenResp(t *testing.T, w *httptest.ResponseRecorder) ociTokenResponse { + t.Helper() + var resp ociTokenResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + return resp +} + +func TestECRToken_ExchangesBasicForBearer(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + req.Header.Set("Authorization", mintBasic(t, iss, ecrTestAccount)) + w := httptest.NewRecorder() + gw.handleECRToken(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + resp := decodeTokenResp(t, w) + require.NotEmpty(t, resp.Token) + assert.Equal(t, resp.Token, resp.AccessToken) + assert.Positive(t, resp.ExpiresIn) + + // The returned token must verify and carry the original account. + claims, err := verify.Verify(resp.Token) + require.NoError(t, err) + assert.Equal(t, ecrTestAccount, claims.AccountID) +} + +func TestECRToken_BearerCredentialAccepted(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + + tok, _, err := iss.Mint(gateway_ecrauth.Principal{AccountID: ecrTestAccount, ARN: "arn:aws:iam::" + ecrTestAccount + ":user/dev"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + gw.handleECRToken(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.NotEmpty(t, decodeTokenResp(t, w).Token) +} + +func TestECRToken_NoCredentialChallenges401(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + w := httptest.NewRecorder() + gw.handleECRToken(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + // Must not re-advertise Bearer here, or clients loop on the token endpoint. + assert.Empty(t, w.Header().Get("WWW-Authenticate")) +} + +func TestECRToken_InvalidCredential401(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + req.Header.Set("Authorization", "Bearer not.a.realtoken") + w := httptest.NewRecorder() + gw.handleECRToken(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestECRToken_CrossAccountForbidden(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + req.Header.Set("Authorization", mintBasic(t, iss, ecrTestAccount)) + ctx := context.WithValue(req.Context(), ctxTargetAccount, "999999999999") + w := httptest.NewRecorder() + gw.handleECRToken(w, req.WithContext(ctx)) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestECRToken_MultipleAuthHeadersRejected(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + req.Header.Add("Authorization", "Bearer a.b.c") + req.Header.Add("Authorization", "Bearer d.e.f") + w := httptest.NewRecorder() + gw.handleECRToken(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestOCIRegistry_TokenRouted proves chi routes GET /v2/token to the token +// endpoint (200) rather than the bridge-guarded /* catch-all. +func TestOCIRegistry_TokenRouted(t *testing.T) { + iss, verify := newECRAuth(t) + gw := &GatewayConfig{DisableLogging: true, Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, ECRTokenIssuer: iss, ECRTokenVerifier: verify} + r := chi.NewRouter() + gw.mountOCIRegistry(r) + + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + req.Host = ecrTestAccount + ".dkr.ecr." + ecrTestRegion + "." + ecrTestSuffix + req.Header.Set("Authorization", mintBasic(t, iss, ecrTestAccount)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.NotEmpty(t, decodeTokenResp(t, w).Token) +} + +func TestECRToken_NotConfigured501(t *testing.T) { + gw := &GatewayConfig{Region: ecrTestRegion, InternalSuffix: ecrTestSuffix} + req := httptest.NewRequest(http.MethodGet, "/v2/token", nil) + w := httptest.NewRecorder() + gw.handleECRToken(w, req) + + assert.Equal(t, http.StatusNotImplemented, w.Code) +} diff --git a/spinifex/gateway/ecrauth_middleware.go b/spinifex/gateway/ecrauth_middleware.go index 6b32bf8d3..8e0e8a5a6 100644 --- a/spinifex/gateway/ecrauth_middleware.go +++ b/spinifex/gateway/ecrauth_middleware.go @@ -87,13 +87,14 @@ func extractECRToken(authz string) (string, bool) { } } -// writeECRChallenge emits the 401 Basic challenge. ECR authenticates the OCI -// registry with Basic AWS: rather than the OAuth2 token-endpoint flow, -// so the scheme must be Basic: a Bearer challenge makes docker chase a -// /v2/token endpoint that does not exist. The realm uses the request Host so -// docker negotiates against the address it actually dialed. +// writeECRChallenge emits the 401 Bearer challenge advertising the /v2/token +// endpoint. OCI clients (docker, crane, skopeo) fetch a token from the realm +// using their stored AWS: credential, then replay it as Bearer. The bridge +// still accepts Basic AWS: directly, so a Basic-only caller also works. The +// realm and service use the request Host so the client negotiates against the +// address it actually dialed. func (gw *GatewayConfig) writeECRChallenge(w http.ResponseWriter, r *http.Request) { - realm := "https://" + r.Host + "/" - w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`) + realm := "https://" + r.Host + "/v2/token" + w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="`+r.Host+`"`) gateway_ecr.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") } diff --git a/spinifex/gateway/ecrauth_middleware_test.go b/spinifex/gateway/ecrauth_middleware_test.go index 0eaa2fcb5..ca15ad3d1 100644 --- a/spinifex/gateway/ecrauth_middleware_test.go +++ b/spinifex/gateway/ecrauth_middleware_test.go @@ -101,8 +101,11 @@ func TestECRAuthBridge_NoAuthChallenges401(t *testing.T) { assert.False(t, *called, "handler must not run unauthenticated") assert.Equal(t, http.StatusUnauthorized, w.Code) - // ECR uses Basic auth, not the Bearer token-endpoint flow. - assert.Equal(t, `Basic realm="https://`+req.Host+`/"`, w.Header().Get("WWW-Authenticate")) + // Bearer challenge points clients at the /v2/token endpoint; the bridge still + // accepts Basic AWS: directly. + assert.Equal(t, + `Bearer realm="https://`+req.Host+`/v2/token",service="`+req.Host+`"`, + w.Header().Get("WWW-Authenticate")) } func TestECRAuthBridge_ValidTokenPassesThrough(t *testing.T) {