Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -131,6 +132,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
OAuthPropagatorController,
SSOAuthController,
SsoProxyLoginController,
PortalLogoutController,
],
providers: [
SignInUpService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { PortalLogoutController } from 'src/engine/core-modules/auth/controllers/portal-logout.controller';

type ConfigKey = 'PLATFORM_DOMAIN';

const buildController = (
configOverrides?: Partial<Record<ConfigKey, unknown>>,
) => {
const config: Record<ConfigKey, unknown> = {
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/',
);
});
});
Original file line number Diff line number Diff line change
@@ -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=<absolute_url>
*
* 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): ConfigVariables => {
Expand Down
Loading