package registryauth import ( "errors" "net/http" "net/http/httptest" "path/filepath" "regexp" "strings" "testing" "time" "github.com/distribution/distribution/v3/registry/auth" pkgauth "atcr.io/pkg/auth" atcrtoken "atcr.io/pkg/auth/token" ) const ( testIssuer = "seamark.dev" testPrimary = "buoy.cr" ) // newTestController builds a controller over the given services, backed by a // freshly generated signing key, and returns it alongside the issuer that mints // tokens it will accept. func newTestController(t *testing.T, services []string) (auth.AccessController, *atcrtoken.Issuer) { t.Helper() keyPath := filepath.Join(t.TempDir(), "jwt.pem") issuer, err := atcrtoken.NewIssuer(keyPath, testIssuer, services[0], 5*time.Minute) if err != nil { t.Fatalf("NewIssuer() error = %v", err) } certPath := strings.TrimSuffix(keyPath, ".pem") + ".crt" ac, err := newController(map[string]any{ "realm": "https://seamark.dev/auth/token", "issuer": testIssuer, "services": services, "rootcertbundle": certPath, }) if err != nil { t.Fatalf("newController() error = %v", err) } return ac, issuer } // pingRequest builds the /v2/ ping a Docker client sends, bearing a token // minted for audience. An empty audience sends no Authorization header. func pingRequest(t *testing.T, issuer *atcrtoken.Issuer, host, audience string) *http.Request { t.Helper() req := httptest.NewRequest(http.MethodGet, "/v2/", nil) req.Host = host if audience == "" { return req } tok, err := issuer.IssueWithExpiration("did:plc:test", nil, atcrtoken.AuthMethodOAuth, time.Minute, audience) if err != nil { t.Fatalf("IssueWithExpiration(%q) error = %v", audience, err) } req.Header.Set("Authorization", "Bearer "+tok) return req } // challengeService extracts the service parameter from the WWW-Authenticate // header the given authorization error would emit. func challengeService(t *testing.T, err error, r *http.Request) string { t.Helper() var challenge auth.Challenge if !errors.As(err, &challenge) { t.Fatalf("error %v is not an auth.Challenge", err) } rec := httptest.NewRecorder() challenge.SetHeaders(r, rec) // Values are quoted and scope= legitimately contains commas // ("repository:x:pull,push"), so match the quoted value rather than // splitting the header on ",". header := rec.Header().Get("WWW-Authenticate") m := regexp.MustCompile(`service="([^"]*)"`).FindStringSubmatch(header) if m == nil { t.Fatalf("no service parameter in WWW-Authenticate %q", header) } return m[1] } // A token is valid only on the front door it was minted for. This is the whole // point of the package: upstream's single `service` accepts one audience // everywhere, which would let a buoy.cr token authorize an atcr.io push. func TestAuthorized_TokenIsScopedToItsFrontDoor(t *testing.T) { services := []string{testPrimary, "seamark.cr", "atcr.io"} ac, issuer := newTestController(t, services) for _, host := range services { t.Run("accepts own audience on "+host, func(t *testing.T) { if _, err := ac.Authorized(pingRequest(t, issuer, host, host)); err != nil { t.Fatalf("Authorized() error = %v, want nil", err) } }) } t.Run("rejects another domain's audience", func(t *testing.T) { req := pingRequest(t, issuer, "atcr.io", testPrimary) if _, err := ac.Authorized(req); err == nil { t.Fatal("Authorized() = nil, want error for cross-domain audience") } }) } // Each front door must advertise its own name, so the client echoes the right // service back to the realm and receives a token for the domain it is using. func TestAuthorized_ChallengeAdvertisesRequestHost(t *testing.T) { ac, issuer := newTestController(t, []string{testPrimary, "seamark.cr", "atcr.io"}) tests := []struct { name string host string want string }{ {"registry domain", "atcr.io", "atcr.io"}, {"another registry domain", "seamark.cr", "seamark.cr"}, {"primary", testPrimary, testPrimary}, // Ports are stripped before matching, mirroring DomainRoutingMiddleware. {"host with port", "atcr.io:443", "atcr.io"}, // Anything DomainRoutingMiddleware would not have routed here falls // back to the primary rather than failing closed. {"unknown host", "example.com", testPrimary}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := pingRequest(t, issuer, tt.host, "") _, err := ac.Authorized(req) if err == nil { t.Fatal("Authorized() = nil, want challenge for missing token") } if got := challengeService(t, err, req); got != tt.want { t.Errorf("challenge service = %q, want %q", got, tt.want) } }) } } // A push scope in the JWT must still be honoured through the delegate; routing // by host must not drop the access check. func TestAuthorized_PassesAccessThroughToDelegate(t *testing.T) { ac, issuer := newTestController(t, []string{testPrimary, "atcr.io"}) granted := []pkgauth.AccessEntry{{ Type: "repository", Name: "alice.test/app", Actions: []string{"pull", "push"}, }} tok, err := issuer.IssueWithExpiration("did:plc:test", granted, atcrtoken.AuthMethodOAuth, time.Minute, "atcr.io") if err != nil { t.Fatalf("IssueWithExpiration() error = %v", err) } newReq := func() *http.Request { req := httptest.NewRequest(http.MethodGet, "/v2/", nil) req.Host = "atcr.io" req.Header.Set("Authorization", "Bearer "+tok) return req } push := auth.Access{ Resource: auth.Resource{Type: "repository", Name: "alice.test/app"}, Action: "push", } if _, err := ac.Authorized(newReq(), push); err != nil { t.Fatalf("Authorized(push) error = %v, want nil", err) } other := auth.Access{ Resource: auth.Resource{Type: "repository", Name: "bob.test/app"}, Action: "push", } if _, err := ac.Authorized(newReq(), other); err == nil { t.Fatal("Authorized(other repo) = nil, want insufficient scope") } } // A single-element list must behave exactly like the upstream single-service // controller, so a single-domain deployment sees no behaviour change. func TestNewController_SingleService(t *testing.T) { ac, issuer := newTestController(t, []string{"atcr.io"}) // The single service answers on its own host and on any other, since there // is no second domain to disambiguate against. for _, host := range []string{"atcr.io", "anything.example"} { if _, err := ac.Authorized(pingRequest(t, issuer, host, "atcr.io")); err != nil { t.Errorf("Authorized(host=%s) error = %v, want nil", host, err) } } } func TestNewController_Errors(t *testing.T) { certPath := filepath.Join(t.TempDir(), "missing.crt") tests := []struct { name string options map[string]any }{ {"missing services", map[string]any{"realm": "r", "issuer": "i", "rootcertbundle": certPath}}, {"empty services", map[string]any{ "realm": "r", "issuer": "i", "services": []string{}, "rootcertbundle": certPath, }}, {"services wrong type", map[string]any{ "realm": "r", "issuer": "i", "services": []any{"atcr.io"}, "rootcertbundle": certPath, }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, err := newController(tt.options); err == nil { t.Fatal("newController() = nil error, want error") } }) } }