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 }