Files
Evan JarrettandClaude Opus 5 2719428071 appview: give each registry domain its own JWT service name
An AppView can front several registry domains that all reach the same
backend (seamark.dev serving buoy.cr, seamark.cr, and soon atcr.io).
Distribution's token access controller holds `service` as a single string
and uses it twice: as the value advertised in the WWW-Authenticate
challenge, and as the sole accepted JWT audience. So it announced one
domain's name on every domain, and honoured one domain's tokens
everywhere. A push to seamark.cr was challenged with service="buoy.cr".

Both uses sit inside Authorized, which already has the request, but the
value is fixed at construction and reachable through no hook — autoredirect
only templates the realm. So register an "atcr-token" controller that
builds one upstream controller per domain and dispatches on r.Host. Each
front door now advertises its own name and demands its own audience. All
signature, certificate and claim verification stays in upstream code; this
only routes.

The token handler stops discarding ?service= and stamps the audience with
the front door the client used, allowlist-checked against the configured
domains so the value stays server-determined despite arriving from the
client. It has to come from the query param because the realm lives on the
UI host, where r.Host names no registry domain.

This is token hygiene and spec conformance, not a privilege boundary: every
domain fronts the same backend, so a client can obtain a token for any of
them just by handshaking there. What it buys is a truthful challenge and
the decoupling needed to later split a domain onto its own AppView.

Also unify the domain list. DomainRoutingMiddleware keyed its map on the
raw config while matching a port-stripped host, so a domain configured with
a port could never match its own requests. It now shares the normalized
cfg.Auth.Services, so routing and authorization agree on one set of names.
cfg.Auth.ServiceName was an exact alias for Services[0] and is replaced by
PrimaryService(), which also removes an empty-slice index.

Rollout: the audience for seamark.cr and bouy.cr changes, so a token minted
just before the restart draws one 401 and Docker re-handshakes into a valid
one. buoy.cr is unchanged (it stays primary), and atcr.io keeps the service
name it already has today. The challenge and the accepted audience come
from the same delegate, so the retry converges by construction. Deploy as a
single flip, not a canary: an old instance ignores ?service= and would keep
minting the primary audience while a new one rejects it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:40:55 -05:00

226 lines
7.1 KiB
Go

