fix(registry): use URL.Host for ghcr.io check and retry with OAuth2 on 401/403

- reqHost() prefers req.URL.Hostname() over req.Host so the ghcr.io
  special case fires correctly for ORAS-constructed requests where the
  host lives in req.URL, not req.Host
- Add dedicated TestAuthorizer_Do_GHCRSkipsBearerProbe test that sets
  the host via URL (as ORAS does) to verify the ghcr.io path
- Redesign Do() retry logic: first attempt uses standard auth; only
  after a 401/403 do we retry with ForceAttemptOAuth2=true to support
  registries whose token endpoints require OAuth2-style requests
  (previously setting it before the first attempt caused 400 errors on
  standard registries)
- Disable attemptBearerAuthentication after successful fallback retry
  so basic-auth-only registries pay the probe cost only once
- Add GoDoc comments on Authorizer, NewAuthorizer, and EnableCache

Signed-off-by: Terry Howe <terrylhowe@gmail.com>
pull/31212/head
Terry Howe 6 days ago
parent 0d9e74dfa7
commit 514b151c72
No known key found for this signature in database

@ -29,6 +29,7 @@ import (
"oras.land/oras-go/v2/registry/remote/credentials"
)
// Authorizer wraps auth.Client to retry authentication with bearer-to-basic fallback on 401/403.
type Authorizer struct {
auth.Client
lock sync.RWMutex
@ -44,6 +45,15 @@ func isGHCR(host string) bool {
return strings.EqualFold(h, "ghcr.io")
}
// reqHost returns the effective host for an outbound request, preferring URL.Hostname() over req.Host.
func reqHost(req *http.Request) string {
if h := req.URL.Hostname(); h != "" {
return h
}
return req.Host
}
// NewAuthorizer creates an Authorizer backed by the given HTTP client and credentials store.
func NewAuthorizer(httpClient *http.Client, credentialsStore credentials.Store, username, password string) *Authorizer {
authorizer := Authorizer{
Client: auth.Client{
@ -64,6 +74,7 @@ func NewAuthorizer(httpClient *http.Client, credentialsStore credentials.Store,
return &authorizer
}
// EnableCache enables per-host token caching on the underlying auth client.
func (a *Authorizer) EnableCache() {
a.Cache = auth.NewCache()
}
@ -92,31 +103,43 @@ func (a *Authorizer) setForceAttemptOAuth2(value bool) {
a.ForceAttemptOAuth2 = value
}
// Do wraps auth.Client.Do to retry with fallback authentication on 401/403 errors.
// Do wraps auth.Client.Do to retry with OAuth2 bearer forced on 401/403 errors.
// The first attempt uses standard auth; only on 401/403 failure do we retry with
// ForceAttemptOAuth2=true to fix registries (e.g. Quay) whose token endpoints
// require OAuth2-style requests.
func (a *Authorizer) Do(originalReq *http.Request) (*http.Response, error) {
if a.getAttemptBearerAuthentication() {
needsAuthentication := originalReq.Header.Get("Authorization") == ""
needsAuthentication := originalReq.Header.Get("Authorization") == ""
if needsAuthentication && a.getAttemptBearerAuthentication() && isGHCR(reqHost(originalReq)) {
a.setForceAttemptOAuth2(false)
a.setAttemptBearerAuthentication(false)
}
resp, err := a.Client.Do(originalReq)
if err == nil {
if needsAuthentication {
if isGHCR(originalReq.Host) {
a.setForceAttemptOAuth2(false)
a.setAttemptBearerAuthentication(false)
} else {
prev := a.getForceAttemptOAuth2()
a.setForceAttemptOAuth2(true)
defer a.setForceAttemptOAuth2(prev)
}
resp, err := a.Client.Do(originalReq)
if err == nil {
a.setAttemptBearerAuthentication(false)
return resp, nil
}
if !strings.Contains(err.Error(), "response status code 401") &&
!strings.Contains(err.Error(), "response status code 403") {
return nil, err
}
// Switch to basic auth fallback before retrying
a.setForceAttemptOAuth2(false)
a.setAttemptBearerAuthentication(false)
}
return resp, nil
}
if !needsAuthentication || !a.getAttemptBearerAuthentication() {
return nil, err
}
if !strings.Contains(err.Error(), "response status code 401") &&
!strings.Contains(err.Error(), "response status code 403") {
return nil, err
}
// Standard auth failed with 401/403; retry forcing OAuth2 bearer flow.
prev := a.getForceAttemptOAuth2()
a.setForceAttemptOAuth2(true)
defer a.setForceAttemptOAuth2(prev)
resp, err = a.Client.Do(originalReq)
if err == nil {
a.setAttemptBearerAuthentication(false)
}
return a.Client.Do(originalReq)
return resp, err
}

@ -361,3 +361,33 @@ func TestAuthorizer_Do_NoRetryOn404(t *testing.T) {
assert.Equal(t, 1, callCount, "Authorizer.Do must not retry on non-401/403 errors")
assert.False(t, authorizer.getForceAttemptOAuth2(), "ForceAttemptOAuth2 must be false after Do()")
}
func TestAuthorizer_Do_GHCRSkipsBearerProbe(t *testing.T) {
var mu sync.Mutex
callCount := 0
transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
mu.Lock()
callCount++
mu.Unlock()
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
Header: make(http.Header),
}, nil
})
authorizer := NewAuthorizer(&http.Client{Transport: transport}, &mockCredentialsStore{}, "", "")
// URL.Host is "ghcr.io" — simulates how ORAS constructs requests (host in URL, not req.Host)
req, err := http.NewRequest(http.MethodGet, "http://ghcr.io/v2/", nil)
require.NoError(t, err)
resp, err := authorizer.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, 1, callCount, "ghcr.io must not trigger a bearer probe + retry")
assert.False(t, authorizer.getForceAttemptOAuth2())
assert.False(t, authorizer.getAttemptBearerAuthentication())
}

Loading…
Cancel
Save