From 06de57030e5048b5eea0855c55927e3f80ae47b0 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 27 Jun 2026 00:15:36 +1000 Subject: [PATCH] feat: handle Docker Bearer token auth for upstream registries When an upstream registry returns 401 with a Www-Authenticate: Bearer challenge, the proxy now fetches an anonymous (or authenticated) token from the auth endpoint and retries the request. This fixes Docker Hub pulls which require token exchange even for public images. --- 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