Files
at-container-registry/pkg/auth/token/handler_scope_repeat_test.go
T
Evan JarrettandClaude Opus 5 2743445e65 appview: fix two /auth/token defects, wrong login host and dropped scopes
Both are pre-existing and were found while working on finding 27.

sendAuthError built its "docker login <host>" line from r.Host. /auth/token is
served on the UI domain as well as on every registry domain, and the
WWW-Authenticate realm points at the UI domain's copy, so a client following
the realm was told to run "docker login seamark.dev" - the one host that
deliberately refuses /v2/* with an OCI UNSUPPORTED error pointing at
seamark.cr. It now uses the service resolved for the token, which is the
registry domain, falling back to the deployment's primary rather than to
r.Host. The single-domain case still prints a host that serves /v2/, and an
unconfigured service supplied by the client cannot steer it.

Separately, the scope parameter was read with .Get, taking the first value
only. The Docker token spec allows scope to be repeated, so a client asking for
two repositories was issued a token covering one and got a 401 on the other.
Both wire forms are now flattened, on the GET query string and on the OAuth2
POST body, which had the same defect via PostFormValue.

Empty and whitespace-only values are dropped. Exact duplicate scope strings
collapse, but two entries naming the same repository with different actions are
left alone: merging them would union the action sets, and every gate downstream
is written only to narrow.

More entries now reach the anonymous gate added in af7522b, which is the
intended effect. Its per-entry verdict is unchanged: public entries survive,
private ones are dropped, and an all-private request still gets the challenge.

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

220 lines
8.5 KiB
Go

package token
import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"sort"
"strings"
"testing"
"time"
"atcr.io/pkg/auth"
)
// accessNames returns the repository names in the token, sorted, so an
// assertion does not depend on parameter ordering.
func accessNames(access []auth.AccessEntry) []string {
names := make([]string, 0, len(access))
for _, entry := range access {
names = append(names, entry.Name)
}
sort.Strings(names)
return names
}
// scopeTestHandler wires an issuer, a device store and a device secret together
// so each test drives a real authenticated request.
func scopeTestHandler(t *testing.T) (*Handler, string) {
t.Helper()
issuer, err := NewIssuer(getSharedTestKey(t), "atcr.io", "registry", 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
secret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
return NewHandler(issuer, deviceStore), secret
}
// getAccess runs the GET form with a pre-built raw query and returns the access
// list from the issued JWT.
func getAccess(t *testing.T, rawQuery string) []auth.AccessEntry {
t.Helper()
h, secret := scopeTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/auth/token?"+rawQuery, nil)
req.SetBasicAuth("alice", secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
return ExtractAccess(decodeToken(t, w))
}
// The defect: the handler read only the first ?scope= value, so a client asking
// for two repositories was issued a token covering one and got a 401 on the
// other. The Docker token spec allows the parameter to repeat, and clients use
// it. This test fails before the fix with exactly one entry.
func TestHandler_Scope_RepeatedParamsYieldAllEntries(t *testing.T) {
access := getAccess(t,
"service=registry"+
"&scope=repository:alice.bsky.social/first:pull"+
"&scope=repository:alice.bsky.social/second:pull")
want := []string{"alice.bsky.social/first", "alice.bsky.social/second"}
if got := accessNames(access); !slices.Equal(got, want) {
t.Fatalf("repeated scope params dropped entries: got %v, want %v (full access %+v)", got, want, access)
}
for _, entry := range access {
if !slices.Equal(entry.Actions, []string{"pull"}) {
t.Errorf("entry %q: expected actions [pull], got %v", entry.Name, entry.Actions)
}
}
}
// Both forms in one request: a repeated parameter where one of the values is
// itself space-separated. Flattening has to happen in both dimensions.
func TestHandler_Scope_MixedRepeatedAndSpaceSeparated(t *testing.T) {
access := getAccess(t,
"service=registry"+
"&scope="+url.QueryEscape("repository:alice.bsky.social/first:pull repository:alice.bsky.social/second:pull")+
"&scope=repository:alice.bsky.social/third:pull")
want := []string{"alice.bsky.social/first", "alice.bsky.social/second", "alice.bsky.social/third"}
if got := accessNames(access); !slices.Equal(got, want) {
t.Fatalf("mixed scope forms lost entries: got %v, want %v", got, want)
}
}
// Regression guard: the space-separated form is what works today and must keep
// working byte for byte.
func TestHandler_Scope_SingleSpaceSeparatedValueStillWorks(t *testing.T) {
access := getAccess(t,
"service=registry"+
"&scope="+url.QueryEscape("repository:alice.bsky.social/first:pull,push repository:alice.bsky.social/second:pull"))
want := []string{"alice.bsky.social/first", "alice.bsky.social/second"}
if got := accessNames(access); !slices.Equal(got, want) {
t.Fatalf("space-separated scope regressed: got %v, want %v", got, want)
}
for _, entry := range access {
if entry.Name == "alice.bsky.social/first" && !slices.Equal(entry.Actions, []string{"pull", "push"}) {
t.Errorf("expected actions [pull push] to survive, got %v", entry.Actions)
}
}
}
// An empty scope value must contribute nothing: ParseScope would reject a bare
// "" as a malformed scope if it ever reached it, and a bogus entry in the token
// is worse than none.
func TestHandler_Scope_EmptyValuesAreIgnored(t *testing.T) {
access := getAccess(t, "service=registry&scope=&scope=&scope="+url.QueryEscape(" "))
if len(access) != 0 {
t.Fatalf("expected empty scope params to yield no access entries, got %+v", access)
}
// An empty value alongside a real one must not disturb the real one.
access = getAccess(t, "service=registry&scope=&scope=repository:alice.bsky.social/first:pull")
if got := accessNames(access); !slices.Equal(got, []string{"alice.bsky.social/first"}) {
t.Fatalf("an empty scope param disturbed the real one: got %v", got)
}
}
// The same scope string twice cannot mean more than once, so it collapses.
// Duplicates are dropped rather than merged: merging entries that name one
// repository with different action sets would union them, and nothing on this
// path is allowed to widen.
func TestHandler_Scope_ExactDuplicatesCollapse(t *testing.T) {
access := getAccess(t,
"service=registry"+
"&scope=repository:alice.bsky.social/first:pull"+
"&scope=repository:alice.bsky.social/first:pull")
if len(access) != 1 {
t.Fatalf("expected the duplicate scope to collapse to one entry, got %+v", access)
}
if !slices.Equal(access[0].Actions, []string{"pull"}) {
t.Errorf("a duplicate must not widen the actions, got %v", access[0].Actions)
}
}
// The POST form (the OAuth2 token spec containerd and Docker try first) reads
// its scope from the form body, and r.PostFormValue has the same first-value
// trap as r.URL.Query().Get.
func TestHandler_Scope_PostFormRepeatedValues(t *testing.T) {
h, secret := scopeTestHandler(t)
form := url.Values{}
form.Set("grant_type", "password")
form.Set("username", "alice")
form.Set("password", secret)
form.Set("service", "registry")
form["scope"] = []string{
"repository:alice.bsky.social/first:pull",
"repository:alice.bsky.social/second:pull repository:alice.bsky.social/third:pull",
"",
}
req := httptest.NewRequest(http.MethodPost, "/auth/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on the POST form, got %d. Body: %s", w.Code, w.Body.String())
}
want := []string{"alice.bsky.social/first", "alice.bsky.social/second", "alice.bsky.social/third"}
if got := accessNames(ExtractAccess(decodeToken(t, w))); !slices.Equal(got, want) {
t.Fatalf("POST form dropped scope entries: got %v, want %v", got, want)
}
}
// More entries now reach the anonymous gate, which is the point: the gate has
// to keep deciding per entry. Public survives, private is dropped.
func TestHandler_Scope_RepeatedParamsAnonymousGateStillNarrows(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"+
"&scope=repository:bob.test/private-app:pull,push")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when one repeated entry is public, got %d. Body: %s", w.Code, w.Body.String())
}
access := ExtractAccess(decodeToken(t, w))
if got := accessNames(access); !slices.Equal(got, []string{"alice.test/public-app"}) {
t.Fatalf("expected only the public entry to survive, got %v", got)
}
if !slices.Equal(access[0].Actions, []string{"pull"}) {
t.Errorf("anonymous token must carry pull and nothing else, got %v", access[0].Actions)
}
// Both entries have to reach the gate now, not just the first one.
if len(gate.saw) != 2 {
t.Errorf("expected the gate to see both repeated entries, saw %+v", gate.saw)
}
}
// Nothing grantable across every repeated entry must still challenge rather
// than mint a token the registry would refuse.
func TestHandler_Scope_RepeatedParamsAllPrivateStillChallenges(t *testing.T) {
gate := &stubAnonymousAuthorizer{} // nothing is public
h := newGateTestHandler(t, gate)
w := anonymousGet(t, h,
"service=registry"+
"&scope=repository:alice.test/private-one:pull"+
"&scope=repository:bob.test/private-two:pull")
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 when no repeated entry is grantable, 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")
}
if strings.Contains(w.Body.String(), `"token"`) {
t.Errorf("expected no token in the challenge body, got %q", w.Body.String())
}
}