Api keys and webhook migration to core (#13011)

TODO: check Zapier trigger records work as expected

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
nitin
2025-07-09 20:33:54 +05:30
committed by GitHub
parent 18792f9f74
commit 484c267aa6
113 changed files with 4563 additions and 1060 deletions
@@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
@@ -79,6 +80,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
Workspace,
User,
AppToken,
ApiKey,
FeatureFlag,
WorkspaceSSOIdentityProvider,
KeyValuePair,
@@ -3,235 +3,263 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;
let workspaceRepository: any;
let userWorkspaceRepository: any;
let userRepository: any;
let twentyORMGlobalManager: any;
let apiKeyRepository: any;
let jwtWrapperService: any;
const jwt = {
sub: 'sub-default',
jti: 'jti-default',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
userRepository = {
findOne: jest.fn(async () => null),
};
userWorkspaceRepository = {
findOne: jest.fn(async () => new UserWorkspace()),
};
const jwtWrapperService: any = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
})),
};
// first we test the API_KEY case
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
beforeEach(() => {
workspaceRepository = {
findOneBy: jest.fn(async () => null),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => null),
})),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
})),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
});
// second we test the ACCESS cases
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
findOneBy: jest.fn(),
};
userRepository = {
findOne: jest.fn(async () => null),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
userRepository = {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
findOne: jest.fn(),
};
userWorkspaceRepository = {
findOne: jest.fn(async () => null),
findOne: jest.fn(),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
apiKeyRepository = {
findOne: jest.fn(),
};
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
afterEach(() => {
jest.clearAllMocks();
});
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
describe('API_KEY validation', () => {
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
userRepository = {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
};
workspaceRepository.findOneBy.mockResolvedValue(null);
userWorkspaceRepository = {
findOne: jest.fn(async () => ({
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: new Date(),
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: null,
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
expect(apiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: payload.jti,
workspaceId: mockWorkspace.id,
},
});
});
});
describe('ACCESS token validation', () => {
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue({
id: 'userWorkspaceId',
})),
};
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
const user = await strategy.validate(payload as JwtPayload);
const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId');
expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId');
});
});
});
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
AuthException,
AuthExceptionCode,
@@ -24,20 +25,18 @@ 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 { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
@@ -87,15 +86,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
),
);
const apiKeyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
workspace.id,
'apiKey',
);
const apiKey = await apiKeyRepository.findOne({
const apiKey = await this.apiKeyRepository.findOne({
where: {
id: payload.jti,
workspaceId: workspace.id,
},
});
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@@ -21,7 +22,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
imports: [
JwtModule,
TypeOrmModule.forFeature(
[User, AppToken, Workspace, UserWorkspace],
[User, AppToken, Workspace, UserWorkspace, ApiKey],
'core',
),
TypeORMModule,
@@ -1,12 +1,12 @@
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
export type AuthContext = {
user?: User | null | undefined;
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
apiKey?: ApiKey | null | undefined;
workspaceMemberId?: string;
workspace?: Workspace;
userWorkspaceId?: string;