Files
at-container-registry/pkg/auth/token/handler_auth_error_test.go
T
Evan JarrettandClaude Opus 5 e542dd12f6 appview: keep the port in the registry host printed to users
On the dev stack (registry_domains: ["127.0.0.1:5000"]) the /auth/token failure
guidance said `docker login 127.0.0.1`, dropping the port docker needs. The
message exists to tell a stuck developer what to run, so a command that cannot
work is the whole defect. Production is unaffected, both registry domains being
port-free.

The stripping itself is deliberate and stays: NormalizeService removes the port
so JWT audiences match the port-stripped routing host. Removing it would
desynchronize the service key from the routing key.

The unstripped form turned out to survive in only one place. resolveService
returns a key from h.services, which SetServices normalizes, and cfg.Auth.Services
is normalized too by deriveServices, so neither holds the original. Only
cfg.Server.RegistryDomains, straight off the YAML, does, and it was never
reaching the token package.

Adds a display-only map keyed by the same NormalizeService function the other
two key on, so a resolved service always maps back to the entry it came from,
and a multi-domain deployment prints the domain the client is authenticating
against rather than the primary. Collisions take the first configured entry,
matching deriveServices' own first-wins dedupe. A miss falls through to the
normalized name, which is today's behaviour.

Audiences and routing are untouched, proven by a test that mints a real token,
parses the JWT and asserts aud is still the normalized host.

The wiring line landed in the previous commit, both changes having been made in
server.go at the same time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 22:31:27 -05:00

318 lines
11 KiB
Go

