Merge pull request #33703 from backstage/feat/auth0-federated-logout

feat(auth): support provider logout redirects, implement Auth0 federated logout
This commit is contained in:
Patrik Oldsberg
2026-04-01 15:27:27 +02:00
committed by GitHub
14 changed files with 393 additions and 6 deletions
@@ -262,4 +262,55 @@ describe('DefaultAuthConnector', () => {
url: 'http://my-host/api/auth/my-provider/start?scope=-ab-&origin=http%3A%2F%2Flocalhost&flow=popup&env=production',
});
});
it('should not resolve when provider returns a logoutUrl', async () => {
const logoutUrl =
'https://test.auth0.com/v2/logout?federated&client_id=abc&returnTo=http%3A%2F%2Flocalhost';
server.use(
rest.post('*', (_req, res, ctx) => res(ctx.json({ logoutUrl }))),
);
const connector = new DefaultAuthConnector(defaultOptions);
// When a logoutUrl is returned, removeSession redirects the browser and
// returns a never-resolving promise. Race against a short delay to verify
// that it does not resolve.
const result = await Promise.race([
connector.removeSession().then(() => 'resolved'),
new Promise<'timeout'>(r => setTimeout(() => r('timeout'), 50)),
]);
expect(result).toBe('timeout');
});
it('should complete normally when provider returns empty logout response', async () => {
server.use(rest.post('*', (_req, res, ctx) => res(ctx.status(200))));
const connector = new DefaultAuthConnector(defaultOptions);
await connector.removeSession();
// No redirect, no error — the original behavior
});
it('should complete normally when response is not JSON', async () => {
server.use(
rest.post('*', (_req, res, ctx) => res(ctx.status(200), ctx.text('OK'))),
);
const connector = new DefaultAuthConnector(defaultOptions);
await connector.removeSession();
// Should complete without error — non-JSON responses are ignored
});
it('should ignore logoutUrl with non-HTTPS protocol', async () => {
server.use(
rest.post('*', (_req, res, ctx) =>
res(ctx.json({ logoutUrl: 'http://evil.com/steal' })),
),
);
const connector = new DefaultAuthConnector(defaultOptions);
await connector.removeSession();
// Should complete normally without redirecting - http:// is rejected
});
});
@@ -194,6 +194,28 @@ export class DefaultAuthConnector<AuthSession>
error.status = res.status;
throw error;
}
// If the auth provider returned a logout URL (e.g. for Auth0 federated
// logout), redirect the browser to clear the provider's session cookies.
try {
const contentType = res.headers.get('content-type');
if (contentType?.includes('application/json')) {
const body = await res.json();
if (body.logoutUrl) {
const url = new URL(body.logoutUrl);
if (url.protocol === 'https:' || url.hostname === 'localhost') {
window.location.href = body.logoutUrl;
return new Promise(() => {});
}
}
}
} catch {
// Provider logout redirect is best-effort - the backend session
// (refresh token cookie and persisted scopes) is already cleared,
// so we degrade gracefully.
}
return undefined;
}
private async showPopup(scopes: Set<string>): Promise<AuthSession> {