diff --git a/internal/testharness/harness.go b/internal/testharness/harness.go index a125aba..8e20ed1 100644 --- a/internal/testharness/harness.go +++ b/internal/testharness/harness.go @@ -27,6 +27,7 @@ import ( "github.com/johannesboyne/gofakes3/backend/s3mem" "atcr.io/pkg/appview" + "atcr.io/pkg/appview/registryauth" "atcr.io/pkg/atproto" atprotodid "atcr.io/pkg/atproto/did" "atcr.io/pkg/billing" @@ -430,13 +431,14 @@ func buildAppViewConfig(addr, baseURL, holdDID, dbPath string) *appview.Config { cfg.Jetstream.RelayEndpoints = []string{} cfg.Auth.TokenExpiration = 5 * time.Minute - cfg.Auth.ServiceName = cfg.Server.RegistryDomains[0] + cfg.Auth.Services = cfg.Server.RegistryDomains cfg.Auth.CertPath = filepath.Join(os.TempDir(), fmt.Sprintf("atcr-test-cert-%d.pem", time.Now().UnixNano())) - cfg.Distribution = buildDistributionConfig(addr, baseURL, holdDID, cfg.Auth.ServiceName, cfg.Auth.CertPath) + cfg.Distribution = buildDistributionConfig(addr, baseURL, holdDID, cfg.Auth.Services, cfg.Auth.CertPath) return cfg } -func buildDistributionConfig(addr, baseURL, holdDID, serviceName, certPath string) *configuration.Configuration { +func buildDistributionConfig(addr, baseURL, holdDID string, services []string, certPath string) *configuration.Configuration { + serviceName := services[0] distConfig := &configuration.Configuration{} distConfig.Version = configuration.MajorMinorVersion(0, 1) distConfig.Log = configuration.Log{ @@ -473,9 +475,9 @@ func buildDistributionConfig(addr, baseURL, holdDID, serviceName, certPath strin }}, } distConfig.Auth = configuration.Auth{ - "token": configuration.Parameters{ + registryauth.AuthType: configuration.Parameters{ "realm": baseURL + "/auth/token", - "service": serviceName, + "services": services, "issuer": serviceName, "rootcertbundle": certPath, "expiration": int((5 * time.Minute).Seconds()), diff --git a/pkg/appview/config.go b/pkg/appview/config.go index 6f3899a..083def2 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -17,6 +17,8 @@ import ( "github.com/distribution/distribution/v3/configuration" "github.com/spf13/viper" + "atcr.io/pkg/appview/registryauth" + "atcr.io/pkg/auth/token" "atcr.io/pkg/billing" "atcr.io/pkg/config" ) @@ -127,9 +129,38 @@ type AuthConfig struct { // TokenExpiration is the JWT expiration duration (5 minutes, not configurable) TokenExpiration time.Duration `yaml:"-"` - // ServiceName is the service name used for JWT issuer and service fields. - // Derived from base URL hostname (e.g., "atcr.io") - ServiceName string `yaml:"-"` + // Services is every registry domain that is a valid JWT audience, in + // priority order. Each token is stamped with the front door the client + // actually used, and each domain's access controller demands its own + // audience. That is scoping, 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. + // + // This is derived from server.registry_domains rather than read directly, + // because the two are not the same list: + // + // - Values are lowercased, port-stripped and deduplicated, so a + // configured "127.0.0.1:5000" keys the same way as the port-stripped + // r.Host it has to match. DomainRoutingMiddleware is handed this list + // too, so both sides agree on one set of names. + // - With no registry domains configured there is still exactly one valid + // audience: the base URL hostname. That name cannot be written back + // into registry_domains, because a non-empty list is what switches + // DomainRoutingMiddleware on, and that would start rejecting /v2/ on + // the only domain a single-domain deployment has. + Services []string `yaml:"-"` +} + +// PrimaryService is the first configured registry domain. It is the AppView's +// own name — used as the registry JWT's issuer, one global signing identity — +// and the audience given to requests arriving on a host that is not itself a +// configured registry domain. Empty only on a Config that never went through +// LoadConfig. +func (a AuthConfig) PrimaryService() string { + if len(a.Services) == 0 { + return "" + } + return a.Services[0] } // CredentialHelperConfig defines credential helper download settings @@ -272,7 +303,7 @@ func LoadConfig(yamlPath string) (*Config, error) { // Post-load: fixed values cfg.Auth.TokenExpiration = 5 * time.Minute - cfg.Auth.ServiceName = deriveServiceName(cfg) + cfg.Auth.Services = deriveServices(cfg) cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" // Post-load: CompanyName defaults to ClientName @@ -303,12 +334,33 @@ func LoadConfig(yamlPath string) (*Config, error) { return cfg, nil } -// deriveServiceName extracts the JWT service name from the config. -func deriveServiceName(cfg *Config) string { - if len(cfg.Server.RegistryDomains) > 0 { - return cfg.Server.RegistryDomains[0] +// deriveServices returns every registry domain that is a valid JWT audience, +// normalized to bare lowercase hostnames and deduplicated. The first entry is +// the primary: the name advertised on any host that is not itself a configured +// registry domain, and the fallback audience. +// +// Always returns at least one entry. With no registry_domains configured the +// AppView serves the registry on its base URL, so that hostname is the only +// service. +func deriveServices(cfg *Config) []string { + services := make([]string, 0, len(cfg.Server.RegistryDomains)) + seen := make(map[string]bool, len(cfg.Server.RegistryDomains)) + + for _, domain := range cfg.Server.RegistryDomains { + // Ports are stripped so these match the port-stripped r.Host that + // DomainRoutingMiddleware and the access controller compare against. + name := token.NormalizeService(domain) + if name == "" || seen[name] { + continue + } + seen[name] = true + services = append(services, name) } - return getServiceName(cfg.Server.BaseURL) + + if len(services) == 0 { + return []string{token.NormalizeService(getServiceName(cfg.Server.BaseURL))} + } + return services } // buildDistributionConfig creates a distribution Configuration from our Config @@ -358,15 +410,23 @@ func buildDistributionConfig(cfg *Config, v *viper.Viper) (*configuration.Config distConfig.Middleware = buildMiddlewareConfig(cfg.Server.PrimaryHoldDID(), cfg.Server.BaseURL, cfg.Server.TestMode) // Auth (use values from cfg.Auth) - // Realm always points to BaseURL where auth endpoints live - // Docker's WWW-Authenticate: realm="https://seamark.dev/auth/token",service="buoy.cr" + // + // Realm always points to BaseURL, where the auth endpoints live. Registry + // domains also serve /auth/token directly (see DomainRoutingMiddleware), + // but a single realm keeps the handshake identical everywhere. + // + // Service is per front door: a push to atcr.io is challenged with + // service="atcr.io" and gets a JWT with that audience, even though the + // realm is on the UI domain. Docker's WWW-Authenticate on atcr.io reads + // realm="https://seamark.dev/auth/token",service="atcr.io". The issuer + // stays global — one signing identity, many audiences. realm := cfg.Server.BaseURL + "/auth/token" distConfig.Auth = configuration.Auth{ - "token": configuration.Parameters{ + registryauth.AuthType: configuration.Parameters{ "realm": realm, - "service": cfg.Auth.ServiceName, - "issuer": cfg.Auth.ServiceName, + "services": cfg.Auth.Services, + "issuer": cfg.Auth.PrimaryService(), "rootcertbundle": cfg.Auth.CertPath, "expiration": int(cfg.Auth.TokenExpiration.Seconds()), }, diff --git a/pkg/appview/config_test.go b/pkg/appview/config_test.go index e38ea56..3a9fe82 100644 --- a/pkg/appview/config_test.go +++ b/pkg/appview/config_test.go @@ -1,10 +1,14 @@ package appview import ( + "net/http" + "net/http/httptest" "os" "strings" "testing" "time" + + "atcr.io/pkg/appview/registryauth" ) func Test_getServiceName(t *testing.T) { @@ -240,8 +244,25 @@ func TestLoadConfig(t *testing.T) { t.Error("distribution middleware missing registry") } - if _, ok := got.Distribution.Auth["token"]; !ok { - t.Error("distribution auth missing token config") + params, ok := got.Distribution.Auth[registryauth.AuthType] + if !ok { + t.Fatalf("distribution auth missing %q config", registryauth.AuthType) + } + // The controller builds one delegate per entry, so this list is + // what decides which domains get their own audience. A nil check + // would pass on garbage. + services, ok := params["services"].([]string) + if !ok { + t.Fatalf("distribution auth services = %T, want []string", params["services"]) + } + if len(services) == 0 { + t.Fatal("distribution auth services is empty; every /v2/ request would fail to authorize") + } + // The issuer must be one of the audiences the controller accepts, + // otherwise the unknown-host fallback mints tokens no delegate + // honours. + if issuer := params["issuer"]; issuer != services[0] { + t.Errorf("issuer = %v, want primary service %q", issuer, services[0]) } }) } @@ -299,3 +320,100 @@ func TestExampleYAML(t *testing.T) { t.Error("expected comment for log_level field") } } + +func Test_deriveServices(t *testing.T) { + tests := []struct { + name string + baseURL string + registryDomains []string + want []string + }{ + { + name: "no registry domains falls back to base url host", + baseURL: "https://atcr.io", + registryDomains: nil, + want: []string{"atcr.io"}, + }, + { + name: "registry domains in order, first is primary", + baseURL: "https://seamark.dev", + registryDomains: []string{"buoy.cr", "seamark.cr", "atcr.io"}, + want: []string{"buoy.cr", "seamark.cr", "atcr.io"}, + }, + { + name: "ports stripped to match port-stripped request hosts", + baseURL: "http://127.0.0.1:5000", + registryDomains: []string{"127.0.0.1:5000", "atcr.io"}, + want: []string{"127.0.0.1", "atcr.io"}, + }, + { + name: "normalized and deduplicated", + baseURL: "https://seamark.dev", + registryDomains: []string{"BUOY.cr", "buoy.cr:443", " atcr.io "}, + want: []string{"buoy.cr", "atcr.io"}, + }, + { + name: "blank entries dropped", + baseURL: "https://seamark.dev", + registryDomains: []string{"", " ", "atcr.io"}, + want: []string{"atcr.io"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{} + cfg.Server.BaseURL = tt.baseURL + cfg.Server.RegistryDomains = tt.registryDomains + + got := deriveServices(cfg) + if len(got) != len(tt.want) { + t.Fatalf("deriveServices() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("deriveServices() = %v, want %v", got, tt.want) + } + } + }) + } +} + +// DomainRoutingMiddleware matches a port-stripped host, so it is handed +// cfg.Auth.Services rather than the raw registry_domains. A domain configured +// with a port has to route to /v2/ rather than being redirected to the UI. +func TestDomainRoutingMiddleware_UsesNormalizedDomains(t *testing.T) { + handler := DomainRoutingMiddleware( + deriveServices(&Config{Server: ServerConfig{ + BaseURL: "https://seamark.dev", + RegistryDomains: []string{"127.0.0.1:5000", "ATCR.io"}, + }}), + "https://seamark.dev", + )(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) // reached the registry + })) + + tests := []struct { + name string + host string + want int + }{ + {"configured with a port, requested without", "127.0.0.1", http.StatusTeapot}, + {"configured with a port, requested with", "127.0.0.1:5000", http.StatusTeapot}, + {"configured uppercase", "atcr.io", http.StatusTeapot}, + {"unknown host still redirects", "example.com", http.StatusTemporaryRedirect}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/", nil) + req.Host = tt.host + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tt.want { + t.Errorf("host %q: status = %d, want %d", tt.host, rec.Code, tt.want) + } + }) + } +} diff --git a/pkg/appview/registryauth/controller.go b/pkg/appview/registryauth/controller.go new file mode 100644 index 0000000..9a90b3d --- /dev/null +++ b/pkg/appview/registryauth/controller.go @@ -0,0 +1,111 @@ +// 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...) +} diff --git a/pkg/appview/registryauth/controller_test.go b/pkg/appview/registryauth/controller_test.go new file mode 100644 index 0000000..1faefe6 --- /dev/null +++ b/pkg/appview/registryauth/controller_test.go @@ -0,0 +1,225 @@ +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") + } + }) + } +} diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 4f21df7..61fdc74 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -28,6 +28,7 @@ import ( appviewlabeler "atcr.io/pkg/appview/labeler" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/readme" + "atcr.io/pkg/appview/registryauth" "atcr.io/pkg/appview/routes" "atcr.io/pkg/appview/storage" "atcr.io/pkg/appview/webhooks" @@ -315,11 +316,17 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, // otherwise `go install` fails the module-path check. mainRouter.Use(middleware.GoImport("atcr.io", cfg.UI.SourceURL)) - // Domain routing middleware + // Domain routing middleware. Gated on the raw config (an empty + // registry_domains means single-domain, where /v2/ must stay on the UI + // host) but fed cfg.Auth.Services, which is the same list normalized. The + // middleware matches a port-stripped host, so a domain configured with a + // port could never match its own requests when compared raw; sharing the + // normalized list also keeps routing and the access controller agreeing on + // exactly one set of names. if len(cfg.Server.RegistryDomains) > 0 { - mainRouter.Use(DomainRoutingMiddleware(cfg.Server.RegistryDomains, cfg.Server.BaseURL)) + mainRouter.Use(DomainRoutingMiddleware(cfg.Auth.Services, cfg.Server.BaseURL)) slog.Info("Domain routing middleware enabled", - "registry_domains", cfg.Server.RegistryDomains, + "registry_domains", cfg.Auth.Services, "ui_base_url", cfg.Server.BaseURL) } @@ -478,12 +485,12 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, }) // Create token issuer - if cfg.Distribution.Auth["token"] != nil { + if cfg.Distribution.Auth[registryauth.AuthType] != nil { rsaKey, certDER, err := loadJWTKeyAndCert(s.Database, cfg.Auth.CertPath) if err != nil { return nil, fmt.Errorf("failed to load JWT key material: %w", err) } - s.TokenIssuer = token.NewIssuerFromKey(rsaKey, certDER, cfg.Auth.ServiceName, cfg.Auth.ServiceName, cfg.Auth.TokenExpiration) + s.TokenIssuer = token.NewIssuerFromKey(rsaKey, certDER, cfg.Auth.PrimaryService(), cfg.Auth.PrimaryService(), cfg.Auth.TokenExpiration) slog.Info("Auth keys initialized") } @@ -558,6 +565,10 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, if s.TokenIssuer != nil { tokenHandler := token.NewHandler(s.TokenIssuer, s.DeviceStore) + // Stamp each JWT with the registry domain the client is pushing to, so + // the audience names the front door actually used. + tokenHandler.SetServices(cfg.Auth.Services) + tokenHandler.SetOAuthSessionValidator(s.Refresher) // Auth-phase gate: crew reconciliation for any token request, plus diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index 5ea3439..6a6f470 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -70,6 +70,10 @@ type Handler struct { oauthSessionValidator OAuthSessionValidator authorizer Authorizer serviceAuthFetcher ServiceAuthFetcher + // services is the set of registry domains this AppView fronts, keyed by + // normalized hostname. Nil means single-domain: the lookups in + // resolveService miss and every token gets the issuer's own service. + services map[string]bool } // NewHandler creates a new token handler @@ -108,6 +112,46 @@ func (h *Handler) SetServiceAuthFetcher(fetcher ServiceAuthFetcher) { h.serviceAuthFetcher = fetcher } +// SetServices declares the registry domains this AppView fronts, e.g. +// ["buoy.cr", "seamark.cr", "atcr.io"]. Each issued JWT is stamped with +// whichever of these the client is authenticating against, so the audience +// names the front door actually used (see pkg/appview/registryauth). Unset +// leaves every token on the issuer's configured service, which is correct for +// a single-domain deployment. +func (h *Handler) SetServices(services []string) { + if len(services) == 0 { + h.services = nil + return + } + set := make(map[string]bool, len(services)) + for _, s := range services { + if n := NormalizeService(s); n != "" { + set[n] = true + } + } + h.services = set +} + +// resolveService picks the registry domain to stamp as the JWT's audience. +// +// The ?service= query parameter is the primary signal: Docker echoes back +// whatever the WWW-Authenticate challenge advertised, and that challenge is +// built per front door. The request's own host is the fallback, which covers +// clients that reach /auth/token directly on a registry domain rather than via +// the realm. Both are client-influenced, so both are only honoured when they +// name a configured registry domain; anything else falls back to the issuer's +// service. That makes the worst case a token scoped to the primary domain, not +// a caller-chosen audience. +func (h *Handler) resolveService(r *http.Request) string { + if s := NormalizeService(r.URL.Query().Get("service")); h.services[s] { + return s + } + if s := NormalizeService(r.Host); h.services[s] { + return s + } + return h.issuer.service +} + // TokenResponse represents the response from /auth/token type TokenResponse struct { Token string `json:"token,omitempty"` // Legacy field @@ -199,8 +243,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) - // Parse query parameters - _ = r.URL.Query().Get("service") // service parameter - validated by issuer + // Parse query parameters. The service names the front door the client is + // authenticating against and becomes the JWT's audience; resolveService + // validates it against the configured registry domains. + service := h.resolveService(r) scopeParam := r.URL.Query().Get("scope") // Parse scopes @@ -403,7 +449,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Issue JWT token - tokenString, err := h.issuer.IssueWithExpiration(did, access, authMethod, issueExp) + tokenString, err := h.issuer.IssueWithExpiration(did, access, authMethod, issueExp, service) if err != nil { slog.Error("Failed to issue token", "error", err, "did", did) http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) diff --git a/pkg/auth/token/issuer.go b/pkg/auth/token/issuer.go index d4952ed..a9f657d 100644 --- a/pkg/auth/token/issuer.go +++ b/pkg/auth/token/issuer.go @@ -72,22 +72,40 @@ func NewIssuerFromKey(privateKey *rsa.PrivateKey, certDER []byte, issuer, servic } } -// Issue creates and signs a new JWT token using the issuer's configured expiration. +// Issue creates and signs a new JWT token using the issuer's configured +// expiration and service. func (i *Issuer) Issue(subject string, access []auth.AccessEntry, authMethod string) (string, error) { - return i.IssueWithExpiration(subject, access, authMethod, i.expiration) + return i.IssueWithExpiration(subject, access, authMethod, i.expiration, i.service) } -// IssueWithExpiration creates and signs a JWT with a per-call expiration. Used -// when the JWT lifetime is bound to a downstream credential whose lifetime can -// be slightly less than the issuer's configured default — e.g. the AppView↔hold -// service-auth, where the cache applies a 10s safety margin against the -// PDS-granted exp. -func (i *Issuer) IssueWithExpiration(subject string, access []auth.AccessEntry, authMethod string, expiration time.Duration) (string, error) { - claims := NewClaims(subject, i.issuer, i.service, expiration, access, authMethod) +// IssueWithExpiration creates and signs a JWT with a per-call expiration and +// audience. +// +// The expiration is per-call because the JWT's lifetime is bound to a +// downstream credential that can expire sooner than the issuer's default — the +// AppView↔hold service-auth, where the cache applies a 10s safety margin +// against the PDS-granted exp. +// +// The audience is per-call because an AppView can front several registry +// domains (server.registry_domains), and the Docker token spec makes `service` +// the name of the registry the client is authenticating against. Stamping the +// front door the client actually used lets the matching access controller +// (pkg/appview/registryauth) demand its own domain's audience. Note this is +// scoping, not a privilege boundary: the same client can obtain a token for any +// configured domain just by handshaking there. +// +// An empty service falls back to the issuer's default. The caller is +// responsible for validating a non-empty service against the configured +// registry domains, since the value ultimately derives from client input. +func (i *Issuer) IssueWithExpiration(subject string, access []auth.AccessEntry, authMethod string, expiration time.Duration, service string) (string, error) { + if service == "" { + service = i.service + } + claims := NewClaims(subject, i.issuer, service, expiration, access, authMethod) slog.Debug("Creating JWT token", "issuer", i.issuer, - "service", i.service, + "service", service, "subject", subject, "access", access, "expiration", expiration) diff --git a/pkg/auth/token/issuer_test.go b/pkg/auth/token/issuer_test.go index ef92f46..efd33eb 100644 --- a/pkg/auth/token/issuer_test.go +++ b/pkg/auth/token/issuer_test.go @@ -180,6 +180,7 @@ func TestIssuer_IssueWithExpiration_HonorsCallerDuration(t *testing.T) { []auth.AccessEntry{{Type: "repository", Name: "alice/myapp", Actions: []string{"pull"}}}, AuthMethodOAuth, 2*time.Minute, + "", // empty service falls back to the issuer's default ) if err != nil { t.Fatalf("IssueWithExpiration() error = %v", err) diff --git a/pkg/auth/token/service.go b/pkg/auth/token/service.go new file mode 100644 index 0000000..eb5dd94 --- /dev/null +++ b/pkg/auth/token/service.go @@ -0,0 +1,38 @@ +package token + +import ( + "net" + "strings" +) + +// NormalizeService reduces a registry service identifier to the bare, +// port-stripped, lowercased hostname used to key registry domains. +// +// It tolerates the forms that actually reach us: a bare host ("atcr.io"), a +// host:port ("127.0.0.1:5000"), and a full URL ("https://atcr.io"), which is +// what the credential helper sends as ?service= when it validates stored +// credentials (pkg/credhelper/device_auth.go). +// +// Ports are stripped so the result matches the port-stripped r.Host that +// DomainRoutingMiddleware compares registry domains against. Returns "" when +// there is no recognisable host. +func NormalizeService(s string) string { + s = strings.TrimSpace(s) + + // Drop any scheme, then anything from the first path/query delimiter on, + // leaving just the authority. + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + } + if i := strings.IndexAny(s, "/?#"); i >= 0 { + s = s[:i] + } + + // SplitHostPort only succeeds when a port is present; a bare host errors + // and is used as-is. Brackets survive that path for an IPv6 literal. + if host, _, err := net.SplitHostPort(s); err == nil { + s = host + } + + return strings.ToLower(strings.Trim(s, "[]")) +} diff --git a/pkg/auth/token/service_test.go b/pkg/auth/token/service_test.go new file mode 100644 index 0000000..7079eea --- /dev/null +++ b/pkg/auth/token/service_test.go @@ -0,0 +1,121 @@ +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) + } +}