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:
@@ -0,0 +1,57 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Index('IDX_API_KEY_WORKSPACE_ID', ['workspaceId'])
|
||||
@Entity({ name: 'apiKey', schema: 'core' })
|
||||
@ObjectType('ApiKey')
|
||||
export class ApiKey {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Field(() => Date)
|
||||
@Column({ type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
revokedAt?: Date | null;
|
||||
|
||||
@Field()
|
||||
@Column('uuid')
|
||||
workspaceId: string;
|
||||
|
||||
@Field(() => Date)
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Field(() => Workspace)
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.apiKeys, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ApiKeyException extends CustomException {
|
||||
declare code: ApiKeyExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiKeyExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ApiKeyExceptionCode {
|
||||
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',
|
||||
API_KEY_REVOKED = 'API_KEY_REVOKED',
|
||||
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApiKey], 'core'), JwtModule],
|
||||
providers: [ApiKeyService, ApiKeyResolver],
|
||||
exports: [ApiKeyService, TypeOrmModule],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CreateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
|
||||
import { GetApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto';
|
||||
import { RevokeApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto';
|
||||
import { UpdateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
|
||||
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { ApiKey } from './api-key.entity';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@Resolver(() => ApiKey)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ApiKeyResolver {
|
||||
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
async apiKeys(@AuthWorkspace() workspace: Workspace): Promise<ApiKey[]> {
|
||||
return this.apiKeyService.findActiveByWorkspaceId(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => ApiKey, { nullable: true })
|
||||
async apiKey(
|
||||
@Args('input') input: GetApiKeyDTO,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ApiKey | null> {
|
||||
try {
|
||||
const apiKey = await this.apiKeyService.findById(input.id, workspace.id);
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
} catch (error) {
|
||||
apiKeyGraphqlApiExceptionHandler(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey)
|
||||
async createApiKey(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: CreateApiKeyDTO,
|
||||
): Promise<ApiKey> {
|
||||
return this.apiKeyService.create({
|
||||
name: input.name,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
revokedAt: input.revokedAt ? new Date(input.revokedAt) : undefined,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey, { nullable: true })
|
||||
async updateApiKey(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: UpdateApiKeyDTO,
|
||||
): Promise<ApiKey | null> {
|
||||
const updateData: Partial<ApiKey> = {};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.expiresAt !== undefined)
|
||||
updateData.expiresAt = new Date(input.expiresAt);
|
||||
if (input.revokedAt !== undefined) {
|
||||
updateData.revokedAt = input.revokedAt ? new Date(input.revokedAt) : null;
|
||||
}
|
||||
|
||||
return this.apiKeyService.update(input.id, workspace.id, updateData);
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey, { nullable: true })
|
||||
async revokeApiKey(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: RevokeApiKeyDTO,
|
||||
): Promise<ApiKey | null> {
|
||||
return this.apiKeyService.revoke(input.id, workspace.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/api-key.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 { ApiKey } from './api-key.entity';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
describe('ApiKeyService', () => {
|
||||
let service: ApiKeyService;
|
||||
let mockApiKeyRepository: any;
|
||||
let mockJwtWrapperService: any;
|
||||
|
||||
const mockWorkspaceId = 'workspace-123';
|
||||
const mockApiKeyId = 'api-key-456';
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: mockApiKeyId,
|
||||
name: 'Test API Key',
|
||||
expiresAt: new Date('2025-12-31'),
|
||||
revokedAt: undefined,
|
||||
workspaceId: mockWorkspaceId,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
workspace: {} as any,
|
||||
};
|
||||
|
||||
const mockRevokedApiKey: ApiKey = {
|
||||
...mockApiKey,
|
||||
id: 'revoked-api-key',
|
||||
revokedAt: new Date('2024-06-01'),
|
||||
};
|
||||
|
||||
const mockExpiredApiKey: ApiKey = {
|
||||
...mockApiKey,
|
||||
id: 'expired-api-key',
|
||||
expiresAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiKeyRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
mockJwtWrapperService = {
|
||||
generateAppSecret: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
{
|
||||
provide: getRepositoryToken(ApiKey, 'core'),
|
||||
useValue: mockApiKeyRepository,
|
||||
},
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: mockJwtWrapperService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApiKeyService>(ApiKeyService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create and save an API key', async () => {
|
||||
const apiKeyData = {
|
||||
name: 'New API Key',
|
||||
expiresAt: new Date('2025-12-31'),
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
mockApiKeyRepository.create.mockReturnValue(mockApiKey);
|
||||
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await service.create(apiKeyData);
|
||||
|
||||
expect(mockApiKeyRepository.create).toHaveBeenCalledWith(apiKeyData);
|
||||
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(mockApiKey);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find an API key by ID and workspace ID', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await service.findById(mockApiKeyId, mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockApiKeyId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null if API key not found', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findById('non-existent', mockWorkspaceId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByWorkspaceId', () => {
|
||||
it('should find all API keys for a workspace', async () => {
|
||||
const mockApiKeys = [mockApiKey, { ...mockApiKey, id: 'another-key' }];
|
||||
|
||||
mockApiKeyRepository.find.mockResolvedValue(mockApiKeys);
|
||||
|
||||
const result = await service.findByWorkspaceId(mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockApiKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByWorkspaceId', () => {
|
||||
it('should find only active (non-revoked) API keys', async () => {
|
||||
const activeApiKeys = [mockApiKey];
|
||||
|
||||
mockApiKeyRepository.find.mockResolvedValue(activeApiKeys);
|
||||
|
||||
const result = await service.findActiveByWorkspaceId(mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(activeApiKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing API key', async () => {
|
||||
const updateData = { name: 'Updated API Key' };
|
||||
const updatedApiKey = { ...mockApiKey, ...updateData };
|
||||
|
||||
mockApiKeyRepository.findOne
|
||||
.mockResolvedValueOnce(mockApiKey)
|
||||
.mockResolvedValueOnce(updatedApiKey);
|
||||
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.update(
|
||||
mockApiKeyId,
|
||||
mockWorkspaceId,
|
||||
updateData,
|
||||
);
|
||||
|
||||
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
|
||||
mockApiKeyId,
|
||||
updateData,
|
||||
);
|
||||
expect(result).toEqual(updatedApiKey);
|
||||
});
|
||||
|
||||
it('should return null if API key to update does not exist', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.update('non-existent', mockWorkspaceId, {
|
||||
name: 'Updated',
|
||||
});
|
||||
|
||||
expect(mockApiKeyRepository.update).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke an API key by setting revokedAt', async () => {
|
||||
const revokedApiKey = { ...mockApiKey, revokedAt: new Date() };
|
||||
|
||||
mockApiKeyRepository.findOne
|
||||
.mockResolvedValueOnce(mockApiKey)
|
||||
.mockResolvedValueOnce(revokedApiKey);
|
||||
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.revoke(mockApiKeyId, mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
|
||||
mockApiKeyId,
|
||||
expect.objectContaining({
|
||||
revokedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(revokedApiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('should validate an active API key', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await service.validateApiKey(
|
||||
mockApiKeyId,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should throw ApiKeyException if API key does not exist', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey('non-existent', mockWorkspaceId),
|
||||
).rejects.toThrow(ApiKeyException);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey('non-existent', mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ApiKeyException if API key is revoked', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockRevokedApiKey);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
|
||||
).rejects.toThrow(ApiKeyException);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ApiKeyExceptionCode.API_KEY_REVOKED,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ApiKeyException if API key is expired', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockExpiredApiKey);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
|
||||
).rejects.toThrow(ApiKeyException);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ApiKeyExceptionCode.API_KEY_EXPIRED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateApiKeyToken', () => {
|
||||
const mockSecret = 'mock-secret';
|
||||
const mockToken = 'mock-jwt-token';
|
||||
|
||||
beforeEach(() => {
|
||||
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
|
||||
mockJwtWrapperService.sign.mockReturnValue(mockToken);
|
||||
});
|
||||
|
||||
it('should generate a JWT token for a valid API key', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
const expiresAt = new Date('2025-12-31');
|
||||
|
||||
const result = await service.generateApiKeyToken(
|
||||
mockWorkspaceId,
|
||||
mockApiKeyId,
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: mockWorkspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
{
|
||||
secret: mockSecret,
|
||||
expiresIn: expect.any(Number),
|
||||
jwtid: mockApiKeyId,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ token: mockToken });
|
||||
});
|
||||
|
||||
it('should return undefined if no API key ID provided', async () => {
|
||||
const result = await service.generateApiKeyToken(mockWorkspaceId);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default expiration if no expiresAt provided', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
|
||||
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
|
||||
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
expiresIn: '100y',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility methods', () => {
|
||||
describe('isExpired', () => {
|
||||
it('should return true for expired API key', () => {
|
||||
const result = service.isExpired(mockExpiredApiKey);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-expired API key', () => {
|
||||
const result = service.isExpired(mockApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRevoked', () => {
|
||||
it('should return true for revoked API key', () => {
|
||||
const result = service.isRevoked(mockRevokedApiKey);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-revoked API key', () => {
|
||||
const result = service.isRevoked(mockApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for active API key', () => {
|
||||
const result = service.isActive(mockApiKey);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for revoked API key', () => {
|
||||
const result = service.isActive(mockRevokedApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for expired API key', () => {
|
||||
const result = service.isActive(mockExpiredApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/api-key.exception';
|
||||
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
@InjectRepository(ApiKey, 'core')
|
||||
private readonly apiKeyRepository: Repository<ApiKey>,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
) {}
|
||||
|
||||
async create(apiKeyData: Partial<ApiKey>): Promise<ApiKey> {
|
||||
const apiKey = this.apiKeyRepository.create(apiKeyData);
|
||||
|
||||
return await this.apiKeyRepository.save(apiKey);
|
||||
}
|
||||
|
||||
async findById(id: string, workspaceId: string): Promise<ApiKey | null> {
|
||||
return await this.apiKeyRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
|
||||
return await this.apiKeyRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
|
||||
return await this.apiKeyRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<ApiKey>,
|
||||
): Promise<ApiKey | null> {
|
||||
const apiKey = await this.findById(id, workspaceId);
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.apiKeyRepository.update(id, updateData);
|
||||
|
||||
return this.findById(id, workspaceId);
|
||||
}
|
||||
|
||||
async revoke(id: string, workspaceId: string): Promise<ApiKey | null> {
|
||||
return await this.update(id, workspaceId, {
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async validateApiKey(id: string, workspaceId: string): Promise<ApiKey> {
|
||||
const apiKey = await this.findById(id, workspaceId);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new ApiKeyException(
|
||||
`API Key with id ${id} not found`,
|
||||
ApiKeyExceptionCode.API_KEY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.revokedAt) {
|
||||
throw new ApiKeyException(
|
||||
'This API Key is revoked',
|
||||
ApiKeyExceptionCode.API_KEY_REVOKED,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'This API Key has been revoked and can no longer be used.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (new Date() > apiKey.expiresAt) {
|
||||
throw new ApiKeyException(
|
||||
'This API Key has expired',
|
||||
ApiKeyExceptionCode.API_KEY_EXPIRED,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'This API Key has expired. Please create a new one.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
apiKeyId?: string,
|
||||
expiresAt?: Date | string,
|
||||
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
|
||||
if (!apiKeyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.validateApiKey(apiKeyId, workspaceId);
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
let expiresIn: string | number;
|
||||
|
||||
if (expiresAt) {
|
||||
expiresIn = Math.floor(
|
||||
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
|
||||
);
|
||||
} else {
|
||||
expiresIn = '100y';
|
||||
}
|
||||
|
||||
const token = this.jwtWrapperService.sign(
|
||||
{
|
||||
sub: workspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
},
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
isExpired(apiKey: ApiKey): boolean {
|
||||
return new Date() > apiKey.expiresAt;
|
||||
}
|
||||
|
||||
isRevoked(apiKey: ApiKey): boolean {
|
||||
return !!apiKey.revokedAt;
|
||||
}
|
||||
|
||||
isActive(apiKey: ApiKey): boolean {
|
||||
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@Field()
|
||||
@IsDateString()
|
||||
expiresAt: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
revokedAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class GetApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class RevokeApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UpdateApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
expiresAt?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
revokedAt?: string;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/api-key.exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof ApiKeyException) {
|
||||
switch (error.code) {
|
||||
case ApiKeyExceptionCode.API_KEY_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case ApiKeyExceptionCode.API_KEY_REVOKED:
|
||||
throw new ForbiddenError(error.message, {
|
||||
userFriendlyMessage: error.userFriendlyMessage,
|
||||
});
|
||||
case ApiKeyExceptionCode.API_KEY_EXPIRED:
|
||||
throw new UserInputError(error.message, {
|
||||
userFriendlyMessage: error.userFriendlyMessage,
|
||||
});
|
||||
default: {
|
||||
const _exhaustiveCheck: never = error.code;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
Reference in New Issue
Block a user