package token
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// The plain-text guidance sent with a 401 has to name a host `docker login`
// can actually handshake against. /auth/token answers on the UI domain (that
// is where the WWW-Authenticate realm points) as well as on every registry
// domain, and the UI domain refuses /v2/* outright, so the guidance must name
// the resolved registry service rather than echoing the request's own host.
func TestSendAuthError_NamesRegistryDomainNotRequestHost(t *testing.T) {
keyPath := getSharedTestKey(t)
tests := []struct {
name string
// services declared on the handler; the issuer's service is the first.
services []string
// host the request arrives on.
host string
// service parameter the client echoes back, if any.
requested string
// registry_domains as configured, before normalization. Empty leaves
// the display list unset, which is the pre-existing behaviour.
displayNames []string
wantLogin string // hostname step 2 must name
wantAbsent []string // hostnames step 2 must not name
}{
{
// seamark-shaped: UI on seamark.dev, registry on seamark.cr. A
// client following the realm lands on the UI host.
name: "split domain, request on the UI host",
services: []string{"seamark.cr"},
host: "seamark.dev",
wantLogin: "seamark.cr",
wantAbsent: []string{"docker login seamark.dev"},
},
{
name: "split domain, request on the registry host",
services: []string{"seamark.cr"},
host: "seamark.cr",
wantLogin: "seamark.cr",
},
{
// Multiple front doors: the guidance follows the service the
// client is authenticating against, not the primary.
name: "secondary registry domain via ?service=",
services: []string{"seamark.cr", "buoy.cr"},
host: "seamark.dev",
requested: "buoy.cr",
wantLogin: "buoy.cr",
wantAbsent: []string{"docker login seamark.dev"},
},
{
// ?service= is client-influenced, so an unconfigured value falls
// back to the primary registry domain, never to r.Host.
name: "unconfigured ?service= falls back to the primary",
services: []string{"seamark.cr"},
host: "seamark.dev",
requested: "evil.example",
wantLogin: "seamark.cr",
wantAbsent: []string{"docker login seamark.dev", "evil.example"},
},
{
// Single-domain deployment (the dev stack): the UI host is the
// registry domain, and the printed hostname is still correct.
name: "single domain, UI host is the registry domain",
services: []string{"localhost"},
host: "localhost:5000",
wantLogin: "localhost",
},
{
// The dev stack as actually configured: registry_domains is
// ["127.0.0.1:5000"], which normalizes to the audience "127.0.0.1".
// The command has to keep the port or it dials the wrong place.
name: "dev stack keeps the configured port",
services: []string{"127.0.0.1"},
displayNames: []string{"127.0.0.1:5000"},
host: "127.0.0.1:5000",
wantLogin: "127.0.0.1:5000",
wantAbsent: []string{"docker login 127.0.0.1\n"},
},
{
// Production shape: port-free domains print exactly as before.
name: "production domain unchanged by the display list",
services: []string{"seamark.cr"},
displayNames: []string{"seamark.cr"},
host: "seamark.dev",
wantLogin: "seamark.cr",
wantAbsent: []string{"docker login seamark.dev"},
},
{
// The checked-in seamark deployment fronts three registry domains.
// The guidance must name the one the client is authenticating
// against, resolved back to its own configured entry.
name: "multi-domain picks the entry the service came from",
services: []string{"buoy.cr", "bouy.cr", "seamark.cr"},
displayNames: []string{"buoy.cr", "bouy.cr", "seamark.cr"},
host: "seamark.dev",
requested: "seamark.cr",
wantLogin: "seamark.cr",
wantAbsent: []string{"docker login buoy.cr", "docker login bouy.cr"},
},
{
// Same, with ports: each normalized service maps back to its own
// configured entry, not to the primary's.
name: "multi-domain with ports maps each service to its own entry",
services: []string{"127.0.0.1", "localhost"},
displayNames: []string{"127.0.0.1:5000", "localhost:5001"},
host: "127.0.0.1:5000",
requested: "localhost:5001",
wantLogin: "localhost:5001",
wantAbsent: []string{"docker login 127.0.0.1"},
},
{
// Two configured entries that normalize to the same host: first
// wins, matching deriveServices' first-wins dedupe.
name: "colliding entries print the first configured form",
services: []string{"atcr.io"},
displayNames: []string{"atcr.io:443", "atcr.io"},
host: "atcr.io",
wantLogin: "atcr.io:443",
},
{
// A service with no configured entry (the issuer's fallback on a
// deployment whose display list does not cover it) prints the
// normalized name, exactly as before this existed.
name: "service absent from the display list falls back to normalized",
services: []string{"seamark.cr"},
displayNames: []string{"buoy.cr:8443"},
host: "seamark.dev",
wantLogin: "seamark.cr",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issuer, err := NewIssuer(keyPath, "atcr.io", tt.services[0], 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
handler.SetServices(tt.services)
handler.SetServiceDisplayNames(tt.displayNames)
// A push-only scope with no credentials has no anonymous
// component, so it draws the plain-text challenge.
target := "/auth/token?scope=repository:bob.bsky.social/myapp:push"
if tt.requested != "" {
target += "&service=" + tt.requested
}
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = tt.host
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d. Body: %s", w.Code, http.StatusUnauthorized, w.Body.String())
}
body := w.Body.String()
want := "docker login " + tt.wantLogin
if !strings.Contains(body, want) {
t.Errorf("guidance does not contain %q.\nBody:\n%s", want, body)
}
for _, absent := range tt.wantAbsent {
if strings.Contains(body, absent) {
t.Errorf("guidance contains %q, which cannot serve /v2/.\nBody:\n%s", absent, body)
}
}
// Step 1 stays on the request's own base URL: the install page
// lives on the UI domain and registry domains redirect there.
if wantInstall := getBaseURL(req) + "/install"; !strings.Contains(body, wantInstall) {
t.Errorf("guidance does not contain install URL %q.\nBody:\n%s", wantInstall, body)
}
})
}
}
// A handler whose issuer has no service configured has no registry domain to
// name, so it drops the docker login step rather than printing a host that
// cannot work.
func TestSendAuthError_NoServiceOmitsDockerLoginStep(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:bob.bsky.social/myapp:push", nil)
req.Host = "seamark.dev"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
if body := w.Body.String(); strings.Contains(body, "docker login") {
t.Errorf("guidance names a docker login host with no service configured.\nBody:\n%s", body)
}
}
// The display list must not leak into the JWT. Audiences stay on the
// normalized, port-stripped service, because that is what the registry
// compares against the port-stripped r.Host it routes on.
func TestServiceDisplayNames_DoNotChangeTokenAudience(t *testing.T) {
keyPath := getSharedTestKey(t)
tests := []struct {
name string
services []string
displayNames []string
host string
requested string
wantAudience string
}{
{
name: "dev stack",
services: []string{"127.0.0.1"},
displayNames: []string{"127.0.0.1:5000"},
host: "127.0.0.1:5000",
wantAudience: "127.0.0.1",
},
{
name: "dev stack, service echoed back with its port",
services: []string{"127.0.0.1"},
displayNames: []string{"127.0.0.1:5000"},
host: "127.0.0.1:5000",
requested: "127.0.0.1:5000",
wantAudience: "127.0.0.1",
},
{
name: "multi-domain with ports",
services: []string{"127.0.0.1", "localhost"},
displayNames: []string{"127.0.0.1:5000", "localhost:5001"},
host: "127.0.0.1:5000",
requested: "localhost:5001",
wantAudience: "localhost",
},
{
name: "production shape",
services: []string{"seamark.cr", "buoy.cr"},
displayNames: []string{"seamark.cr", "buoy.cr"},
host: "seamark.dev",
requested: "buoy.cr",
wantAudience: "buoy.cr",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issuer, err := NewIssuer(keyPath, "atcr.io", tt.services[0], 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
handler.SetServices(tt.services)
handler.SetServiceDisplayNames(tt.displayNames)
// An anonymous pull actually mints a token, so the audience is
// observable end to end rather than through resolveService alone.
target := "/auth/token?scope=repository:bob.bsky.social/myapp:pull"
if tt.requested != "" {
target += "&service=" + tt.requested
}
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = tt.host
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
}
claims := parseTokenClaims(t, decodeToken(t, w), issuer)
if len(claims.Audience) != 1 || claims.Audience[0] != tt.wantAudience {
t.Errorf("audience = %v, want [%q]", claims.Audience, tt.wantAudience)
}
// And the resolved service key itself, which is what routing and
// the access controller compare against.
if got := handler.resolveService(req, tt.requested); got != tt.wantAudience {
t.Errorf("resolveService() = %q, want %q", got, tt.wantAudience)
}
})
}
}
// parseTokenClaims verifies the JWT against the issuer's own key and returns
// its claims.
func parseTokenClaims(t *testing.T, tokenString string, issuer *Issuer) *Claims {
t.Helper()
parsed, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(*jwt.Token) (any, error) {
return issuer.publicKey, nil
})
if err != nil {
t.Fatalf("parse token: %v", err)
}
claims, ok := parsed.Claims.(*Claims)
if !ok {
t.Fatalf("claims type = %T, want *Claims", parsed.Claims)
}
return claims
}