From 0e5015493f74559eada78c39846f41bdc5d0dec9 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Thu, 4 Jun 2026 19:11:40 +0500 Subject: [PATCH 1/7] Add standard PR template Co-Authored-By: Claude Opus 4.6 --- .github/pull_request_template.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000..7c4ba90c76502 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Related Ticket + + +## Description + + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Improvement / Enhancement +- [ ] Refactoring +- [ ] Performance improvement +- [ ] Documentation +- [ ] Infrastructure / CI + +## Testing + +- [ ] Tested locally +- [ ] New / updated tests included + +## Screenshots + + +## Checklist +- [ ] No lint or build errors +- [ ] All existing tests pass +- [ ] Documentation updated (if needed) From 8645ab6799052910d8819c25478eff42fbd3194b Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Thu, 9 Jul 2026 17:00:20 +0500 Subject: [PATCH 2/7] Fix Redis crash loop from unhandled socket errors node-redis session and cache clients crash the process when Memorystore drops idle TLS connections. Add error handlers, pingInterval, TCP keepalive, and reconnect strategy to both the session-storage and cache-storage Redis clients. --- .../cache-storage.module-factory.ts | 29 +++++++++++++++---- .../session-storage.module-factory.ts | 17 +++++++++-- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts b/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts index 303cf9cd2182f..fb51cc9e4724c 100644 --- a/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts +++ b/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts @@ -1,13 +1,14 @@ import { type CacheModuleOptions } from '@nestjs/cache-manager'; -import { redisStore } from 'cache-manager-redis-yet'; +import { redisInsStore } from 'cache-manager-redis-yet'; +import { createClient } from 'redis'; import { CacheStorageType } from 'src/engine/core-modules/cache-storage/types/cache-storage-type.enum'; import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -export const cacheStorageModuleFactory = ( +export const cacheStorageModuleFactory = async ( twentyConfigService: TwentyConfigService, -): CacheModuleOptions => { +): Promise => { const cacheStorageType = CacheStorageType.Redis; const cacheStorageTtl = twentyConfigService.get('CACHE_STORAGE_TTL'); const cacheModuleOptions: CacheModuleOptions = { @@ -28,10 +29,28 @@ export const cacheStorageModuleFactory = ( ); } + const redisClient = createClient({ + url: redisUrl, + pingInterval: 30_000, + socket: { + keepAlive: true, + keepAliveInitialDelay: 30_000, + reconnectStrategy: (retries: number) => + Math.min(retries * 200, 5_000), + }, + }); + + redisClient.on('error', (err) => + console.error('Cache Redis client error:', err), + ); + + await redisClient.connect(); + return { ...cacheModuleOptions, - store: redisStore, - url: redisUrl, + store: redisInsStore(redisClient, { + ttl: cacheStorageTtl * 1000, + }), }; } default: diff --git a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts index c8aab18e95272..21e7231900848 100644 --- a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts +++ b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts @@ -57,11 +57,22 @@ export const getSessionStorageOptions = ( const redisClient = createClient({ url: connectionString, + pingInterval: 30_000, + socket: { + keepAlive: true, + keepAliveInitialDelay: 30_000, + reconnectStrategy: (retries: number) => + Math.min(retries * 200, 5_000), + }, }); - redisClient.connect().catch((err) => { - throw new Error(`Redis connection failed: ${err}`); - }); + redisClient.on('error', (err) => + console.error('Session Redis client error:', err), + ); + + redisClient.connect().catch((err) => + console.error('Session Redis initial connect failed:', err), + ); return { ...sessionStorage, From 98a49bc93ccf965f6e684365ec676df9c9f5016a Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Thu, 9 Jul 2026 17:23:51 +0500 Subject: [PATCH 3/7] Fix Prettier formatting on session storage factory --- .../session-storage/session-storage.module-factory.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts index 21e7231900848..36ccf10a317e8 100644 --- a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts +++ b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts @@ -70,9 +70,11 @@ export const getSessionStorageOptions = ( console.error('Session Redis client error:', err), ); - redisClient.connect().catch((err) => - console.error('Session Redis initial connect failed:', err), - ); + redisClient + .connect() + .catch((err) => + console.error('Session Redis initial connect failed:', err), + ); return { ...sessionStorage, From 43106d4447817bcb875a3fe26757f767b438f0ac Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 13 Jul 2026 15:59:29 +0500 Subject: [PATCH 4/7] Fix keepAlive typing and redisInsStore cast for tsc - Use keepAlive: 30_000 (number) instead of keepAlive: true (boolean) since node-redis v4 types socket.keepAlive as number | false - Remove keepAliveInitialDelay which node-redis ignores - Cast redisClient as RedisClientType for redisInsStore compatibility --- .../cache-storage/cache-storage.module-factory.ts | 7 +++---- .../session-storage/session-storage.module-factory.ts | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts b/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts index fb51cc9e4724c..cd068cde28587 100644 --- a/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts +++ b/packages/twenty-server/src/engine/core-modules/cache-storage/cache-storage.module-factory.ts @@ -1,7 +1,7 @@ import { type CacheModuleOptions } from '@nestjs/cache-manager'; import { redisInsStore } from 'cache-manager-redis-yet'; -import { createClient } from 'redis'; +import { createClient, type RedisClientType } from 'redis'; import { CacheStorageType } from 'src/engine/core-modules/cache-storage/types/cache-storage-type.enum'; import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -33,8 +33,7 @@ export const cacheStorageModuleFactory = async ( url: redisUrl, pingInterval: 30_000, socket: { - keepAlive: true, - keepAliveInitialDelay: 30_000, + keepAlive: 30_000, reconnectStrategy: (retries: number) => Math.min(retries * 200, 5_000), }, @@ -48,7 +47,7 @@ export const cacheStorageModuleFactory = async ( return { ...cacheModuleOptions, - store: redisInsStore(redisClient, { + store: redisInsStore(redisClient as RedisClientType, { ttl: cacheStorageTtl * 1000, }), }; diff --git a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts index 36ccf10a317e8..00308f987c38a 100644 --- a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts +++ b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts @@ -59,8 +59,7 @@ export const getSessionStorageOptions = ( url: connectionString, pingInterval: 30_000, socket: { - keepAlive: true, - keepAliveInitialDelay: 30_000, + keepAlive: 30_000, reconnectStrategy: (retries: number) => Math.min(retries * 200, 5_000), }, From c90f4b6931bbe52e7080802adc0c0cd90a060e99 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 13 Jul 2026 19:16:11 +0500 Subject: [PATCH 5/7] Add Layer 2 corporate_id enforcement to auth middleware When SMB_CORPORATE_ID is set and AUTH_TYPE=SSO, decode the X-Auth-Request-Access-Token JWT payload and verify custom:is_corporate="true" and custom:corporate_id matches. Enforced in MiddlewareService (REST + GraphQL), JwtAuthGuard, and SsoProxyLoginController. No-op when env var is unset. --- .../controllers/sso-proxy-login.controller.ts | 23 ++++++- .../twenty-config/config-variables.ts | 9 +++ .../src/engine/guards/jwt-auth.guard.ts | 18 +++++ .../engine/middlewares/middleware.service.ts | 27 ++++++++ .../src/engine/utils/proxy-identity.util.ts | 68 +++++++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts index aea556dd01aaa..47c9413d9266a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts @@ -1,16 +1,18 @@ import { Controller, + ForbiddenException, Get, Headers, HttpStatus, Logger, NotFoundException, + Req, Res, UseFilters, UseGuards, } from '@nestjs/common'; -import { Response } from 'express'; +import { type Request, Response } from 'express'; import ms from 'ms'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; @@ -22,6 +24,10 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { + CorporateIdError, + assertCorporateId, +} from 'src/engine/utils/proxy-identity.util'; const TOKEN_PAIR_COOKIE_NAME = 'tokenPair'; @@ -54,6 +60,7 @@ export class SsoProxyLoginController { @Get('proxy-login') @UseGuards(PublicEndpointGuard, NoPermissionGuard) async proxyLogin( + @Req() req: Request, @Headers('x-auth-request-email') headerEmail: string | string[] | undefined, @Headers('x-auth-request-user') headerUser: string | string[] | undefined, @Res() res: Response, @@ -62,6 +69,20 @@ export class SsoProxyLoginController { throw new NotFoundException(); } + // Layer 2 corporate ID enforcement — reject at login time if the + // access token's corporate_id does not match this deployment. + try { + assertCorporateId(req, this.twentyConfigService); + } catch (error) { + if (error instanceof CorporateIdError) { + this.logger.warn(`SSO proxy-login refused: ${error.message}`); + + throw new ForbiddenException({ error: 'access_denied' }); + } + + throw error; + } + const email = this.resolveEmail(headerEmail, headerUser); if (!email) { 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 460e807862337..cb74b7ca84631 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 @@ -1721,6 +1721,15 @@ export class ConfigVariables { @IsOptional() SMB_NAME = ''; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'Cognito corporate_id for Layer 2 tenant isolation. When set, every authenticated request must carry an X-Auth-Request-Access-Token JWT whose custom:is_corporate="true" and custom:corporate_id matches this value. Empty = skip check (backward compatible).', + type: ConfigVariableType.STRING, + }) + @IsOptional() + SMB_CORPORATE_ID = ''; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ADVANCED_SETTINGS, description: diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index c0530c05a4b2b..c99ef696de6e5 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -12,6 +12,8 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util'; import { + CorporateIdError, + assertCorporateId, clearTokenPairCookie, matchesProxyIdentity, } from 'src/engine/utils/proxy-identity.util'; @@ -73,6 +75,22 @@ export class JwtAuthGuard implements CanActivate { return false; } + // Layer 2 corporate ID enforcement — when SMB_CORPORATE_ID is set + // and AUTH_TYPE=SSO, verify the access token's custom:corporate_id. + if (this.twentyConfigService.get('AUTH_TYPE') === 'SSO') { + try { + assertCorporateId(request, this.twentyConfigService); + } catch (error) { + if (error instanceof CorporateIdError) { + this.logger.warn(`Auth refused: ${error.message}`); + + return false; + } + + throw error; + } + } + const metadataVersion = data.workspace ? await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts index 25fdea7eafc64..530257e329665 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts @@ -24,6 +24,8 @@ import { handleExceptionAndConvertToGraphQLError, } from 'src/engine/utils/global-exception-handler.util'; import { + CorporateIdError, + assertCorporateId, clearTokenPairCookie, matchesProxyIdentity, } from 'src/engine/utils/proxy-identity.util'; @@ -110,6 +112,7 @@ export class MiddlewareService { const data = await this.accessTokenService.validateTokenByRequest(request); this.assertProxyIdentityMatchesUser(request, response, data.user?.email); + this.assertCorporateIdMatches(request); const metadataVersion = data.workspace ? await this.workspaceStorageCacheService.getMetadataVersion( @@ -140,6 +143,7 @@ export class MiddlewareService { const data = await this.accessTokenService.validateTokenByRequest(request); this.assertProxyIdentityMatchesUser(request, response, data.user?.email); + this.assertCorporateIdMatches(request); const metadataVersion = data.workspace ? await this.workspaceStorageCacheService.getMetadataVersion( @@ -187,6 +191,29 @@ export class MiddlewareService { ); } + // Layer 2 corporate ID enforcement. When SMB_CORPORATE_ID is set, + // every authenticated request must carry an access token with matching + // custom:corporate_id. Gated on AUTH_TYPE=SSO so non-SSO deployments + // are unaffected. + private assertCorporateIdMatches(request: Request): void { + if (this.twentyConfigService.get('AUTH_TYPE') !== 'SSO') { + return; + } + + try { + assertCorporateId(request, this.twentyConfigService); + } catch (error) { + if (error instanceof CorporateIdError) { + throw new AuthException( + error.message, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + throw error; + } + } + private hasErrorStatus(error: unknown): error is { status: number } { return isDefined((error as { status: number })?.status); } diff --git a/packages/twenty-server/src/engine/utils/proxy-identity.util.ts b/packages/twenty-server/src/engine/utils/proxy-identity.util.ts index 0d2f7e8bb39a0..374a2d57a9c4a 100644 --- a/packages/twenty-server/src/engine/utils/proxy-identity.util.ts +++ b/packages/twenty-server/src/engine/utils/proxy-identity.util.ts @@ -106,6 +106,74 @@ export const normalizeProxyIdentity = ( return `${trimmed}@${domain}`; }; +/** + * Layer 2 tenant isolation: verify that the caller's mPass access token + * carries `custom:corporate_id` matching this deployment's + * `SMB_CORPORATE_ID`. When the env var is empty the check is skipped + * entirely (backward-compatible default). + * + * The JWT signature is NOT verified here — oauth2-proxy already did that + * before forwarding the request. We only base64-decode the payload. + * + * Throws `AuthException` with FORBIDDEN code on mismatch. + */ +export const assertCorporateId = ( + request: Request, + configService: TwentyConfigService, +): void => { + const expectedCorporateId = configService.get('SMB_CORPORATE_ID'); + + if (!expectedCorporateId) { + return; + } + + const accessToken = request.get('x-auth-request-access-token'); + + if (!accessToken) { + throw new CorporateIdError('Access denied: missing access token'); + } + + try { + const parts = accessToken.split('.'); + + if (parts.length < 2) { + throw new CorporateIdError('Access denied: malformed access token'); + } + + const payloadB64 = parts[1]; + const payload = JSON.parse( + Buffer.from(payloadB64, 'base64url').toString(), + ); + + if (payload['custom:is_corporate'] !== 'true') { + throw new CorporateIdError('Access denied: not a corporate account'); + } + + if (payload['custom:corporate_id'] !== expectedCorporateId) { + throw new CorporateIdError('Access denied: corporate ID mismatch'); + } + } catch (error) { + if (error instanceof CorporateIdError) { + throw error; + } + + throw new CorporateIdError('Access denied: invalid access token'); + } +}; + +/** + * Typed error for corporate ID enforcement so callers can distinguish + * it from other errors and map to a 403 response. + */ +export class CorporateIdError extends Error { + readonly statusCode = 403; + + constructor(message: string) { + super(message); + this.name = 'CorporateIdError'; + } +} + /** * Expire the tokenPair cookie. Defensive: in some test harnesses * `response.clearCookie` may not be wired up, so guard with a typeof From e8d5960306110e29b362f3e2f23fc90443b4461b Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 13 Jul 2026 19:32:22 +0500 Subject: [PATCH 6/7] Skip corporate ID check for non-proxy traffic Requests without x-auth-request-access-token (MCP OAuth, API-key, internal) never went through oauth2-proxy and have no Cognito claims to validate. The middleware/guard paths now silently skip instead of 403-ing. The proxy-login controller retains strict enforcement since it is always behind ForwardAuth. --- .../controllers/sso-proxy-login.controller.ts | 11 +++++++++++ .../src/engine/utils/proxy-identity.util.ts | 17 +++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts index 47c9413d9266a..1f19e106f9ae1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts @@ -71,6 +71,17 @@ export class SsoProxyLoginController { // Layer 2 corporate ID enforcement — reject at login time if the // access token's corporate_id does not match this deployment. + // Unlike middleware/guard paths, proxy-login is always behind + // Traefik ForwardAuth, so a missing header with enforcement on + // is suspicious and must be rejected. + const expectedCorporateId = this.twentyConfigService.get('SMB_CORPORATE_ID'); + + if (expectedCorporateId && !req.get('x-auth-request-access-token')) { + this.logger.warn('SSO proxy-login refused: missing access token header'); + + throw new ForbiddenException({ error: 'access_denied' }); + } + try { assertCorporateId(req, this.twentyConfigService); } catch (error) { diff --git a/packages/twenty-server/src/engine/utils/proxy-identity.util.ts b/packages/twenty-server/src/engine/utils/proxy-identity.util.ts index 374a2d57a9c4a..3afbeb2c23b11 100644 --- a/packages/twenty-server/src/engine/utils/proxy-identity.util.ts +++ b/packages/twenty-server/src/engine/utils/proxy-identity.util.ts @@ -106,17 +106,10 @@ export const normalizeProxyIdentity = ( return `${trimmed}@${domain}`; }; -/** - * Layer 2 tenant isolation: verify that the caller's mPass access token - * carries `custom:corporate_id` matching this deployment's - * `SMB_CORPORATE_ID`. When the env var is empty the check is skipped - * entirely (backward-compatible default). - * - * The JWT signature is NOT verified here — oauth2-proxy already did that - * before forwarding the request. We only base64-decode the payload. - * - * Throws `AuthException` with FORBIDDEN code on mismatch. - */ +// Layer 2 tenant isolation: verify the caller's mPass access token +// carries custom:corporate_id matching SMB_CORPORATE_ID. Skipped when +// the env var is empty OR when the request has no access token header +// (MCP, API-key, and internal traffic bypass oauth2-proxy). export const assertCorporateId = ( request: Request, configService: TwentyConfigService, @@ -130,7 +123,7 @@ export const assertCorporateId = ( const accessToken = request.get('x-auth-request-access-token'); if (!accessToken) { - throw new CorporateIdError('Access denied: missing access token'); + return; } try { From 4588cfda4d259d336e4d5d55a1d74cad864852e0 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Tue, 14 Jul 2026 21:08:39 +0500 Subject: [PATCH 7/7] Preserve returnToPath across SSO proxy-login redirect The MCP OAuth /authorize page was lost during the SSO round-trip because returnToPath lived only in a Jotai atom destroyed by the full-page navigation to /auth/sso/proxy-login. Thread it as a query parameter so the controller can redirect back after setting the JWT cookie. --- .../twenty-front/src/pages/auth/SignInUp.tsx | 18 ++++++++++++++++-- .../controllers/sso-proxy-login.controller.ts | 12 +++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 8c261f298324b..4819abe72579a 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -95,9 +95,23 @@ export const SignInUp = () => { // served separately from the backend. In the unified-image setup // both share an origin and the absolute URL collapses to the same // path; in split-deploy mode it correctly hits the API. - window.location.replace( - `${REACT_APP_SERVER_BASE_URL}/auth/sso/proxy-login`, + // + // Thread returnToPath so the server can redirect back after setting + // the JWT cookie -- without this, navigating to /authorize (MCP + // OAuth consent) would land the user on the dashboard instead of + // returning to the consent screen after SSO. + const returnToPath = + window.location.pathname + window.location.search + window.location.hash; + const proxyUrl = new URL( + '/auth/sso/proxy-login', + REACT_APP_SERVER_BASE_URL || window.location.origin, ); + + if (returnToPath && returnToPath !== '/') { + proxyUrl.searchParams.set('returnToPath', returnToPath); + } + + window.location.replace(proxyUrl.toString()); } }, [isSsoEnabled]); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts index 1f19e106f9ae1..e2745ff19b59b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.ts @@ -133,7 +133,17 @@ export class SsoProxyLoginController { }, }); - return res.redirect(HttpStatus.FOUND, '/'); + // Honour returnToPath so callers like the MCP OAuth /authorize page + // can survive the SSO round-trip. Validate that the value is a + // same-origin path (starts with exactly one `/`) to prevent open + // redirect attacks via protocol-relative URLs (`//evil.com`). + const returnToPath = req.query.returnToPath as string | undefined; + const destination = + returnToPath?.startsWith('/') && !returnToPath.startsWith('//') + ? returnToPath + : '/'; + + return res.redirect(HttpStatus.FOUND, destination); } private resolveEmail(