package registryauth
import (
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/distribution/distribution/v3/registry/auth"
pkgauth "atcr.io/pkg/auth"
atcrtoken "atcr.io/pkg/auth/token"
)
const (
testIssuer = "seamark.dev"
testPrimary = "buoy.cr"
)
// newTestController builds a controller over the given services, backed by a
// freshly generated signing key, and returns it alongside the issuer that mints
// tokens it will accept.
func newTestController(t *testing.T, services []string) (auth.AccessController, *atcrtoken.Issuer) {
t.Helper()
keyPath := filepath.Join(t.TempDir(), "jwt.pem")
issuer, err := atcrtoken.NewIssuer(keyPath, testIssuer, services[0], 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
certPath := strings.TrimSuffix(keyPath, ".pem") + ".crt"
ac, err := newController(map[string]any{
"realm": "https://seamark.dev/auth/token",
"issuer": testIssuer,
"services": services,
"rootcertbundle": certPath,
})
if err != nil {
t.Fatalf("newController() error = %v", err)
}
return ac, issuer
}
// pingRequest builds the /v2/ ping a Docker client sends, bearing a token
// minted for audience. An empty audience sends no Authorization header.
func pingRequest(t *testing.T, issuer *atcrtoken.Issuer, host, audience string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
req.Host = host
if audience == "" {
return req
}
tok, err := issuer.IssueWithExpiration("did:plc:test", nil, atcrtoken.AuthMethodOAuth, time.Minute, audience)
if err != nil {
t.Fatalf("IssueWithExpiration(%q) error = %v", audience, err)
}
req.Header.Set("Authorization", "Bearer "+tok)
return req
}
// challengeService extracts the service parameter from the WWW-Authenticate
// header the given authorization error would emit.
func challengeService(t *testing.T, err error, r *http.Request) string {
t.Helper()
var challenge auth.Challenge
if !errors.As(err, &challenge) {
t.Fatalf("error %v is not an auth.Challenge", err)
}
rec := httptest.NewRecorder()
challenge.SetHeaders(r, rec)
// Values are quoted and scope= legitimately contains commas
// ("repository:x:pull,push"), so match the quoted value rather than
// splitting the header on ",".
header := rec.Header().Get("WWW-Authenticate")
m := regexp.MustCompile(`service="([^"]*)"`).FindStringSubmatch(header)
if m == nil {
t.Fatalf("no service parameter in WWW-Authenticate %q", header)
}
return m[1]
}
// A token is valid only on the front door it was minted for. This is the whole
// point of the package: upstream's single `service` accepts one audience
// everywhere, which would let a buoy.cr token authorize an atcr.io push.
func TestAuthorized_TokenIsScopedToItsFrontDoor(t *testing.T) {
services := []string{testPrimary, "seamark.cr", "atcr.io"}
ac, issuer := newTestController(t, services)
for _, host := range services {
t.Run("accepts own audience on "+host, func(t *testing.T) {
if _, err := ac.Authorized(pingRequest(t, issuer, host, host)); err != nil {
t.Fatalf("Authorized() error = %v, want nil", err)
}
})
}
t.Run("rejects another domain's audience", func(t *testing.T) {
req := pingRequest(t, issuer, "atcr.io", testPrimary)
if _, err := ac.Authorized(req); err == nil {
t.Fatal("Authorized() = nil, want error for cross-domain audience")
}
})
}
// Each front door must advertise its own name, so the client echoes the right
// service back to the realm and receives a token for the domain it is using.
func TestAuthorized_ChallengeAdvertisesRequestHost(t *testing.T) {
ac, issuer := newTestController(t, []string{testPrimary, "seamark.cr", "atcr.io"})
tests := []struct {
name string
host string
want string
}{
{"registry domain", "atcr.io", "atcr.io"},
{"another registry domain", "seamark.cr", "seamark.cr"},
{"primary", testPrimary, testPrimary},
// Ports are stripped before matching, mirroring DomainRoutingMiddleware.
{"host with port", "atcr.io:443", "atcr.io"},
// Anything DomainRoutingMiddleware would not have routed here falls
// back to the primary rather than failing closed.
{"unknown host", "example.com", testPrimary},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := pingRequest(t, issuer, tt.host, "")
_, err := ac.Authorized(req)
if err == nil {
t.Fatal("Authorized() = nil, want challenge for missing token")
}
if got := challengeService(t, err, req); got != tt.want {
t.Errorf("challenge service = %q, want %q", got, tt.want)
}
})
}
}
// A push scope in the JWT must still be honoured through the delegate; routing
// by host must not drop the access check.
func TestAuthorized_PassesAccessThroughToDelegate(t *testing.T) {
ac, issuer := newTestController(t, []string{testPrimary, "atcr.io"})
granted := []pkgauth.AccessEntry{{
Type: "repository",
Name: "alice.test/app",
Actions: []string{"pull", "push"},
}}
tok, err := issuer.IssueWithExpiration("did:plc:test", granted, atcrtoken.AuthMethodOAuth, time.Minute, "atcr.io")
if err != nil {
t.Fatalf("IssueWithExpiration() error = %v", err)
}
newReq := func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
req.Host = "atcr.io"
req.Header.Set("Authorization", "Bearer "+tok)
return req
}
push := auth.Access{
Resource: auth.Resource{Type: "repository", Name: "alice.test/app"},
Action: "push",
}
if _, err := ac.Authorized(newReq(), push); err != nil {
t.Fatalf("Authorized(push) error = %v, want nil", err)
}
other := auth.Access{
Resource: auth.Resource{Type: "repository", Name: "bob.test/app"},
Action: "push",
}
if _, err := ac.Authorized(newReq(), other); err == nil {
t.Fatal("Authorized(other repo) = nil, want insufficient scope")
}
}
// A single-element list must behave exactly like the upstream single-service
// controller, so a single-domain deployment sees no behaviour change.
func TestNewController_SingleService(t *testing.T) {
ac, issuer := newTestController(t, []string{"atcr.io"})
// The single service answers on its own host and on any other, since there
// is no second domain to disambiguate against.
for _, host := range []string{"atcr.io", "anything.example"} {
if _, err := ac.Authorized(pingRequest(t, issuer, host, "atcr.io")); err != nil {
t.Errorf("Authorized(host=%s) error = %v, want nil", host, err)
}
}
}
func TestNewController_Errors(t *testing.T) {
certPath := filepath.Join(t.TempDir(), "missing.crt")
tests := []struct {
name string
options map[string]any
}{
{"missing services", map[string]any{"realm": "r", "issuer": "i", "rootcertbundle": certPath}},
{"empty services", map[string]any{
"realm": "r", "issuer": "i", "services": []string{}, "rootcertbundle": certPath,
}},
{"services wrong type", map[string]any{
"realm": "r", "issuer": "i",
"services": []any{"atcr.io"}, "rootcertbundle": certPath,
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := newController(tt.options); err == nil {
t.Fatal("newController() = nil error, want error")
}
})
}
}