From 8ec7de50e3ed90198c993f1c7b373a48aa436f24 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 27 Jun 2026 00:18:06 +1000 Subject: [PATCH] feat: handle Docker Bearer token auth for upstream registries (#60) Docker Hub (and other registries) return 401 with a `Www-Authenticate: Bearer realm=...` challenge even for public images. The proxy now: 1. Detects 401 + Bearer challenge 2. Parses realm/service/scope from the header 3. Fetches an anonymous token (or authenticated if username/password configured) 4. Retries the original request with the Bearer token Fixes: `docker pull artifactapi.../dockerhub/library/redis:latest` returning "unauthorized: upstream returned 401" Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/60 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- internal/proxy/engine.go | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go index ddb75a1..3934d34 100644 --- a/internal/proxy/engine.go +++ b/internal/proxy/engine.go @@ -4,10 +4,12 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "log/slog" "net/http" + "strings" "time" "git.unkin.net/unkin/artifactapi/internal/cache" @@ -147,6 +149,21 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa return nil, &UpstreamError{Err: err} } + if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + token, err := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote) + if err == nil && token != "" { + req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req2.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req2) + if err != nil { + return nil, &UpstreamError{Err: err} + } + } else { + return nil, &ProxyError{Status: http.StatusUnauthorized, Message: "upstream returned 401"} + } + } + if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)} @@ -319,6 +336,71 @@ func (r readerAt) ReadAt(p []byte, off int64) (n int, err error) { return } +func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) { + if !strings.HasPrefix(wwwAuth, "Bearer ") { + return "", fmt.Errorf("not a Bearer challenge") + } + + params := map[string]string{} + for _, part := range strings.Split(wwwAuth[7:], ",") { + part = strings.TrimSpace(part) + eq := strings.Index(part, "=") + if eq < 0 { + continue + } + key := part[:eq] + val := strings.Trim(part[eq+1:], `"`) + params[key] = val + } + + realm := params["realm"] + if realm == "" { + return "", fmt.Errorf("no realm in Bearer challenge") + } + + tokenURL := realm + sep := "?" + if s, ok := params["service"]; ok { + tokenURL += sep + "service=" + s + sep = "&" + } + if s, ok := params["scope"]; ok { + tokenURL += sep + "scope=" + s + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err != nil { + return "", err + } + + if remote.Username != "" && remote.Password != "" { + req.SetBasicAuth(remote.Username, remote.Password) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token endpoint returned %d", resp.StatusCode) + } + + var tokenResp struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", err + } + + if tokenResp.Token != "" { + return tokenResp.Token, nil + } + return tokenResp.AccessToken, nil +} + type ProxyError struct { Status int Message string