Skip to content

Commit 069274f

Browse files
committed
🔄 refactor(registries): route to credentialed instance + fail loud on rejected creds
- Stop seeding anonymous '<provider>.public' default when the user has configured at least one credentialed instance (any of token, password, auth, clientemail, privatekey, accesskeyid, secretaccesskey) — the credentialed instance handles all traffic for that provider. - Replace non-deterministic Object.values().find() image-to-registry routing with explicit priority: credentialed beats anonymous, alpha tie-break within tier. Log routing decisions at debug. - Remove silent anonymous fallback in authenticateBearerFromAuthUrlWithPublicFallback when credentials are rejected with 401/403. Now throws an actionable error so the UI's existing 'Check failed' badge surfaces the auth problem instead of cascading into anonymous-tier 429s. Root cause of issue #342: a default '.public' instance plus the credentialed instance both registered for the same provider, with JS-engine insertion order picking the winner. When the anonymous instance won, all traffic hit the anonymous-tier rate limit despite the user having a valid PAT, and credential rejection failures were swallowed silently so the user had no way to diagnose. Fixes: GH-342
1 parent 36ce1ac commit 069274f

10 files changed

Lines changed: 311 additions & 151 deletions

File tree

‎app/registries/BaseRegistry.test.ts‎

Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -433,16 +433,10 @@ test('authenticateBearerFromAuthUrl should throw when token is missing', async (
433433
).rejects.toThrow('token endpoint response does not contain token');
434434
});
435435

436-
test('authenticateBearerFromAuthUrlWithPublicFallback should retry without credentials and honor providerLabel', async () => {
436+
test('authenticateBearerFromAuthUrlWithPublicFallback should throw actionable error (not silently retry) when credentials are rejected with 401', async () => {
437437
const authenticateSpy = vi
438438
.spyOn(baseRegistry as any, 'authenticateBearerFromAuthUrl')
439-
.mockRejectedValueOnce(new Error('token request failed (Request failed with status code 401)'))
440-
.mockResolvedValueOnce({
441-
headers: {
442-
Authorization: 'Bearer public-token',
443-
},
444-
});
445-
const warnSpy = vi.spyOn(baseRegistry.log, 'warn').mockImplementation(() => undefined);
439+
.mockRejectedValueOnce(new Error('token request failed (Request failed with status code 401)'));
446440

447441
await expect(
448442
(baseRegistry as any).authenticateBearerFromAuthUrlWithPublicFallback(
@@ -453,31 +447,12 @@ test('authenticateBearerFromAuthUrlWithPublicFallback should retry without crede
453447
providerLabel: 'Docker Hub',
454448
},
455449
),
456-
).resolves.toEqual({
457-
headers: {
458-
Authorization: 'Bearer public-token',
459-
},
460-
});
461-
462-
expect(authenticateSpy).toHaveBeenNthCalledWith(
463-
1,
464-
{ headers: {}, url: 'https://registry.example.com/v2/library/nginx/manifests/latest' },
465-
'https://registry.example.com/token',
466-
'dXNlcjpwYXNz',
467-
undefined,
468-
undefined,
469-
);
470-
expect(authenticateSpy).toHaveBeenNthCalledWith(
471-
2,
472-
{ headers: {}, url: 'https://registry.example.com/v2/library/nginx/manifests/latest' },
473-
'https://registry.example.com/token',
474-
undefined,
475-
undefined,
476-
undefined,
477-
);
478-
expect(warnSpy).toHaveBeenCalledWith(
479-
expect.stringContaining('Docker Hub credentials were rejected for registry'),
450+
).rejects.toThrow(
451+
/Authentication failed for registry.*HTTP 401.*Docker Hub credentials were rejected/,
480452
);
453+
454+
// Must NOT retry anonymously — only one call
455+
expect(authenticateSpy).toHaveBeenCalledTimes(1);
481456
});
482457

483458
test('getRejectedCredentialStatus should use RE2JS for status matching', () => {
@@ -567,34 +542,21 @@ test('authenticateBearerFromAuthUrlWithPublicFallback should rethrow when reject
567542
).rejects.toBe(error);
568543
});
569544

570-
test('authenticateBearerFromAuthUrlWithPublicFallback should default providerLabel to registry id', async () => {
545+
test('authenticateBearerFromAuthUrlWithPublicFallback should use registry id as provider label when none is supplied', async () => {
571546
baseRegistry.type = 'registry';
572547
baseRegistry.name = 'base';
573-
const authenticateSpy = vi
574-
.spyOn(baseRegistry as any, 'authenticateBearerFromAuthUrl')
575-
.mockRejectedValueOnce(new Error('token request failed (Request failed with status code 403)'))
576-
.mockResolvedValueOnce({
577-
headers: {
578-
Authorization: 'Bearer public-token',
579-
},
580-
});
581-
const warnSpy = vi.spyOn(baseRegistry.log, 'warn').mockImplementation(() => undefined);
548+
vi.spyOn(baseRegistry as any, 'authenticateBearerFromAuthUrl').mockRejectedValueOnce(
549+
new Error('token request failed (Request failed with status code 403)'),
550+
);
582551

583552
await expect(
584553
(baseRegistry as any).authenticateBearerFromAuthUrlWithPublicFallback(
585554
{ headers: {}, url: 'https://registry.example.com/v2/library/nginx/manifests/latest' },
586555
'https://registry.example.com/token',
587556
'dXNlcjpwYXNz',
588557
),
589-
).resolves.toEqual({
590-
headers: {
591-
Authorization: 'Bearer public-token',
592-
},
593-
});
594-
595-
expect(authenticateSpy).toHaveBeenCalledTimes(2);
596-
expect(warnSpy).toHaveBeenCalledWith(
597-
'registry.base credentials were rejected for registry registry.base (status 403); retrying token request without credentials for public image checks',
558+
).rejects.toThrow(
559+
/Authentication failed for registry registry\.base.*HTTP 403.*registry\.base credentials were rejected/,
598560
);
599561
});
600562

‎app/registries/BaseRegistry.ts‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,22 @@ class BaseRegistry<
434434
return match.find() ? match.group(1) : undefined;
435435
}
436436

437+
/**
438+
* Bearer-token auth via the registry's token endpoint.
439+
*
440+
* - When `credentials` is undefined (the instance is registered as
441+
* anonymous), this is a single unauthenticated token request.
442+
* - When `credentials` is supplied (the instance is registered as
443+
* credentialed) and the token endpoint rejects them with one of
444+
* `rejectedCredentialStatuses`, this throws an actionable error instead
445+
* of silently falling back to anonymous. Silent anonymous fallback for
446+
* credentialed instances was the root cause of authenticated users still
447+
* hitting per-IP anonymous rate limits (issue #342).
448+
*
449+
* The historical `WithPublicFallback` suffix in the name is retained for
450+
* caller stability; the semantic it referred to (silent retry without
451+
* credentials on rejection) is intentionally removed.
452+
*/
437453
protected async authenticateBearerFromAuthUrlWithPublicFallback(
438454
requestOptions: RegistryRequestOptions,
439455
authUrl: string,
@@ -461,17 +477,12 @@ class BaseRegistry<
461477
throw error;
462478
}
463479

480+
// Credentials were supplied but rejected — throw a clear, actionable
481+
// error instead of silently falling back to anonymous. The anonymous
482+
// tier would cause 429s that are harder to diagnose than a clean failure.
464483
const providerLabel = options.providerLabel || this.getId();
465-
this.log.warn(
466-
`${providerLabel} credentials were rejected for registry ${this.getId()} (status ${rejectedStatus}); retrying token request without credentials for public image checks`,
467-
);
468-
469-
return this.authenticateBearerFromAuthUrl(
470-
requestOptions,
471-
authUrl,
472-
undefined,
473-
options.tokenExtractor,
474-
options.tokenFailureMessage,
484+
throw new Error(
485+
`Authentication failed for registry ${this.getId()} (HTTP ${rejectedStatus}): ${providerLabel} credentials were rejected. Check the configured token/login/password and their scopes.`,
475486
);
476487
}
477488
}

‎app/registries/providers/gcr/Gcr.test.ts‎

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,20 @@ test('authenticate should return unchanged options when no clientemail configure
118118
expect(result).toEqual({ headers: {} });
119119
});
120120

121-
test('authenticate should retry anonymously when configured credentials are rejected with 403', async () => {
121+
test('authenticate should throw actionable error when configured credentials are rejected with 403', async () => {
122122
const { default: axios } = await import('axios');
123123
const gcrWithCreds = new Gcr();
124124
await gcrWithCreds.register('registry', 'gcr', 'test', {
125125
clientemail: TEST_CLIENT_EMAIL,
126126
privatekey: TEST_PRIVATE_KEY,
127127
});
128-
axios
129-
.mockRejectedValueOnce(new Error('Request failed with status code 403'))
130-
.mockResolvedValueOnce({ data: { token: 'anon-token' } });
131-
const warnSpy = vi.spyOn(gcrWithCreds.log, 'warn');
128+
axios.mockRejectedValueOnce(new Error('Request failed with status code 403'));
132129

133-
const result = await gcrWithCreds.authenticate({ name: 'project/image' }, { headers: {} });
130+
await expect(
131+
gcrWithCreds.authenticate({ name: 'project/image' }, { headers: {} }),
132+
).rejects.toThrow(
133+
/Authentication failed for registry gcr\.test \(HTTP 403\): GCR credentials were rejected/,
134+
);
134135

135136
const expectedBasic = Buffer.from(
136137
`_json_key:${JSON.stringify({
@@ -139,29 +140,15 @@ test('authenticate should retry anonymously when configured credentials are reje
139140
})}`,
140141
'utf-8',
141142
).toString('base64');
142-
expect(axios).toHaveBeenNthCalledWith(1, {
143+
expect(axios).toHaveBeenCalledTimes(1);
144+
expect(axios).toHaveBeenCalledWith({
143145
method: 'GET',
144146
url: 'https://gcr.io/v2/token?scope=repository:project/image:pull',
145147
headers: {
146148
Accept: 'application/json',
147149
Authorization: `Basic ${expectedBasic}`,
148150
},
149151
});
150-
expect(axios).toHaveBeenNthCalledWith(2, {
151-
method: 'GET',
152-
url: 'https://gcr.io/v2/token?scope=repository:project/image:pull',
153-
headers: {
154-
Accept: 'application/json',
155-
},
156-
});
157-
expect(warnSpy).toHaveBeenCalledWith(
158-
expect.stringContaining('GCR credentials were rejected for registry gcr.test (status 403)'),
159-
);
160-
expect(result).toEqual({
161-
headers: {
162-
Authorization: 'Bearer anon-token',
163-
},
164-
});
165152
});
166153

167154
test('authenticate should throw when gcr token is missing', async () => {

‎app/registries/providers/ghcr/Ghcr.test.ts‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -86,39 +86,29 @@ describe('GitHub Container Registry', () => {
8686
expect(result.headers.Authorization).toBe('Bearer registry-token');
8787
});
8888

89-
test('should retry anonymously when configured credentials are rejected with 403', async () => {
89+
test('should throw actionable error when configured credentials are rejected with 403', async () => {
9090
ghcr.configuration = { username: 'test-user', token: 'test-token' };
9191
axios.mockRejectedValueOnce(new Error('Request failed with status code 403'));
92-
axios.mockResolvedValueOnce({ data: { token: 'anon-token' } });
9392
const image = { name: 'user/repo' };
9493
const requestOptions = {
9594
headers: {},
9695
url: 'https://ghcr.io/v2/user/repo/manifests/latest',
9796
};
98-
const warnSpy = vi.spyOn(ghcr.log, 'warn');
9997

100-
const result = await ghcr.authenticate(image, requestOptions);
98+
await expect(ghcr.authenticate(image, requestOptions)).rejects.toThrow(
99+
/Authentication failed for registry ghcr\.test \(HTTP 403\): GHCR credentials were rejected/,
100+
);
101101

102102
const expectedBasic = Buffer.from('test-user:test-token', 'utf-8').toString('base64');
103-
expect(axios).toHaveBeenNthCalledWith(1, {
103+
expect(axios).toHaveBeenCalledTimes(1);
104+
expect(axios).toHaveBeenCalledWith({
104105
method: 'GET',
105106
url: 'https://ghcr.io/token?service=ghcr.io&scope=repository%3Auser%2Frepo%3Apull',
106107
headers: {
107108
Accept: 'application/json',
108109
Authorization: `Basic ${expectedBasic}`,
109110
},
110111
});
111-
expect(axios).toHaveBeenNthCalledWith(2, {
112-
method: 'GET',
113-
url: 'https://ghcr.io/token?service=ghcr.io&scope=repository%3Auser%2Frepo%3Apull',
114-
headers: {
115-
Accept: 'application/json',
116-
},
117-
});
118-
expect(warnSpy).toHaveBeenCalledWith(
119-
expect.stringContaining('GHCR credentials were rejected for registry ghcr.test (status 403)'),
120-
);
121-
expect(result.headers.Authorization).toBe('Bearer anon-token');
122112
});
123113

124114
test('should not retry anonymously when no credentials are configured', async () => {

‎app/registries/providers/hub/Hub.test.ts‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -158,41 +158,28 @@ describe('Docker Hub Registry', () => {
158158
expect(result.headers.Authorization).toBe('Bearer public-token');
159159
});
160160

161-
test('should retry anonymously when configured credentials are rejected with 401', async () => {
161+
test('should throw actionable error when configured credentials are rejected with 401', async () => {
162162
const { default: axios } = await import('axios');
163-
axios
164-
.mockRejectedValueOnce(new Error('Request failed with status code 401'))
165-
.mockResolvedValueOnce({ data: { token: 'public-token' } });
163+
axios.mockRejectedValueOnce(new Error('Request failed with status code 401'));
166164

167165
hub.getAuthCredentials = vi.fn().mockReturnValue('base64credentials');
168-
const warnSpy = vi.spyOn(hub.log, 'warn');
169166

170167
const image = { name: 'library/nginx' };
171168
const requestOptions = { headers: {} };
172169

173-
const result = await hub.authenticate(image, requestOptions);
170+
await expect(hub.authenticate(image, requestOptions)).rejects.toThrow(
171+
/Authentication failed for registry hub\.test \(HTTP 401\): Docker Hub credentials were rejected/,
172+
);
174173

175-
expect(axios).toHaveBeenNthCalledWith(1, {
174+
expect(axios).toHaveBeenCalledTimes(1);
175+
expect(axios).toHaveBeenCalledWith({
176176
method: 'GET',
177177
url: 'https://auth.docker.io/token?service=registry.docker.io&scope=repository%3Alibrary%2Fnginx%3Apull&grant_type=password',
178178
headers: {
179179
Accept: 'application/json',
180180
Authorization: 'Basic base64credentials',
181181
},
182182
});
183-
expect(axios).toHaveBeenNthCalledWith(2, {
184-
method: 'GET',
185-
url: 'https://auth.docker.io/token?service=registry.docker.io&scope=repository%3Alibrary%2Fnginx%3Apull&grant_type=password',
186-
headers: {
187-
Accept: 'application/json',
188-
},
189-
});
190-
expect(warnSpy).toHaveBeenCalledWith(
191-
expect.stringContaining(
192-
'Docker Hub credentials were rejected for registry hub.test (status 401)',
193-
),
194-
);
195-
expect(result.headers.Authorization).toBe('Bearer public-token');
196183
});
197184

198185
test('should fetch published date from Docker Hub tag metadata', async () => {

‎app/registries/providers/quay/Quay.test.ts‎

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ test('authenticate should not populate header with base64 bearer when anonymous'
159159
);
160160
});
161161

162-
test('authenticate should retry anonymously when configured credentials are rejected with 403', async () => {
162+
test('authenticate should throw actionable error when configured credentials are rejected with 403', async () => {
163163
const quayInstance = new Quay();
164164
await quayInstance.register('registry', 'quay', 'test', {
165165
namespace: 'namespace',
@@ -168,39 +168,25 @@ test('authenticate should retry anonymously when configured credentials are reje
168168
});
169169
quayInstance.log = log;
170170
axios.mockRejectedValueOnce(new Error('Request failed with status code 403'));
171-
axios.mockResolvedValueOnce({ data: { token: 'anon-token' } });
172-
const warnSpy = vi.spyOn(quayInstance.log, 'warn');
173171

174-
const result = await quayInstance.authenticate(
175-
{ name: 'test/image' },
176-
{ headers: {}, url: 'https://quay.io/v2/test/image/manifests/latest' },
172+
await expect(
173+
quayInstance.authenticate(
174+
{ name: 'test/image' },
175+
{ headers: {}, url: 'https://quay.io/v2/test/image/manifests/latest' },
176+
),
177+
).rejects.toThrow(
178+
/Authentication failed for registry quay\.test \(HTTP 403\): Quay credentials were rejected/,
177179
);
178180

179-
expect(axios).toHaveBeenNthCalledWith(1, {
181+
expect(axios).toHaveBeenCalledTimes(1);
182+
expect(axios).toHaveBeenCalledWith({
180183
method: 'GET',
181184
url: 'https://quay.io/v2/auth?service=quay.io&scope=repository:test/image:pull',
182185
headers: {
183186
Accept: 'application/json',
184187
Authorization: 'Basic bmFtZXNwYWNlK2FjY291bnQ6dG9rZW4=',
185188
},
186189
});
187-
expect(axios).toHaveBeenNthCalledWith(2, {
188-
method: 'GET',
189-
url: 'https://quay.io/v2/auth?service=quay.io&scope=repository:test/image:pull',
190-
headers: {
191-
Accept: 'application/json',
192-
},
193-
});
194-
expect(warnSpy).toHaveBeenCalledWith(
195-
expect.stringContaining('Quay credentials were rejected for registry quay.test (status 403)'),
196-
);
197-
expect(result).toEqual(
198-
expect.objectContaining({
199-
headers: {
200-
Authorization: 'Bearer anon-token',
201-
},
202-
}),
203-
);
204190
});
205191

206192
test('authenticate should throw when token request fails', async () => {

0 commit comments

Comments
 (0)