package token import ( "context" "encoding/json" "net/http" "net/http/httptest" "slices" "testing" "time" "atcr.io/pkg/auth" ) // stubAnonymousAuthorizer keeps the entries whose repository name appears in // public, drops the rest, and records what it was asked. Entries that grant // nothing pass through, mirroring the contract the real gate follows. type stubAnonymousAuthorizer struct { public []string calls int saw []auth.AccessEntry } func (s *stubAnonymousAuthorizer) AuthorizeAnonymous(_ context.Context, access []auth.AccessEntry) []auth.AccessEntry { s.calls++ s.saw = append(s.saw, access...) kept := make([]auth.AccessEntry, 0, len(access)) for _, entry := range access { if len(entry.Actions) == 0 || slices.Contains(s.public, entry.Name) { kept = append(kept, entry) } } return kept } // passthroughAnonymousAuthorizer is the fail-open shape: whatever comes in // comes back out. This is what the real gate returns when a hold lookup errors. type passthroughAnonymousAuthorizer struct{ calls int } func (p *passthroughAnonymousAuthorizer) AuthorizeAnonymous(_ context.Context, access []auth.AccessEntry) []auth.AccessEntry { p.calls++ return access } func newGateTestHandler(t *testing.T, gate AnonymousAuthorizer) *Handler { t.Helper() issuer, err := NewIssuer(getSharedTestKey(t), "atcr.io", "registry", 15*time.Minute) if err != nil { t.Fatalf("NewIssuer() error = %v", err) } h := NewHandler(issuer, nil) h.SetAnonymousAuthorizer(gate) return h } // anonymousGet drives the credential-less GET form and returns the recorder. func anonymousGet(t *testing.T, h *Handler, query string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/auth/token?"+query, nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) return w } func decodeToken(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() var resp TokenResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("decode token response: %v", err) } if resp.Token == "" { t.Fatal("expected a non-empty token") } return resp.Token } // A public hold is the case that must not regress: anonymous pull of a public // image is the whole point of the credential-less path. func TestHandler_AnonymousGate_PublicHoldStillGrantsPull(t *testing.T) { gate := &stubAnonymousAuthorizer{public: []string{"alice.test/public-app"}} h := newGateTestHandler(t, gate) w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/public-app:pull") if w.Code != http.StatusOK { t.Fatalf("expected 200 for a public hold, got %d. Body: %s", w.Code, w.Body.String()) } tok := decodeToken(t, w) if am := ExtractAuthMethod(tok); am != AuthMethodAnonymous { t.Errorf("expected auth method %q, got %q", AuthMethodAnonymous, am) } access := ExtractAccess(tok) if len(access) != 1 || access[0].Name != "alice.test/public-app" { t.Fatalf("expected pull access for alice.test/public-app, got %+v", access) } if !slices.Equal(access[0].Actions, []string{"pull"}) { t.Errorf("expected actions [pull], got %v", access[0].Actions) } if gate.calls != 1 { t.Errorf("expected the gate to be consulted once, got %d calls", gate.calls) } } // The defect this gate exists to fix: /auth/token used to sign a pull token for // a repository whose hold denies anonymous reads, and /v2/ then refused that // exact token. A 401 challenge here is what makes docker ask for credentials. func TestHandler_AnonymousGate_PrivateHoldChallengesWithNoToken(t *testing.T) { gate := &stubAnonymousAuthorizer{} // nothing is public h := newGateTestHandler(t, gate) w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/private-app:pull") if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for a private hold, got %d. Body: %s", w.Code, w.Body.String()) } if w.Header().Get("WWW-Authenticate") == "" { t.Error("expected a WWW-Authenticate challenge so docker prompts for credentials") } // An empty-access token would be the smaller version of the same bug: the // client proceeds to /v2/ and collects its 401 there instead. var resp TokenResponse if err := json.NewDecoder(w.Body).Decode(&resp); err == nil && resp.Token != "" { t.Errorf("expected no token in the challenge body, got one: %q", resp.Token) } } // Scopes in one request can name different owners and therefore different // holds, so the verdict is per entry: keep what is grantable, drop the rest. func TestHandler_AnonymousGate_MixedScopeKeepsOnlyPublicEntries(t *testing.T) { gate := &stubAnonymousAuthorizer{public: []string{"alice.test/public-app"}} h := newGateTestHandler(t, gate) // Multiple repositories arrive as one space-separated scope value, which is // the form this handler parses. w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/public-app:pull%20repository:bob.test/private-app:pull") if w.Code != http.StatusOK { t.Fatalf("expected 200 when one scope survives, got %d. Body: %s", w.Code, w.Body.String()) } access := ExtractAccess(decodeToken(t, w)) if len(access) != 1 { t.Fatalf("expected exactly the public entry to survive, got %+v", access) } if access[0].Name != "alice.test/public-app" { t.Errorf("expected alice.test/public-app, got %q", access[0].Name) } // Both entries must reach the gate: dropping one is its decision, not the // handler's. if len(gate.saw) != 2 { t.Errorf("expected the gate to see both entries, saw %+v", gate.saw) } } // Anonymous discovery must not pay for the gate, and there is no hold to look // up: the ping carries no scope at all. func TestHandler_AnonymousGate_ScopelessPingSkipsTheGate(t *testing.T) { gate := &stubAnonymousAuthorizer{} // would deny everything if consulted h := newGateTestHandler(t, gate) w := anonymousGet(t, h, "service=registry") if w.Code != http.StatusOK { t.Fatalf("expected 200 for the /v2/ ping, got %d. Body: %s", w.Code, w.Body.String()) } if access := ExtractAccess(decodeToken(t, w)); len(access) != 0 { t.Errorf("expected empty access on the ping token, got %+v", access) } if gate.calls != 0 { t.Errorf("the ping must not trigger a hold lookup, gate was called %d times", gate.calls) } } // NarrowToPullOnly preserves an actionless entry deliberately ("callers rely on // the entry surviving"). It grants nothing, so the gate must pass it through // rather than read it as a denial, and it must not cost a hold lookup. func TestHandler_AnonymousGate_ActionlessScopeSurvivesUnchecked(t *testing.T) { gate := &stubAnonymousAuthorizer{public: []string{"alice.test/public-app"}} h := newGateTestHandler(t, gate) w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/app:%20repository:alice.test/public-app:pull") if w.Code != http.StatusOK { t.Fatalf("expected 200 for an actionless scope alongside a public one, got %d. Body: %s", w.Code, w.Body.String()) } access := ExtractAccess(decodeToken(t, w)) if len(access) != 2 { t.Fatalf("expected both entries to survive, got %+v", access) } var actionless *auth.AccessEntry for i := range access { if access[i].Name == "alice.test/app" { actionless = &access[i] } } if actionless == nil { t.Fatalf("the actionless entry was dropped: %+v", access) } if len(actionless.Actions) != 0 { t.Errorf("expected the actionless entry to survive unchanged, got actions %v", actionless.Actions) } } // The gate fails open on any lookup error, because /v2/ is still the enforcing // layer and a DNS blip or a cold captain cache must not start refusing tokens // for public images. The handler's job is to honour that verdict. func TestHandler_AnonymousGate_LookupErrorFailsOpen(t *testing.T) { gate := &passthroughAnonymousAuthorizer{} h := newGateTestHandler(t, gate) w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/app:pull") if w.Code != http.StatusOK { t.Fatalf("expected 200 when the gate fails open, got %d. Body: %s", w.Code, w.Body.String()) } access := ExtractAccess(decodeToken(t, w)) if len(access) != 1 || access[0].Name != "alice.test/app" { t.Errorf("expected the entry to survive a failed-open lookup, got %+v", access) } if gate.calls != 1 { t.Errorf("expected the gate to be consulted once, got %d calls", gate.calls) } } // With no gate wired (a deployment that has not configured one), the path keeps // its previous behavior rather than failing closed. func TestHandler_AnonymousGate_UnsetAuthorizerGrantsAsBefore(t *testing.T) { issuer, err := NewIssuer(getSharedTestKey(t), "atcr.io", "registry", 15*time.Minute) if err != nil { t.Fatalf("NewIssuer() error = %v", err) } h := NewHandler(issuer, nil) w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/app:pull") if w.Code != http.StatusOK { t.Fatalf("expected 200 with no gate configured, got %d. Body: %s", w.Code, w.Body.String()) } if access := ExtractAccess(decodeToken(t, w)); len(access) != 1 { t.Errorf("expected the requested entry to survive, got %+v", access) } }