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:
@@ -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,
|
||||
|
||||
+225
-197
@@ -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
-11
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user