Skip to content

Commit d998a7a

Browse files
feat(#3309): add Kagenti auth, 401 retry, and user header
Implement remaining Keycloak service-account auth tasks for the Kagenti provider module (tasks 7.3, 7.5b, 7.6): - KeycloakAuthClient: OAuth2 Client Credentials Grant with token caching, configurable expiry buffer (default 60s), and invalidateToken() for 401 retry flow. - KagentiApiClient: HTTP client wrapping fetch with bearer token injection, max-1-retry on 401 (invalidate + fresh token + single retry), and X-Backstage-User header for audit. - KagentiProvider: delegates to KagentiApiClient when auth is configured, falls back to direct fetch when not. - KagentiProviderFactory: reads boost.kagenti.auth.* config with partial config detection and warning. - Tests: 15 new tests covering token fetch/cache/invalidation, 401 retry with propagation, user header presence/absence, and combined auth + user header scenarios. Closes #3309
1 parent 03662b1 commit d998a7a

7 files changed

Lines changed: 823 additions & 13 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { LoggerService } from '@backstage/backend-plugin-api';
18+
import { KagentiApiClient } from './KagentiApiClient';
19+
import { KeycloakAuthClient } from './KeycloakAuthClient';
20+
21+
function createMockLogger(): LoggerService {
22+
return {
23+
info: jest.fn(),
24+
warn: jest.fn(),
25+
error: jest.fn(),
26+
debug: jest.fn(),
27+
child: jest.fn().mockReturnThis(),
28+
};
29+
}
30+
31+
describe('KagentiApiClient', () => {
32+
let logger: LoggerService;
33+
34+
beforeEach(() => {
35+
logger = createMockLogger();
36+
});
37+
38+
afterEach(() => {
39+
jest.restoreAllMocks();
40+
});
41+
42+
describe('without auth', () => {
43+
it('sends request without Authorization header', async () => {
44+
const client = new KagentiApiClient({
45+
baseUrl: 'http://kagenti:8080',
46+
logger,
47+
});
48+
49+
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
50+
ok: true,
51+
status: 200,
52+
} as Response);
53+
54+
await client.requestCore({
55+
method: 'POST',
56+
path: '/a2a/tasks',
57+
body: { id: 'task-1' },
58+
});
59+
60+
expect(mockFetch).toHaveBeenCalledWith(
61+
'http://kagenti:8080/a2a/tasks',
62+
expect.objectContaining({
63+
method: 'POST',
64+
headers: { 'Content-Type': 'application/json' },
65+
body: JSON.stringify({ id: 'task-1' }),
66+
}),
67+
);
68+
});
69+
});
70+
71+
describe('with auth', () => {
72+
let authClient: KeycloakAuthClient;
73+
74+
beforeEach(() => {
75+
authClient = new KeycloakAuthClient(
76+
{
77+
tokenEndpoint: 'http://keycloak/token',
78+
clientId: 'test-client',
79+
clientSecret: 'test-secret',
80+
},
81+
60,
82+
);
83+
});
84+
85+
it('injects bearer token into requests', async () => {
86+
jest.spyOn(authClient, 'getBearerToken').mockResolvedValue('my-token');
87+
88+
const client = new KagentiApiClient({
89+
baseUrl: 'http://kagenti:8080',
90+
logger,
91+
authClient,
92+
});
93+
94+
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
95+
ok: true,
96+
status: 200,
97+
} as Response);
98+
99+
await client.requestCore({
100+
method: 'POST',
101+
path: '/a2a/tasks',
102+
body: { id: 'task-1' },
103+
});
104+
105+
expect(mockFetch).toHaveBeenCalledWith(
106+
'http://kagenti:8080/a2a/tasks',
107+
expect.objectContaining({
108+
headers: expect.objectContaining({
109+
Authorization: 'Bearer my-token',
110+
}),
111+
}),
112+
);
113+
});
114+
115+
it('retries once on 401 with fresh token', async () => {
116+
const getBearerToken = jest
117+
.spyOn(authClient, 'getBearerToken')
118+
.mockResolvedValueOnce('stale-token')
119+
.mockResolvedValueOnce('fresh-token');
120+
const invalidate = jest.spyOn(authClient, 'invalidateToken');
121+
122+
const client = new KagentiApiClient({
123+
baseUrl: 'http://kagenti:8080',
124+
logger,
125+
authClient,
126+
});
127+
128+
const mockFetch = jest
129+
.spyOn(global, 'fetch')
130+
.mockResolvedValueOnce({
131+
ok: false,
132+
status: 401,
133+
statusText: 'Unauthorized',
134+
} as Response)
135+
.mockResolvedValueOnce({
136+
ok: true,
137+
status: 200,
138+
} as Response);
139+
140+
const response = await client.requestCore({
141+
method: 'POST',
142+
path: '/a2a/tasks',
143+
body: { id: 'task-1' },
144+
});
145+
146+
expect(response.status).toBe(200);
147+
expect(invalidate).toHaveBeenCalledTimes(1);
148+
expect(getBearerToken).toHaveBeenCalledTimes(2);
149+
expect(mockFetch).toHaveBeenCalledTimes(2);
150+
});
151+
152+
it('propagates 401 after retry fails', async () => {
153+
jest
154+
.spyOn(authClient, 'getBearerToken')
155+
.mockResolvedValueOnce('stale-token')
156+
.mockResolvedValueOnce('still-bad-token');
157+
158+
const client = new KagentiApiClient({
159+
baseUrl: 'http://kagenti:8080',
160+
logger,
161+
authClient,
162+
});
163+
164+
jest.spyOn(global, 'fetch').mockResolvedValue({
165+
ok: false,
166+
status: 401,
167+
statusText: 'Unauthorized',
168+
} as Response);
169+
170+
const response = await client.requestCore({
171+
method: 'POST',
172+
path: '/a2a/tasks',
173+
body: { id: 'task-1' },
174+
});
175+
176+
// Second 401 is propagated, not retried again
177+
expect(response.status).toBe(401);
178+
expect(logger.error).toHaveBeenCalledWith(
179+
'Received 401 from Kagenti after token refresh retry',
180+
);
181+
});
182+
183+
it('does not retry on 401 without auth client', async () => {
184+
const client = new KagentiApiClient({
185+
baseUrl: 'http://kagenti:8080',
186+
logger,
187+
});
188+
189+
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
190+
ok: false,
191+
status: 401,
192+
statusText: 'Unauthorized',
193+
} as Response);
194+
195+
const response = await client.requestCore({
196+
method: 'POST',
197+
path: '/a2a/tasks',
198+
body: { id: 'task-1' },
199+
});
200+
201+
expect(response.status).toBe(401);
202+
expect(mockFetch).toHaveBeenCalledTimes(1);
203+
});
204+
});
205+
206+
describe('X-Backstage-User header', () => {
207+
it('sets header when userRef is provided', async () => {
208+
const client = new KagentiApiClient({
209+
baseUrl: 'http://kagenti:8080',
210+
logger,
211+
});
212+
213+
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
214+
ok: true,
215+
status: 200,
216+
} as Response);
217+
218+
await client.requestCore({
219+
method: 'POST',
220+
path: '/a2a/tasks',
221+
body: { id: 'task-1' },
222+
userRef: 'user:default/john',
223+
});
224+
225+
expect(mockFetch).toHaveBeenCalledWith(
226+
'http://kagenti:8080/a2a/tasks',
227+
expect.objectContaining({
228+
headers: expect.objectContaining({
229+
'X-Backstage-User': 'user:default/john',
230+
}),
231+
}),
232+
);
233+
});
234+
235+
it('omits header when userRef is not provided', async () => {
236+
const client = new KagentiApiClient({
237+
baseUrl: 'http://kagenti:8080',
238+
logger,
239+
});
240+
241+
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
242+
ok: true,
243+
status: 200,
244+
} as Response);
245+
246+
await client.requestCore({
247+
method: 'POST',
248+
path: '/a2a/tasks',
249+
body: { id: 'task-1' },
250+
});
251+
252+
const calledHeaders = (mockFetch.mock.calls[0][1] as RequestInit)
253+
.headers as Record<string, string>;
254+
expect(calledHeaders['X-Backstage-User']).toBeUndefined();
255+
});
256+
});
257+
258+
describe('auth and user header together', () => {
259+
it('includes both Authorization and X-Backstage-User', async () => {
260+
const authClient = new KeycloakAuthClient(
261+
{
262+
tokenEndpoint: 'http://keycloak/token',
263+
clientId: 'test-client',
264+
clientSecret: 'test-secret',
265+
},
266+
60,
267+
);
268+
jest.spyOn(authClient, 'getBearerToken').mockResolvedValue('my-token');
269+
270+
const client = new KagentiApiClient({
271+
baseUrl: 'http://kagenti:8080',
272+
logger,
273+
authClient,
274+
});
275+
276+
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
277+
ok: true,
278+
status: 200,
279+
} as Response);
280+
281+
await client.requestCore({
282+
method: 'POST',
283+
path: '/a2a/tasks',
284+
body: { id: 'task-1' },
285+
userRef: 'user:default/jane',
286+
});
287+
288+
expect(mockFetch).toHaveBeenCalledWith(
289+
'http://kagenti:8080/a2a/tasks',
290+
expect.objectContaining({
291+
headers: expect.objectContaining({
292+
Authorization: 'Bearer my-token',
293+
'X-Backstage-User': 'user:default/jane',
294+
}),
295+
}),
296+
);
297+
});
298+
});
299+
});

0 commit comments

Comments
 (0)