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

112 lines
4.4 KiB
Go

// Package registryauth provides the "atcr-token" distribution access
// controller: a multi-domain wrapper around distribution's built-in "token"
// controller.
//
// An AppView can front several registry domains (server.registry_domains) that
// all reach the same backend, e.g. seamark.dev serving buoy.cr, seamark.cr and
// atcr.io. Distribution 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 advertises one domain's name on every domain,
// and accepts one domain's tokens everywhere.
//
// Both uses sit inside Authorized, which already has the request. This builds
// one upstream controller per registry domain and dispatches on the request's
// host, so each front door advertises its own name and demands its own
// audience. All signature, certificate and claim verification stays upstream.
//
// This is token hygiene and spec conformance, not a privilege boundary. Every
// domain fronts the same backend with the same access rules, so a client that
// can get a token for one can get a token for another simply by handshaking
// there. What it buys is a truthful challenge and tokens that name the domain
// they were minted for.
package registryauth
import (
"fmt"
"maps"
"net/http"
"github.com/distribution/distribution/v3/registry/auth"
// Delegates are upstream "token" controllers, so that backend must be
// registered before newController runs.
_ "github.com/distribution/distribution/v3/registry/auth/token"
atcrtoken "atcr.io/pkg/auth/token"
)
// AuthType is the name this controller registers under. Use it as the key in
// configuration.Auth in place of distribution's "token".
const AuthType = "atcr-token"
const (
// paramServices is every registry domain this AppView fronts, in priority
// order. The first is the primary: the fallback for requests whose host is
// not a configured registry domain. A single-element list behaves exactly
// like the upstream controller.
//
// Entries must already be normalized and deduplicated bare hostnames —
// appview.deriveServices produces them, and it has to normalize anyway to
// derive the JWT issuer from the same list. Doing it again here would be a
// second copy of the same rule, with the two free to drift.
//
// This replaces upstream's "service", which callers never supply: each
// delegate gets its own, set below.
paramServices = "services"
// paramService is upstream's single-service key, set per delegate.
paramService = "service"
)
func init() {
if err := auth.Register(AuthType, auth.InitFunc(newController)); err != nil {
panic(fmt.Sprintf("registryauth: %v", err))
}
}
type controller struct {
byHost map[string]auth.AccessController
primary auth.AccessController
}
var _ auth.AccessController = (*controller)(nil)
func newController(options map[string]any) (auth.AccessController, error) {
services, ok := options[paramServices].([]string)
if !ok || len(services) == 0 {
return nil, fmt.Errorf("%s auth requires a non-empty []string option: %q", AuthType, paramServices)
}
// One upstream controller per domain. Every option is shared except
// `service`, so the delegates are identical apart from the name they
// advertise and the audience they accept.
c := &controller{byHost: make(map[string]auth.AccessController, len(services))}
for _, svc := range services {
opts := maps.Clone(options)
delete(opts, paramServices)
opts[paramService] = svc
delegate, err := auth.GetAccessController("token", opts)
if err != nil {
return nil, fmt.Errorf("%s auth: building controller for %q: %w", AuthType, svc, err)
}
c.byHost[svc] = delegate
}
c.primary = c.byHost[services[0]]
return c, nil
}
// Authorized routes to the controller for the domain the request arrived on.
//
// This reads only r.Host, matching DomainRoutingMiddleware, which is what
// decided the request could reach the registry at all — the two must agree on
// the host or a request could be routed as one domain and authorized as
// another. Falling back to the primary keeps the previous single-service
// behaviour for a host the middleware would normally have redirected.
func (c *controller) Authorized(r *http.Request, access ...auth.Access) (*auth.Grant, error) {
if delegate := c.byHost[atcrtoken.NormalizeService(r.Host)]; delegate != nil {
return delegate.Authorized(r, access...)
}
return c.primary.Authorized(r, access...)
}