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:<jwt> 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:<jwt> directly.
This commit is contained in:
Josh Siviour
2026-06-19 10:32:32 +10:00
committed by GitHub
parent 5d9e948b31
commit bd77ea8ee1
5 changed files with 259 additions and 16 deletions
+14 -7
View File
@@ -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)
}
})
})
}
+95
View File
@@ -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:<jwt> from GetAuthorizationToken, or Bearer <jwt>); 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)
}
}
+137
View File
@@ -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)
}
+8 -7
View File
@@ -87,13 +87,14 @@ func extractECRToken(authz string) (string, bool) {
}
}
// writeECRChallenge emits the 401 Basic challenge. ECR authenticates the OCI
// registry with Basic AWS:<token> 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:<jwt> credential, then replay it as Bearer. The bridge
// still accepts Basic AWS:<jwt> 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")
}
+5 -2
View File
@@ -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:<jwt> 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) {