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()) } }