feat: Added workspace-level impersonation module and functionality (#14360)

## Description

- This PR is focused on issue
https://github.com/twentyhq/core-team-issues/issues/1421
- Added workspace-level impersonation apart from current
server-level-impersonation
- Introduced WorkspaceImpersonationModule, including service and
resolver for user impersonation within workspaces.
- Enhanced AdminPanelService and AdminPanelResolver to support
impersonation logic, including authentication checks and token
generation.
- added Member Management Interface in settings/members table
- added GraphQL mutation for workspace-level impersonation



## Visual Proof

<img width="919" height="444" alt="Screenshot 2025-09-09 at 1 29 22 AM"
src="https://github.com/user-attachments/assets/a361c6eb-03de-48db-b68a-7d3a489fafde"
/>


https://github.com/user-attachments/assets/454bc1aa-8b8f-4de1-9304-7c147da71dd4


## Impersontion Cookie

<img width="1792" height="297" alt="Screenshot 2025-09-11 at 9 44 06 PM"
src="https://github.com/user-attachments/assets/a8a37d5e-d38d-4472-94a7-4e4b36fba14e"
/>



<img width="1235" height="1041" alt="Screenshot 2025-09-19 at 1 24
09 AM"
src="https://github.com/user-attachments/assets/875e8f9c-e0cb-404b-8291-8fadc6c2a163"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
Harshit Singh
2025-09-23 21:37:53 +05:30
committed by GitHub
parent 8b2726b3fb
commit 09dd49d6d6
50 changed files with 2142 additions and 377 deletions
@@ -15,6 +15,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@@ -46,6 +47,10 @@ describe('AuthResolver', () => {
provide: getRepositoryToken(User),
useValue: {},
},
{
provide: getRepositoryToken(UserWorkspace),
useValue: {},
},
{
provide: AuthService,
useValue: {},
@@ -5,8 +5,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import omit from 'lodash.omit';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
@@ -73,6 +73,7 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
@@ -100,6 +101,8 @@ export class AuthResolver {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace)
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository<AppToken>,
private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
@@ -120,6 +123,7 @@ export class AuthResolver {
private emailVerificationTokenService: EmailVerificationTokenService,
private sSOService: SSOService,
private readonly auditService: AuditService,
private readonly permissionsService: PermissionsService,
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard)
@@ -559,20 +563,36 @@ export class AuthResolver {
);
if (tokenPayload.authProvider === AuthProviderEnum.Impersonation) {
await this.validateAndLogImpersonation(
const {
workspaceId,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
impersonatorUserId,
impersonatedUserId,
} = await this.validateAndLogImpersonation(
tokenPayload,
workspace,
user.email,
);
return await this.authService.generateImpersonationAccessTokenAndRefreshToken(
{
workspaceId,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
impersonatorUserId,
impersonatedUserId,
},
);
} else {
await this.validateRegularAuthentication(workspace, userWorkspace);
}
return await this.authService.verify(
user.email,
workspace.id,
tokenPayload.authProvider,
);
return await this.authService.verify(
user.email,
workspace.id,
tokenPayload.authProvider,
);
}
}
private async validateAndDecodeLoginToken(
@@ -639,79 +659,97 @@ export class AuthResolver {
tokenPayload: LoginTokenJwtPayload,
workspace: Workspace,
targetUserEmail: string,
): Promise<void> {
const { impersonatorUserId } = tokenPayload;
) {
const { impersonatorUserWorkspaceId } = tokenPayload;
const impersonatorUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: { id: impersonatorUserWorkspaceId },
relations: ['user', 'workspace'],
});
const toImpersonateUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: {
user: { email: targetUserEmail },
workspaceId: workspace.id,
},
relations: ['user', 'workspace'],
});
if (
!isDefined(impersonatorUserWorkspace) ||
!isDefined(toImpersonateUserWorkspace)
) {
throw new AuthException(
'Impersonator or target user workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const isServerLevelImpersonation =
toImpersonateUserWorkspace.workspace.id !==
impersonatorUserWorkspace.workspace.id;
const auditService = this.auditService.createContext({
workspaceId: workspace.id,
userId: impersonatorUserId,
userId: impersonatorUserWorkspace.user.id,
});
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_attempt',
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserId}`,
eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_attempt`,
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`,
});
if (workspace.allowImpersonation !== true) {
throw new AuthException(
'Impersonation not allowed on this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const hasServerLevelImpersonatePermission =
impersonatorUserWorkspace.user.canImpersonate === true &&
impersonatorUserWorkspace.workspace.allowImpersonation === true;
if (!impersonatorUserId) {
if (isServerLevelImpersonation && !hasServerLevelImpersonatePermission) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_failed',
message: `Invalid impersonation token (missing impersonator user ID) for ${targetUserEmail}`,
message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
throw new AuthException(
'Invalid impersonation token (missing impersonator user ID)',
'Server level impersonation not allowed on this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const impersonatorUser = await this.userRepository.findOne({
where: { id: impersonatorUserId },
});
const hasWorkspaceLevelImpersonatePermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId: impersonatorUserWorkspace.id,
setting: PermissionFlagType.IMPERSONATE,
workspaceId: workspace.id,
});
if (!impersonatorUser) {
if (
!hasWorkspaceLevelImpersonatePermission &&
!hasServerLevelImpersonatePermission
) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_failed',
message: `Impersonator user not found: ${impersonatorUserId} for ${targetUserEmail}`,
eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_failed`,
message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
throw new AuthException(
'Impersonator user not found',
'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (impersonatorUser.canImpersonate !== true) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_failed',
message: `User not authorized to impersonate: ${impersonatorUserId} for ${targetUserEmail}`,
});
throw new AuthException(
'User not authorized to impersonate',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
await this.logImpersonationEvent(workspace.id, impersonatorUserId);
}
private async logImpersonationEvent(
workspaceId: string,
impersonatorUserId: string,
): Promise<void> {
const auditService = this.auditService.createContext({
workspaceId,
userId: impersonatorUserId,
});
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.login_token_exchanged',
message: 'Impersonation token exchanged',
eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_success`,
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
return {
workspaceId: workspace.id,
impersonatorUserWorkspaceId: impersonatorUserWorkspace.id,
impersonatedUserWorkspaceId: toImpersonateUserWorkspace.id,
impersonatorUserId: impersonatorUserWorkspace.user.id,
impersonatedUserId: toImpersonateUserWorkspace.user.id,
};
}
@Mutation(() => AuthorizeApp)
@@ -5,6 +5,7 @@ import bcrypt from 'bcrypt';
import { type Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import {
AuthException,
AuthExceptionCode,
@@ -12,21 +13,21 @@ import {
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { type ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthService } from './auth.service';
@@ -143,6 +144,10 @@ describe('AuthService', () => {
}),
},
},
{
provide: AuditService,
useValue: {},
},
],
}).compile();
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'node:crypto';
import crypto, { randomUUID } from 'node:crypto';
import { t, msg } from '@lingui/core/macro';
import { msg, t } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
@@ -18,6 +18,7 @@ import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import {
AuthException,
AuthExceptionCode,
@@ -87,6 +88,7 @@ export class AuthService {
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository<AppToken>,
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -310,6 +312,65 @@ export class AuthService {
};
}
async generateImpersonationAccessTokenAndRefreshToken({
workspaceId,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
impersonatorUserId,
impersonatedUserId,
}: {
workspaceId: string;
impersonatorUserWorkspaceId: string;
impersonatedUserWorkspaceId: string;
impersonatorUserId: string;
impersonatedUserId: string;
}): Promise<AuthTokens> {
const correlationId = randomUUID();
const analytics = this.auditService.createContext({
workspaceId,
userId: impersonatorUserId,
});
await analytics.insertWorkspaceEvent('Monitoring', {
eventName: 'workspace.impersonation.attempted',
message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
});
const accessToken = await this.accessTokenService.generateAccessToken({
userId: impersonatedUserId,
workspaceId,
authProvider: AuthProviderEnum.Impersonation,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
});
const refreshToken = await this.refreshTokenService.generateRefreshToken(
{
userId: impersonatedUserId,
workspaceId,
authProvider: AuthProviderEnum.Impersonation,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
},
true,
);
await analytics.insertWorkspaceEvent('Monitoring', {
eventName: 'workspace.impersonation.issued',
message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
});
return {
tokens: {
accessOrWorkspaceAgnosticToken: accessToken,
refreshToken,
},
};
}
async countAvailableWorkspacesByEmail(email: string): Promise<number> {
return Object.values(
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
@@ -1,3 +1,5 @@
import { randomUUID } from 'crypto';
import {
AuthException,
AuthExceptionCode,
@@ -7,6 +9,15 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
jest.mock('twenty-shared/utils', () => ({
...jest.requireActual('twenty-shared/utils'),
assertIsDefinedOrThrow: jest.fn((value, error) => {
if (value === null || value === undefined) {
throw error;
}
}),
}));
describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;
let workspaceRepository: any;
@@ -14,6 +25,7 @@ describe('JwtAuthStrategy', () => {
let userRepository: any;
let apiKeyRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
const jwt = {
sub: 'sub-default',
@@ -40,6 +52,10 @@ describe('JwtAuthStrategy', () => {
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
permissionsService = {
userHasWorkspaceSettingPermission: jest.fn(),
};
});
afterEach(() => {
@@ -61,6 +77,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -90,6 +107,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -122,6 +140,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -154,6 +173,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
const result = await strategy.validate(payload as JwtPayload);
@@ -172,10 +192,15 @@ describe('JwtAuthStrategy', () => {
describe('ACCESS token validation', () => {
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: 'sub-default',
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
@@ -188,6 +213,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -202,10 +228,15 @@ describe('JwtAuthStrategy', () => {
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: 'sub-default',
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
@@ -220,6 +251,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -234,10 +266,15 @@ describe('JwtAuthStrategy', () => {
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: 'sub-default',
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
@@ -245,7 +282,9 @@ describe('JwtAuthStrategy', () => {
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue({
id: 'userWorkspaceId',
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
});
strategy = new JwtAuthStrategy(
@@ -254,12 +293,604 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId');
expect(user.userWorkspaceId).toBe(validUserWorkspaceId);
});
});
describe('Impersonation validation', () => {
it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatedUserWorkspaceId: validUserWorkspaceId,
// Missing impersonatorUserWorkspaceId
};
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Invalid or missing user workspace ID in impersonation token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthException if impersonation token has missing impersonatedUserWorkspaceId', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
// Missing impersonatedUserWorkspaceId
};
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Invalid or missing user workspace ID in impersonation token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthException if user tries to impersonate themselves', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId: validUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId, // Same as impersonator
};
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'User cannot impersonate themselves',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthException if impersonator user workspace not found', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = true;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(null) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce({
// For impersonatedUserWorkspace lookup
id: validUserWorkspaceId,
user: { id: randomUUID() },
workspace: mockWorkspace,
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthException if impersonated user workspace not found', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = true;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(null); // For impersonatedUserWorkspace lookup
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthException for server level impersonation without permission', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const differentWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = false; // Disabled
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: randomUUID(), canImpersonate: false }, // No server level permission
workspace: { id: differentWorkspaceId }, // Different workspace
};
const mockImpersonatedUserWorkspace = {
id: validUserWorkspaceId,
user: { id: randomUUID() },
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
false,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Server level impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthException when no impersonation permissions are granted', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = false;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: randomUUID(), canImpersonate: false },
workspace: mockWorkspace, // Same workspace
};
const mockImpersonatedUserWorkspace = {
id: validUserWorkspaceId,
user: { id: randomUUID() },
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
false,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthException when impersonatedUserWorkspaceId does not match userWorkspaceId', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const impersonatedUserWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId, // Different from userWorkspaceId
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = true;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: randomUUID(), canImpersonate: true },
workspace: mockWorkspace,
};
const mockImpersonatedUserWorkspace = {
id: impersonatedUserWorkspaceId,
user: { id: randomUUID() },
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Token user workspace ID does not match impersonated user workspace ID',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should successfully validate workspace level impersonation with permission', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = false; // Server level disabled
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: randomUUID(), canImpersonate: false },
workspace: mockWorkspace, // Same workspace
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockUserWorkspace); // For impersonatedUserWorkspace lookup (same as main)
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result.user?.lastName).toBe('lastNameDefault');
expect(result.userWorkspaceId).toBe(validUserWorkspaceId);
expect(result.impersonationContext).toBeDefined();
expect(result.impersonationContext?.impersonatorUserWorkspaceId).toBe(
impersonatorUserWorkspaceId,
);
expect(result.impersonationContext?.impersonatedUserWorkspaceId).toBe(
validUserWorkspaceId,
);
});
it('should successfully validate server level impersonation with permission', async () => {
const validUserId = randomUUID();
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const differentWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: 'ACCESS',
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new Workspace();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = true; // Server level enabled
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: randomUUID(), canImpersonate: true }, // Server level permission
workspace: { id: differentWorkspaceId }, // Different workspace
};
const mockImpersonatedUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace) // For impersonatedUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For access token lookup
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result.user?.lastName).toBe('lastNameDefault');
expect(result.userWorkspaceId).toBe(validUserWorkspaceId);
expect(result.impersonationContext).toBeDefined();
expect(result.impersonationContext?.impersonatorUserWorkspaceId).toBe(
impersonatorUserWorkspaceId,
);
expect(result.impersonationContext?.impersonatedUserWorkspaceId).toBe(
validUserWorkspaceId,
);
});
});
});
@@ -4,8 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import { Strategy } from 'passport-jwt';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
@@ -25,6 +25,8 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
@@ -37,6 +39,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(ApiKey)
private readonly apiKeyRepository: Repository<ApiKey>,
private readonly permissionsService: PermissionsService,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
@@ -107,17 +110,26 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
payload: AccessTokenJwtPayload,
): Promise<AuthContext> {
let user: User | null = null;
let context: AuthContext = {};
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
if (!workspace) {
if (!isDefined(workspace)) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (payload.isImpersonating === true) {
context.impersonationContext = await this.validateImpersonation(
payload,
workspace,
);
}
const userId = payload.sub ?? payload.userId;
if (!userId) {
@@ -139,9 +151,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
id: payload.userWorkspaceId,
},
where: { id: payload.userWorkspaceId },
relations: ['user', 'workspace'],
});
assertIsDefinedOrThrow(
@@ -155,7 +166,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
),
);
return {
context = {
...context,
user,
workspace,
authProvider: payload.authProvider,
@@ -163,6 +175,100 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
userWorkspaceId: userWorkspace.id,
workspaceMemberId: payload.workspaceMemberId,
};
return context;
}
private async validateImpersonation(
payload: AccessTokenJwtPayload,
workspace: Workspace,
) {
// Validate required impersonation fields
if (
!payload.impersonatorUserWorkspaceId ||
!payload.impersonatedUserWorkspaceId
) {
throw new AuthException(
'Invalid or missing user workspace ID in impersonation token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (payload.impersonatedUserWorkspaceId !== payload.userWorkspaceId) {
throw new AuthException(
'Token user workspace ID does not match impersonated user workspace ID',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (
payload.impersonatedUserWorkspaceId ===
payload.impersonatorUserWorkspaceId
) {
throw new AuthException(
'User cannot impersonate themselves',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const impersonatorUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: { id: payload.impersonatorUserWorkspaceId },
relations: ['user', 'workspace'],
});
const impersonatedUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: { id: payload.impersonatedUserWorkspaceId },
relations: ['user', 'workspace'],
});
if (
!isDefined(impersonatorUserWorkspace) ||
!isDefined(impersonatedUserWorkspace)
) {
throw new AuthException(
'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
const isServerLevelImpersonation =
impersonatorUserWorkspace.workspace.id !==
impersonatedUserWorkspace.workspace.id;
const hasServerLevelImpersonatePermission =
impersonatorUserWorkspace.user.canImpersonate === true &&
workspace.allowImpersonation === true;
if (isServerLevelImpersonation && !hasServerLevelImpersonatePermission) {
throw new AuthException(
'Server level impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const hasWorkspaceLevelImpersonatePermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId: impersonatorUserWorkspace.id,
setting: PermissionFlagType.IMPERSONATE,
workspaceId: impersonatedUserWorkspace.workspace.id,
});
if (
!hasWorkspaceLevelImpersonatePermission &&
!hasServerLevelImpersonatePermission
) {
throw new AuthException(
'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return {
impersonatorUserWorkspaceId: payload.impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: payload.impersonatedUserWorkspaceId,
};
}
private async validateWorkspaceAgnosticToken(
@@ -1,6 +1,8 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { type Request } from 'express';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
@@ -104,8 +106,8 @@ describe('AccessTokenService', () => {
describe('generateAccessToken', () => {
it('should generate an access token successfully', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userId = randomUUID();
const workspaceId = randomUUID();
const mockUser = {
id: userId,
};
@@ -113,8 +115,8 @@ describe('AccessTokenService', () => {
activationStatus: WorkspaceActivationStatus.ACTIVE,
id: workspaceId,
};
const mockUserWorkspace = { id: 'userWorkspaceId' };
const mockWorkspaceMember = { id: 'workspace-member-id' };
const mockUserWorkspace = { id: randomUUID() };
const mockWorkspaceMember = { id: randomUUID() };
const mockToken = 'mock-token';
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
@@ -152,6 +154,66 @@ describe('AccessTokenService', () => {
);
});
it('embeds impersonation claims when provided', async () => {
const userId = randomUUID();
const workspaceId = randomUUID();
const impersonatorUserWorkspaceId = randomUUID();
const impersonatedUserWorkspaceId = randomUUID();
const mockUser = { id: userId } as User;
const mockWorkspace = {
activationStatus: WorkspaceActivationStatus.ACTIVE,
id: workspaceId,
} as Workspace;
const mockUserWorkspace = {
id: impersonatedUserWorkspaceId,
} as UserWorkspace;
const mockWorkspaceMember = { id: randomUUID() };
const mockToken = 'mock-token';
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as Workspace);
jest
.spyOn(userWorkspaceRepository, 'findOne')
.mockResolvedValueOnce(mockUserWorkspace as UserWorkspace)
.mockResolvedValueOnce({
id: impersonatorUserWorkspaceId,
workspaceId,
} as UserWorkspace)
.mockResolvedValueOnce({
id: impersonatedUserWorkspaceId,
workspaceId,
} as UserWorkspace);
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue({
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
const signSpy = jest
.spyOn(jwtWrapperService, 'sign')
.mockReturnValue(mockToken);
await service.generateAccessToken({
userId,
workspaceId,
authProvider: AuthProviderEnum.Impersonation,
isImpersonating: true,
impersonatorUserWorkspaceId: impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: impersonatedUserWorkspaceId,
});
expect(signSpy).toHaveBeenCalledWith(
expect.objectContaining({
isImpersonating: true,
impersonatorUserWorkspaceId: impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: impersonatedUserWorkspaceId,
}),
expect.any(Object),
);
});
it('should throw an error if user is not found', async () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
@@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import { type Request } from 'express';
import ms from 'ms';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import {
AuthException,
@@ -22,13 +22,13 @@ import {
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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceNotFoundDefaultError } from 'src/engine/core-modules/user-workspace/user-workspace.exception';
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { UserWorkspaceNotFoundDefaultError } from 'src/engine/core-modules/user-workspace/user-workspace.exception';
@Injectable()
export class AccessTokenService {
@@ -49,6 +49,9 @@ export class AccessTokenService {
userId,
workspaceId,
authProvider,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
@@ -106,6 +109,11 @@ export class AccessTokenService {
assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError);
const payloadImpersonatorUserWorkspaceId =
isImpersonating === true ? impersonatorUserWorkspaceId : undefined;
const payloadOriginalUserWorkspaceId =
isImpersonating === true ? impersonatedUserWorkspaceId : undefined;
const jwtPayload: AccessTokenJwtPayload = {
sub: user.id,
userId: user.id,
@@ -114,6 +122,9 @@ export class AccessTokenService {
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.ACCESS,
authProvider,
isImpersonating: isImpersonating === true,
impersonatorUserWorkspaceId: payloadImpersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
};
return {
@@ -133,25 +144,9 @@ export class AccessTokenService {
const decoded = this.jwtWrapperService.decode<AccessTokenJwtPayload>(token);
const {
user,
apiKey,
workspace,
workspaceMemberId,
userWorkspace,
userWorkspaceId,
authProvider,
} = await this.jwtStrategy.validate(decoded);
const context = await this.jwtStrategy.validate(decoded);
return {
user,
apiKey,
workspace,
userWorkspace,
workspaceMemberId,
userWorkspaceId,
authProvider,
};
return context;
}
async validateTokenByRequest(request: Request): Promise<AuthContext> {
@@ -92,7 +92,7 @@ describe('LoginTokenService', () => {
const mockSecret = 'mock-secret';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
const impersonatorUserId = 'impersonator-id';
const impersonatorUserWorkspaceId = 'impersonator-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
@@ -104,7 +104,7 @@ describe('LoginTokenService', () => {
email,
workspaceId,
AuthProviderEnum.Impersonation,
{ impersonatorUserId },
{ impersonatorUserWorkspaceId },
);
expect(result).toEqual({
@@ -121,7 +121,7 @@ describe('LoginTokenService', () => {
workspaceId,
type: 'LOGIN',
authProvider: AuthProviderEnum.Impersonation,
impersonatorUserId,
impersonatorUserWorkspaceId,
},
{ secret: mockSecret, expiresIn: expect.any(String) },
);
@@ -23,14 +23,14 @@ export class LoginTokenService {
email: string,
workspaceId: string,
authProvider: AuthProviderEnum,
options?: { impersonatorUserId?: string },
options?: { impersonatorUserWorkspaceId?: string },
): Promise<AuthToken> {
const jwtPayload: LoginTokenJwtPayload = {
type: JwtTokenTypeEnum.LOGIN,
sub: email,
workspaceId,
authProvider,
impersonatorUserId: options?.impersonatorUserId,
impersonatorUserWorkspaceId: options?.impersonatorUserWorkspaceId,
};
const secret = this.jwtWrapperService.generateAppSecret(
@@ -3,12 +3,15 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
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 { User } from 'src/engine/core-modules/user/user.entity';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { RefreshTokenService } from './refresh-token.service';
@@ -73,14 +76,14 @@ describe('RefreshTokenService', () => {
id: 'token-id',
workspaceId: 'workspace-id',
revokedAt: null,
};
const mockUser: Partial<User> = {
} as AppToken;
const mockUser = {
id: 'some-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
defaultAvatarUrl: '',
};
} as User;
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
@@ -88,8 +91,8 @@ describe('RefreshTokenService', () => {
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload);
jest
.spyOn(appTokenRepository, 'findOneBy')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
.mockResolvedValue(mockAppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
const result = await service.verifyRefreshToken(mockToken);
@@ -173,4 +176,48 @@ describe('RefreshTokenService', () => {
).rejects.toThrow(AuthException);
});
});
it('returns impersonation claims from verified refresh token', async () => {
const refreshToken = 'rtok';
const userId = 'user-id';
const tokenId = 'token-id';
(jwtWrapperService.verifyJwtToken as jest.Mock).mockResolvedValue(
undefined,
);
(jwtWrapperService.decode as jest.Mock).mockReturnValue({
sub: userId,
jti: tokenId,
type: 'REFRESH',
targetedTokenType: 'ACCESS',
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
});
const token = { id: tokenId, type: AppTokenType.RefreshToken } as AppToken;
jest.spyOn(appTokenRepository, 'findOneBy').mockResolvedValue(token);
const user = { id: userId } as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
const out = await service.verifyRefreshToken(refreshToken);
expect(out.isImpersonating).toBe(true);
expect(out.impersonatorUserWorkspaceId).toBe('uw-imp');
expect(out.impersonatedUserWorkspaceId).toBe('uw-orig');
});
it('throws on malformed refresh token', async () => {
(jwtWrapperService.verifyJwtToken as jest.Mock).mockResolvedValue(
undefined,
);
(jwtWrapperService.decode as jest.Mock).mockReturnValue({});
await expect(service.verifyRefreshToken('rtok')).rejects.toThrow(
AuthException,
);
});
});
@@ -14,13 +14,13 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
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 { User } from 'src/engine/core-modules/user/user.entity';
import {
type RefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
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 { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class RefreshTokenService {
@@ -103,17 +103,23 @@ export class RefreshTokenService {
token,
authProvider: jwtPayload.authProvider,
targetedTokenType: jwtPayload.targetedTokenType,
isImpersonating: jwtPayload.isImpersonating,
impersonatorUserWorkspaceId: jwtPayload.impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: jwtPayload.impersonatedUserWorkspaceId,
};
}
async generateRefreshToken(
payload: Omit<RefreshTokenJwtPayload, 'type' | 'sub' | 'jti'>,
isImpersonationToken: boolean = false,
): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.REFRESH,
payload.workspaceId ?? payload.userId,
);
const expiresIn = this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
const expiresIn = isImpersonationToken
? '1d'
: this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
@@ -80,15 +80,16 @@ describe('RenewTokenService', () => {
const mockAppToken: Partial<AppToken> = {
id: mockTokenId,
workspaceId: mockWorkspaceId,
user: mockUser,
userId: mockUser.id,
};
} as AppToken;
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
user: mockUser,
token: mockAppToken as AppToken,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
isImpersonating: false,
impersonatorUserWorkspaceId: undefined,
impersonatedUserWorkspaceId: undefined,
});
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
jest
@@ -112,17 +113,75 @@ describe('RenewTokenService', () => {
{ id: mockTokenId },
{ revokedAt: expect.any(Date) },
);
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith({
userId: mockUser.id,
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
expect.objectContaining({
userId: mockUser.id,
workspaceId: mockWorkspaceId,
authProvider: AuthProviderEnum.Password,
}),
);
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
expect.objectContaining({
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
userId: mockUser.id,
workspaceId: mockWorkspaceId,
}),
);
});
it('should propagate impersonation claims when present', async () => {
const mockRefreshToken = 'valid-refresh-token';
const mockUser = { id: 'user-id' } as User;
const mockWorkspaceId = 'workspace-id';
const mockTokenId = 'token-id';
const mockAccessToken = {
token: 'new-access-token',
expiresAt: new Date(),
};
const mockNewRefreshToken = {
token: 'new-refresh-token',
expiresAt: new Date(),
targetedTokenType: JwtTokenTypeEnum.ACCESS,
};
const mockAppToken = {
id: mockTokenId,
workspaceId: mockWorkspaceId,
authProvider: AuthProviderEnum.Password,
});
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith({
} as AppToken;
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
user: mockUser,
token: mockAppToken as AppToken,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
userId: mockUser.id,
workspaceId: mockWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
});
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
const accessSpy = jest
.spyOn(accessTokenService, 'generateAccessToken')
.mockResolvedValue(mockAccessToken);
const refreshSpy = jest
.spyOn(refreshTokenService, 'generateRefreshToken')
.mockResolvedValue(mockNewRefreshToken);
await service.generateTokensFromRefreshToken(mockRefreshToken);
expect(accessSpy).toHaveBeenCalledWith(
expect.objectContaining({
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
}),
);
expect(refreshSpy).toHaveBeenCalledWith(
expect.objectContaining({
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
}),
);
});
it('should throw an error if refresh token is not provided', async () => {
@@ -42,6 +42,9 @@ export class RenewTokenService {
token: { id, workspaceId },
authProvider,
targetedTokenType: targetedTokenTypeFromPayload,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
} = await this.refreshTokenService.verifyRefreshToken(token);
// Revoke old refresh token
@@ -58,8 +61,6 @@ export class RenewTokenService {
const targetedTokenType =
targetedTokenTypeFromPayload ?? JwtTokenTypeEnum.ACCESS;
// Support legacy tokens where authProvider might be undefined
// TODO: remove in November 2025
const resolvedAuthProvider = authProvider ?? AuthProviderEnum.Password;
const accessToken =
@@ -75,6 +76,9 @@ export class RenewTokenService {
userId: user.id,
workspaceId,
authProvider: resolvedAuthProvider,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
});
const refreshToken = await this.refreshTokenService.generateRefreshToken({
@@ -82,6 +86,9 @@ export class RenewTokenService {
workspaceId,
authProvider: resolvedAuthProvider,
targetedTokenType,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
});
return {
@@ -15,6 +15,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
@@ -28,6 +29,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
]),
TypeORMModule,
DataSourceModule,
PermissionsModule,
],
providers: [
RenewTokenService,
@@ -12,6 +12,10 @@ export type AuthContext = {
userWorkspaceId?: string;
userWorkspace?: UserWorkspace;
authProvider?: AuthProviderEnum;
impersonationContext?: {
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
};
export enum JwtTokenTypeEnum {
@@ -44,7 +48,7 @@ export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.LOGIN;
workspaceId: string;
authProvider: AuthProviderEnum;
impersonatorUserId?: string;
impersonatorUserWorkspaceId?: string;
};
export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & {
@@ -61,6 +65,9 @@ export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
jti?: string;
authProvider?: AuthProviderEnum;
targetedTokenType: JwtTokenTypeEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & {
@@ -83,6 +90,9 @@ export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
workspaceMemberId?: string;
userWorkspaceId: string;
authProvider: AuthProviderEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {