mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
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>
122 lines
3.6 KiB
Go
122 lines
3.6 KiB
Go
package token
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeService(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"bare host", "atcr.io", "atcr.io"},
|
|
{"uppercase", "ATCR.io", "atcr.io"},
|
|
{"surrounding space", " atcr.io ", "atcr.io"},
|
|
{"host with port", "127.0.0.1:5000", "127.0.0.1"},
|
|
// The credential helper validates stored credentials against
|
|
// appViewURL + "/auth/token?service=" + appViewURL, so ?service=
|
|
// arrives as a full URL rather than a hostname.
|
|
{"https url", "https://atcr.io", "atcr.io"},
|
|
{"http url with port", "http://127.0.0.1:5000", "127.0.0.1"},
|
|
{"url with path", "https://atcr.io/auth/token", "atcr.io"},
|
|
{"url with query", "https://atcr.io/auth/token?service=x", "atcr.io"},
|
|
{"bracketed ipv6 with port", "[::1]:5000", "::1"},
|
|
{"bracketed ipv6", "[::1]", "::1"},
|
|
{"empty", "", ""},
|
|
{"only space", " ", ""},
|
|
{"scheme only", "https://", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := NormalizeService(tt.in); got != tt.want {
|
|
t.Errorf("NormalizeService(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerResolveService(t *testing.T) {
|
|
const primary = "buoy.cr"
|
|
services := []string{primary, "seamark.cr", "atcr.io"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
services []string
|
|
query string
|
|
host string
|
|
want string
|
|
description string
|
|
}{
|
|
{
|
|
name: "service param names a registry domain", services: services,
|
|
query: "atcr.io", host: "seamark.dev", want: "atcr.io",
|
|
description: "Docker echoes back the challenge's service; the realm lives on the UI host",
|
|
},
|
|
{
|
|
name: "service param as full url", services: services,
|
|
query: "https://atcr.io", host: "seamark.dev", want: "atcr.io",
|
|
description: "the credential helper sends the appview URL as ?service=",
|
|
},
|
|
{
|
|
name: "falls back to request host", services: services,
|
|
query: "", host: "atcr.io", want: "atcr.io",
|
|
description: "clients reaching /auth/token directly on a registry domain",
|
|
},
|
|
{
|
|
name: "unknown service param falls back to primary", services: services,
|
|
query: "evil.example", host: "seamark.dev", want: primary,
|
|
description: "the audience must never be caller-chosen",
|
|
},
|
|
{
|
|
name: "unknown service param does not beat a known host", services: services,
|
|
query: "evil.example", host: "atcr.io", want: "atcr.io",
|
|
},
|
|
{
|
|
name: "ui host is not a registry domain", services: services,
|
|
query: "", host: "seamark.dev", want: primary,
|
|
},
|
|
{
|
|
name: "no services configured", services: nil,
|
|
query: "atcr.io", host: "atcr.io", want: primary,
|
|
description: "single-domain deployments keep the issuer's service",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := &Handler{issuer: &Issuer{service: primary}}
|
|
h.SetServices(tt.services)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
|
|
req.Host = tt.host
|
|
if tt.query != "" {
|
|
q := req.URL.Query()
|
|
q.Set("service", tt.query)
|
|
req.URL.RawQuery = q.Encode()
|
|
}
|
|
|
|
if got := h.resolveService(req); got != tt.want {
|
|
t.Errorf("resolveService() = %q, want %q (%s)", got, tt.want, tt.description)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerSetServicesNormalizes(t *testing.T) {
|
|
h := &Handler{issuer: &Issuer{service: "buoy.cr"}}
|
|
h.SetServices([]string{"ATCR.io", "127.0.0.1:5000", " ", "seamark.cr"})
|
|
|
|
for _, want := range []string{"atcr.io", "127.0.0.1", "seamark.cr"} {
|
|
if !h.services[want] {
|
|
t.Errorf("services missing %q, got %v", want, h.services)
|
|
}
|
|
}
|
|
if len(h.services) != 3 {
|
|
t.Errorf("services = %v, want 3 entries", h.services)
|
|
}
|
|
}
|