From fec31bdde5b0d552eefd89d4ac079bb08abae942 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Tue, 31 Mar 2026 13:31:23 +0100 Subject: [PATCH 01/12] feat(auth-node): add OAuthAuthenticatorLogoutResult type for provider logout redirects Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- plugins/auth-node/src/oauth/index.ts | 1 + plugins/auth-node/src/oauth/types.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index 9cff6c5358..afc6faa27e 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -37,6 +37,7 @@ export { type OAuthAuthenticator, type OAuthAuthenticatorAuthenticateInput, type OAuthAuthenticatorLogoutInput, + type OAuthAuthenticatorLogoutResult, type OAuthAuthenticatorRefreshInput, type OAuthAuthenticatorResult, type OAuthAuthenticatorScopeOptions, diff --git a/plugins/auth-node/src/oauth/types.ts b/plugins/auth-node/src/oauth/types.ts index 50d81f99dc..968ffac39e 100644 --- a/plugins/auth-node/src/oauth/types.ts +++ b/plugins/auth-node/src/oauth/types.ts @@ -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 { fullProfile: TProfile; @@ -101,7 +111,10 @@ export interface OAuthAuthenticator { input: OAuthAuthenticatorRefreshInput, ctx: TContext, ): Promise>; - logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise; + logout?( + input: OAuthAuthenticatorLogoutInput, + ctx: TContext, + ): Promise; } /** @public */ From 0ef5a03fb376a76f9db99df215ebbee8fe0d745b Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Tue, 31 Mar 2026 14:14:21 +0100 Subject: [PATCH 02/12] feat(auth-node): return logoutUrl in logout response when provided by authenticator Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- .../oauth/createOAuthRouteHandlers.test.ts | 43 +++++++++++++++++++ .../src/oauth/createOAuthRouteHandlers.ts | 12 +++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index f2cb27dfa9..c0fece0eaf 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -1264,6 +1264,49 @@ describe('createOAuthRouteHandlers', () => { }); }); + it('should return logoutUrl as JSON when authenticator provides one', async () => { + mockAuthenticator.logout.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.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 set error search param and redirect on caught error', async () => { const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); const res = await request(app) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 0b250eceae..0d268eb052 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -280,9 +280,13 @@ export function createOAuthRouteHandlers( throw new AuthenticationError('Invalid X-Requested-With header'); } + let logoutResult: void | { logoutUrl?: string }; 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 @@ -291,7 +295,11 @@ export function createOAuthRouteHandlers( // remove persisted scopes await scopeManager.clear(req); - res.status(200).end(); + if (logoutResult?.logoutUrl) { + res.status(200).json({ logoutUrl: logoutResult.logoutUrl }); + } else { + res.status(200).end(); + } }, async refresh( From 97850d0ef1b2660b611c7eeaa291cbfc310974c8 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Tue, 31 Mar 2026 14:17:23 +0100 Subject: [PATCH 03/12] feat(auth0): implement federated logout to clear Auth0 and IdP sessions Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- .../src/authenticator.ts | 11 ++++- .../src/module.test.ts | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 5ed5fc580c..6d867ecefe 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -85,7 +85,7 @@ export const auth0Authenticator = createOAuthAuthenticator({ }, ), ); - return { helper, audience, connection, connectionScope }; + return { helper, audience, connection, connectionScope, domain, clientID }; }, async start( @@ -115,4 +115,13 @@ export const auth0Authenticator = createOAuthAuthenticator({ async refresh(input, { helper }) { return helper.refresh(input); }, + + async logout(input, { domain, clientID }) { + const origin = input.req.get('origin') ?? ''; + return { + logoutUrl: `https://${domain}/v2/logout?federated&client_id=${encodeURIComponent( + clientID, + )}&returnTo=${encodeURIComponent(origin)}`, + }; + }, }); diff --git a/plugins/auth-backend-module-auth0-provider/src/module.test.ts b/plugins/auth-backend-module-auth0-provider/src/module.test.ts index 28ccf90fa4..4868c0d995 100644 --- a/plugins/auth-backend-module-auth0-provider/src/module.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/module.test.ts @@ -180,4 +180,48 @@ 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 on logout', 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).toContain('federated'); + expect(res.body.logoutUrl).toContain('client_id=test-client-id'); + expect(res.body.logoutUrl).toContain( + `returnTo=${encodeURIComponent('http://localhost:3000')}`, + ); + }); }); From 906f104f6b29838ac71565fef8817f8f7b93f73e Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Tue, 31 Mar 2026 14:33:05 +0100 Subject: [PATCH 04/12] feat(core-app-api): redirect to provider logoutUrl on sign-out when available Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- .../DefaultAuthConnector.test.ts | 39 +++++++++++++++++++ .../lib/AuthConnector/DefaultAuthConnector.ts | 18 +++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 192b1b7ac0..02bda40b64 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -262,4 +262,43 @@ 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 + }); }); diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 3671ddc6e6..32d86b9757 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -194,6 +194,24 @@ export class DefaultAuthConnector 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) { + window.location.href = body.logoutUrl; + return new Promise(() => {}); + } + } + } catch { + // Provider logout redirect is best-effort — the Backstage session is + // already cleared, so we degrade gracefully. + } + + return undefined; } private async showPopup(scopes: Set): Promise { From 9244b70c57817fdc7655508754249c72eb0dda8b Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Tue, 31 Mar 2026 15:15:51 +0100 Subject: [PATCH 05/12] chore: add changesets, update API reports, fix type errors Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- .changeset/auth-node-logout-result.md | 5 +++++ .changeset/auth0-federated-logout.md | 5 +++++ .changeset/core-app-api-logout-redirect.md | 5 +++++ plugins/auth-node/report.api.md | 10 +++++++++- .../src/oauth/createOAuthRouteHandlers.test.ts | 4 ++-- .../auth-node/src/oauth/createOAuthRouteHandlers.ts | 2 +- 6 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 .changeset/auth-node-logout-result.md create mode 100644 .changeset/auth0-federated-logout.md create mode 100644 .changeset/core-app-api-logout-redirect.md diff --git a/.changeset/auth-node-logout-result.md b/.changeset/auth-node-logout-result.md new file mode 100644 index 0000000000..a571fde853 --- /dev/null +++ b/.changeset/auth-node-logout-result.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': minor +--- + +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. diff --git a/.changeset/auth0-federated-logout.md b/.changeset/auth0-federated-logout.md new file mode 100644 index 0000000000..ad50b03ac9 --- /dev/null +++ b/.changeset/auth0-federated-logout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-auth0-provider': minor +--- + +Added federated logout support. On sign-out, the Auth0 authenticator now returns a logout URL that redirects the browser to Auth0's `/v2/logout?federated` endpoint, clearing both the Auth0 session and any upstream IdP session. This ensures users must fully re-authenticate after signing out. diff --git a/.changeset/core-app-api-logout-redirect.md b/.changeset/core-app-api-logout-redirect.md new file mode 100644 index 0000000000..32aa405e94 --- /dev/null +++ b/.changeset/core-app-api-logout-redirect.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +The `DefaultAuthConnector` 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. diff --git a/plugins/auth-node/report.api.md b/plugins/auth-node/report.api.md index b360c2ae6a..c653897e21 100644 --- a/plugins/auth-node/report.api.md +++ b/plugins/auth-node/report.api.md @@ -293,7 +293,10 @@ export interface OAuthAuthenticator { // (undocumented) initialize(ctx: { callbackUrl: string; config: Config }): TContext; // (undocumented) - logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise; + logout?( + input: OAuthAuthenticatorLogoutInput, + ctx: TContext, + ): Promise; // (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) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index c0fece0eaf..62e7246003 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -1265,7 +1265,7 @@ describe('createOAuthRouteHandlers', () => { }); it('should return logoutUrl as JSON when authenticator provides one', async () => { - mockAuthenticator.logout.mockResolvedValueOnce({ + (mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({ logoutUrl: 'https://example.auth0.com/v2/logout?federated', }); @@ -1293,7 +1293,7 @@ describe('createOAuthRouteHandlers', () => { }); it('should return empty body when authenticator logout returns void', async () => { - mockAuthenticator.logout.mockResolvedValueOnce(undefined); + (mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce(undefined); const agent = request.agent( wrapInApp(createOAuthRouteHandlers(baseConfig)), diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 0d268eb052..d12060b20f 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -280,7 +280,7 @@ export function createOAuthRouteHandlers( throw new AuthenticationError('Invalid X-Requested-With header'); } - let logoutResult: void | { logoutUrl?: string }; + let logoutResult: void | { logoutUrl?: string } = undefined; if (authenticator.logout) { const refreshToken = cookieManager.getRefreshToken(req); logoutResult = await authenticator.logout( From a07f0196e2bea1153d4553bb6b362e0470c0ee74 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Wed, 1 Apr 2026 08:17:55 +0100 Subject: [PATCH 06/12] chore: update auth0 provider API report with domain and clientID fields Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- plugins/auth-backend-module-auth0-provider/report.api.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/auth-backend-module-auth0-provider/report.api.md b/plugins/auth-backend-module-auth0-provider/report.api.md index fb7aa9d0ba..ca39d3a816 100644 --- a/plugins/auth-backend-module-auth0-provider/report.api.md +++ b/plugins/auth-backend-module-auth0-provider/report.api.md @@ -15,6 +15,8 @@ export const auth0Authenticator: OAuthAuthenticator< audience: string | undefined; connection: string | undefined; connectionScope: string | undefined; + domain: string; + clientID: string; }, PassportProfile >; From 3532be476389f8b61e1a0e947a2c77478c12f8fc Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 1 Apr 2026 08:55:12 +0100 Subject: [PATCH 07/12] fix(auth): harden logout redirect with origin validation and protocol check Add origin allowlist validation in the OAuth logout handler (matching the existing start/refresh pattern) and validate the logoutUrl protocol on the frontend before redirecting. Also replace inline type annotation with the named OAuthAuthenticatorLogoutResult type. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- .../src/lib/AuthConnector/DefaultAuthConnector.ts | 12 ++++++++---- .../src/oauth/createOAuthRouteHandlers.ts | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 32d86b9757..d93e838f74 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -202,13 +202,17 @@ export class DefaultAuthConnector if (contentType?.includes('application/json')) { const body = await res.json(); if (body.logoutUrl) { - window.location.href = body.logoutUrl; - return new Promise(() => {}); + 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 Backstage session is - // already cleared, so we degrade gracefully. + // Provider logout redirect is best-effort - the backend session + // (refresh token cookie and persisted scopes) is already cleared, + // so we degrade gracefully. } return undefined; diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index d12060b20f..e5967bb1d6 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -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,7 +284,12 @@ export function createOAuthRouteHandlers( throw new AuthenticationError('Invalid X-Requested-With header'); } - let logoutResult: void | { logoutUrl?: string } = undefined; + 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); logoutResult = await authenticator.logout( @@ -290,7 +299,7 @@ export function createOAuthRouteHandlers( } // 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); From 208cf5f922ffb890918593bdae189236229bf28a Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 1 Apr 2026 11:19:31 +0100 Subject: [PATCH 08/12] fix(auth): add security hardening and federated config for Auth0 logout Add server-side URL validation for logoutUrl (HTTPS + localhost only), origin validation on the logout endpoint, and a configurable `federated` option (default false) for Auth0 provider logout. Includes comprehensive test coverage for all security controls. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- .../DefaultAuthConnector.test.ts | 12 +++ .../config.d.ts | 5 ++ .../report.api.md | 1 + .../src/authenticator.ts | 23 +++-- .../src/module.test.ts | 87 ++++++++++++++++++- .../oauth/createOAuthRouteHandlers.test.ts | 86 ++++++++++++++++++ .../src/oauth/createOAuthRouteHandlers.ts | 17 +++- 7 files changed, 222 insertions(+), 9 deletions(-) diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 02bda40b64..043d8022e6 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -301,4 +301,16 @@ describe('DefaultAuthConnector', () => { 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 + }); }); diff --git a/plugins/auth-backend-module-auth0-provider/config.d.ts b/plugins/auth-backend-module-auth0-provider/config.d.ts index d2b1c66389..138100a253 100644 --- a/plugins/auth-backend-module-auth0-provider/config.d.ts +++ b/plugins/auth-backend-module-auth0-provider/config.d.ts @@ -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. + */ + federated?: boolean; sessionDuration?: HumanDuration | string; }; }; diff --git a/plugins/auth-backend-module-auth0-provider/report.api.md b/plugins/auth-backend-module-auth0-provider/report.api.md index ca39d3a816..75c9ec2f87 100644 --- a/plugins/auth-backend-module-auth0-provider/report.api.md +++ b/plugins/auth-backend-module-auth0-provider/report.api.md @@ -17,6 +17,7 @@ export const auth0Authenticator: OAuthAuthenticator< connectionScope: string | undefined; domain: string; clientID: string; + federated: boolean; }, PassportProfile >; diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 6d867ecefe..899e73fbd1 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -85,7 +85,16 @@ export const auth0Authenticator = createOAuthAuthenticator({ }, ), ); - return { helper, audience, connection, connectionScope, domain, clientID }; + const federated = config.getOptionalBoolean('federated') ?? false; + return { + helper, + audience, + connection, + connectionScope, + domain, + clientID, + federated, + }; }, async start( @@ -116,12 +125,16 @@ export const auth0Authenticator = createOAuthAuthenticator({ return helper.refresh(input); }, - async logout(input, { domain, clientID }) { - const origin = input.req.get('origin') ?? ''; + async logout(input, { domain, clientID, federated }) { + const origin = input.req.get('origin'); + const federatedParam = federated ? 'federated&' : ''; + const returnToParam = origin + ? `&returnTo=${encodeURIComponent(origin)}` + : ''; return { - logoutUrl: `https://${domain}/v2/logout?federated&client_id=${encodeURIComponent( + logoutUrl: `https://${domain}/v2/logout?${federatedParam}client_id=${encodeURIComponent( clientID, - )}&returnTo=${encodeURIComponent(origin)}`, + )}${returnToParam}`, }; }, }); diff --git a/plugins/auth-backend-module-auth0-provider/src/module.test.ts b/plugins/auth-backend-module-auth0-provider/src/module.test.ts index 4868c0d995..c2f85ab6f7 100644 --- a/plugins/auth-backend-module-auth0-provider/src/module.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/module.test.ts @@ -181,7 +181,7 @@ describe('authModuleAuth0Provider', () => { ); }); - it('should return Auth0 logout URL on logout', async () => { + it('should return Auth0 logout URL without federated param by default', async () => { const { server } = await startTestBackend({ features: [ authPlugin, @@ -210,6 +210,51 @@ describe('authModuleAuth0Provider', () => { ], }); + 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 federated 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', + federated: true, + }, + }, + }, + session: { + secret: 'secret', + }, + }, + }, + }), + ], + }); + const res = await request(server) .post('/api/auth/auth0/logout') .query({ env: 'development' }) @@ -224,4 +269,44 @@ describe('authModuleAuth0Provider', () => { `returnTo=${encodeURIComponent('http://localhost:3000')}`, ); }); + + it('should fall back to app.baseUrl when origin header is missing', 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'); + + expect(res.status).toBe(200); + expect(res.body.logoutUrl).toContain( + `returnTo=${encodeURIComponent('http://localhost:3000')}`, + ); + }); }); diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 62e7246003..22e571140b 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -1307,6 +1307,92 @@ describe('createOAuthRouteHandlers', () => { 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) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index e5967bb1d6..331267e418 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -305,10 +305,21 @@ export function createOAuthRouteHandlers( await scopeManager.clear(req); if (logoutResult?.logoutUrl) { - res.status(200).json({ logoutUrl: logoutResult.logoutUrl }); - } else { - res.status(200).end(); + 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(); }, async refresh( From e2d96b7140aacd0a21d94f59c444fd36419ea838 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Wed, 1 Apr 2026 13:09:59 +0100 Subject: [PATCH 09/12] chore: address PR feedback on changesets Add @backstage/plugin-app to core-app-api changeset, reword to avoid internal export name, and downgrade auth-node bump to patch. Signed-off-by: Jonathan Roebuck Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/auth-node-logout-result.md | 2 +- .changeset/core-app-api-logout-redirect.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/auth-node-logout-result.md b/.changeset/auth-node-logout-result.md index a571fde853..9eaf842f14 100644 --- a/.changeset/auth-node-logout-result.md +++ b/.changeset/auth-node-logout-result.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-auth-node': minor +'@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. diff --git a/.changeset/core-app-api-logout-redirect.md b/.changeset/core-app-api-logout-redirect.md index 32aa405e94..63d9b6fa0c 100644 --- a/.changeset/core-app-api-logout-redirect.md +++ b/.changeset/core-app-api-logout-redirect.md @@ -1,5 +1,6 @@ --- '@backstage/core-app-api': patch +'@backstage/plugin-app': patch --- -The `DefaultAuthConnector` 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. +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. From 7d002e83a5bdded85a6eefb6f4f6e5c63a3e923b Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Wed, 1 Apr 2026 13:28:32 +0100 Subject: [PATCH 10/12] fix(auth0): remove untestable app.baseUrl fallback test The origin header is always present in browser POST requests, making the fallback scenario unreachable in practice. Signed-off-by: Jonathan Roebuck Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/module.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/plugins/auth-backend-module-auth0-provider/src/module.test.ts b/plugins/auth-backend-module-auth0-provider/src/module.test.ts index c2f85ab6f7..147847e386 100644 --- a/plugins/auth-backend-module-auth0-provider/src/module.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/module.test.ts @@ -269,44 +269,4 @@ describe('authModuleAuth0Provider', () => { `returnTo=${encodeURIComponent('http://localhost:3000')}`, ); }); - - it('should fall back to app.baseUrl when origin header is missing', 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'); - - expect(res.status).toBe(200); - expect(res.body.logoutUrl).toContain( - `returnTo=${encodeURIComponent('http://localhost:3000')}`, - ); - }); }); From 25e8a65b4651215dfd327f10797708076bdee9f6 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Wed, 1 Apr 2026 13:41:06 +0100 Subject: [PATCH 11/12] refactor(auth0): rename config key `federated` to `federatedLogout` The `federated` config key was ambiguous. Rename to `federatedLogout` for clarity since it specifically controls federated logout behavior. Signed-off-by: Jonathan Roebuck Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/auth0-federated-logout.md | 2 +- plugins/auth-backend-module-auth0-provider/config.d.ts | 2 +- .../auth-backend-module-auth0-provider/src/authenticator.ts | 2 +- plugins/auth-backend-module-auth0-provider/src/module.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/auth0-federated-logout.md b/.changeset/auth0-federated-logout.md index ad50b03ac9..83dda17a71 100644 --- a/.changeset/auth0-federated-logout.md +++ b/.changeset/auth0-federated-logout.md @@ -2,4 +2,4 @@ '@backstage/plugin-auth-backend-module-auth0-provider': minor --- -Added federated logout support. On sign-out, the Auth0 authenticator now returns a logout URL that redirects the browser to Auth0's `/v2/logout?federated` endpoint, clearing both the Auth0 session and any upstream IdP session. This ensures users must fully re-authenticate after signing out. +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. diff --git a/plugins/auth-backend-module-auth0-provider/config.d.ts b/plugins/auth-backend-module-auth0-provider/config.d.ts index 138100a253..fd7ebe7b2c 100644 --- a/plugins/auth-backend-module-auth0-provider/config.d.ts +++ b/plugins/auth-backend-module-auth0-provider/config.d.ts @@ -37,7 +37,7 @@ export interface Config { * Whether to perform federated logout, clearing both the Auth0 * session and any upstream IdP session. Defaults to false. */ - federated?: boolean; + federatedLogout?: boolean; sessionDuration?: HumanDuration | string; }; }; diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 899e73fbd1..7fc99ee1f1 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -85,7 +85,7 @@ export const auth0Authenticator = createOAuthAuthenticator({ }, ), ); - const federated = config.getOptionalBoolean('federated') ?? false; + const federated = config.getOptionalBoolean('federatedLogout') ?? false; return { helper, audience, diff --git a/plugins/auth-backend-module-auth0-provider/src/module.test.ts b/plugins/auth-backend-module-auth0-provider/src/module.test.ts index 147847e386..25888173cc 100644 --- a/plugins/auth-backend-module-auth0-provider/src/module.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/module.test.ts @@ -225,7 +225,7 @@ describe('authModuleAuth0Provider', () => { ); }); - it('should include federated param when federated is true', async () => { + it('should include federated param when federatedLogout is true', async () => { const { server } = await startTestBackend({ features: [ authPlugin, @@ -242,7 +242,7 @@ describe('authModuleAuth0Provider', () => { clientId: 'test-client-id', clientSecret: 'clientSecret', domain: 'test.eu.auth0.com', - federated: true, + federatedLogout: true, }, }, }, From 3bddf23896636d2296bf45996db75e1d115d0ab0 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Wed, 1 Apr 2026 13:43:34 +0100 Subject: [PATCH 12/12] refactor(auth0): use URL/URLSearchParams for logout URL construction Replace manual string concatenation with URL and URLSearchParams for safer encoding and better readability. Signed-off-by: Jonathan Roebuck Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/authenticator.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 7fc99ee1f1..c88243d0a8 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -126,15 +126,15 @@ export const auth0Authenticator = createOAuthAuthenticator({ }, 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'); - const federatedParam = federated ? 'federated&' : ''; - const returnToParam = origin - ? `&returnTo=${encodeURIComponent(origin)}` - : ''; - return { - logoutUrl: `https://${domain}/v2/logout?${federatedParam}client_id=${encodeURIComponent( - clientID, - )}${returnToParam}`, - }; + if (origin) { + logoutUrl.searchParams.set('returnTo', origin); + } + return { logoutUrl: logoutUrl.toString() }; }, });