From 7bd9e7c2ac7e4f575e7a829e92d857b83290c07e Mon Sep 17 00:00:00 2001 From: Gang Wang Date: Tue, 26 May 2026 03:27:37 +0000 Subject: [PATCH 1/3] feat: support distribution v3.x via x5c JWT header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit distribution/distribution v3.0.0 replaced libtrust with go-jose and keys its trustedKeys map by RFC 7638 JWK thumbprint, so tokens signed with the old libtrust-style kid header (which v2.8.1 used) are no longer matched. Switch to emitting the signing certificate in the JWS x5c protected header. Both v2.8.1 (parseAndVerifyCertChain) and v3.0.0 (verifyCertChain) verify x5c against the rootcertbundle, so the same token works on both — no libtrust dependency needed. - pkg/server/auth.go: drop libtrust kid derivation; embed the signing cert as base64 DER in the x5c header. - go.mod/go.sum: remove github.com/docker/libtrust (archived). - scripts/simple-tests.sh: parametrize REGISTRY_VERSIONS so CI exercises both v2.8.1 and v3.0.0 (defaults to both). - pkg/server/server_test.go: cover the x5c regression and the previously-untested Authenticate / Authorize / DecodeScope* paths (raises pkg/server coverage from 0% to ~27%). - README/README_zh: note v2/v3 dual compatibility and v3's config path. - CLAUDE.md: project guidance with the x5c rationale. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 110 +++++++++++ README.md | 4 +- README_zh.md | 4 +- go.mod | 1 - go.sum | 2 - pkg/server/auth.go | 22 +-- pkg/server/options/basic.go | 2 +- pkg/server/server_test.go | 385 ++++++++++++++++++++++++++++++++++++ scripts/simple-tests.sh | 68 ++++--- 9 files changed, 556 insertions(+), 42 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f15b74d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +`registry-auth` is a Go service that provides authentication and authorization for the [Docker Registry](https://github.com/distribution/distribution). It implements two of the Registry's auth flows: + +- **Token auth**: Registry-auth runs alongside the Registry. The client first hits the Registry, gets a 401 with the `WWW-Authenticate` URL, then exchanges Basic credentials at `/auth/token` for a JWT that the Registry verifies against the shared certificate. +- **Proxy auth**: Registry-auth sits in front of the Registry. It accepts Basic auth on `/v2/*`, mints a token internally, swaps it onto the request's `Authorization` header, and reverse-proxies to the Registry backend. + +Both flows share the same JWT signer; the Docker Registry must be configured with the same `rootcertbundle` and `issuer` as registry-auth's `--auth-public-cert-file` and `--auth-issuer`. + +## Common commands + +```bash +make build # build _output///registry-auth (CGO_ENABLED=0) +make test # go test -v -cover ./... +make fmt vet # gofmt + go vet +make image # docker build -> ghcr.io/alauda/registry-auth: +make all # fmt + vet + test + build + strip + +# Single test (no shortcut in Makefile) +go test -v ./pkg/server -run TestHandleAuth + +# End-to-end integration test — pulls a real registry binary + skopeo, runs +# registry-auth, push/pull image. Used by the Dockerfile's RUN_TEST stage. +# Requires curl, openssl, go, and root-ish perms to write /usr/bin. Run from repo root. +bash scripts/simple-tests.sh +``` + +The version string baked into the binary comes from `git describe`; building from a detached or dirty tree marks `GitTreeState=dirty`. + +## Architecture + +### Layered entrypoint + +`cmd/registry-auth/main.go` → `cmd/registry-auth/app/app.go` wires together option groups and calls `pkg/app.NewApp`. `pkg/app` is a small cobra+viper+pflag harness shared across Alauda apps: + +- `Options` (`pkg/server/options/interface.go`) aggregates a list of `Optioner`s; each option group owns its flags, viper bindings, and an `ApplyToServer` hook. +- `app.runCommand` calls `ApplyFlags` (CLI → struct), then `ApplyToServer` (struct → `*server.Server`), then `server.Start`. +- To add a new flag, add it to an existing options file under `pkg/server/options/` (or create a new `Optioner` and register it in `cmd/registry-auth/app/app.go`). + +### Server + +`pkg/server/server.go` holds the long-lived `Server`. `ApplyToServer` wires routes onto a `go-restful` container: + +- `GET /auth/token` → `HandleAuth` (token mode) +- `/v2/*` → `HandleProxy` (proxy mode; reverse-proxies to `--registry-backend`) +- `/health` → static OK + +Both modes funnel through `Server.signToken` (`pkg/server/handlers.go`), which calls `AuthProcessor.Authenticate` → `Authorize` → `Sign`. + +### AuthProcessor (`pkg/server/auth.go`) + +The single source of auth truth. It holds two parallel sets of user/auth maps: + +- `StaticUsers` / `StaticAuths` — populated from the YAML/JSON file (`--auth-config-file`). +- `SecretUsers` / `SecretAuths` — populated from Kubernetes Secrets (`--auth-config-namespace` + `--auth-config-selector`). + +**Lookup order** in both `Authenticate` and `Authorize`: Secret first, then Static, then third-party (`pkg/server/thirdparty.go`). If a request's user has no matching authorization, `signToken` retries authorization as the special `_anonymous` user — that's how unauthenticated pulls are configured. + +Password verification supports three formats in this priority: bcrypt (`$...`), reserved `PBKDF2:` prefix (not implemented), and plaintext fallback. Use `htpasswd -nbB user pass` to generate bcrypt hashes. + +`DecodeScope` parses the `scope=` query parameter (token mode). `DecodeScopeFromUrl` infers `type:name:actions` from the URL/method (proxy mode); HTTP methods map to actions as GET/HEAD→`pull`, PUT/PATCH/POST→`push`, DELETE→`delete`. `/v2/_catalog` uses `registry:catalog:*`. + +In proxy mode `IsScopeActionMatch` short-circuits: if the request's actions are already a subset of what was granted, `signToken` returns `ErrNotHandleAuthHeader` and the handler leaves the existing `Authorization` header alone (avoids re-signing on every blob request). + +### Config sources (all watched live) + +- `file.go` — `fsnotify` watcher on `--auth-config-file`. Calls `LoadFromFile` on Create/Write events. +- `secret.go` — client-go informer over Secrets in `--auth-config-namespace` matching `--auth-config-selector` (default `registry-auth-config=true`). The Secret's `config` key contains the same YAML schema as the file. Add/Update/Delete events are reflected into `SecretUsers`/`SecretAuths` via `LoadFromSecret`. +- `thirdparty.go` — `--auth-thirdparty-server` URL. Registry-auth POSTs Basic-auth'd login requests there; the response is a list of `Authorization` entries cached per user. Only consulted when the user is in neither map. + +### JWT signing + +`NewAuthProcessor` parses the public cert (`loadCert`) and constructs a `jose.Signer` with RS256, emitting the signing cert as a JWS `x5c` header (`encoding/base64` StdEncoding of `cert.Raw`). The private key must be RSA. Claims include `iss`, `sub`, `aud` (from `?service=` or `--service`), `exp`, `nbf`, `iat`, `jti`, and the `access` array. + +Why `x5c` and not `kid`: distribution v2 keys its `trustedKeys` map by `libtrust.KeyID()` (libtrust is archived), while distribution v3 keys it by RFC 7638 JWK thumbprint — the two formats don't agree. Both versions, however, first verify the JWT's `x5c` cert chain against the `rootcertbundle`, so emitting `x5c` makes tokens accepted by both v2.x and v3.x without depending on libtrust. + +## Configuration schema + +`auth-config-file` and the `config` key of watched Secrets share this YAML schema: + +```yaml +users: + user1: plaintext-password + user2: $2y$05$...bcrypt-hash +auths: + user1: + - target: repo/path # literal match + actions: [pull, push] + - target: team/.* # regex match + useRegexp: true + actions: [pull] + _anonymous: # fallthrough for unauthenticated or unmatched users + - target: .* + useRegexp: true + actions: [pull] +``` + +`Authorization.Type` defaults to `repository` if omitted; set it to `registry` for catalog-scoped grants. + +## Notes / gotchas + +- `BasicOptions.ApplyToServer` (`pkg/server/options/basic.go:130`) assigns `o.AuthConfigNamespace` into `server.BasicConfig.AuthConfigLabelSelector` — looks like a copy-paste bug. Selector configured via the flag will not reach the server. If you touch this code, fix to use `o.AuthConfigLabelSelector`. +- `AuthProcessor.LoadFromSecret` (`pkg/server/auth.go:295`) reads `dataOld[SecretKey]` when populating new entries (should be `dataNew`). Updates to Secrets may not behave as expected — verify before relying on dynamic Secret updates. +- The `go.mod` pins `k8s.io/client-go` to `v0.24.4` via `replace`; other `k8s.io/*` deps must stay compatible with that version. +- `pkg/server/auth.go` still uses `io/ioutil`; that's fine, just don't "modernize" mid-task without reason. +- Tokens are verified via `x5c` (not `kid`). Don't reintroduce `libtrust` or a `kid` header without also handling v2/v3 thumbprint differences — see the JWT signing section above. diff --git a/README.md b/README.md index 29e464d..df4aebb 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,9 @@ data: The Docker Registry need configure to work with Registry-Auth. You can configure it by config file or environment variables. -You can add the following configs in /etc/docker/registry/config.yml: +Registry-Auth works with both `distribution/distribution` v2.x and v3.x. The token configuration keys (`realm`, `service`, `issuer`, `rootcertbundle`, `autoredirect`) and `REGISTRY_AUTH_TOKEN_*` environment variables are identical across both versions. Note that v3 changed the default config path from `/etc/docker/registry/config.yml` to `/etc/distribution/config.yml`. + +You can add the following configs in /etc/docker/registry/config.yml (v2) or /etc/distribution/config.yml (v3): ``` yaml auth: diff --git a/README_zh.md b/README_zh.md index 55ca671..ecf4245 100644 --- a/README_zh.md +++ b/README_zh.md @@ -157,7 +157,9 @@ data: 当使用 Registry-Auth 时,Docker Registry 也要做必要的设置。 你可以选择通过 **配置文件** 或 **环境变量** 的方式配置 Docker Registry。 -你可以在 /etc/docker/registry/config.yml 中配置如下字段: +Registry-Auth 同时兼容 `distribution/distribution` v2.x 与 v3.x,token 段的配置项(`realm`、`service`、`issuer`、`rootcertbundle`、`autoredirect`)与 `REGISTRY_AUTH_TOKEN_*` 环境变量两版完全一致。注意 v3 的默认配置路径由 `/etc/docker/registry/config.yml` 改为 `/etc/distribution/config.yml`。 + +你可以在 /etc/docker/registry/config.yml(v2)或 /etc/distribution/config.yml(v3)中配置如下字段: ``` yaml auth: diff --git a/go.mod b/go.mod index 0ce4f47..a4f5b3f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ toolchain go1.24.11 replace k8s.io/client-go => k8s.io/client-go v0.24.4 require ( - github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 github.com/emicklei/go-restful v2.16.0+incompatible github.com/fatih/color v1.13.0 github.com/google/uuid v1.1.2 diff --git a/go.sum b/go.sum index 3279df4..4e85dc1 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= diff --git a/pkg/server/auth.go b/pkg/server/auth.go index fc56b1f..d50e04a 100644 --- a/pkg/server/auth.go +++ b/pkg/server/auth.go @@ -1,7 +1,6 @@ package server import ( - "crypto" "crypto/rsa" "crypto/x509" "encoding/base64" @@ -19,7 +18,6 @@ import ( "github.com/thoas/go-funk" - "github.com/docker/libtrust" "github.com/google/uuid" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -81,7 +79,7 @@ type AuthProcessor struct { ThirdpartyAuth ThirdpartyAuth signer jose.Signer - kid string + cert *x509.Certificate } type ConfigFile struct { @@ -195,7 +193,7 @@ func NewAuthProcessor(privateKeyFile, publicCertFile, issuer string, tokenDurati Issuer: issuer, TokenDuration: time.Second * time.Duration(tokenDuration), } - if err := a.getKid(publicCertFile); err != nil { + if err := a.loadCert(publicCertFile); err != nil { return nil, err } @@ -206,7 +204,7 @@ func NewAuthProcessor(privateKeyFile, publicCertFile, issuer string, tokenDurati return a, nil } -func (a *AuthProcessor) getKid(publicCertFile string) error { +func (a *AuthProcessor) loadCert(publicCertFile string) error { data, err := ioutil.ReadFile(publicCertFile) if err != nil { return err @@ -221,12 +219,7 @@ func (a *AuthProcessor) getKid(publicCertFile string) error { return fmt.Errorf("parse certificate '%s' error: %v ", publicCertFile, err) } - pubKey, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) - if err != nil { - return fmt.Errorf("parse public key '%s' error: %v ", publicCertFile, err) - } - - a.kid = pubKey.KeyID() + a.cert = cert return nil } @@ -245,12 +238,15 @@ func (a *AuthProcessor) generateSigner(privateKeyFile string) error { return fmt.Errorf("the private key is not in RSA format") } - op := &jose.SignerOptions{} + op := (&jose.SignerOptions{}).WithHeader( + jose.HeaderKey("x5c"), + []string{base64.StdEncoding.EncodeToString(a.cert.Raw)}, + ) a.signer, err = jose.NewSigner(jose.SigningKey{ Algorithm: jose.RS256, Key: rsaPrivKey, - }, op.WithHeader(jose.HeaderKey("kid"), a.kid)) + }, op) return err } diff --git a/pkg/server/options/basic.go b/pkg/server/options/basic.go index 0772382..0b59bc2 100644 --- a/pkg/server/options/basic.go +++ b/pkg/server/options/basic.go @@ -40,7 +40,7 @@ type BasicOptions struct { config.BasicConfig } -//// NewConsoleOptions creates a ConsoleOptions object with default parameters. +// // NewConsoleOptions creates a ConsoleOptions object with default parameters. func NewBasicOptions() *BasicOptions { return &BasicOptions{} } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index abb4e43..c2deffa 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -1 +1,386 @@ package server + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + jose "gopkg.in/go-jose/go-jose.v2" + "gopkg.in/go-jose/go-jose.v2/jwt" +) + +func TestMain(m *testing.M) { + // parseAuths / file watch paths call logger.Error on errors; make it a no-op for tests. + logger = zap.NewNop() + os.Exit(m.Run()) +} + +func generateCertPair(t *testing.T, dir string) (certPath, keyPath string, cert *x509.Certificate) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "registry-auth-test"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + BasicConstraintsValid: true, + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + cert, err = x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + certPath = filepath.Join(dir, "token.crt") + keyPath = filepath.Join(dir, "token.key") + if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil { + t.Fatal(err) + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER}), 0600); err != nil { + t.Fatal(err) + } + return +} + +func newTestProcessor(t *testing.T) *AuthProcessor { + t.Helper() + certPath, keyPath, _ := generateCertPair(t, t.TempDir()) + p, err := NewAuthProcessor(keyPath, certPath, "test-issuer", 600) + if err != nil { + t.Fatalf("NewAuthProcessor: %v", err) + } + return p +} + +// TestSign_EmitsX5cHeaderAndVerifies is the key regression test for the v3 compatibility +// change: tokens must carry an `x5c` JWS header (and no `kid`) so that both distribution +// v2.8.1 (parseAndVerifyCertChain) and v3.0.0 (verifyCertChain) verify them against the +// rootcertbundle. +func TestSign_EmitsX5cHeaderAndVerifies(t *testing.T) { + certPath, keyPath, cert := generateCertPair(t, t.TempDir()) + p, err := NewAuthProcessor(keyPath, certPath, "iss", 600) + if err != nil { + t.Fatalf("NewAuthProcessor: %v", err) + } + + scope := AccessScope{{Type: RepositoryAccessType, Name: "foo/bar", Actions: []string{PullAction}}} + tok, err := p.Sign("alice", "svc", scope) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + // 1. Raw protected header must carry x5c (and must not carry kid). + parts := strings.Split(tok.Token, ".") + if len(parts) != 3 { + t.Fatalf("unexpected JWT segment count: %d", len(parts)) + } + rawHdr, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("decode header: %v", err) + } + if !strings.Contains(string(rawHdr), `"x5c":`) { + t.Fatalf("expected x5c header, got %s", rawHdr) + } + if strings.Contains(string(rawHdr), `"kid":`) { + t.Fatalf("unexpected kid header: %s", rawHdr) + } + + // 2. Reparse and verify the cert chain against the cert as root — mirrors the + // distribution v2/v3 verify path. + jws, err := jose.ParseSigned(tok.Token) + if err != nil { + t.Fatalf("ParseSigned: %v", err) + } + h := jws.Signatures[0].Header + roots := x509.NewCertPool() + roots.AddCert(cert) + chains, err := h.Certificates(x509.VerifyOptions{ + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }) + if err != nil { + t.Fatalf("Header.Certificates verify: %v", err) + } + if len(chains) == 0 || len(chains[0]) == 0 { + t.Fatal("no verified chain returned") + } + + // 3. Validate signature and check claim contents. + parsed, err := jwt.ParseSigned(tok.Token) + if err != nil { + t.Fatalf("ParseSigned (jwt): %v", err) + } + var claims Claims + if err := parsed.Claims(cert.PublicKey, &claims); err != nil { + t.Fatalf("Claims verify: %v", err) + } + if claims.Issuer != "iss" { + t.Fatalf("iss = %q", claims.Issuer) + } + if claims.Subject != "alice" { + t.Fatalf("sub = %q", claims.Subject) + } + if claims.Audience != "svc" { + t.Fatalf("aud = %q", claims.Audience) + } + if len(claims.Access) != 1 || claims.Access[0].Name != "foo/bar" { + t.Fatalf("access = %+v", claims.Access) + } +} + +func basicHeader(user, pass string) string { + return BasicPrefix + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) +} + +func TestAuthenticate_EmptyHeaderIsAnonymous(t *testing.T) { + p := newTestProcessor(t) + user, err := p.Authenticate("") + if err != nil { + t.Fatalf("err: %v", err) + } + if user != AnonymousUser { + t.Fatalf("want %q, got %q", AnonymousUser, user) + } +} + +func TestAuthenticate_Plaintext(t *testing.T) { + p := newTestProcessor(t) + p.StaticUsers = map[string]string{"alice": "wonderland"} + got, err := p.Authenticate(basicHeader("alice", "wonderland")) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "alice" { + t.Fatalf("got %q", got) + } +} + +func TestAuthenticate_Bcrypt(t *testing.T) { + p := newTestProcessor(t) + hash, err := bcrypt.GenerateFromPassword([]byte("s3cret"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + p.StaticUsers = map[string]string{"bob": string(hash)} + got, err := p.Authenticate(basicHeader("bob", "s3cret")) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "bob" { + t.Fatalf("got %q", got) + } +} + +func TestAuthenticate_WrongPassword(t *testing.T) { + p := newTestProcessor(t) + p.StaticUsers = map[string]string{"alice": "wonderland"} + if _, err := p.Authenticate(basicHeader("alice", "nope")); err != ErrAuthFailed { + t.Fatalf("want ErrAuthFailed, got %v", err) + } +} + +func TestAuthenticate_UnknownUserNoThirdparty(t *testing.T) { + p := newTestProcessor(t) + if _, err := p.Authenticate(basicHeader("ghost", "x")); err != ErrAuthFailed { + t.Fatalf("want ErrAuthFailed, got %v", err) + } +} + +func TestLoadFromFile_YAMLAndAuthorize(t *testing.T) { + p := newTestProcessor(t) + cfg := []byte(`users: + alice: pwd1 +auths: + alice: + - target: foo/bar + actions: [pull] + - target: "team/.*" + useRegexp: true + actions: [pull, push] + _anonymous: + - target: "public/.*" + useRegexp: true + actions: [pull] +`) + if err := p.LoadFromFile(cfg); err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + + if p.StaticUsers["alice"] != "pwd1" { + t.Fatalf("users: %+v", p.StaticUsers) + } + + // Default Type filled in by parseAuths. + if p.StaticAuths["alice"][0].Type != RepositoryAccessType { + t.Fatalf("expected default repository type, got %q", p.StaticAuths["alice"][0].Type) + } + // Regex compiled. + if p.StaticAuths["alice"][1].regexp == nil { + t.Fatalf("expected regexp compiled for team/.*") + } + + // Literal match. + got := p.Authorize("alice", AccessScope{{Type: RepositoryAccessType, Name: "foo/bar"}}) + if len(got) != 1 || got[0].Actions[0] != PullAction { + t.Fatalf("literal authorize: %+v", got) + } + + // Regex match. + got = p.Authorize("alice", AccessScope{{Type: RepositoryAccessType, Name: "team/svc"}}) + if len(got) != 1 || len(got[0].Actions) != 2 { + t.Fatalf("regex authorize: %+v", got) + } + + // No matching rule for user → nil. + if got := p.Authorize("alice", AccessScope{{Type: RepositoryAccessType, Name: "other/repo"}}); got != nil { + t.Fatalf("expected nil, got %+v", got) + } + + // Anonymous fallback rule reachable for public/* with the "_anonymous" key. + got = p.Authorize(AnonymousUser, AccessScope{{Type: RepositoryAccessType, Name: "public/x"}}) + if len(got) != 1 || got[0].Actions[0] != PullAction { + t.Fatalf("anonymous authorize: %+v", got) + } +} + +func TestDecodeScope_FromQuery(t *testing.T) { + req := httptest.NewRequest("GET", "http://x/auth/token?service=svc&scope=repository:foo/bar:pull,push", nil) + got, err := DecodeScope(req) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d entries", len(got)) + } + if got[0].Type != RepositoryAccessType || got[0].Name != "foo/bar" { + t.Fatalf("got %+v", got[0]) + } + if len(got[0].Actions) != 2 || got[0].Actions[0] != PullAction || got[0].Actions[1] != PushAction { + t.Fatalf("actions: %+v", got[0].Actions) + } +} + +func TestDecodeScope_Empty(t *testing.T) { + req := httptest.NewRequest("GET", "http://x/auth/token", nil) + got, err := DecodeScope(req) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Fatalf("expected nil scope, got %+v", got) + } +} + +func TestDecodeScope_Malformed(t *testing.T) { + req := httptest.NewRequest("GET", "http://x/auth/token?scope=garbage", nil) + if _, err := DecodeScope(req); err == nil { + t.Fatal("expected error for malformed scope") + } +} + +func TestDecodeScopeFromUrl(t *testing.T) { + cases := []struct { + name string + method string + path string + wantType string + wantName string + wantAction string + wantErr bool + }{ + {"base", "GET", "/v2/", "", "", "", false}, + {"catalog", "GET", "/v2/_catalog", RegistryAccessType, "catalog", CatalogAction, false}, + {"manifest_pull", "GET", "/v2/foo/bar/manifests/v1.0.0", RepositoryAccessType, "foo/bar", PullAction, false}, + {"manifest_push", "PUT", "/v2/foo/bar/manifests/v1", RepositoryAccessType, "foo/bar", PushAction, false}, + {"blob_pull", "GET", "/v2/foo/bar/blobs/sha256:deadbeef", RepositoryAccessType, "foo/bar", PullAction, false}, + {"blob_push", "POST", "/v2/foo/bar/blobs/uploads/", RepositoryAccessType, "foo/bar", PushAction, false}, + {"tags_list", "GET", "/v2/foo/bar/tags/list", RepositoryAccessType, "foo/bar", PullAction, false}, + {"manifest_delete", "DELETE", "/v2/foo/bar/manifests/sha256:abc", RepositoryAccessType, "foo/bar", DeleteAction, false}, + {"unknown", "GET", "/v2/foo/bar/weird/x", "", "", "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(c.method, "http://x"+c.path, nil) + got, err := DecodeScopeFromUrl(req) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", got) + } + return + } + if err != nil { + t.Fatalf("err: %v", err) + } + if c.path == "/v2/" { + if got != nil { + t.Fatalf("base path: expected nil, got %+v", got) + } + return + } + if len(got) != 1 { + t.Fatalf("got %d entries", len(got)) + } + if got[0].Type != c.wantType { + t.Fatalf("type: want %q, got %q", c.wantType, got[0].Type) + } + if got[0].Name != c.wantName { + t.Fatalf("name: want %q, got %q", c.wantName, got[0].Name) + } + if len(got[0].Actions) != 1 || got[0].Actions[0] != c.wantAction { + t.Fatalf("actions: want [%s], got %+v", c.wantAction, got[0].Actions) + } + }) + } +} + +func TestIsScopeActionMatch(t *testing.T) { + // /v2/ always matches (auth bypass for the ping endpoint). + if !IsScopeActionMatch( + httptest.NewRequest("GET", "http://x/v2/", nil), + nil, nil, + ) { + t.Fatal("/v2/ should always match") + } + + // Requested actions are a subset of result → match. + if !IsScopeActionMatch( + httptest.NewRequest("GET", "http://x/v2/foo/manifests/v1", nil), + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PullAction, PushAction}}}, + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PullAction}}}, + ) { + t.Fatal("subset should match") + } + + // Requested includes push but result only has pull → no match. + if IsScopeActionMatch( + httptest.NewRequest("PUT", "http://x/v2/foo/blobs/uploads/x", nil), + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PullAction}}}, + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PushAction}}}, + ) { + t.Fatal("non-subset should not match") + } +} diff --git a/scripts/simple-tests.sh b/scripts/simple-tests.sh index 1c75c25..abc2456 100644 --- a/scripts/simple-tests.sh +++ b/scripts/simple-tests.sh @@ -1,5 +1,5 @@ #!/bin/bash -REGISTRY_VERSION=${REGISTRY_VERSION:-"v2.8.1"} +REGISTRY_VERSIONS=${REGISTRY_VERSIONS:-"v2.8.1 v3.0.0"} SKOPEO_VERSION=${SKOPEO_VERSION:-"v1.10.0"} ADMIN_PASSWORD=${ADMIN_PASSWORD:-"$(head -c 20 /dev/random | base64 -w 0)"} @@ -12,17 +12,31 @@ function prepare_certs() { openssl req -new -newkey rsa:2048 -days 365 -x509 -keyout token.key -out token.crt -nodes -subj '/CN=registry-auth-token' } -function prepare_registry() { - local url="https://github.com/distribution/distribution/releases/download/${REGISTRY_VERSION}/registry_${REGISTRY_VERSION:1}_linux_$(go env GOARCH).tar.gz" +function install_registry() { + local version="$1" + local url="https://github.com/distribution/distribution/releases/download/${version}/registry_${version:1}_linux_$(go env GOARCH).tar.gz" curl -L "${url}" | tar xvz -C /usr/bin/ --exclude LICENSE --exclude READEME.md - pwd - ls +} + +function start_registry() { + rm -rf /var/lib/registry REGISTRY_AUTH_TOKEN_AUTOREDIRECT=true \ REGISTRY_AUTH_TOKEN_REALM=/auth/token \ REGISTRY_AUTH_TOKEN_SERVICE=token-service \ REGISTRY_AUTH_TOKEN_ISSUER=registry-token-issuer \ REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=token.crt \ /usr/bin/registry serve registry.yaml & + REGISTRY_PID=$! + # give it a moment to bind :5000 + sleep 2 +} + +function stop_registry() { + if [ -n "${REGISTRY_PID}" ]; then + kill "${REGISTRY_PID}" 2>/dev/null || true + wait "${REGISTRY_PID}" 2>/dev/null || true + REGISTRY_PID="" + fi } function prepare_skopeo() { @@ -63,46 +77,54 @@ function prepare_registry_auth() { } function do_tests() { + local version="$1" + # version-tagged repo so different registry runs don't share state via the + # client-side archive name only — push target encodes the version too. + local repo_tag="registry-${version}" - # prepare artificat + # prepare artifact (use a stable upstream tag; we only care that push/pull round-trips) skopeo copy --insecure-policy --override-os=linux \ - docker://"registry:${REGISTRY_VERSION:1}" \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}.tar" \ + docker://"registry:2.8.1" \ + docker-archive:"/tmp/registry-src.tar" - # push expecting fail + # push expecting fail (no creds) set +e skopeo copy --insecure-policy --dest-tls-verify=false \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}.tar" \ - docker://"127.0.0.1:8080/registry:${REGISTRY_VERSION}" + docker-archive:"/tmp/registry-src.tar" \ + docker://"127.0.0.1:8080/${repo_tag}:latest" if [ "$?" -eq "0" ]; then - echo "push without auth failed" - exit -1 + echo "push without auth against ${version} succeeded unexpectedly" + exit 1 fi set -e # push with auth skopeo copy --insecure-policy --dest-tls-verify=false --dest-creds="admin:${ADMIN_PASSWORD}" \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}.tar" \ - docker://"127.0.0.1:8080/registry:${REGISTRY_VERSION}" \ + docker-archive:"/tmp/registry-src.tar" \ + docker://"127.0.0.1:8080/${repo_tag}:latest" - # pull + # pull (anonymous, since _anonymous can pull) skopeo copy --insecure-policy --src-tls-verify=false \ - docker://"127.0.0.1:8080/registry:${REGISTRY_VERSION}" \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}-local.tar" - - - diff "/tmp/registry-${REGISTRY_VERSION}-local.tar" "/tmp/registry-${REGISTRY_VERSION}.tar" + docker://"127.0.0.1:8080/${repo_tag}:latest" \ + docker-archive:"/tmp/registry-pulled-${version}.tar" + diff "/tmp/registry-pulled-${version}.tar" "/tmp/registry-src.tar" } function main() { prepare_certs - prepare_registry prepare_skopeo prepare_auth prepare_registry_auth - do_tests + + for version in ${REGISTRY_VERSIONS}; do + echo "=== testing against distribution ${version} ===" + install_registry "${version}" + start_registry + do_tests "${version}" + stop_registry + done } main From b77f429f5a627ab13de2ae7c1817ee9232b390b4 Mon Sep 17 00:00:00 2001 From: Gang Wang Date: Tue, 26 May 2026 03:35:52 +0000 Subject: [PATCH 2/3] fix(ci): hoist source-image fetch out of per-version loop scripts/simple-tests.sh ran `skopeo copy ... docker-archive:/tmp/registry-src.tar` inside do_tests, so the second iteration (v3.0.0) failed with "docker-archive doesn't support modifying existing images" because the v2.8.1 pass had already written the file. Move the source-image fetch into its own prepare_source_image called once from main(), and guard with -f so reruns are idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/simple-tests.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/simple-tests.sh b/scripts/simple-tests.sh index abc2456..91dbf20 100644 --- a/scripts/simple-tests.sh +++ b/scripts/simple-tests.sh @@ -76,17 +76,23 @@ function prepare_registry_auth() { --auth-config-file=auth.yaml & } +function prepare_source_image() { + # Pull the upstream image into a docker-archive once. `docker-archive:` does + # not allow overwriting an existing file, so we cannot run this per-version + # inside the loop. + if [ ! -f /tmp/registry-src.tar ]; then + skopeo copy --insecure-policy --override-os=linux \ + docker://"registry:2.8.1" \ + docker-archive:"/tmp/registry-src.tar" + fi +} + function do_tests() { local version="$1" # version-tagged repo so different registry runs don't share state via the # client-side archive name only — push target encodes the version too. local repo_tag="registry-${version}" - # prepare artifact (use a stable upstream tag; we only care that push/pull round-trips) - skopeo copy --insecure-policy --override-os=linux \ - docker://"registry:2.8.1" \ - docker-archive:"/tmp/registry-src.tar" - # push expecting fail (no creds) set +e skopeo copy --insecure-policy --dest-tls-verify=false \ @@ -117,6 +123,7 @@ function main() { prepare_skopeo prepare_auth prepare_registry_auth + prepare_source_image for version in ${REGISTRY_VERSIONS}; do echo "=== testing against distribution ${version} ===" From 16d704d0ad4cca6d506050255b5d614cbfeb5702 Mon Sep 17 00:00:00 2001 From: Gang Wang Date: Tue, 26 May 2026 03:44:30 +0000 Subject: [PATCH 3/3] fix(token): lowercase JSON tags on ClaimAccess so v3 accepts the scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaimAccess fields had no `json:` tags, so the JWT's `access` array was marshalled with the Go field names — `Type`/`Name`/`Actions`. Distribution v2.8.1 happened to accept that (Go's encoding/json does a case-insensitive fallback), but distribution v3 uses go-jose v3's JSON fork which dropped that fallback. v3 parsed the access entries as empty and returned "insufficient scope" on blob HEAD/GET, even though registry-auth had signed a token covering pull,push. Tag the fields with the lowercase keys mandated by the Docker token spec (type/name/actions) and extend TestSign_EmitsX5cHeaderAndVerifies to assert the raw payload uses those keys, so this can't regress. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/server/auth.go | 6 +++--- pkg/server/server_test.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/pkg/server/auth.go b/pkg/server/auth.go index d50e04a..077fab5 100644 --- a/pkg/server/auth.go +++ b/pkg/server/auth.go @@ -61,9 +61,9 @@ type UserAuthorization struct { } type ClaimAccess struct { - Type string - Name string - Actions []string + Type string `json:"type"` + Name string `json:"name"` + Actions []string `json:"actions"` } type AccessScope []ClaimAccess diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index c2deffa..5f5b373 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -150,6 +150,20 @@ func TestSign_EmitsX5cHeaderAndVerifies(t *testing.T) { if len(claims.Access) != 1 || claims.Access[0].Name != "foo/bar" { t.Fatalf("access = %+v", claims.Access) } + + // distribution v3 uses go-jose v3's JSON fork which (unlike encoding/json) + // does NOT fall back to a case-insensitive field match, so the access + // claim must serialize with lowercase keys (type/name/actions). Decode the + // raw JWT payload and assert that. + rawPayload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + if !strings.Contains(string(rawPayload), `"type":"repository"`) || + !strings.Contains(string(rawPayload), `"name":"foo/bar"`) || + !strings.Contains(string(rawPayload), `"actions":["pull"]`) { + t.Fatalf("access claim must use lowercase keys (type/name/actions); got payload: %s", rawPayload) + } } func basicHeader(user, pass string) string {