mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
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:
+14
-7
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user