Skip to content

Commit 9f85990

Browse files
committed
feat(auth): add GET /auth/portal-logout for cross-app logout chain
1 parent b5c2878 commit 9f85990

4 files changed

Lines changed: 221 additions & 0 deletions

File tree

packages/twenty-server/src/engine/core-modules/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/contro
1515
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
1616
import { OAuthPropagatorController } from 'src/engine/core-modules/auth/controllers/oauth-propagator.controller';
1717
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
18+
import { PortalLogoutController } from 'src/engine/core-modules/auth/controllers/portal-logout.controller';
1819
import { SsoProxyLoginController } from 'src/engine/core-modules/auth/controllers/sso-proxy-login.controller';
1920
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
2021
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
@@ -131,6 +132,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
131132
OAuthPropagatorController,
132133
SSOAuthController,
133134
SsoProxyLoginController,
135+
PortalLogoutController,
134136
],
135137
providers: [
136138
SignInUpService,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { PortalLogoutController } from 'src/engine/core-modules/auth/controllers/portal-logout.controller';
2+
3+
type ConfigKey = 'PLATFORM_DOMAIN';
4+
5+
const buildController = (
6+
configOverrides?: Partial<Record<ConfigKey, unknown>>,
7+
) => {
8+
const config: Record<ConfigKey, unknown> = {
9+
PLATFORM_DOMAIN: 'foss.arbisoft.com',
10+
...configOverrides,
11+
};
12+
13+
const twentyConfigService = {
14+
get: jest.fn((key: ConfigKey) => config[key]),
15+
};
16+
17+
const controller = new PortalLogoutController(twentyConfigService as any);
18+
19+
const res: any = {
20+
clearCookie: jest.fn(),
21+
redirect: jest.fn(),
22+
status: jest.fn().mockReturnThis(),
23+
send: jest.fn(),
24+
};
25+
26+
const portalLogout = (next?: string) => controller.portalLogout(next, res);
27+
28+
return { controller, twentyConfigService, res, portalLogout };
29+
};
30+
31+
describe('PortalLogoutController.portalLogout', () => {
32+
it('clears the tokenPair cookie regardless of ?next= validity', () => {
33+
const { res, portalLogout } = buildController();
34+
35+
portalLogout('https://evil.example/');
36+
37+
expect(res.clearCookie).toHaveBeenCalledWith('tokenPair', { path: '/' });
38+
});
39+
40+
it('302s to ?next= when host is PLATFORM_DOMAIN', () => {
41+
const { res, portalLogout } = buildController();
42+
43+
portalLogout('https://foss.arbisoft.com/done');
44+
45+
expect(res.redirect).toHaveBeenCalledWith(
46+
302,
47+
'https://foss.arbisoft.com/done',
48+
);
49+
});
50+
51+
it('302s to ?next= when host is a subdomain of PLATFORM_DOMAIN', () => {
52+
const { res, portalLogout } = buildController();
53+
54+
portalLogout('https://twenty.foss.arbisoft.com/x');
55+
56+
expect(res.redirect).toHaveBeenCalledWith(
57+
302,
58+
'https://twenty.foss.arbisoft.com/x',
59+
);
60+
});
61+
62+
it('200s without redirect when ?next= host is unrelated', () => {
63+
const { res, portalLogout } = buildController();
64+
65+
portalLogout('https://evil.example/steal');
66+
67+
expect(res.redirect).not.toHaveBeenCalled();
68+
expect(res.status).toHaveBeenCalledWith(200);
69+
expect(res.send).toHaveBeenCalled();
70+
});
71+
72+
it('enforces dot boundary on suffix match', () => {
73+
// `foss.arbisoft.com.evil` must NOT match `foss.arbisoft.com`.
74+
const { res, portalLogout } = buildController();
75+
76+
portalLogout('https://foss.arbisoft.com.evil/x');
77+
78+
expect(res.redirect).not.toHaveBeenCalled();
79+
expect(res.status).toHaveBeenCalledWith(200);
80+
});
81+
82+
it('rejects non-http(s) schemes', () => {
83+
const { res, portalLogout } = buildController();
84+
85+
portalLogout('javascript:alert(document.cookie)');
86+
87+
expect(res.redirect).not.toHaveBeenCalled();
88+
expect(res.status).toHaveBeenCalledWith(200);
89+
});
90+
91+
it('200s without redirect when ?next= is omitted', () => {
92+
const { res, portalLogout } = buildController();
93+
94+
portalLogout();
95+
96+
expect(res.redirect).not.toHaveBeenCalled();
97+
expect(res.status).toHaveBeenCalledWith(200);
98+
expect(res.clearCookie).toHaveBeenCalledWith('tokenPair', { path: '/' });
99+
});
100+
101+
it('rejects every ?next= when PLATFORM_DOMAIN is unset', () => {
102+
const { res, portalLogout } = buildController({ PLATFORM_DOMAIN: '' });
103+
104+
portalLogout('https://foss.arbisoft.com/x');
105+
106+
expect(res.redirect).not.toHaveBeenCalled();
107+
expect(res.status).toHaveBeenCalledWith(200);
108+
});
109+
110+
it('rejects malformed ?next= values', () => {
111+
const { res, portalLogout } = buildController();
112+
113+
portalLogout(':::garbage');
114+
115+
expect(res.redirect).not.toHaveBeenCalled();
116+
expect(res.status).toHaveBeenCalledWith(200);
117+
});
118+
119+
it('normalises a leading dot in PLATFORM_DOMAIN', () => {
120+
const { res, portalLogout } = buildController({
121+
PLATFORM_DOMAIN: '.foss.arbisoft.com',
122+
});
123+
124+
portalLogout('https://twenty.foss.arbisoft.com/');
125+
126+
expect(res.redirect).toHaveBeenCalledWith(
127+
302,
128+
'https://twenty.foss.arbisoft.com/',
129+
);
130+
});
131+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
Controller,
3+
Get,
4+
Logger,
5+
Query,
6+
Res,
7+
UseFilters,
8+
UseGuards,
9+
} from '@nestjs/common';
10+
11+
import { type Response } from 'express';
12+
13+
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
14+
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
15+
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
16+
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
17+
import { clearTokenPairCookie } from 'src/engine/utils/proxy-identity.util';
18+
19+
@Controller('auth')
20+
@UseFilters(AuthRestApiExceptionFilter)
21+
export class PortalLogoutController {
22+
private readonly logger = new Logger(PortalLogoutController.name);
23+
24+
constructor(private readonly twentyConfigService: TwentyConfigService) {}
25+
26+
/**
27+
* GET /auth/portal-logout?next=<absolute_url>
28+
*
29+
* Cross-origin redirect-chain entry-point for the foss-server-bundle
30+
* portal's "Log out of all apps" flow. Clears the `tokenPair` cookie
31+
* and 302s to `next` (validated against PLATFORM_DOMAIN).
32+
*
33+
* CSRF-exempt by design: the portal cannot share Twenty's session
34+
* cookie cross-origin. Residual force-logout risk is acknowledged —
35+
* only the `tokenPair` cookie is cleared; the SPA's next request
36+
* automatically routes through `/auth/sso/proxy-login` and re-issues
37+
* a fresh tokenPair, so the user is auto-relogged in unless they've
38+
* also signed out at oauth2-proxy.
39+
*/
40+
@Get('portal-logout')
41+
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
42+
portalLogout(
43+
@Query('next') nextRaw: string | undefined,
44+
@Res() res: Response,
45+
): void {
46+
clearTokenPairCookie(res);
47+
48+
const next = (nextRaw ?? '').trim();
49+
if (next && this.isAllowedNext(next)) {
50+
res.redirect(302, next);
51+
return;
52+
}
53+
res.status(200).send();
54+
}
55+
56+
private isAllowedNext(url: string): boolean {
57+
// Suffix match enforces a dot boundary so foss.arbisoft.com.evil
58+
// does NOT match the foss.arbisoft.com PLATFORM_DOMAIN.
59+
const platformDomain = (
60+
this.twentyConfigService.get('PLATFORM_DOMAIN') ?? ''
61+
)
62+
.toLowerCase()
63+
.trim()
64+
.replace(/^\.+/, '');
65+
if (!platformDomain) return false;
66+
67+
let parsed: URL;
68+
try {
69+
parsed = new URL(url);
70+
} catch {
71+
return false;
72+
}
73+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
74+
return false;
75+
}
76+
const host = parsed.hostname.toLowerCase();
77+
return host === platformDomain || host.endsWith('.' + platformDomain);
78+
}
79+
}

packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,6 +1720,15 @@ export class ConfigVariables {
17201720
})
17211721
@IsOptional()
17221722
SMB_NAME = '';
1723+
1724+
@ConfigVariablesMetadata({
1725+
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
1726+
description:
1727+
'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.',
1728+
type: ConfigVariableType.STRING,
1729+
})
1730+
@IsOptional()
1731+
PLATFORM_DOMAIN = '';
17231732
}
17241733

17251734
export const validate = (config: Record<string, unknown>): ConfigVariables => {

0 commit comments

Comments
 (0)