From 32da0fd750b789e02e11f6eacd51dae3fa2fa347 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 25 Oct 2026 13:24:23 +1100 Subject: [PATCH 1/2] Gate local repo writes behind OIDC tokens Add ARTIFACTAPI_AUTH_UPLOADS to require a verified token for local uploads, deletes, evictions, /api/v1 publishes and /v2 push. Accept the token as Bearer, Token, Basic password or bare header, and serve a Docker registry token endpoint so pulls stay anonymous. --- README.md | 4 +- docs/ansible/local.md | 2 +- docs/common/auth.md | 53 ++++++++++++++++++++ docs/docker/local.md | 3 ++ docs/gems/local.md | 2 +- docs/npm/local.md | 3 +- internal/auth/oidc.go | 71 ++++++++++++++++++++++---- internal/auth/oidc_test.go | 85 +++++++++++++++++++++++++++++++ internal/config/env.go | 10 ++++ internal/config/env_test.go | 20 ++++++++ internal/server/oidc_test.go | 96 ++++++++++++++++++++++++++++++++++++ internal/server/server.go | 22 ++++++--- 12 files changed, 352 insertions(+), 19 deletions(-) create mode 100644 docs/common/auth.md diff --git a/README.md b/README.md index 7ca3d60..5e55941 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173` Providers classify paths automatically. Users only configure what to proxy and TTLs. Classification precedence, TTLs and eviction for every remote: [docs/common/caching.md](docs/common/caching.md). +Write authentication and per-client token usage: [docs/common/auth.md](docs/common/auth.md). Each type's paths, Terraform examples and local/virtual behaviour are in `docs//`: @@ -208,8 +209,9 @@ additive and idempotent for the same reason; a test enforces it. | `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id | | `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM | | `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) | -| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.service.consul:8200/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require `Authorization: Bearer ` verified against the issuer's JWKS. Uploads, object/file deletes, probe, reads and `/v2` stay open. Unset = no auth | +| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.example.com/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require a token verified against the issuer's JWKS. Unset = no auth. See [docs/common/auth.md](docs/common/auth.md) | | `ARTIFACTAPI_OIDC_AUDIENCE` | | Comma-separated allowed `aud` values (e.g. the Vault OIDC role's client_id). Required when `ARTIFACTAPI_OIDC_ISSUER` is set | +| `ARTIFACTAPI_AUTH_UPLOADS` | `false` | Also require a token for every local repo write: file uploads/deletes, object evictions, `/api/v1/local` publishes and `/v2` push. Requires `ARTIFACTAPI_OIDC_ISSUER` | ## Development diff --git a/docs/ansible/local.md b/docs/ansible/local.md index 33bd7ad..66a2d96 100644 --- a/docs/ansible/local.md +++ b/docs/ansible/local.md @@ -2,7 +2,7 @@ A local `ansible` repo is a Galaxy v3 server: `ansible-galaxy collection publish` and `ansible-galaxy collection install` work against `/api/v1/local/`. -`publish` needs an API key; any value works. +`publish` needs an API key; any value works unless [uploads are gated](../common/auth.md). ```bash ansible-galaxy collection build diff --git a/docs/common/auth.md b/docs/common/auth.md new file mode 100644 index 0000000..9b31aaf --- /dev/null +++ b/docs/common/auth.md @@ -0,0 +1,53 @@ +# Write authentication + +artifactapi verifies writes against an OIDC issuer's JWKS, such as a Vault +identity OIDC role. Reads, probes and anonymous pulls never need a token. + +| Variable | Gates | +|---|---| +| `ARTIFACTAPI_OIDC_ISSUER` + `ARTIFACTAPI_OIDC_AUDIENCE` | creating, updating and deleting remotes and virtuals | +| `ARTIFACTAPI_AUTH_UPLOADS=true` | also every local repo write: `PUT`/`DELETE /api/v2/remotes//files/*`, `DELETE /api/v2/{remotes,locals}//objects/*`, `PUT`/`POST`/`DELETE /api/v1/local//*` (npm publish, gem push/yank, ansible-galaxy publish) and Docker Registry V2 push/delete under `/v2` | + +Any token from the issuer whose `aud` is in `ARTIFACTAPI_OIDC_AUDIENCE` may +write to every repo. + +## Minting a token + +```sh +vault read -field=token identity/oidc/token/ > token +``` + +The role's `client_id` must be one of the configured audiences. + +## Passing the token + +The token is accepted as `Authorization: Bearer `, as `Token `, +as the password of HTTP Basic credentials with any username, or as a bare +header value. + +```sh +# curl +curl -fsSL -H "Authorization: Bearer $(cat token)" --upload-file f.tgz \ + https://artifactapi.example.com/api/v2/remotes//files/f.tgz +curl -fsSL -u "ci:$(cat token)" --upload-file f.tgz \ + https://artifactapi.example.com/api/v2/remotes//files/f.tgz + +# docker / podman / helm (OCI): any username, token as password +docker login artifactapi.example.com -u ci --password-stdin < token +helm registry login artifactapi.example.com -u ci --password-stdin < token + +# npm (.npmrc) +//artifactapi.example.com/api/v1/local//:_authToken= + +# gem +GEM_HOST_API_KEY="$(cat token)" gem push --host https://artifactapi.example.com/api/v1/local/ mygem-1.0.0.gem + +# ansible-galaxy +ansible-galaxy collection publish c.tar.gz \ + --server https://artifactapi.example.com/api/v1/local// --api-key "$(cat token)" +``` + +With uploads gated, `/v2/` answers anonymous requests with a Bearer challenge +pointing at `/v2/token`. That endpoint returns the verified Basic password as +the registry token, or an anonymous token that only reads, so `docker pull` +keeps working without a login. diff --git a/docs/docker/local.md b/docs/docker/local.md index 54d7a55..749c88d 100644 --- a/docs/docker/local.md +++ b/docs/docker/local.md @@ -10,6 +10,9 @@ docker push artifactapi.example.com/docker-internal/myapp:latest docker pull artifactapi.example.com/docker-internal/myapp:latest ``` +With [uploads gated](../common/auth.md), `docker login` with any username and +a token as the password before pushing; pulls stay anonymous. + The first path segment after `/v2/` is the artifactapi repo name; the remainder is the image name. Blobs and manifests are stored through the shared content-addressable store (deduplicated by digest, reaped by GC once diff --git a/docs/gems/local.md b/docs/gems/local.md index caf720c..c019418 100644 --- a/docs/gems/local.md +++ b/docs/gems/local.md @@ -2,7 +2,7 @@ A local `gems` repo is a RubyGems source: `gem push`, `gem yank`, `gem install` and `bundle install` work against `/api/v1/local/`. `gem push` needs an -API key; any value works. +API key; any value works unless [uploads are gated](../common/auth.md). ```bash GEM_HOST_API_KEY=unused gem push --host https://artifactapi.example.com/api/v1/local/ mygem-1.0.0.gem diff --git a/docs/npm/local.md b/docs/npm/local.md index 798a48b..22b42b5 100644 --- a/docs/npm/local.md +++ b/docs/npm/local.md @@ -2,7 +2,8 @@ A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped) and `npm install` work against `/api/v1/local//`. npm requires an -`_authToken` for the registry before it will publish; any value works. +`_authToken` for the registry before it will publish; any value works unless +[uploads are gated](../common/auth.md). ```ini # .npmrc diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index d544bd0..05105ef 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/json" "errors" "log/slog" "net/http" @@ -104,14 +105,17 @@ func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier, // Require gates every request through next on a valid token. A nil receiver // passes everything through. func (b *BearerOIDC) Require(next http.Handler) http.Handler { + return b.require(next, "Bearer") +} + +func (b *BearerOIDC) require(next http.Handler, challenge string) http.Handler { if b == nil { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") - raw = strings.TrimSpace(raw) - if !strings.EqualFold(scheme, "Bearer") || raw == "" { - unauthorized(w, "missing bearer token") + raw := token(r) + if raw == "" { + unauthorized(w, challenge, "missing token") return } v, err := b.getVerifier(r.Context()) @@ -124,12 +128,12 @@ func (b *BearerOIDC) Require(next http.Handler) http.Handler { tok, err := v.Verify(r.Context(), raw) if err != nil { slog.Info("bearer token rejected", "error", err) - unauthorized(w, "invalid token") + unauthorized(w, challenge, "invalid token") return } if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) { slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience) - unauthorized(w, "invalid token") + unauthorized(w, challenge, "invalid token") return } slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path) @@ -137,9 +141,58 @@ func (b *BearerOIDC) Require(next http.Handler) http.Handler { }) } +// token reads the JWT from `Bearer`/`Token` credentials, the password of +// `Basic` credentials (any username), or a bare header value as `gem push` +// sends its API key. +func token(r *http.Request) string { + if _, pass, ok := r.BasicAuth(); ok { + return pass + } + scheme, raw, found := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") + if !found { + return scheme + } + if strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "Token") { + return strings.TrimSpace(raw) + } + return "" +} + // RequireWrites gates only mutating methods. func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler { - gated := b.Require(next) + return writesOnly(next, b.Require(next)) +} + +// RequireRegistryWrites gates Docker Registry V2 writes behind the token flow +// Docker clients need: an unauthenticated ping gets a Bearer challenge naming +// realm, which serves the Basic password back as the token once verified, or +// an anonymous token so pulls stay open. +func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.Request) string) http.Handler { + if b == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + challenge := `Bearer realm="` + realm(r) + `",service="artifactapi"` + hasAuth := r.Header.Get("Authorization") != "" + switch { + case r.URL.Path == "/v2/token" && !hasAuth: + writeToken(w, "anonymous") + case r.URL.Path == "/v2/token": + b.require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeToken(w, token(r)) }), challenge).ServeHTTP(w, r) + case r.URL.Path == "/v2/" && !hasAuth: + unauthorized(w, challenge, "authentication required") + default: + writesOnly(next, b.require(next, challenge)).ServeHTTP(w, r) + } + }) +} + +func writeToken(w http.ResponseWriter, tok string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok}) +} + +func writesOnly(next, gated http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: @@ -150,9 +203,9 @@ func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler { }) } -func unauthorized(w http.ResponseWriter, msg string) { +func unauthorized(w http.ResponseWriter, challenge, msg string) { w.Header().Set("Content-Type", "application/json") - w.Header().Set("WWW-Authenticate", "Bearer") + w.Header().Set("WWW-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"` + msg + `"}`)) } diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index c7e9c6d..837507b 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "sync" "sync/atomic" "testing" @@ -253,3 +254,87 @@ func TestRequireWritesLeavesReadsOpen(t *testing.T) { } } } + +func basic(user, pass string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) +} + +func TestTokenSchemes(t *testing.T) { + iss := testsupport.NewOIDCIssuer() + defer iss.Close() + h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireWrites(ok) + valid := iss.Token(nil, "artifactapi", "ci", time.Hour) + cases := []struct { + name string + authz string + want int + }{ + {"bearer", "Bearer " + valid, http.StatusNoContent}, + {"token scheme", "Token " + valid, http.StatusNoContent}, + {"basic password", basic("anyone", valid), http.StatusNoContent}, + {"basic empty user", basic("", valid), http.StatusNoContent}, + {"bare jwt", valid, http.StatusNoContent}, + {"basic wrong aud", basic("ci", iss.Token(nil, "nope", "ci", time.Hour)), http.StatusUnauthorized}, + {"basic token as user", basic(valid, "x"), http.StatusUnauthorized}, + {"bare garbage", "garbage", http.StatusUnauthorized}, + {"unknown scheme", "Digest " + valid, http.StatusUnauthorized}, + } + for _, c := range cases { + if got := serveAuth(h, http.MethodPut, c.authz); got != c.want { + t.Errorf("%s: status %d, want %d", c.name, got, c.want) + } + } +} + +func TestRequireRegistryWrites(t *testing.T) { + iss := testsupport.NewOIDCIssuer() + defer iss.Close() + h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireRegistryWrites(ok, func(*http.Request) string { return "https://reg.example/v2/token" }) + valid := iss.Token(nil, "artifactapi", "ci", time.Hour) + do := func(method, path, authz string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, nil) + if authz != "" { + r.Header.Set("Authorization", authz) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + challenge := `Bearer realm="https://reg.example/v2/token",service="artifactapi"` + + w := do(http.MethodGet, "/v2/", "") + if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge { + t.Errorf("anonymous ping: %d %q", w.Code, w.Header().Get("WWW-Authenticate")) + } + if w := do(http.MethodGet, "/v2/", "Bearer anonymous"); w.Code != http.StatusNoContent { + t.Errorf("ping with token: %d", w.Code) + } + if w := do(http.MethodGet, "/v2/token", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) { + t.Errorf("anonymous token: %d %s", w.Code, w.Body) + } + if w := do(http.MethodGet, "/v2/token", basic("ci", valid)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"`+valid+`"`) { + t.Errorf("token exchange: %d %s", w.Code, w.Body) + } + if w := do(http.MethodGet, "/v2/token", basic("ci", "bogus")); w.Code != http.StatusUnauthorized { + t.Errorf("bad credentials: %d", w.Code) + } + for _, m := range []string{http.MethodGet, http.MethodHead} { + if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent { + t.Errorf("anonymous %s: %d", m, w.Code) + } + } + for _, m := range []string{http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete} { + w := do(m, "/v2/repo/img/blobs/uploads/", "Bearer anonymous") + if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge { + t.Errorf("anonymous %s: %d %q", m, w.Code, w.Header().Get("WWW-Authenticate")) + } + for _, authz := range []string{"Bearer " + valid, basic("ci", valid)} { + if w := do(m, "/v2/repo/img/blobs/uploads/", authz); w.Code != http.StatusNoContent { + t.Errorf("authenticated %s: %d", m, w.Code) + } + } + } + if got := serve(NewBearerOIDC("", nil).RequireRegistryWrites(ok, nil), http.MethodPut, ""); got != http.StatusNoContent { + t.Errorf("disabled: status %d", got) + } +} diff --git a/internal/config/env.go b/internal/config/env.go index 527d247..150cce5 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -61,6 +61,8 @@ type Config struct { // Bearer-JWT auth on configuration writes; empty issuer disables it. OIDCIssuer string OIDCAudiences []string + // AuthUploads extends that auth to local repo uploads, deletes and evictions. + AuthUploads bool } func (c *Config) DatabaseDSN() string { @@ -105,6 +107,13 @@ func Load() (*Config, error) { if oidcIssuer != "" && len(oidcAudiences) == 0 { return nil, fmt.Errorf("ARTIFACTAPI_OIDC_AUDIENCE is required when ARTIFACTAPI_OIDC_ISSUER is set") } + authUploads, err := strconv.ParseBool(getenv("ARTIFACTAPI_AUTH_UPLOADS", "false")) + if err != nil { + return nil, fmt.Errorf("invalid ARTIFACTAPI_AUTH_UPLOADS: %w", err) + } + if authUploads && oidcIssuer == "" { + return nil, fmt.Errorf("ARTIFACTAPI_OIDC_ISSUER is required when ARTIFACTAPI_AUTH_UPLOADS is set") + } cfg := &Config{ ListenAddr: getenv("LISTEN_ADDR", ":8000"), @@ -142,6 +151,7 @@ func Load() (*Config, error) { OIDCIssuer: oidcIssuer, OIDCAudiences: oidcAudiences, + AuthUploads: authUploads, } return cfg, nil diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 3e09284..a6e39c4 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -81,3 +81,23 @@ func TestLoadOIDC(t *testing.T) { t.Error("expected error for issuer without audience") } } + +func TestLoadAuthUploads(t *testing.T) { + cfg, err := Load() + if err != nil || cfg.AuthUploads { + t.Fatalf("default: %v, AuthUploads=%v", err, cfg.AuthUploads) + } + t.Setenv("ARTIFACTAPI_AUTH_UPLOADS", "true") + if _, err := Load(); err == nil { + t.Error("expected error for AUTH_UPLOADS without issuer") + } + t.Setenv("ARTIFACTAPI_OIDC_ISSUER", "https://vault.example/v1/identity/oidc") + t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", "artifactapi") + if cfg, err := Load(); err != nil || !cfg.AuthUploads { + t.Fatalf("enabled: %v, AuthUploads=%v", err, cfg.AuthUploads) + } + t.Setenv("ARTIFACTAPI_AUTH_UPLOADS", "maybe") + if _, err := Load(); err == nil { + t.Error("expected error for invalid ARTIFACTAPI_AUTH_UPLOADS") + } +} diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go index c9ed292..61aedb5 100644 --- a/internal/server/oidc_test.go +++ b/internal/server/oidc_test.go @@ -1,6 +1,7 @@ package server import ( + "encoding/base64" "io" "net/http" "net/http/httptest" @@ -78,3 +79,98 @@ func TestServerOIDCWriteAuth(t *testing.T) { expect(204, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", good) expect(204, "DELETE", "/api/v2/remotes/srv-oidc", "", good) } + +func TestServerAuthUploads(t *testing.T) { + requireStack(t) + iss := testsupport.NewOIDCIssuer() + defer iss.Close() + + cfg := *testSrv.cfg + cfg.OIDCIssuer = iss.URL + cfg.OIDCAudiences = []string{"artifactapi"} + cfg.AuthUploads = true + s := *testSrv + s.cfg = &cfg + ts := httptest.NewServer(s.routes()) + defer ts.Close() + + good := iss.Token(nil, "artifactapi", "ci", time.Hour) + do := func(method, path, body, authz string) int { + t.Helper() + rq, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) + rq.Header.Set("Content-Type", "application/json") + if authz != "" { + rq.Header.Set("Authorization", authz) + } + resp, err := http.DefaultClient.Do(rq) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return resp.StatusCode + } + bearer := "Bearer " + good + for _, typ := range []string{"generic", "docker", "npm", "gems"} { + body := `{"name":"srv-up-` + typ + `","package_type":"` + typ + `","repo_type":"local"}` + if got := do("POST", "/api/v2/remotes", body, bearer); got != 201 { + t.Fatalf("create %s: status %d", typ, got) + } + } + + writes := []struct{ method, path, body string }{ + {"PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload"}, + {"DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", ""}, + {"DELETE", "/api/v2/remotes/srv-up-generic/objects/a.bin", ""}, + {"DELETE", "/api/v2/locals/srv-up-generic/objects/a.bin", ""}, + {"PUT", "/api/v1/local/srv-up-npm/pkg", "{}"}, + {"POST", "/api/v1/local/srv-up-gems/api/v1/gems", "x"}, + {"DELETE", "/api/v1/local/srv-up-gems/api/v1/gems/yank", ""}, + {"POST", "/v2/srv-up-docker/img/blobs/uploads/", ""}, + {"PATCH", "/v2/srv-up-docker/img/blobs/uploads/x", ""}, + {"PUT", "/v2/srv-up-docker/img/manifests/t", "{}"}, + {"DELETE", "/v2/srv-up-docker/img/manifests/t", ""}, + } + for _, w := range writes { + if got := do(w.method, w.path, w.body, ""); got != 401 { + t.Errorf("anonymous %s %s: status %d, want 401", w.method, w.path, got) + } + } + + if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload", bearer); got != 201 { + t.Errorf("bearer upload: status %d", got) + } + if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/b.bin", "payload", basic(good)); got != 201 { + t.Errorf("basic upload: status %d", got) + } + if got := do("POST", "/v2/srv-up-docker/img/blobs/uploads/", "", basic(good)); got != 202 { + t.Errorf("basic docker upload: status %d", got) + } + expectOpen := func(method, path string) { + t.Helper() + if got := do(method, path, "", ""); got >= 400 { + t.Errorf("anonymous %s %s: status %d", method, path, got) + } + } + expectOpen("GET", "/api/v2/remotes/srv-up-generic/files/a.bin") + expectOpen("GET", "/api/v2/remotes/srv-up-generic/objects") + expectOpen("GET", "/api/v1/local/srv-up-generic/a.bin") + expectOpen("GET", "/v2/token") + if got := do("GET", "/v2/", "", ""); got != 401 { + t.Errorf("anonymous ping: status %d, want 401 challenge", got) + } + if got := do("POST", "/api/v2/probe", `{}`, ""); got == 401 { + t.Error("probe gated") + } + + if got := do("DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", "", bearer); got >= 400 { + t.Errorf("bearer delete: status %d", got) + } + for _, typ := range []string{"generic", "docker", "npm", "gems"} { + do("DELETE", "/api/v2/remotes/srv-up-"+typ, "", bearer) + } +} + +func basic(tok string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte("ci:"+tok)) +} diff --git a/internal/server/server.go b/internal/server/server.go index 5cdacc0..b4021c9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -169,8 +169,17 @@ func (s *Server) routes() chi.Router { s.virtEngine.NPMLocal = proxyHandler.LocalNPMPackument s.virtEngine.GemsLocal = proxyHandler.LocalGemsIndex s.virtEngine.AnsibleLocal = proxyHandler.LocalAnsible - r.Mount("/api/v1", proxyHandler.Routes()) - r.Mount("/v2", proxyHandler.DockerV2Routes()) + writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences) + uploadAuth := passthrough + dockerV2 := http.Handler(proxyHandler.DockerV2Routes()) + if s.cfg.AuthUploads { + uploadAuth = writeAuth.RequireWrites + dockerV2 = writeAuth.RequireRegistryWrites(dockerV2, func(r *http.Request) string { + return v1.Scheme(r) + "://" + r.Host + "/v2/token" + }) + } + r.Mount("/api/v1", uploadAuth(proxyHandler.Routes())) + r.Mount("/v2", dockerV2) remotesHandler := v2.NewRemotesHandler(s.db, s.cache, map[models.PackageType]v2.Primer{ models.PackageGitHubRPM: s.syncer, @@ -182,7 +191,6 @@ func (s *Server) routes() chi.Router { statsHandler := v2.NewStatsHandler(s.db) eventsHandler := v2.NewEventsHandler() probeHandler := v2.NewProbeHandler(s.engine, s.db) - writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences) r.Route("/api/v2", func(r chi.Router) { r.Mount("/remotes", writeAuth.RequireWrites(remotesHandler.Routes())) @@ -192,19 +200,19 @@ func (s *Server) routes() chi.Router { r.Mount("/events", eventsHandler.Routes()) r.Mount("/probe", probeHandler.Routes()) - r.Route("/remotes/{name}/objects", func(r chi.Router) { + r.With(uploadAuth).Route("/remotes/{name}/objects", func(r chi.Router) { objHandler := v2.NewObjectsHandler(s.db) r.Get("/", objHandler.Routes(s.engine).ServeHTTP) r.Delete("/*", objHandler.Routes(s.engine).ServeHTTP) }) - r.Route("/locals/{name}/objects", func(r chi.Router) { + r.With(uploadAuth).Route("/locals/{name}/objects", func(r chi.Router) { objHandler := v2.NewObjectsHandler(s.db) r.Get("/", objHandler.LocalRoutes().ServeHTTP) r.Delete("/*", objHandler.LocalRoutes().ServeHTTP) }) - r.Route("/remotes/{name}/files", func(r chi.Router) { + r.With(uploadAuth).Route("/remotes/{name}/files", func(r chi.Router) { r.Put("/*", s.localHandler.Routes().ServeHTTP) r.Get("/*", s.localHandler.Routes().ServeHTTP) r.Delete("/*", s.localHandler.Routes().ServeHTTP) @@ -214,6 +222,8 @@ func (s *Server) routes() chi.Router { return r } +func passthrough(h http.Handler) http.Handler { return h } + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) -- 2.47.3 From 61cb4c872c1769922de6d0c9bc787aa613524fc0 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 25 Oct 2026 13:36:28 +1100 Subject: [PATCH 2/2] Issue anonymous registry token for rejected credentials --- internal/auth/oidc.go | 47 ++++++++++++++++++++++++++++---------- internal/auth/oidc_test.go | 7 ++++-- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 05105ef..b3f0fd8 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "slices" @@ -118,29 +119,38 @@ func (b *BearerOIDC) require(next http.Handler, challenge string) http.Handler { unauthorized(w, challenge, "missing token") return } - v, err := b.getVerifier(r.Context()) - if err != nil { + sub, err := b.verify(r.Context(), raw) + if errors.Is(err, errIssuerUnavailable) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`)) return } - tok, err := v.Verify(r.Context(), raw) if err != nil { slog.Info("bearer token rejected", "error", err) unauthorized(w, challenge, "invalid token") return } - if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) { - slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience) - unauthorized(w, challenge, "invalid token") - return - } - slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path) + slog.Info("bearer token accepted", "sub", sub, "method", r.Method, "path", r.URL.Path) next.ServeHTTP(w, r) }) } +func (b *BearerOIDC) verify(ctx context.Context, raw string) (string, error) { + v, err := b.getVerifier(ctx) + if err != nil { + return "", errIssuerUnavailable + } + tok, err := v.Verify(ctx, raw) + if err != nil { + return "", err + } + if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) { + return "", fmt.Errorf("audience %v not allowed", tok.Audience) + } + return tok.Subject, nil +} + // token reads the JWT from `Bearer`/`Token` credentials, the password of // `Basic` credentials (any username), or a bare header value as `gem push` // sends its API key. @@ -175,10 +185,8 @@ func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.R challenge := `Bearer realm="` + realm(r) + `",service="artifactapi"` hasAuth := r.Header.Get("Authorization") != "" switch { - case r.URL.Path == "/v2/token" && !hasAuth: - writeToken(w, "anonymous") case r.URL.Path == "/v2/token": - b.require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeToken(w, token(r)) }), challenge).ServeHTTP(w, r) + writeToken(w, b.registryToken(r)) case r.URL.Path == "/v2/" && !hasAuth: unauthorized(w, challenge, "authentication required") default: @@ -187,6 +195,21 @@ func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.R }) } +// registryToken echoes a valid credential back as the registry token. Missing +// or invalid credentials (e.g. an expired `docker login`) get the anonymous +// token so pulls keep working; writes are still verified per request. +func (b *BearerOIDC) registryToken(r *http.Request) string { + raw := token(r) + if raw == "" { + return "anonymous" + } + if _, err := b.verify(r.Context(), raw); err != nil { + slog.Info("registry credential rejected, issuing anonymous token", "error", err) + return "anonymous" + } + return raw +} + func writeToken(w http.ResponseWriter, tok string) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok}) diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index 837507b..3654194 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -315,8 +315,11 @@ func TestRequireRegistryWrites(t *testing.T) { if w := do(http.MethodGet, "/v2/token", basic("ci", valid)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"`+valid+`"`) { t.Errorf("token exchange: %d %s", w.Code, w.Body) } - if w := do(http.MethodGet, "/v2/token", basic("ci", "bogus")); w.Code != http.StatusUnauthorized { - t.Errorf("bad credentials: %d", w.Code) + expired := iss.Token(nil, "artifactapi", "ci", -time.Minute) + for _, authz := range []string{basic("ci", "bogus"), basic("ci", expired), "Bearer " + expired} { + if w := do(http.MethodGet, "/v2/token", authz); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) { + t.Errorf("rejected credential %q: %d %s", authz, w.Code, w.Body) + } } for _, m := range []string{http.MethodGet, http.MethodHead} { if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent { -- 2.47.3