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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-node': patch
---
Added `OAuthAuthenticatorLogoutResult` type. The `logout` method on `OAuthAuthenticator` can now optionally return `{ logoutUrl }` to trigger a browser redirect after sign-out. This allows providers like Auth0 to clear their session cookies by redirecting to their logout endpoint.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-auth0-provider': minor
---
Added federated logout support. Set `federatedLogout: true` in the Auth0 provider config to clear both the Auth0 session and any upstream IdP session on sign-out. The authenticator returns a logout URL that redirects the browser to Auth0's `/v2/logout?federated` endpoint, ensuring users must fully re-authenticate after signing out.
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': patch
'@backstage/plugin-app': patch
---
The default auth implementation now checks for a `logoutUrl` in the logout response body. If the auth provider returns one (e.g. Auth0 federated logout), the browser is redirected to that URL to clear the provider's session cookies. This is backward compatible — providers that return an empty response are unaffected.
@@ -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> {
@@ -33,6 +33,11 @@ export interface Config {
connection?: string;
connectionScope?: string;
organization?: string;
/**
* Whether to perform federated logout, clearing both the Auth0
* session and any upstream IdP session. Defaults to false.
*/
federatedLogout?: boolean;
sessionDuration?: HumanDuration | string;
};
};
@@ -15,6 +15,9 @@ export const auth0Authenticator: OAuthAuthenticator<
audience: string | undefined;
connection: string | undefined;
connectionScope: string | undefined;
domain: string;
clientID: string;
federated: boolean;
},
PassportProfile
>;
@@ -85,7 +85,16 @@ export const auth0Authenticator = createOAuthAuthenticator({
},
),
);
return { helper, audience, connection, connectionScope };
const federated = config.getOptionalBoolean('federatedLogout') ?? false;
return {
helper,
audience,
connection,
connectionScope,
domain,
clientID,
federated,
};
},
async start(
@@ -115,4 +124,17 @@ export const auth0Authenticator = createOAuthAuthenticator({
async refresh(input, { helper }) {
return helper.refresh(input);
},
async logout(input, { domain, clientID, federated }) {
const logoutUrl = new URL(`https://${domain}/v2/logout`);
if (federated) {
logoutUrl.searchParams.set('federated', '');
}
logoutUrl.searchParams.set('client_id', clientID);
const origin = input.req.get('origin');
if (origin) {
logoutUrl.searchParams.set('returnTo', origin);
}
return { logoutUrl: logoutUrl.toString() };
},
});
@@ -180,4 +180,93 @@ describe('authModuleAuth0Provider', () => {
'Organization mismatch. The organization provided in the request does not match the organization configured in the strategy.',
);
});
it('should return Auth0 logout URL without federated param by default', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleAuth0Provider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
auth0: {
development: {
clientId: 'test-client-id',
clientSecret: 'clientSecret',
domain: 'test.eu.auth0.com',
},
},
},
session: {
secret: 'secret',
},
},
},
}),
],
});
const res = await request(server)
.post('/api/auth/auth0/logout')
.query({ env: 'development' })
.set('X-Requested-With', 'XMLHttpRequest')
.set('Origin', 'http://localhost:3000');
expect(res.status).toBe(200);
expect(res.body.logoutUrl).toContain('test.eu.auth0.com/v2/logout');
expect(res.body.logoutUrl).not.toContain('federated');
expect(res.body.logoutUrl).toContain('client_id=test-client-id');
expect(res.body.logoutUrl).toContain(
`returnTo=${encodeURIComponent('http://localhost:3000')}`,
);
});
it('should include federated param when federatedLogout is true', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleAuth0Provider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
auth0: {
development: {
clientId: 'test-client-id',
clientSecret: 'clientSecret',
domain: 'test.eu.auth0.com',
federatedLogout: true,
},
},
},
session: {
secret: 'secret',
},
},
},
}),
],
});
const res = await request(server)
.post('/api/auth/auth0/logout')
.query({ env: 'development' })
.set('X-Requested-With', 'XMLHttpRequest')
.set('Origin', 'http://localhost:3000');
expect(res.status).toBe(200);
expect(res.body.logoutUrl).toContain('test.eu.auth0.com/v2/logout');
expect(res.body.logoutUrl).toContain('federated');
expect(res.body.logoutUrl).toContain('client_id=test-client-id');
expect(res.body.logoutUrl).toContain(
`returnTo=${encodeURIComponent('http://localhost:3000')}`,
);
});
});
+9 -1
View File
@@ -293,7 +293,10 @@ export interface OAuthAuthenticator<TContext, TProfile> {
// (undocumented)
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
// (undocumented)
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
logout?(
input: OAuthAuthenticatorLogoutInput,
ctx: TContext,
): Promise<void | OAuthAuthenticatorLogoutResult>;
// (undocumented)
refresh(
input: OAuthAuthenticatorRefreshInput,
@@ -329,6 +332,11 @@ export interface OAuthAuthenticatorLogoutInput {
req: Request_2;
}
// @public (undocumented)
export interface OAuthAuthenticatorLogoutResult {
logoutUrl?: string;
}
// @public (undocumented)
export interface OAuthAuthenticatorRefreshInput {
// (undocumented)
@@ -1264,6 +1264,135 @@ describe('createOAuthRouteHandlers', () => {
});
});
it('should return logoutUrl as JSON when authenticator provides one', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'https://example.auth0.com/v2/logout?federated',
});
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-refresh-token=my-refresh-token',
'127.0.0.1',
'/my-provider',
);
const res = await agent
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({
logoutUrl: 'https://example.auth0.com/v2/logout?federated',
});
// Cookie should still be cleared even when logoutUrl is returned
expect(getRefreshTokenCookie(agent)).toBeUndefined();
});
it('should return empty body when authenticator logout returns void', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce(undefined);
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
const res = await agent
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it('should reject logout from disallowed origin', async () => {
const app = wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
isOriginAllowed: origin => origin === 'http://allowed.example.com',
}),
);
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest')
.set('Origin', 'https://evil.com');
expect(res.status).toBe(403);
expect(res.body).toMatchObject({
error: {
name: 'NotAllowedError',
message: "Origin 'https://evil.com' is not allowed",
},
});
});
it('should strip logoutUrl with non-HTTPS protocol', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'http://evil.com/redirect',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it('should accept logoutUrl with HTTPS protocol', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'https://auth.example.com/v2/logout',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({
logoutUrl: 'https://auth.example.com/v2/logout',
});
});
it('should accept logoutUrl targeting localhost', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'http://localhost:3000/logout-callback',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({
logoutUrl: 'http://localhost:3000/logout-callback',
});
});
it('should handle malformed logoutUrl gracefully', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'not-a-valid-url',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it('should set error search param and redirect on caught error', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
@@ -39,7 +39,11 @@ import {
ProfileTransform,
SignInResolver,
} from '../types';
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
import {
OAuthAuthenticator,
OAuthAuthenticatorLogoutResult,
OAuthAuthenticatorResult,
} from './types';
import { Config, readDurationFromConfig } from '@backstage/config';
import { CookieScopeManager } from './CookieScopeManager';
@@ -280,17 +284,41 @@ export function createOAuthRouteHandlers<TProfile>(
throw new AuthenticationError('Invalid X-Requested-With header');
}
const origin = req.get('origin');
if (origin && !isOriginAllowed(origin)) {
throw new NotAllowedError(`Origin '${origin}' is not allowed`);
}
let logoutResult: void | OAuthAuthenticatorLogoutResult = undefined;
if (authenticator.logout) {
const refreshToken = cookieManager.getRefreshToken(req);
await authenticator.logout({ req, refreshToken }, authenticatorCtx);
logoutResult = await authenticator.logout(
{ req, refreshToken },
authenticatorCtx,
);
}
// remove refresh token cookie if it is set
cookieManager.removeRefreshToken(res, req.get('origin'));
cookieManager.removeRefreshToken(res, origin);
// remove persisted scopes
await scopeManager.clear(req);
if (logoutResult?.logoutUrl) {
try {
const logoutUrl = new URL(logoutResult.logoutUrl);
if (
logoutUrl.protocol === 'https:' ||
logoutUrl.hostname === 'localhost'
) {
res.status(200).json({ logoutUrl: logoutResult.logoutUrl });
return;
}
} catch {
// Malformed URL — fall through to empty response
}
}
res.status(200).end();
},
+1
View File
@@ -37,6 +37,7 @@ export {
type OAuthAuthenticator,
type OAuthAuthenticatorAuthenticateInput,
type OAuthAuthenticatorLogoutInput,
type OAuthAuthenticatorLogoutResult,
type OAuthAuthenticatorRefreshInput,
type OAuthAuthenticatorResult,
type OAuthAuthenticatorScopeOptions,
+14 -1
View File
@@ -76,6 +76,16 @@ export interface OAuthAuthenticatorLogoutInput {
req: Request;
}
/** @public */
export interface OAuthAuthenticatorLogoutResult {
/**
* If set, the frontend will redirect the browser to this URL after clearing
* the Backstage session. Use this to terminate provider-side sessions (e.g.
* Auth0's `/v2/logout` endpoint).
*/
logoutUrl?: string;
}
/** @public */
export interface OAuthAuthenticatorResult<TProfile> {
fullProfile: TProfile;
@@ -101,7 +111,10 @@ export interface OAuthAuthenticator<TContext, TProfile> {
input: OAuthAuthenticatorRefreshInput,
ctx: TContext,
): Promise<OAuthAuthenticatorResult<TProfile>>;
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
logout?(
input: OAuthAuthenticatorLogoutInput,
ctx: TContext,
): Promise<void | OAuthAuthenticatorLogoutResult>;
}
/** @public */