Skip to content

Commit 0a89bcf

Browse files
jamsdenclaude
andcommitted
feat: interactive SSO callback as last resort on 401 auth failure
When all automated auth methods fail (JEE, JAS, Basic, programmatic SSO) and the server still returns 401, invoke ssoCallback with the resource URL as a last resort. This handles servers like Codebeamer where SSO is available but not discoverable from response headers — the user authenticates interactively via a browser window. Also: properly reject with AUTH_EXHAUSTED when 401 persists after all attempted methods, instead of returning the raw 401 response. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4d8ceaa commit 0a89bcf

2 files changed

Lines changed: 40 additions & 9 deletions

File tree

OSLCClient.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,36 @@ export default class OSLCClient {
330330
}
331331
}
332332

333-
// No authentication challenge — return response as-is
333+
// 5. Interactive SSO callback as last resort — when all automated methods
334+
// failed (or none matched) and we still have an auth failure, let the user
335+
// authenticate interactively via browser window.
336+
if (status === 401 && this.ssoCallback && !attempted.includes('sso-interactive')) {
337+
attempted.push('sso-interactive');
338+
try {
339+
const resourceUrl = originalRequest.url;
340+
console.log(`[OSLCClient] Trying interactive SSO callback for ${resourceUrl?.substring(0, 80)}`);
341+
const callbackResult = await this.ssoCallback(resourceUrl);
342+
if (callbackResult) {
343+
if (isNodeEnvironment && CookieJar && callbackResult instanceof CookieJar) {
344+
this.jar = callbackResult;
345+
}
346+
originalRequest._oslcAuthHandled = true;
347+
delete originalRequest.auth; // Remove failed Basic auth
348+
const retryResponse = await this.client.request(originalRequest);
349+
return this._handleAuthDispatch(retryResponse, cycle + 1, attempted);
350+
}
351+
} catch (ssoError) {
352+
oslcClientLogHttpError('Interactive SSO callback failed', ssoError);
353+
}
354+
}
355+
356+
// No authentication challenge or all methods already attempted
357+
if (status === 401 && attempted.length > 0) {
358+
// Still 401 after trying auth methods — all failed
359+
return this._createAuthExhaustedRejection(response, attempted);
360+
}
361+
362+
// Non-401 response (success, or no auth challenge) — return as-is
334363
return response;
335364
}
336365

__tests__/OSLCClient.auth.test.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -256,21 +256,23 @@ describe('auth dispatch', () => {
256256
data: 'Unauthorized',
257257
};
258258

259-
// Basic auth retry also returns 401 — since 'basic' is already in
260-
// attempted, dispatch won't retry it and falls through to no-challenge
261-
// (returning the 401 response as-is from the re-dispatch)
259+
// Basic auth retry also returns 401
262260
client.client.request = jest.fn(async () => ({
263261
status: 401,
264262
headers: {},
265263
config: originalConfig,
266264
data: 'Unauthorized',
267265
}));
268266

269-
// Basic auth is tried once, fails, re-dispatch sees 401 with basic
270-
// already attempted → returns the 401 response (no more methods to try)
271-
const result = await client._handleAuthDispatch(unauthorizedResponse, 0);
272-
expect(result.status).toBe(401);
273-
expect(client.client.request).toHaveBeenCalledTimes(1);
267+
// Basic auth tried once, fails → still 401 with methods attempted → AUTH_EXHAUSTED
268+
try {
269+
await client._handleAuthDispatch(unauthorizedResponse, 0);
270+
throw new Error('Should have rejected');
271+
} catch (error) {
272+
expect(error.code).toBe('AUTH_EXHAUSTED');
273+
expect(error.attempted).toContain('basic');
274+
expect(client.client.request).toHaveBeenCalledTimes(1);
275+
}
274276
});
275277

276278
test('SSO detection — dispatches on 3xx redirect to IdP URL', async () => {

0 commit comments

Comments
 (0)