diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 27f5593ce6b1f..fff9a8f55bff6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -15,6 +15,7 @@ import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/contro import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; import { OAuthPropagatorController } from 'src/engine/core-modules/auth/controllers/oauth-propagator.controller'; import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; +import { PortalLogoutController } from 'src/engine/core-modules/auth/controllers/portal-logout.controller'; import { SsoProxyLoginController } from 'src/engine/core-modules/auth/controllers/sso-proxy-login.controller'; import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; @@ -131,6 +132,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; OAuthPropagatorController, SSOAuthController, SsoProxyLoginController, + PortalLogoutController, ], providers: [ SignInUpService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/portal-logout.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/portal-logout.controller.spec.ts new file mode 100644 index 0000000000000..f75527cab3ab3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/portal-logout.controller.spec.ts @@ -0,0 +1,131 @@ +import { PortalLogoutController } from 'src/engine/core-modules/auth/controllers/portal-logout.controller'; + +type ConfigKey = 'PLATFORM_DOMAIN'; + +const buildController = ( + configOverrides?: Partial>, +) => { + const config: Record = { + PLATFORM_DOMAIN: 'foss.arbisoft.com', + ...configOverrides, + }; + + const twentyConfigService = { + get: jest.fn((key: ConfigKey) => config[key]), + }; + + const controller = new PortalLogoutController(twentyConfigService as any); + + const res: any = { + clearCookie: jest.fn(), + redirect: jest.fn(), + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + const portalLogout = (next?: string) => controller.portalLogout(next, res); + + return { controller, twentyConfigService, res, portalLogout }; +}; + +describe('PortalLogoutController.portalLogout', () => { + it('clears the tokenPair cookie regardless of ?next= validity', () => { + const { res, portalLogout } = buildController(); + + portalLogout('https://evil.example/'); + + expect(res.clearCookie).toHaveBeenCalledWith('tokenPair', { path: '/' }); + }); + + it('302s to ?next= when host is PLATFORM_DOMAIN', () => { + const { res, portalLogout } = buildController(); + + portalLogout('https://foss.arbisoft.com/done'); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + 'https://foss.arbisoft.com/done', + ); + }); + + it('302s to ?next= when host is a subdomain of PLATFORM_DOMAIN', () => { + const { res, portalLogout } = buildController(); + + portalLogout('https://twenty.foss.arbisoft.com/x'); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + 'https://twenty.foss.arbisoft.com/x', + ); + }); + + it('200s without redirect when ?next= host is unrelated', () => { + const { res, portalLogout } = buildController(); + + portalLogout('https://evil.example/steal'); + + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalled(); + }); + + it('enforces dot boundary on suffix match', () => { + // `foss.arbisoft.com.evil` must NOT match `foss.arbisoft.com`. + const { res, portalLogout } = buildController(); + + portalLogout('https://foss.arbisoft.com.evil/x'); + + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('rejects non-http(s) schemes', () => { + const { res, portalLogout } = buildController(); + + portalLogout('javascript:alert(document.cookie)'); + + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('200s without redirect when ?next= is omitted', () => { + const { res, portalLogout } = buildController(); + + portalLogout(); + + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.clearCookie).toHaveBeenCalledWith('tokenPair', { path: '/' }); + }); + + it('rejects every ?next= when PLATFORM_DOMAIN is unset', () => { + const { res, portalLogout } = buildController({ PLATFORM_DOMAIN: '' }); + + portalLogout('https://foss.arbisoft.com/x'); + + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('rejects malformed ?next= values', () => { + const { res, portalLogout } = buildController(); + + portalLogout(':::garbage'); + + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('normalises a leading dot in PLATFORM_DOMAIN', () => { + const { res, portalLogout } = buildController({ + PLATFORM_DOMAIN: '.foss.arbisoft.com', + }); + + portalLogout('https://twenty.foss.arbisoft.com/'); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + 'https://twenty.foss.arbisoft.com/', + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/portal-logout.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/portal-logout.controller.ts new file mode 100644 index 0000000000000..238bc164afa93 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/portal-logout.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Logger, + Query, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { type Response } from 'express'; + +import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { clearTokenPairCookie } from 'src/engine/utils/proxy-identity.util'; + +@Controller('auth') +@UseFilters(AuthRestApiExceptionFilter) +export class PortalLogoutController { + private readonly logger = new Logger(PortalLogoutController.name); + + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + /** + * GET /auth/portal-logout?next= + * + * Cross-origin redirect-chain entry-point for the foss-server-bundle + * portal's "Log out of all apps" flow. Clears the `tokenPair` cookie + * and 302s to `next` (validated against PLATFORM_DOMAIN). + * + * CSRF-exempt by design: the portal cannot share Twenty's session + * cookie cross-origin. Residual force-logout risk is acknowledged — + * only the `tokenPair` cookie is cleared; the SPA's next request + * automatically routes through `/auth/sso/proxy-login` and re-issues + * a fresh tokenPair, so the user is auto-relogged in unless they've + * also signed out at oauth2-proxy. + */ + @Get('portal-logout') + @UseGuards(PublicEndpointGuard, NoPermissionGuard) + portalLogout( + @Query('next') nextRaw: string | undefined, + @Res() res: Response, + ): void { + clearTokenPairCookie(res); + + const next = (nextRaw ?? '').trim(); + if (next && this.isAllowedNext(next)) { + res.redirect(302, next); + return; + } + res.status(200).send(); + } + + private isAllowedNext(url: string): boolean { + // Suffix match enforces a dot boundary so foss.arbisoft.com.evil + // does NOT match the foss.arbisoft.com PLATFORM_DOMAIN. + const platformDomain = ( + this.twentyConfigService.get('PLATFORM_DOMAIN') ?? '' + ) + .toLowerCase() + .trim() + .replace(/^\.+/, ''); + if (!platformDomain) return false; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + const host = parsed.hostname.toLowerCase(); + return host === platformDomain || host.endsWith('.' + platformDomain); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 1b49bd7a7e83e..3ca0a5ba58beb 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1720,6 +1720,15 @@ export class ConfigVariables { }) @IsOptional() SMB_NAME = ''; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'Root domain of the foss-server-bundle deployment. Used by /auth/portal-logout?next= as the redirect allowlist — only URLs whose host equals PLATFORM_DOMAIN or is a subdomain of it are followed.', + type: ConfigVariableType.STRING, + }) + @IsOptional() + PLATFORM_DOMAIN = ''; } export const validate = (config: Record): ConfigVariables => {