diff --git a/.changeset/auth-node-logout-result.md b/.changeset/auth-node-logout-result.md new file mode 100644 index 0000000000..9eaf842f14 --- /dev/null +++ b/.changeset/auth-node-logout-result.md @@ -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. diff --git a/.changeset/auth0-federated-logout.md b/.changeset/auth0-federated-logout.md new file mode 100644 index 0000000000..83dda17a71 --- /dev/null +++ b/.changeset/auth0-federated-logout.md @@ -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. diff --git a/.changeset/core-app-api-logout-redirect.md b/.changeset/core-app-api-logout-redirect.md new file mode 100644 index 0000000000..63d9b6fa0c --- /dev/null +++ b/.changeset/core-app-api-logout-redirect.md @@ -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. 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..043d8022e6 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,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 + }); }); diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 3671ddc6e6..d93e838f74 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -194,6 +194,28 @@ 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) { + 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): Promise { diff --git a/plugins/auth-backend-module-auth0-provider/config.d.ts b/plugins/auth-backend-module-auth0-provider/config.d.ts index d2b1c66389..fd7ebe7b2c 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. + */ + federatedLogout?: 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 fb7aa9d0ba..75c9ec2f87 100644 --- a/plugins/auth-backend-module-auth0-provider/report.api.md +++ b/plugins/auth-backend-module-auth0-provider/report.api.md @@ -15,6 +15,9 @@ export const auth0Authenticator: OAuthAuthenticator< audience: string | undefined; connection: string | undefined; 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 5ed5fc580c..c88243d0a8 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 }; + 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() }; + }, }); 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..25888173cc 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,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')}`, + ); + }); }); 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 f2cb27dfa9..22e571140b 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -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) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 0b250eceae..331267e418 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,17 +284,41 @@ export function createOAuthRouteHandlers( 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(); }, 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 */