mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3298797603
commit
2719428071
@@ -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()),
|
||||
|
||||
+74
-14
@@ -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()),
|
||||
},
|
||||
|
||||
+120
-2
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+28
-10
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "[]"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user