From 61cb4c872c1769922de6d0c9bc787aa613524fc0 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 25 Oct 2026 13:36:28 +1100 Subject: [PATCH] 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 {