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(