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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

49 changes: 48 additions & 1 deletion packages/twenty-server/src/engine/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<boolean> {
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();

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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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<string, unknown>;
config?: Partial<Record<string, string>>;
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,6 +13,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceManyOrAllFlatEntityMapsCacheModule,
TokenModule,
JwtModule,
TwentyConfigModule,
],
providers: [MiddlewareService],
exports: [MiddlewareService],
Expand Down
Loading
Loading