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..3d815083fd054 --- /dev/null +++ b/packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts @@ -0,0 +1,567 @@ +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('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) => + 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('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('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) => { + 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 + // 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(); + }); + + 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(); + }); + + 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, + ), + }; + 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: '/', + }); + }); + }); + + 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..c0530c05a4b2b 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -5,10 +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 { + clearTokenPairCookie, + matchesProxyIdentity, +} from 'src/engine/utils/proxy-identity.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Injectable() @@ -18,14 +24,55 @@ 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) && + !matchesProxyIdentity( + request, + data.user.email, + this.twentyConfigService, + ) + ) { + 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, 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: '/' }); + } +};