Skip to content

Commit 38f90d6

Browse files
committed
feat(auth): add TokenFetcher interface and token abstraction
Extract bearer-token acquisition from auth.Client into a pluggable TokenFetcher interface: FetchToken(ctx, TokenParams, Credential) (string, error) Three implementations land alongside the interface: - DistributionTokenFetcher: GET against the distribution-spec token endpoint with optional basic auth. - OAuth2TokenFetcher: POST OAuth2 password/refresh_token grant. - CompositeTokenFetcher: selects between the above based on credential shape and a LegacyMode toggle, mirroring the existing in-client logic. A new optional Client.TokenFetcher field lets callers inject a custom strategy (e.g. ECR/GCR-specific token exchange) without forking the client. When nil, the built-in fallback in fetchBearerToken preserves the existing behavior, so this is a purely additive change to the public API. ForceAttemptOAuth2 is unchanged. Test coverage in token_test.go is independent of the client; two new client_test.go cases (TestClient_Do_Bearer_CustomTokenFetcher and _Error) exercise the wiring end-to-end. Signed-off-by: Terry Howe <terrylhowe@gmail.com>
1 parent 3d90c80 commit 38f90d6

4 files changed

Lines changed: 1199 additions & 0 deletions

File tree

registry/remote/auth/client.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ type Client struct {
9999
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/#getting-a-token
100100
ClientID string
101101

102+
// TokenFetcher is an optional custom token fetcher for bearer
103+
// authentication. If nil, the built-in token-fetching logic is used
104+
// (see ForceAttemptOAuth2).
105+
TokenFetcher TokenFetcher
106+
102107
// ForceAttemptOAuth2 controls whether to follow OAuth2 with password grant
103108
// instead the distribution spec when authenticating using username and
104109
// password.
@@ -285,6 +290,19 @@ func (c *Client) fetchBearerToken(ctx context.Context, registry, realm, service
285290
if err != nil {
286291
return "", err
287292
}
293+
294+
// Use custom TokenFetcher if provided
295+
if c.TokenFetcher != nil {
296+
params := TokenParams{
297+
Registry: registry,
298+
Realm: realm,
299+
Service: service,
300+
Scopes: scopes,
301+
}
302+
return c.TokenFetcher.FetchToken(ctx, params, cred)
303+
}
304+
305+
// Fall back to original implementation
288306
if cred.AccessToken != "" {
289307
return cred.AccessToken, nil
290308
}

registry/remote/auth/client_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3977,3 +3977,126 @@ func TestClient_fetchBasicAuth(t *testing.T) {
39773977
t.Errorf("incorrect error: %v, expected %v", err, ErrBasicCredentialNotFound)
39783978
}
39793979
}
3980+
3981+
func TestClient_Do_Bearer_CustomTokenFetcher(t *testing.T) {
3982+
wantToken := "custom_fetcher_token"
3983+
var requestCount, wantRequestCount int64
3984+
var successCount, wantSuccessCount int64
3985+
var service string
3986+
scope := "repository:test:pull"
3987+
3988+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3989+
atomic.AddInt64(&requestCount, 1)
3990+
if r.Method != http.MethodGet || r.URL.Path != "/" {
3991+
t.Errorf("unexpected access: %s %s", r.Method, r.URL)
3992+
w.WriteHeader(http.StatusNotFound)
3993+
return
3994+
}
3995+
header := "Bearer " + wantToken
3996+
if auth := r.Header.Get("Authorization"); auth != header {
3997+
challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", "https://auth.example.com/token", service, scope)
3998+
w.Header().Set("Www-Authenticate", challenge)
3999+
w.WriteHeader(http.StatusUnauthorized)
4000+
return
4001+
}
4002+
atomic.AddInt64(&successCount, 1)
4003+
}))
4004+
defer ts.Close()
4005+
uri, err := url.Parse(ts.URL)
4006+
if err != nil {
4007+
t.Fatalf("invalid test http server: %v", err)
4008+
}
4009+
service = uri.Host
4010+
4011+
// Custom token fetcher that always returns a fixed token.
4012+
customFetcher := &mockTokenFetcherForClient{token: wantToken}
4013+
4014+
client := &Client{
4015+
CredentialFunc: func(ctx context.Context, reg string) (credentials.Credential, error) {
4016+
return credentials.Credential{
4017+
Username: "user",
4018+
Password: "pass",
4019+
}, nil
4020+
},
4021+
TokenFetcher: customFetcher,
4022+
}
4023+
4024+
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
4025+
if err != nil {
4026+
t.Fatalf("failed to create test request: %v", err)
4027+
}
4028+
resp, err := client.Do(req)
4029+
if err != nil {
4030+
t.Fatalf("Client.Do() error = %v", err)
4031+
}
4032+
if resp.StatusCode != http.StatusOK {
4033+
t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK)
4034+
}
4035+
if wantRequestCount += 2; requestCount != wantRequestCount {
4036+
t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount)
4037+
}
4038+
if wantSuccessCount++; successCount != wantSuccessCount {
4039+
t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount)
4040+
}
4041+
4042+
// Verify the custom fetcher was actually called.
4043+
if !customFetcher.called {
4044+
t.Error("custom TokenFetcher was not called")
4045+
}
4046+
}
4047+
4048+
func TestClient_Do_Bearer_CustomTokenFetcher_Error(t *testing.T) {
4049+
var service string
4050+
scope := "repository:test:pull"
4051+
4052+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4053+
challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", "https://auth.example.com/token", service, scope)
4054+
w.Header().Set("Www-Authenticate", challenge)
4055+
w.WriteHeader(http.StatusUnauthorized)
4056+
}))
4057+
defer ts.Close()
4058+
uri, err := url.Parse(ts.URL)
4059+
if err != nil {
4060+
t.Fatalf("invalid test http server: %v", err)
4061+
}
4062+
service = uri.Host
4063+
4064+
fetcherErr := errors.New("custom fetcher error")
4065+
client := &Client{
4066+
CredentialFunc: func(ctx context.Context, reg string) (credentials.Credential, error) {
4067+
return credentials.Credential{
4068+
Username: "user",
4069+
Password: "pass",
4070+
}, nil
4071+
},
4072+
TokenFetcher: &mockTokenFetcherForClient{err: fetcherErr},
4073+
}
4074+
4075+
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
4076+
if err != nil {
4077+
t.Fatalf("failed to create test request: %v", err)
4078+
}
4079+
_, err = client.Do(req)
4080+
if err == nil {
4081+
t.Fatal("Client.Do() expected error, got nil")
4082+
}
4083+
if !strings.Contains(err.Error(), "custom fetcher error") {
4084+
t.Errorf("Client.Do() error = %v, want error containing %q", err, "custom fetcher error")
4085+
}
4086+
}
4087+
4088+
// mockTokenFetcherForClient is a test helper that returns a fixed token and
4089+
// tracks whether it was called.
4090+
type mockTokenFetcherForClient struct {
4091+
token string
4092+
err error
4093+
called bool
4094+
}
4095+
4096+
func (m *mockTokenFetcherForClient) FetchToken(ctx context.Context, params TokenParams, cred credentials.Credential) (string, error) {
4097+
m.called = true
4098+
if m.err != nil {
4099+
return "", m.err
4100+
}
4101+
return m.token, nil
4102+
}

0 commit comments

Comments
 (0)