nitin
2026-02-13 19:48:09 +05:30
committed by GitHub
parent 5cc0c03262
commit 463ce43442
26 changed files with 845 additions and 174 deletions
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
@@ -38,6 +39,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
],
providers: [
ApplicationResolver,
ApplicationDevelopmentResolver,
MarketplaceResolver,
ApplicationManifestMigrationService,
ApplicationSyncService,
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType('ApplicationTokenPair')
export class ApplicationTokenPairDTO {
@Field(() => AuthToken)
applicationAccessToken: AuthToken;
@Field(() => AuthToken)
applicationRefreshToken: AuthToken;
}
@@ -0,0 +1,140 @@
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { DevelopmentGuard } from 'src/engine/guards/development.guard';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter)
@UseGuards(
WorkspaceAuthGuard,
DevelopmentGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
export class ApplicationDevelopmentResolver {
constructor(
private readonly applicationTokenService: ApplicationTokenService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly fileStorageService: FileStorageService,
) {}
@Mutation(() => AuthToken)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async generateApplicationToken(
@Args() { applicationId }: GenerateApplicationTokenInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<AuthToken> {
return this.applicationTokenService.generateApplicationAccessToken({
workspaceId,
applicationId,
});
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async syncApplication(
@Args() { manifest }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
});
return true;
}
@Mutation(() => ApplicationDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async createOneApplication(
@Args('input') input: CreateApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return await this.applicationService.create({
...input,
sourceType: 'local',
workspaceId,
});
}
@Mutation(() => FileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async uploadApplicationFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, mimetype }: FileUpload,
@Args()
{
applicationUniversalIdentifier,
fileFolder,
filePath,
}: UploadApplicationFileInput,
): Promise<FileDTO> {
const allowedApplicationFileFolders: FileFolder[] = [
FileFolder.BuiltLogicFunction,
FileFolder.BuiltFrontComponent,
FileFolder.PublicAsset,
FileFolder.Source,
FileFolder.Dependencies,
];
if (!allowedApplicationFileFolders.includes(fileFolder)) {
throw new ApplicationException(
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const buffer = await streamToBuffer(createReadStream());
return await this.fileStorageService.writeFile({
sourceFile: buffer,
mimeType: mimetype,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
resourcePath: filePath,
settings: { isTemporaryFile: false, toDelete: false },
});
}
}
@@ -6,13 +6,7 @@ import {
} from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -21,52 +15,41 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ApplicationResolver {
constructor(
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly applicationTokenService: ApplicationTokenService,
private readonly twentyConfigService: TwentyConfigService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Query(() => [ApplicationDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async findManyApplications(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -75,6 +58,7 @@ export class ApplicationResolver {
}
@Query(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async checkApplicationExist(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -90,6 +74,7 @@ export class ApplicationResolver {
}
@Query(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async findOneApplication(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -104,61 +89,32 @@ export class ApplicationResolver {
});
}
@Mutation(() => AuthToken)
@Mutation(() => ApplicationTokenPairDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async generateApplicationToken(
@Args() { applicationId }: GenerateApplicationTokenInput,
async renewApplicationToken(
@Args('applicationRefreshToken') applicationRefreshToken: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<AuthToken> {
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
): Promise<ApplicationTokenPairDTO> {
const applicationRefreshTokenPayload =
this.applicationTokenService.validateApplicationRefreshToken(
applicationRefreshToken,
);
if (
nodeEnv !== NodeEnvironment.DEVELOPMENT &&
nodeEnv !== NodeEnvironment.TEST
) {
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
throw new ApplicationException(
'This endpoint is only available in development mode',
'Refresh token workspace does not match authenticated workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
const APPLICATION_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
return this.applicationTokenService.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: APPLICATION_TOKEN_EXPIRY_SECONDS,
});
}
@Mutation(() => ApplicationDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async createOneApplication(
@Args('input') input: CreateApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return await this.applicationService.create({
...input,
sourceType: 'local',
workspaceId,
});
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async syncApplication(
@Args() { manifest }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
});
return true;
return this.applicationTokenService.renewApplicationTokens(
applicationRefreshTokenPayload,
);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async installApplication(
@Args() { workspaceMigration: { actions } }: InstallApplicationInput,
@@ -198,6 +154,7 @@ export class ApplicationResolver {
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async uninstallApplication(
@Args() { universalIdentifier }: UninstallApplicationInput,
@@ -210,46 +167,4 @@ export class ApplicationResolver {
return true;
}
@Mutation(() => FileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async uploadApplicationFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, mimetype }: FileUpload,
@Args()
{
applicationUniversalIdentifier,
fileFolder,
filePath,
}: UploadApplicationFileInput,
): Promise<FileDTO> {
const allowedApplicationFileFolders: FileFolder[] = [
FileFolder.BuiltLogicFunction,
FileFolder.BuiltFrontComponent,
FileFolder.PublicAsset,
FileFolder.Source,
FileFolder.Dependencies,
];
if (!allowedApplicationFileFolders.includes(fileFolder)) {
throw new ApplicationException(
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const buffer = await streamToBuffer(createReadStream());
return await this.fileStorageService.writeFile({
sourceFile: buffer,
mimeType: mimetype,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
resourcePath: filePath,
settings: { isTemporaryFile: false, toDelete: false },
});
}
}
@@ -356,14 +356,14 @@ describe('JwtAuthStrategy', () => {
});
});
describe('APPLICATION token validation', () => {
it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => {
describe('APPLICATION_ACCESS token validation', () => {
it('should throw AuthExceptionCode if type is APPLICATION_ACCESS, and application not found', async () => {
const validApplicationId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validApplicationId,
type: JwtTokenTypeEnum.APPLICATION,
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
applicationId: validApplicationId,
workspaceId: validWorkspaceId,
};
@@ -18,7 +18,7 @@ import {
import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationTokenJwtPayload,
ApplicationAccessTokenJwtPayload,
type AuthContext,
type FileTokenJwtPayload,
type JwtPayload,
@@ -333,7 +333,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
private async validateApplicationToken(
payload: ApplicationTokenJwtPayload,
payload: ApplicationAccessTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
@@ -359,6 +359,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
// TODO: Token carries userId/userWorkspaceId but they are unused.
// Compute the intersection of user and application permissions instead.
return {
application,
workspace,
@@ -388,7 +390,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
return await this.validateAccessToken(payload);
}
if (payload.type === JwtTokenTypeEnum.APPLICATION) {
if (payload.type === JwtTokenTypeEnum.APPLICATION_ACCESS) {
return await this.validateApplicationToken(payload);
}
@@ -3,11 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
describe('ApplicationTokenService', () => {
@@ -55,8 +57,8 @@ describe('ApplicationTokenService', () => {
expect(service).toBeDefined();
});
describe('generateApplicationToken', () => {
it('should generate an application token successfully', async () => {
describe('generateApplicationAccessToken', () => {
it('should generate an application access token successfully', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
@@ -71,7 +73,7 @@ describe('ApplicationTokenService', () => {
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationToken({
const result = await service.generateApplicationAccessToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
@@ -90,9 +92,11 @@ describe('ApplicationTokenService', () => {
);
});
it('should handle missing userId successfully', async () => {
it('should include optional userWorkspaceId and userId in payload', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const userWorkspaceId = 'user-workspace-id';
const userId = 'user-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
@@ -105,9 +109,11 @@ describe('ApplicationTokenService', () => {
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationToken({
const result = await service.generateApplicationAccessToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
expiresInSeconds: 10,
});
@@ -119,7 +125,9 @@ describe('ApplicationTokenService', () => {
expect.objectContaining({
sub: applicationId,
applicationId,
workspaceId: workspaceId,
workspaceId,
userWorkspaceId,
userId,
}),
expect.any(Object),
);
@@ -137,7 +145,7 @@ describe('ApplicationTokenService', () => {
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
await expect(
service.generateApplicationToken({
service.generateApplicationAccessToken({
applicationId: 'non-existent-application',
workspaceId: 'workspace-id',
expiresInSeconds: 10,
@@ -149,11 +157,131 @@ describe('ApplicationTokenService', () => {
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.generateApplicationToken({
service.generateApplicationAccessToken({
applicationId: 'application-id',
workspaceId: 'non-existent-workspace',
expiresInSeconds: 10,
}),
).rejects.toThrow(WorkspaceException);
});
describe('validateApplicationRefreshToken', () => {
it('should validate and return payload for a valid refresh token', () => {
const mockToken = 'valid-refresh-token';
const mockPayload = {
sub: 'application-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
};
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockReturnValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
const result = service.validateApplicationRefreshToken(mockToken);
expect(result).toEqual(mockPayload);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
json: true,
});
});
it('should throw when token type is not APPLICATION_REFRESH', () => {
const mockToken = 'access-token';
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockReturnValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({
sub: 'application-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
});
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
AuthException,
);
});
it('should throw when token verification fails', () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
throw new Error('Invalid token');
});
expect(() =>
service.validateApplicationRefreshToken(mockToken),
).toThrow();
});
});
describe('generateApplicationTokenPair', () => {
it('should generate both access and refresh tokens', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationTokenPair({
workspaceId,
applicationId,
});
expect(result.applicationAccessToken).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(result.applicationRefreshToken).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledTimes(2);
});
});
describe('renewApplicationTokens', () => {
it('should generate a new token pair from validated payload', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.renewApplicationTokens({
workspaceId,
applicationId,
});
expect(result.applicationAccessToken).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(result.applicationRefreshToken).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
});
});
});
@@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { type Repository } from 'typeorm';
import { addMilliseconds } from 'date-fns';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import ms from 'ms';
@@ -9,20 +9,29 @@ import ms from 'ms';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
ApplicationTokenJwtPayload,
type ApplicationAccessTokenJwtPayload,
type ApplicationRefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
@Injectable()
export class ApplicationTokenService {
constructor(
@Inject(JwtWrapperService)
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@@ -30,17 +39,113 @@ export class ApplicationTokenService {
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async generateApplicationToken({
async generateApplicationAccessToken({
workspaceId,
applicationId,
expiresInSeconds,
}: Omit<ApplicationTokenJwtPayload, 'type' | 'sub'> & {
expiresInSeconds: number;
userWorkspaceId,
userId,
expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
}: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
expiresInSeconds?: number;
}): Promise<AuthToken> {
const expiresIn = `${expiresInSeconds}s`;
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
return this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresInSeconds,
});
}
async generateApplicationTokenPair({
workspaceId,
applicationId,
userWorkspaceId,
userId,
}: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
}): Promise<{
applicationAccessToken: AuthToken;
applicationRefreshToken: AuthToken;
}> {
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
[
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresInSeconds: APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
}),
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresInSeconds: APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS,
}),
],
);
return { applicationAccessToken, applicationRefreshToken };
}
validateApplicationRefreshToken(
refreshToken: string,
): ApplicationRefreshTokenJwtPayload {
this.jwtWrapperService.verifyJwtToken(refreshToken);
const payload =
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
refreshToken,
{ json: true },
);
if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) {
throw new AuthException(
'Expected an application refresh token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
return payload;
}
async renewApplicationTokens(payload: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
}): Promise<{
applicationAccessToken: AuthToken;
applicationRefreshToken: AuthToken;
}> {
return this.generateApplicationTokenPair({
workspaceId: payload.workspaceId,
applicationId: payload.applicationId,
userWorkspaceId: payload.userWorkspaceId,
userId: payload.userId,
});
}
private async validateWorkspaceAndApplication(
workspaceId: string,
applicationId: string,
): Promise<void> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
@@ -58,18 +163,43 @@ export class ApplicationTokenService {
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
),
);
}
const jwtPayload: ApplicationTokenJwtPayload = {
private signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType,
expiresInSeconds,
}: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
tokenType:
| JwtTokenTypeEnum.APPLICATION_ACCESS
| JwtTokenTypeEnum.APPLICATION_REFRESH;
expiresInSeconds: number;
}): AuthToken {
const expiresIn = `${expiresInSeconds}s`;
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload:
| ApplicationAccessTokenJwtPayload
| ApplicationRefreshTokenJwtPayload = {
sub: applicationId,
applicationId,
workspaceId,
type: JwtTokenTypeEnum.APPLICATION,
type: tokenType,
...(userWorkspaceId ? { userWorkspaceId } : {}),
...(userId ? { userId } : {}),
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APPLICATION,
tokenType,
workspaceId,
),
expiresIn,
@@ -40,7 +40,8 @@ export enum JwtTokenTypeEnum {
POSTGRES_PROXY = 'POSTGRES_PROXY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION = 'APPLICATION',
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
}
type CommonPropertiesJwtPayload = {
@@ -102,10 +103,20 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
jti?: string;
};
export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION;
export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_ACCESS;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};
export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_REFRESH;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
@@ -127,7 +138,8 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
export type JwtPayload =
| AccessTokenJwtPayload
| ApiKeyTokenJwtPayload
| ApplicationTokenJwtPayload
| ApplicationAccessTokenJwtPayload
| ApplicationRefreshTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| LoginTokenJwtPayload
| TransientTokenJwtPayload
@@ -179,7 +179,7 @@ export class LogicFunctionExecutorService {
flatApplicationVariables: FlatApplicationVariable[];
}) {
const applicationAccessToken =
await this.applicationTokenService.generateApplicationToken({
await this.applicationTokenService.generateApplicationAccessToken({
workspaceId,
applicationId: flatApplication.id,
expiresInSeconds: Math.max(