From d8b3e2b68dbc48127fa8e0d571396a0f8c5f8cfd Mon Sep 17 00:00:00 2001 From: awais786 Date: Sat, 16 May 2026 02:09:48 +0500 Subject: [PATCH 1/5] fix(jwt-auth.guard): refuse and clear tokenPair when proxy identity differs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the stale-session-on-user-switch class of bug in Twenty. In SSO mode, JwtAuthGuard previously validated the Bearer JWT from the tokenPair cookie without ever consulting the upstream identity that oauth2-proxy was asserting. Once Twenty issued its tokenPair cookie via /auth/sso/proxy-login, the cookie was the sole source of identity on every subsequent request. After a portal "Logout all" + login as a different user — which clears the shared _oauth2_proxy cookie and Cognito SSO session but NOT Twenty's tokenPair cookie on its own subdomain — refreshing the Twenty tab kept serving the previous user from the stale Bearer. Implements proxy-auth-middleware Rule 2 ("Identity mismatch SHALL flush the existing session immediately") per the cross-app contract in awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md. Behaviour: - AUTH_TYPE != SSO: bypass entirely (Twenty's native auth path). - No `data.user` on the auth context (API key, application token): bypass (programmatic identity, no upstream user to compare). - SSO + user present + proxy header matches JWT email (normalised on both sides): pass through, no DB writes. - SSO + user present + proxy header absent: pass through. Header absence is NOT a logout signal per the spec — internal calls, OPTIONS preflight, and direct backend hits legitimately arrive without it. - SSO + user present + proxy header asserts a DIFFERENT identity: response.clearCookie('tokenPair', { path: '/' }), return false. The frontend bootstraps without a tokenPair, routes through /auth/sso/proxy-login under the new upstream identity, and gets fresh tokens. normalizeProxyEmail uses indexOf-based email-shape detection rather than regex per the spec's polynomial-backtracking avoidance rule. Bare-username Cognito pools (user_id_claim=cognito:username) get synthesised against DEFAULT_EMAIL_DOMAIN so the comparison matches the shape the SSO proxy-login flow uses to provision the user. Tests (jwt-auth.guard.spec.ts, 11 cases): - SSO match → passes through, no clearCookie - SSO header absent → passes through, no clearCookie - SSO whitespace-only header → passes through (treats as absent) - SSO mismatch → refuse + clearCookie('tokenPair', { path: '/' }) - SSO case- and whitespace-variant match — bidirectional norm guard - SSO bare-username synthesis against DEFAULT_EMAIL_DOMAIN - AUTH_TYPE unset → mismatch check NOT run - API-key auth (no user) → mismatch check NOT run - Application auth (no user) → mismatch check NOT run - clearCookie unavailable (defensive) → still refuses cleanly - validateTokenByRequest throws → returns false Co-Authored-By: Claude Opus 4.7 (1M context) --- .../guards/__tests__/jwt-auth.guard.spec.ts | 388 ++++++++++++++++++ .../src/engine/guards/jwt-auth.guard.ts | 102 ++++- 2 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts diff --git a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts new file mode 100644 index 0000000000000..5dbc6f540771a --- /dev/null +++ b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts @@ -0,0 +1,388 @@ +import { type ExecutionContext } from '@nestjs/common'; + +import { type AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { type WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +type RequestStub = { + get: jest.Mock; + headers?: Record; +}; + +type ResponseStub = { + clearCookie: jest.Mock; +}; + +type ServiceStubs = { + accessTokenService: AccessTokenService; + workspaceCacheStorageService: WorkspaceCacheStorageService; + twentyConfigService: TwentyConfigService; +}; + +const buildExecutionContext = ( + request: RequestStub, + response: ResponseStub, +): ExecutionContext => { + // bind-data-to-request-object reads request.headers['x-locale']. + // Inject an empty headers bag if the test didn't supply one — keeps + // the per-test setup focused on the auth-flow inputs. + if (!request.headers) { + request.headers = {}; + } + + return { + switchToHttp: jest.fn(() => ({ + getRequest: () => request, + getResponse: () => response, + })), + } as unknown as ExecutionContext; +}; + +const buildServices = ( + authContext: Record, + config: Partial> = {}, +): ServiceStubs => ({ + accessTokenService: { + validateTokenByRequest: jest.fn().mockResolvedValue(authContext), + } as unknown as AccessTokenService, + workspaceCacheStorageService: { + getMetadataVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkspaceCacheStorageService, + twentyConfigService: { + get: jest.fn((key: string) => config[key] ?? ''), + } as unknown as TwentyConfigService, +}); + +describe('JwtAuthGuard', () => { + describe('SSO mode — proxy identity reconciliation', () => { + it('passes through when proxy email matches JWT user email', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'alice@example.com' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + workspace: { id: 'w-1' }, + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('passes through when proxy header is absent (header absence is not a logout signal)', async () => { + const request: RequestStub = { + get: jest.fn(() => undefined), + headers: {}, + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('passes through when proxy header is whitespace only', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? ' ' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('refuses and clears tokenPair when proxy email differs from JWT user', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'bob@example.com' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(false); + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); + }); + + it('treats case- and whitespace-variant proxy email as matching the JWT user', async () => { + // Bidirectional normalisation guard. Header value comes through + // normalised to lowercase + trimmed; JWT user email comes through + // `.toLowerCase()`. Dropping normalisation on either side falsely + // registers a mismatch on every case-variant request. + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' + ? ' ALICE@Example.COM ' + : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('synthesises bare username against DEFAULT_EMAIL_DOMAIN before comparing', async () => { + // When the Cognito pool is configured with user_id_claim=cognito:username, + // X-Auth-Request-Email carries a bare username (no @). The guard MUST + // synthesise the same shape Twenty's SSO proxy-login uses to provision + // the user — otherwise the match check spuriously fails on every + // request even though the upstream identity hasn't changed. + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? '1020010000019120' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: '1020010000019120@askii.ai' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO', DEFAULT_EMAIL_DOMAIN: 'askii.ai' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + }); + + describe('Non-SSO bypass paths', () => { + it('does not run the mismatch check when AUTH_TYPE is not SSO', async () => { + // In non-SSO deployments the X-Auth-Request-* headers carry no + // weight. Even with a mismatched header, the JWT is the source of + // truth and the request must pass. + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'bob@example.com' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + // AUTH_TYPE intentionally unset + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('does not run the mismatch check for API-key auth (no user on context)', async () => { + // API keys are programmatic identity. They don't carry a User + // object on the auth context, so the proxy-identity comparison + // is not applicable. + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'bob@example.com' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + apiKey: { id: 'k-1' }, + workspace: { id: 'w-1' }, + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('does not run the mismatch check for application-context auth (no user)', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'bob@example.com' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + application: { id: 'app-1' }, + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + }); + + describe('Failure mode hygiene', () => { + it('refuses cleanly when response.clearCookie is unavailable', async () => { + // Defensive: some test harnesses or pre-flight ExpressRouter slices + // may not have clearCookie wired. The guard must still refuse the + // request rather than throwing. + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'bob@example.com' : undefined, + ), + }; + const response = {} as ResponseStub; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(false); + }); + + it('returns false when validateTokenByRequest throws', async () => { + const request: RequestStub = { get: jest.fn(() => undefined) }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + {}, + { AUTH_TYPE: 'SSO' }, + ); + + (services.accessTokenService.validateTokenByRequest as jest.Mock).mockRejectedValueOnce( + new Error('Invalid token'), + ); + + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(false); + }); + }); +}); 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 a5aaadf0a8fd0..4b783fa72e60a 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -5,12 +5,16 @@ import { Logger, } from '@nestjs/common'; +import { type Request, type Response } from 'express'; import { isDefined } from 'twenty-shared/utils'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +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 { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +const TOKEN_PAIR_COOKIE_NAME = 'tokenPair'; + @Injectable() export class JwtAuthGuard implements CanActivate { private readonly logger = new Logger(JwtAuthGuard.name); @@ -18,14 +22,51 @@ export class JwtAuthGuard implements CanActivate { constructor( private readonly accessTokenService: AccessTokenService, private readonly workspaceStorageCacheService: WorkspaceCacheStorageService, + private readonly twentyConfigService: TwentyConfigService, ) {} async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); try { const data = await this.accessTokenService.validateTokenByRequest(request); + + // SSO stale-session detection (proxy-auth-middleware Rule 2). When + // oauth2-proxy asserts a different identity than what this JWT was + // issued for, the tokenPair cookie that produced this Bearer is stale. + // + // Repro: portal "Logout all" clears the shared _oauth2_proxy cookie + // and Cognito SSO but NOT Twenty's tokenPair cookie on its subdomain. + // A different user then logs in upstream and refreshes the Twenty tab. + // Without this check, validateTokenByRequest happily decodes the + // stale Bearer and we serve the previous user. + // + // On mismatch: clear the tokenPair cookie BEFORE returning false so + // the browser stops sending the stale Bearer. The frontend bootstrap + // then has no tokenPair → routes through /auth/sso/proxy-login → + // new tokens issued for the new upstream user. + // + // Gating: + // - AUTH_TYPE=SSO — non-SSO deployments use Twenty's native auth, + // no upstream identity to compare against + // - data.user — only browser SSO sessions carry a User; API + // keys, application contexts, and tokens without + // a resolved user bypass this check unchanged + if ( + this.twentyConfigService.get('AUTH_TYPE') === 'SSO' && + isDefined(data.user?.email) && + !this.matchesProxyIdentity(request, data.user.email) + ) { + this.clearTokenPairCookie(response); + this.logger.warn( + `Auth refused: proxy identity differs from JWT user; tokenPair cleared`, + ); + + return false; + } + const metadataVersion = data.workspace ? await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, @@ -56,4 +97,63 @@ export class JwtAuthGuard implements CanActivate { return false; } } + + /** + * Compare the proxy-asserted email against the JWT user's email with + * bidirectional normalisation. Returns true on match OR when the proxy + * header is absent (per proxy-auth-middleware spec: header absence is + * NOT a logout signal — internal calls, OPTIONS preflight, and direct + * backend hits legitimately arrive without it). + */ + private matchesProxyIdentity(request: Request, jwtEmail: string): boolean { + const headerRaw = request.get('x-auth-request-email'); + + if (!headerRaw || headerRaw.trim() === '') { + return true; + } + + return this.normalizeProxyEmail(headerRaw) === jwtEmail.toLowerCase(); + } + + /** + * Normalise a raw `x-auth-request-email` header value into the canonical + * email used for user lookup. + * + * - Lowercased and whitespace-trimmed. + * - If it isn't email-shaped (e.g. oauth2-proxy is forwarding a bare + * Cognito username via user_id_claim=cognito:username), synthesise + * `@${DEFAULT_EMAIL_DOMAIN}` so the resulting key matches the + * one Twenty's SSO proxy-login flow uses to provision the user. + * + * The email-shape check uses indexOf rather than a regex to avoid + * polynomial-backtracking complexity on adversarial input — per + * openspec proxy-auth-middleware §"email-shape detection on header + * values SHALL avoid polynomial-backtracking regex". + */ + private normalizeProxyEmail(raw: string): string { + const trimmed = raw.toLowerCase().trim(); + const atIdx = trimmed.indexOf('@'); + const dotIdx = trimmed.indexOf('.', atIdx + 1); + const isEmailShaped = atIdx > 0 && dotIdx > atIdx + 1; + + if (isEmailShaped) { + return trimmed; + } + + const domain = this.twentyConfigService.get('DEFAULT_EMAIL_DOMAIN'); + const localPart = trimmed.split('@')[0]; + + return `${localPart}@${domain}`; + } + + /** + * Expire the tokenPair cookie. Defensive: in some test harnesses + * `response.clearCookie` may not be wired up, so guard with a typeof + * check before calling. + */ + private clearTokenPairCookie(response: Response): void { + if (typeof response?.clearCookie === 'function') { + response.clearCookie(TOKEN_PAIR_COOKIE_NAME, { path: '/' }); + } + } } From 4a3acb157d3f9b1247d693d475e45a19c9dbe5df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 21:17:38 +0000 Subject: [PATCH 2/5] fix(jwt-auth.guard): honor proxy user header fallback Agent-Logs-Url: https://github.com/Pressingly/twenty/sessions/1a2157d8-8957-4e36-bd85-54886713ef06 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- .../guards/__tests__/jwt-auth.guard.spec.ts | 105 ++++++++++++++++-- .../src/engine/guards/jwt-auth.guard.ts | 37 ++++-- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts index 5dbc6f540771a..1cc793ce8a5a7 100644 --- a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts +++ b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts @@ -140,6 +140,34 @@ describe('JwtAuthGuard', () => { expect(response.clearCookie).not.toHaveBeenCalled(); }); + it('falls back to X-Auth-Request-User when X-Auth-Request-Email is absent', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-user' ? 'alice@example.com' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + it('refuses and clears tokenPair when proxy email differs from JWT user', async () => { const request: RequestStub = { get: jest.fn((header: string) => @@ -204,6 +232,44 @@ describe('JwtAuthGuard', () => { expect(response.clearCookie).not.toHaveBeenCalled(); }); + it('prefers X-Auth-Request-Email over X-Auth-Request-User when both are present', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => { + if (header === 'x-auth-request-email') { + return 'bob@example.com'; + } + + if (header === 'x-auth-request-user') { + return 'alice@example.com'; + } + + return undefined; + }), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(false); + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); + }); + it('synthesises bare username against DEFAULT_EMAIL_DOMAIN before comparing', async () => { // When the Cognito pool is configured with user_id_claim=cognito:username, // X-Auth-Request-Email carries a bare username (no @). The guard MUST @@ -236,6 +302,34 @@ describe('JwtAuthGuard', () => { expect(result).toBe(true); expect(response.clearCookie).not.toHaveBeenCalled(); }); + + it('synthesises bare X-Auth-Request-User against DEFAULT_EMAIL_DOMAIN before comparing', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-user' ? '1020010000019120' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: '1020010000019120@askii.ai' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO', DEFAULT_EMAIL_DOMAIN: 'askii.ai' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); }); describe('Non-SSO bypass paths', () => { @@ -363,14 +457,11 @@ describe('JwtAuthGuard', () => { it('returns false when validateTokenByRequest throws', async () => { const request: RequestStub = { get: jest.fn(() => undefined) }; const response: ResponseStub = { clearCookie: jest.fn() }; - const services = buildServices( - {}, - { AUTH_TYPE: 'SSO' }, - ); + const services = buildServices({}, { AUTH_TYPE: 'SSO' }); - (services.accessTokenService.validateTokenByRequest as jest.Mock).mockRejectedValueOnce( - new Error('Invalid token'), - ); + ( + services.accessTokenService.validateTokenByRequest as jest.Mock + ).mockRejectedValueOnce(new Error('Invalid token')); const guard = new JwtAuthGuard( services.accessTokenService, 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 4b783fa72e60a..d5111617c1895 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -99,25 +99,44 @@ export class JwtAuthGuard implements CanActivate { } /** - * Compare the proxy-asserted email against the JWT user's email with - * bidirectional normalisation. Returns true on match OR when the proxy - * header is absent (per proxy-auth-middleware spec: header absence is - * NOT a logout signal — internal calls, OPTIONS preflight, and direct - * backend hits legitimately arrive without it). + * Compare the proxy-asserted identity against the JWT user's email with + * bidirectional normalisation. Mirrors SSO proxy-login resolution: + * prefer `x-auth-request-email`, then fall back to `x-auth-request-user`. + * + * Returns true on match OR when both proxy headers are absent (per + * proxy-auth-middleware spec: header absence is NOT a logout signal — + * internal calls, OPTIONS preflight, and direct backend hits legitimately + * arrive without it). */ private matchesProxyIdentity(request: Request, jwtEmail: string): boolean { - const headerRaw = request.get('x-auth-request-email'); + const headerRaw = this.resolveProxyIdentity(request); - if (!headerRaw || headerRaw.trim() === '') { + if (!headerRaw) { return true; } return this.normalizeProxyEmail(headerRaw) === jwtEmail.toLowerCase(); } + private resolveProxyIdentity(request: Request): string | null { + const proxyEmail = request.get('x-auth-request-email')?.trim(); + + if (proxyEmail) { + return proxyEmail; + } + + const proxyUser = request.get('x-auth-request-user')?.trim(); + + if (proxyUser) { + return proxyUser; + } + + return null; + } + /** - * Normalise a raw `x-auth-request-email` header value into the canonical - * email used for user lookup. + * Normalise a raw proxy identity header value into the canonical email + * used for user lookup. * * - Lowercased and whitespace-trimmed. * - If it isn't email-shaped (e.g. oauth2-proxy is forwarding a bare From 095250b3f74a926d861c3eeb724c06f8df48ab05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 13:27:45 +0000 Subject: [PATCH 3/5] fix(jwt-auth.guard): match proxy-login email resolution Agent-Logs-Url: https://github.com/Pressingly/twenty/sessions/0ce3b648-9f2d-4277-879c-7cd33477f39a Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- .../guards/__tests__/jwt-auth.guard.spec.ts | 56 +++++++++++++++++++ .../src/engine/guards/jwt-auth.guard.ts | 37 +++++++----- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts index 1cc793ce8a5a7..45afbbd9964f2 100644 --- a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts +++ b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts @@ -232,6 +232,34 @@ describe('JwtAuthGuard', () => { expect(response.clearCookie).not.toHaveBeenCalled(); }); + it('treats proxy identities containing @ as email-shaped even without a dot suffix', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'Alice@corp' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@corp' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO', DEFAULT_EMAIL_DOMAIN: 'askii.ai' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + it('prefers X-Auth-Request-Email over X-Auth-Request-User when both are present', async () => { const request: RequestStub = { get: jest.fn((header: string) => { @@ -330,6 +358,34 @@ describe('JwtAuthGuard', () => { expect(result).toBe(true); expect(response.clearCookie).not.toHaveBeenCalled(); }); + + it('passes through when bare proxy identity arrives without DEFAULT_EMAIL_DOMAIN configured', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-user' ? 'bare_username' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'alice@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(true); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); }); describe('Non-SSO bypass paths', () => { 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 d5111617c1895..fe636b2fc691a 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -115,7 +115,13 @@ export class JwtAuthGuard implements CanActivate { return true; } - return this.normalizeProxyEmail(headerRaw) === jwtEmail.toLowerCase(); + const normalizedProxyIdentity = this.normalizeProxyIdentity(headerRaw); + + if (!normalizedProxyIdentity) { + return true; + } + + return normalizedProxyIdentity === jwtEmail.toLowerCase(); } private resolveProxyIdentity(request: Request): string | null { @@ -136,33 +142,34 @@ export class JwtAuthGuard implements CanActivate { /** * Normalise a raw proxy identity header value into the canonical email - * used for user lookup. + * used for user lookup, matching SSO proxy-login semantics. * * - Lowercased and whitespace-trimmed. - * - If it isn't email-shaped (e.g. oauth2-proxy is forwarding a bare + * - Any value containing `@` is treated as an email as-is, matching + * SsoProxyLoginController.resolveEmail(). + * - If it doesn't contain `@` (e.g. oauth2-proxy is forwarding a bare * Cognito username via user_id_claim=cognito:username), synthesise * `@${DEFAULT_EMAIL_DOMAIN}` so the resulting key matches the * one Twenty's SSO proxy-login flow uses to provision the user. - * - * The email-shape check uses indexOf rather than a regex to avoid - * polynomial-backtracking complexity on adversarial input — per - * openspec proxy-auth-middleware §"email-shape detection on header - * values SHALL avoid polynomial-backtracking regex". */ - private normalizeProxyEmail(raw: string): string { + private normalizeProxyIdentity(raw: string): string | null { const trimmed = raw.toLowerCase().trim(); - const atIdx = trimmed.indexOf('@'); - const dotIdx = trimmed.indexOf('.', atIdx + 1); - const isEmailShaped = atIdx > 0 && dotIdx > atIdx + 1; - if (isEmailShaped) { + if (trimmed.includes('@')) { return trimmed; } const domain = this.twentyConfigService.get('DEFAULT_EMAIL_DOMAIN'); - const localPart = trimmed.split('@')[0]; - return `${localPart}@${domain}`; + if (!domain) { + this.logger.warn( + 'Proxy identity contains a bare username but DEFAULT_EMAIL_DOMAIN is not configured.', + ); + + return null; + } + + return `${trimmed}@${domain}`; } /** From 4ac21b60b8b042407f030aa1c0dc73f9a506ff18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 13:28:55 +0000 Subject: [PATCH 4/5] fix(jwt-auth.guard): fail closed on unresolved proxy identity Agent-Logs-Url: https://github.com/Pressingly/twenty/sessions/0ce3b648-9f2d-4277-879c-7cd33477f39a Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- .../guards/__tests__/jwt-auth.guard.spec.ts | 38 +++++++++++++++++-- .../src/engine/guards/jwt-auth.guard.ts | 5 ++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts index 45afbbd9964f2..3d815083fd054 100644 --- a/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts +++ b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts @@ -260,6 +260,36 @@ describe('JwtAuthGuard', () => { expect(response.clearCookie).not.toHaveBeenCalled(); }); + it('refuses mismatched proxy identities containing @ even without a dot suffix', async () => { + const request: RequestStub = { + get: jest.fn((header: string) => + header === 'x-auth-request-email' ? 'Alice@corp' : undefined, + ), + }; + const response: ResponseStub = { clearCookie: jest.fn() }; + const services = buildServices( + { + user: { email: 'bob@example.com' }, + userWorkspaceId: 'uw-1', + }, + { AUTH_TYPE: 'SSO', DEFAULT_EMAIL_DOMAIN: 'askii.ai' }, + ); + const guard = new JwtAuthGuard( + services.accessTokenService, + services.workspaceCacheStorageService, + services.twentyConfigService, + ); + + const result = await guard.canActivate( + buildExecutionContext(request, response), + ); + + expect(result).toBe(false); + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); + }); + it('prefers X-Auth-Request-Email over X-Auth-Request-User when both are present', async () => { const request: RequestStub = { get: jest.fn((header: string) => { @@ -359,7 +389,7 @@ describe('JwtAuthGuard', () => { expect(response.clearCookie).not.toHaveBeenCalled(); }); - it('passes through when bare proxy identity arrives without DEFAULT_EMAIL_DOMAIN configured', async () => { + it('refuses and clears tokenPair when bare proxy identity arrives without DEFAULT_EMAIL_DOMAIN configured', async () => { const request: RequestStub = { get: jest.fn((header: string) => header === 'x-auth-request-user' ? 'bare_username' : undefined, @@ -383,8 +413,10 @@ describe('JwtAuthGuard', () => { buildExecutionContext(request, response), ); - expect(result).toBe(true); - expect(response.clearCookie).not.toHaveBeenCalled(); + expect(result).toBe(false); + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); }); }); 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 fe636b2fc691a..fe0fa761a0b21 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -118,7 +118,7 @@ export class JwtAuthGuard implements CanActivate { const normalizedProxyIdentity = this.normalizeProxyIdentity(headerRaw); if (!normalizedProxyIdentity) { - return true; + return false; } return normalizedProxyIdentity === jwtEmail.toLowerCase(); @@ -151,6 +151,9 @@ export class JwtAuthGuard implements CanActivate { * Cognito username via user_id_claim=cognito:username), synthesise * `@${DEFAULT_EMAIL_DOMAIN}` so the resulting key matches the * one Twenty's SSO proxy-login flow uses to provision the user. + * - If bare usernames are present but DEFAULT_EMAIL_DOMAIN is missing, + * return null so the caller can fail closed rather than comparing + * against an invalid synthesized identity. */ private normalizeProxyIdentity(raw: string): string | null { const trimmed = raw.toLowerCase().trim(); From e709fa6886ecf3ba876e306d4e67a635ab3b2841 Mon Sep 17 00:00:00 2001 From: awais786 Date: Sun, 17 May 2026 18:34:20 +0500 Subject: [PATCH 5/5] fix(auth): extend SSO mismatch check to GraphQL + REST hydration paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JwtAuthGuard only runs on routes decorated with the guard. Twenty's REST and GraphQL middleware also validate the JWT and hydrate the request via MiddlewareService — without the mismatch check there, a stale cookie request could still be served as the previous user through those paths. Extract matchesProxyIdentity / resolveProxyIdentity / normalizeProxyIdentity / clearTokenPairCookie into a shared proxy-identity.util.ts. Invoke from both hydrateGraphqlRequest and hydrateRestRequest in MiddlewareService. JwtAuthGuard now consumes the same utility so logic can't drift across call sites. Bidirectional normalisation (`.toLowerCase().trim()` on BOTH the proxy header and the JWT user email) is required by openspec/specs/proxy-auth-middleware/spec.md "Match is case- and whitespace-insensitive". Asymmetric normalisation would falsely trigger mismatch on whitespace-padded legacy DB rows. Added a specific regression-guard test covering both case- and whitespace- variants on either side. Gating preserved: AUTH_TYPE !== 'SSO' skips entirely (non-SSO deploys untouched); jwtEmail absent skips (API keys / app tokens have no User); proxy headers absent passes through (not a logout signal per spec). Tests: 12 new cases covering match, mismatch, header absence, AUTH_TYPE=PASSWORD, API key, no-token short-circuit, user-header fallback, fail-closed on bare username + missing DEFAULT_EMAIL_DOMAIN, missing response object, and the case/ whitespace regression guards. --- .../src/engine/guards/jwt-auth.guard.ts | 102 +------ .../__tests__/middleware.service.spec.ts | 284 ++++++++++++++++++ ...l-hydrate-request-from-token.middleware.ts | 2 +- .../engine/middlewares/middleware.module.ts | 2 + .../engine/middlewares/middleware.service.ts | 58 +++- .../middlewares/rest-core.middleware.ts | 2 +- .../src/engine/utils/proxy-identity.util.ts | 118 ++++++++ 7 files changed, 471 insertions(+), 97 deletions(-) create mode 100644 packages/twenty-server/src/engine/middlewares/__tests__/middleware.service.spec.ts create mode 100644 packages/twenty-server/src/engine/utils/proxy-identity.util.ts 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 fe0fa761a0b21..c0530c05a4b2b 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -11,10 +11,12 @@ import { isDefined } from 'twenty-shared/utils'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; 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 { + clearTokenPairCookie, + matchesProxyIdentity, +} from 'src/engine/utils/proxy-identity.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; -const TOKEN_PAIR_COOKIE_NAME = 'tokenPair'; - @Injectable() export class JwtAuthGuard implements CanActivate { private readonly logger = new Logger(JwtAuthGuard.name); @@ -57,9 +59,13 @@ export class JwtAuthGuard implements CanActivate { if ( this.twentyConfigService.get('AUTH_TYPE') === 'SSO' && isDefined(data.user?.email) && - !this.matchesProxyIdentity(request, data.user.email) + !matchesProxyIdentity( + request, + data.user.email, + this.twentyConfigService, + ) ) { - this.clearTokenPairCookie(response); + clearTokenPairCookie(response); this.logger.warn( `Auth refused: proxy identity differs from JWT user; tokenPair cleared`, ); @@ -97,92 +103,4 @@ export class JwtAuthGuard implements CanActivate { return false; } } - - /** - * Compare the proxy-asserted identity against the JWT user's email with - * bidirectional normalisation. Mirrors SSO proxy-login resolution: - * prefer `x-auth-request-email`, then fall back to `x-auth-request-user`. - * - * Returns true on match OR when both proxy headers are absent (per - * proxy-auth-middleware spec: header absence is NOT a logout signal — - * internal calls, OPTIONS preflight, and direct backend hits legitimately - * arrive without it). - */ - private matchesProxyIdentity(request: Request, jwtEmail: string): boolean { - const headerRaw = this.resolveProxyIdentity(request); - - if (!headerRaw) { - return true; - } - - const normalizedProxyIdentity = this.normalizeProxyIdentity(headerRaw); - - if (!normalizedProxyIdentity) { - return false; - } - - return normalizedProxyIdentity === jwtEmail.toLowerCase(); - } - - private resolveProxyIdentity(request: Request): string | null { - const proxyEmail = request.get('x-auth-request-email')?.trim(); - - if (proxyEmail) { - return proxyEmail; - } - - const proxyUser = request.get('x-auth-request-user')?.trim(); - - if (proxyUser) { - return proxyUser; - } - - return null; - } - - /** - * Normalise a raw proxy identity header value into the canonical email - * used for user lookup, matching SSO proxy-login semantics. - * - * - Lowercased and whitespace-trimmed. - * - Any value containing `@` is treated as an email as-is, matching - * SsoProxyLoginController.resolveEmail(). - * - If it doesn't contain `@` (e.g. oauth2-proxy is forwarding a bare - * Cognito username via user_id_claim=cognito:username), synthesise - * `@${DEFAULT_EMAIL_DOMAIN}` so the resulting key matches the - * one Twenty's SSO proxy-login flow uses to provision the user. - * - If bare usernames are present but DEFAULT_EMAIL_DOMAIN is missing, - * return null so the caller can fail closed rather than comparing - * against an invalid synthesized identity. - */ - private normalizeProxyIdentity(raw: string): string | null { - const trimmed = raw.toLowerCase().trim(); - - if (trimmed.includes('@')) { - return trimmed; - } - - const domain = this.twentyConfigService.get('DEFAULT_EMAIL_DOMAIN'); - - if (!domain) { - this.logger.warn( - 'Proxy identity contains a bare username but DEFAULT_EMAIL_DOMAIN is not configured.', - ); - - return null; - } - - return `${trimmed}@${domain}`; - } - - /** - * Expire the tokenPair cookie. Defensive: in some test harnesses - * `response.clearCookie` may not be wired up, so guard with a typeof - * check before calling. - */ - private clearTokenPairCookie(response: Response): void { - if (typeof response?.clearCookie === 'function') { - response.clearCookie(TOKEN_PAIR_COOKIE_NAME, { path: '/' }); - } - } } diff --git a/packages/twenty-server/src/engine/middlewares/__tests__/middleware.service.spec.ts b/packages/twenty-server/src/engine/middlewares/__tests__/middleware.service.spec.ts new file mode 100644 index 0000000000000..bd831e1c30037 --- /dev/null +++ b/packages/twenty-server/src/engine/middlewares/__tests__/middleware.service.spec.ts @@ -0,0 +1,284 @@ +import { type AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { type ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { type JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { MiddlewareService } from 'src/engine/middlewares/middleware.service'; +import { type WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +type RequestStub = { + get: jest.Mock; + headers?: Record; +}; + +type ResponseStub = { + clearCookie: jest.Mock; +}; + +const buildRequest = ( + proxyHeaders: { email?: string; user?: string } = {}, +): RequestStub => ({ + get: jest.fn((header: string) => { + if (header === 'x-auth-request-email') return proxyHeaders.email; + if (header === 'x-auth-request-user') return proxyHeaders.user; + + return undefined; + }), + headers: {}, +}); + +const buildResponse = (): ResponseStub => ({ clearCookie: jest.fn() }); + +type BuildOpts = { + authContext?: Record; + config?: Partial>; + tokenPresent?: boolean; +}; + +const buildService = ({ + authContext = { + user: { email: 'alice@example.com' }, + workspace: { id: 'w-1', databaseSchema: 'workspace_1' }, + userWorkspaceId: 'uw-1', + }, + config = { AUTH_TYPE: 'SSO' }, + tokenPresent = true, +}: BuildOpts = {}) => { + const accessTokenService = { + validateTokenByRequest: jest.fn().mockResolvedValue(authContext), + } as unknown as AccessTokenService; + + const workspaceCacheStorageService = { + getMetadataVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as WorkspaceCacheStorageService; + + const flatEntityMapsCacheService = + {} as WorkspaceManyOrAllFlatEntityMapsCacheService; + + const exceptionHandlerService = {} as ExceptionHandlerService; + + const jwtWrapperService = { + extractJwtFromRequest: jest.fn(() => () => (tokenPresent ? 'token' : null)), + } as unknown as JwtWrapperService; + + const twentyConfigService = { + get: jest.fn((key: string) => config[key] ?? ''), + } as unknown as TwentyConfigService; + + return { + service: new MiddlewareService( + accessTokenService, + workspaceCacheStorageService, + flatEntityMapsCacheService, + exceptionHandlerService, + jwtWrapperService, + twentyConfigService, + ), + accessTokenService, + }; +}; + +describe('MiddlewareService — SSO proxy identity reconciliation', () => { + describe('hydrateGraphqlRequest', () => { + it('passes through when proxy email matches JWT user email', async () => { + const { service } = buildService(); + const request = buildRequest({ email: 'alice@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('throws AuthException and clears tokenPair when proxy email differs', async () => { + const { service } = buildService(); + const request = buildRequest({ email: 'mallory@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).rejects.toMatchObject({ + code: 'UNAUTHENTICATED', + }); + + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); + }); + + it('passes through when proxy headers are absent (not a logout signal)', async () => { + const { service } = buildService(); + const request = buildRequest(); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('skips identity check when AUTH_TYPE !== SSO', async () => { + const { service } = buildService({ config: { AUTH_TYPE: 'PASSWORD' } }); + const request = buildRequest({ email: 'mallory@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('skips identity check when JWT carries no user (API key / application context)', async () => { + const { service } = buildService({ + authContext: { + apiKey: { id: 'ak-1' }, + workspace: { id: 'w-1', databaseSchema: 'workspace_1' }, + }, + }); + const request = buildRequest({ email: 'mallory@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('short-circuits when no token is present (sets locale, never validates)', async () => { + const { service, accessTokenService } = buildService({ + tokenPresent: false, + }); + const request = buildRequest({ email: 'mallory@example.com' }); + const response = buildResponse(); + + await service.hydrateGraphqlRequest(request as never, response as never); + + expect(accessTokenService.validateTokenByRequest).not.toHaveBeenCalled(); + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('falls back to x-auth-request-user when x-auth-request-email is absent', async () => { + const { service } = buildService({ + config: { AUTH_TYPE: 'SSO', DEFAULT_EMAIL_DOMAIN: 'example.com' }, + }); + const request = buildRequest({ user: 'alice' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('fails closed when proxy sends bare username and DEFAULT_EMAIL_DOMAIN is missing', async () => { + const { service } = buildService(); + const request = buildRequest({ user: 'alice' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).rejects.toMatchObject({ code: 'UNAUTHENTICATED' }); + + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); + }); + }); + + describe('hydrateRestRequest', () => { + it('passes through when proxy email matches JWT user email', async () => { + const { service } = buildService(); + const request = buildRequest({ email: 'alice@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateRestRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('throws AuthException and clears tokenPair when proxy email differs', async () => { + const { service } = buildService(); + const request = buildRequest({ email: 'mallory@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateRestRequest(request as never, response as never), + ).rejects.toMatchObject({ code: 'UNAUTHENTICATED' }); + + expect(response.clearCookie).toHaveBeenCalledWith('tokenPair', { + path: '/', + }); + }); + + it('skips identity check when AUTH_TYPE !== SSO', async () => { + const { service } = buildService({ config: { AUTH_TYPE: 'PASSWORD' } }); + const request = buildRequest({ email: 'mallory@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateRestRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('tolerates missing response object (defensive — older callers)', async () => { + const { service } = buildService(); + const request = buildRequest({ email: 'mallory@example.com' }); + + await expect( + service.hydrateRestRequest(request as never, undefined as never), + ).rejects.toMatchObject({ code: 'UNAUTHENTICATED' }); + }); + }); + + describe('case- and whitespace-variant proxy emails match the JWT user', () => { + // Regression guard for the bidirectional-normalisation requirement in + // openspec/specs/proxy-auth-middleware/spec.md "Match is case- and + // whitespace-insensitive". oauth2-proxy may forward a header value + // with different case or surrounding whitespace than the canonical + // lowercase user.email stored at provisioning time. If either side + // drops `.toLowerCase().trim()`, every case-variant header silently + // kicks the cookie-authed request back to ForwardAuth on every + // request. Tests both directions so neither side can regress. + + it('matches when proxy header is uppercased + whitespace-padded', async () => { + const { service } = buildService(); + const request = buildRequest({ email: ' ALICE@EXAMPLE.COM ' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + + it('matches when JWT email has padding the header does not', async () => { + // Legacy DB row with trailing whitespace; canonical proxy header. + const { service } = buildService({ + authContext: { + user: { email: ' alice@example.com ' }, + workspace: { id: 'w-1', databaseSchema: 'workspace_1' }, + userWorkspaceId: 'uw-1', + }, + }); + const request = buildRequest({ email: 'alice@example.com' }); + const response = buildResponse(); + + await expect( + service.hydrateGraphqlRequest(request as never, response as never), + ).resolves.toBeUndefined(); + + expect(response.clearCookie).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index a06cc3de91975..58ac59394110b 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -12,7 +12,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware async use(req: Request, res: Response, next: NextFunction) { try { - await this.middlewareService.hydrateGraphqlRequest(req); + await this.middlewareService.hydrateGraphqlRequest(req, res); } catch (error) { this.middlewareService.writeGraphqlResponseOnExceptionCaught(res, error); diff --git a/packages/twenty-server/src/engine/middlewares/middleware.module.ts b/packages/twenty-server/src/engine/middlewares/middleware.module.ts index 3d8adaa973167..768da714cb30e 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.module.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { MiddlewareService } from 'src/engine/middlewares/middleware.service'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -12,6 +13,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ WorkspaceManyOrAllFlatEntityMapsCacheModule, TokenModule, JwtModule, + TwentyConfigModule, ], providers: [MiddlewareService], exports: [MiddlewareService], diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts index 8e365ccfefd54..25fdea7eafc64 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts @@ -5,13 +5,17 @@ import { type Request, type Response } from 'express'; import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; -import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { getAuthExceptionRestStatus } from 'src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant'; import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util'; @@ -19,6 +23,10 @@ import { handleException, handleExceptionAndConvertToGraphQLError, } from 'src/engine/utils/global-exception-handler.util'; +import { + clearTokenPairCookie, + matchesProxyIdentity, +} from 'src/engine/utils/proxy-identity.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { type CustomException } from 'src/utils/custom-exception'; @@ -30,6 +38,7 @@ export class MiddlewareService { private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly jwtWrapperService: JwtWrapperService, + private readonly twentyConfigService: TwentyConfigService, ) {} public isTokenPresent(request: Request): boolean { @@ -97,8 +106,11 @@ export class MiddlewareService { res.end(); } - public async hydrateRestRequest(request: Request) { + public async hydrateRestRequest(request: Request, response?: Response) { const data = await this.accessTokenService.validateTokenByRequest(request); + + this.assertProxyIdentityMatchesUser(request, response, data.user?.email); + const metadataVersion = data.workspace ? await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, @@ -116,7 +128,7 @@ export class MiddlewareService { bindDataToRequestObject(data, request, metadataVersion); } - public async hydrateGraphqlRequest(request: Request) { + public async hydrateGraphqlRequest(request: Request, response?: Response) { if (!this.isTokenPresent(request)) { request.locale = (request.headers['x-locale'] as keyof typeof APP_LOCALES) ?? @@ -126,6 +138,9 @@ export class MiddlewareService { } const data = await this.accessTokenService.validateTokenByRequest(request); + + this.assertProxyIdentityMatchesUser(request, response, data.user?.email); + const metadataVersion = data.workspace ? await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, @@ -135,6 +150,43 @@ export class MiddlewareService { bindDataToRequestObject(data, request, metadataVersion); } + /** + * SSO stale-session detection for the GraphQL and REST data API paths. + * Mirrors the JwtAuthGuard check applied to the REST controller path. When + * oauth2-proxy asserts a different identity than what the JWT was issued + * for, clear the tokenPair cookie and refuse — the browser then has no + * tokenPair on the next request, so the SPA bootstrap hits + * /auth/sso/proxy-login and gets a fresh tokenPair for the new identity. + * + * Gating: + * - AUTH_TYPE=SSO — non-SSO deployments use Twenty's native auth + * - jwtEmail isDefined — only browser SSO sessions carry a User + */ + private assertProxyIdentityMatchesUser( + request: Request, + response: Response | undefined, + jwtEmail: string | undefined, + ): void { + if (this.twentyConfigService.get('AUTH_TYPE') !== 'SSO') { + return; + } + + if (!isDefined(jwtEmail)) { + return; + } + + if (matchesProxyIdentity(request, jwtEmail, this.twentyConfigService)) { + return; + } + + clearTokenPairCookie(response); + + throw new AuthException( + 'Proxy identity differs from JWT user; tokenPair cleared', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + private hasErrorStatus(error: unknown): error is { status: number } { return isDefined((error as { status: number })?.status); } diff --git a/packages/twenty-server/src/engine/middlewares/rest-core.middleware.ts b/packages/twenty-server/src/engine/middlewares/rest-core.middleware.ts index 7ca9b79b946f8..2871608e81c0f 100644 --- a/packages/twenty-server/src/engine/middlewares/rest-core.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/rest-core.middleware.ts @@ -10,7 +10,7 @@ export class RestCoreMiddleware implements NestMiddleware { async use(req: Request, res: Response, next: NextFunction) { try { - await this.middlewareService.hydrateRestRequest(req); + await this.middlewareService.hydrateRestRequest(req, res); } catch (error) { this.middlewareService.writeRestResponseOnExceptionCaught(res, 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 new file mode 100644 index 0000000000000..0d2f7e8bb39a0 --- /dev/null +++ b/packages/twenty-server/src/engine/utils/proxy-identity.util.ts @@ -0,0 +1,118 @@ +import { Logger } from '@nestjs/common'; + +import { type Request, type Response } from 'express'; + +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +export const TOKEN_PAIR_COOKIE_NAME = 'tokenPair'; + +const logger = new Logger('ProxyIdentity'); + +/** + * Compare the proxy-asserted identity against an authenticated user's email + * with bidirectional normalisation. Mirrors SSO proxy-login resolution: + * prefer `x-auth-request-email`, then fall back to `x-auth-request-user`. + * + * Returns true on match OR when both proxy headers are absent (per + * proxy-auth-middleware spec: header absence is NOT a logout signal — + * internal calls, OPTIONS preflight, and direct backend hits legitimately + * arrive without it). + */ +export const matchesProxyIdentity = ( + request: Request, + jwtEmail: string, + configService: TwentyConfigService, +): boolean => { + const headerRaw = resolveProxyIdentity(request); + + if (!headerRaw) { + return true; + } + + const normalizedProxyIdentity = normalizeProxyIdentity( + headerRaw, + configService, + ); + + if (!normalizedProxyIdentity) { + return false; + } + + // Bidirectional normalisation: both sides MUST be lowercased AND + // whitespace-trimmed before comparison. Asymmetric normalisation + // (e.g. trimming the header but not the JWT side) is observationally + // equivalent to no normalisation — any whitespace-padded value in + // the User row (legacy data, fixtures, non-SSO provisioning paths) + // would spuriously trigger mismatch and clear the cookie on every + // request. Mirrors Plane's `_normalise_email` + Outline's + // `normalizeProxyEmail` + `(user.email ?? "").toLowerCase().trim()` + // patterns; required by + // openspec/specs/proxy-auth-middleware/spec.md + // "Match is case- and whitespace-insensitive". + return normalizedProxyIdentity === jwtEmail.toLowerCase().trim(); +}; + +export const resolveProxyIdentity = (request: Request): string | null => { + const proxyEmail = request.get('x-auth-request-email')?.trim(); + + if (proxyEmail) { + return proxyEmail; + } + + const proxyUser = request.get('x-auth-request-user')?.trim(); + + if (proxyUser) { + return proxyUser; + } + + return null; +}; + +/** + * Normalise a raw proxy identity header value into the canonical email + * used for user lookup, matching SSO proxy-login semantics. + * + * - Lowercased and whitespace-trimmed. + * - Any value containing `@` is treated as an email as-is, matching + * SsoProxyLoginController.resolveEmail(). + * - If it doesn't contain `@` (e.g. oauth2-proxy is forwarding a bare + * Cognito username via user_id_claim=cognito:username), synthesise + * `@${DEFAULT_EMAIL_DOMAIN}` so the resulting key matches the + * one Twenty's SSO proxy-login flow uses to provision the user. + * - If bare usernames are present but DEFAULT_EMAIL_DOMAIN is missing, + * return null so the caller can fail closed rather than comparing + * against an invalid synthesized identity. + */ +export const normalizeProxyIdentity = ( + raw: string, + configService: TwentyConfigService, +): string | null => { + const trimmed = raw.toLowerCase().trim(); + + if (trimmed.includes('@')) { + return trimmed; + } + + const domain = configService.get('DEFAULT_EMAIL_DOMAIN'); + + if (!domain) { + logger.warn( + 'Proxy identity contains a bare username but DEFAULT_EMAIL_DOMAIN is not configured.', + ); + + return null; + } + + return `${trimmed}@${domain}`; +}; + +/** + * Expire the tokenPair cookie. Defensive: in some test harnesses + * `response.clearCookie` may not be wired up, so guard with a typeof + * check before calling. + */ +export const clearTokenPairCookie = (response: Response | undefined): void => { + if (typeof response?.clearCookie === 'function') { + response.clearCookie(TOKEN_PAIR_COOKIE_NAME, { path: '/' }); + } +};