// 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...) }