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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user