1895 extensibility v1 application tokens 3 (#16504)

- moves applicationRoleId to application entity
- add new `APPLICATION` FieldActorSource and `APPLICATION`
JwtTokenTypeEnum value
- create a new token with applicationId when executing a function
- when applicationId is in token, check for application.defaultRole
permissions
-use twenty-shared types in `twenty-sdk/application`
- create a new import from generate called "Twenty" that you can use
directly without having to set TWENTY_API_KEY AND TWENTY_API_URL (keep
metadata or core parameter only)
- provide to serverless unique one time BEARER TOKEN to run it

Result
<img width="977" height="566" alt="image"
src="https://github.com/user-attachments/assets/e78428a0-5b13-4975-aa13-58ee3b32450c"
/>

<img width="910" height="596" alt="image"
src="https://github.com/user-attachments/assets/6ec72bf5-7655-4093-a45e-ad269595a324"
/>

<img width="741" height="568" alt="image"
src="https://github.com/user-attachments/assets/7683944c-fd79-4417-8fb2-8e4815cc112f"
/>
This commit is contained in:
martmull
2025-12-15 17:44:23 +01:00
committed by GitHub
parent e33f18bfa8
commit e289f3056e
103 changed files with 1427 additions and 512 deletions
@@ -14,6 +14,7 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { buildCreatedByFromApplication } from 'src/engine/core-modules/actor/utils/build-created-by-from-application.util';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CreateInput = Record<string, any>;
@@ -106,7 +107,7 @@ export class CreatedByFromAuthContextService {
private async buildCreatedBy(
authContext: AuthContext,
): Promise<ActorMetadata> {
const { workspace, user, apiKey } = authContext;
const { workspace, user, apiKey, application } = authContext;
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
@@ -143,6 +144,12 @@ export class CreatedByFromAuthContextService {
});
}
if (isDefined(application)) {
return buildCreatedByFromApplication({
application,
});
}
throw new Error(
'Unable to build createdBy metadata - no valid actor information found in auth context',
);
@@ -0,0 +1,15 @@
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
type BuildCreatedByFromApplicationArgs = {
application: ApplicationEntity;
};
export const buildCreatedByFromApplication = ({
application,
}: BuildCreatedByFromApplicationArgs): ActorMetadata => ({
source: FieldActorSource.APPLICATION,
name: application.name,
workspaceMemberId: null,
context: {},
});
@@ -389,7 +389,7 @@ describe('ApiKeyService', () => {
);
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.ACCESS,
JwtTokenTypeEnum.API_KEY,
mockWorkspaceId,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
@@ -143,7 +143,7 @@ export class ApiKeyService {
await this.validateApiKey(apiKeyId, workspaceId);
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
JwtTokenTypeEnum.API_KEY,
workspaceId,
);
@@ -16,10 +16,10 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
@Module({
imports: [
@@ -37,9 +37,9 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
WorkspaceMigrationV2Module,
PermissionsModule,
RoleModule,
RoleTargetModule,
ObjectPermissionModule,
PermissionFlagModule,
WorkflowCommonModule,
],
providers: [
ApplicationResolver,
@@ -3,11 +3,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { parse } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { Sources } from 'twenty-shared/types';
import { HTTPMethod, Sources } from 'twenty-shared/types';
import {
ApplicationManifest,
FieldManifest,
ObjectManifest,
RoleManifest,
ServerlessFunctionManifest,
ServerlessFunctionTriggerManifest,
} from 'twenty-shared/application';
@@ -39,10 +40,10 @@ import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-f
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
import { PermissionFlagService } from 'src/engine/metadata-modules/permission-flag/permission-flag.service';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
@Injectable()
export class ApplicationSyncService {
@@ -62,7 +63,6 @@ export class ApplicationSyncService {
private readonly routeTriggerV2Service: RouteTriggerV2Service,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly roleService: RoleService,
private readonly roleTargetService: RoleTargetService,
private readonly objectPermissionService: ObjectPermissionService,
private readonly fieldPermissionService: FieldPermissionService,
private readonly permissionService: PermissionFlagService,
@@ -106,6 +106,12 @@ export class ApplicationSyncService {
});
}
await this.syncRoles({
manifest,
workspaceId,
applicationId: application.id,
});
this.logger.log('✅ Application sync from manifest completed');
}
@@ -130,17 +136,12 @@ export class ApplicationSyncService {
version: packageJson.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
serverlessFunctionLayerId: null,
defaultServerlessFunctionRoleId: null,
workspaceId,
}));
let serverlessFunctionLayerId = application.serverlessFunctionLayerId;
await this.syncApplicationRole({
applicationId: application.id,
manifest,
workspaceId,
});
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(serverlessFunctionLayerId)) {
serverlessFunctionLayerId = (
@@ -175,114 +176,177 @@ export class ApplicationSyncService {
description: manifest.application.description,
version: packageJson.version,
serverlessFunctionLayerId,
defaultServerlessFunctionRoleId: null,
});
}
private async syncApplicationRole({
private async syncRoles({
manifest,
applicationId,
workspaceId,
applicationId,
}: {
manifest: ApplicationManifest;
applicationId: string;
workspaceId: string;
applicationId: string;
}) {
const applicationRole = manifest.application.applicationRole;
let defaultServerlessFunctionRoleId: string | null = null;
if (
isDefined(applicationRole) &&
isDefined(applicationRole.universalIdentifier)
) {
let role = await this.roleService.getRoleByUniversalIdentifier({
universalIdentifier: applicationRole.universalIdentifier,
for (const role of manifest.roles ?? []) {
let existingRole = await this.roleService.getRoleByUniversalIdentifier({
universalIdentifier: role.universalIdentifier,
workspaceId,
});
if (role) {
if (existingRole) {
await this.roleService.updateRole({
input: {
id: role.id,
update: {
...applicationRole,
canAccessAllTools: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
canBeAssignedToApplications: true,
},
id: existingRole.id,
update: role,
},
workspaceId,
});
} else {
role = await this.roleService.createRole({
input: {
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
...applicationRole,
canAccessAllTools: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
canBeAssignedToApplications: true,
},
workspaceId,
applicationId: applicationId,
});
await this.roleTargetService.create({
createRoleTargetInput: {
roleId: role.id,
applicationId: applicationId,
universalIdentifier: role.universalIdentifier,
targetMetadataForeignKey: 'targetApplicationId',
targetId: applicationId,
},
existingRole = await this.roleService.createRole({
input: role,
workspaceId,
applicationId,
});
}
await this.syncApplicationRolePermissions({
role,
workspaceId,
roleId: existingRole.id,
});
if (
isDefined(applicationRole.objectPermissions) &&
applicationRole.objectPermissions.length > 0
existingRole.universalIdentifier ===
manifest.application.functionRoleUniversalIdentifier
) {
defaultServerlessFunctionRoleId = existingRole.id;
}
}
if (isDefined(defaultServerlessFunctionRoleId)) {
await this.applicationService.update(applicationId, {
defaultServerlessFunctionRoleId: defaultServerlessFunctionRoleId,
});
}
}
private async syncApplicationRolePermissions({
role,
workspaceId,
roleId,
}: {
role: RoleManifest;
workspaceId: string;
roleId: string;
}) {
if (
(role.objectPermissions ?? []).length > 0 ||
(role.fieldPermissions ?? []).length > 0
) {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular: objectIdByNameSingular } =
buildObjectIdByNameMaps(flatObjectMetadataMaps);
const formattedObjectPermissions = role.objectPermissions
?.map((perm) => ({
...perm,
objectMetadataId: isDefined(perm.objectNameSingular)
? objectIdByNameSingular[perm.objectNameSingular]
: isDefined(perm.objectUniversalIdentifier)
? flatObjectMetadataMaps.idByUniversalIdentifier[
perm.objectUniversalIdentifier
]
: undefined,
}))
.filter((perm): perm is typeof perm & { objectMetadataId: string } =>
isDefined(perm.objectMetadataId),
);
if (isDefined(formattedObjectPermissions)) {
await this.objectPermissionService.upsertObjectPermissions({
workspaceId,
input: {
roleId: role.id,
objectPermissions: applicationRole.objectPermissions,
roleId,
objectPermissions: formattedObjectPermissions,
},
});
}
if (
isDefined(applicationRole.fieldPermissions) &&
applicationRole.fieldPermissions.length > 0
) {
const formattedFieldPermissions = role?.fieldPermissions
?.map((perm) => {
const objectMetadataId = isDefined(perm.objectNameSingular)
? objectIdByNameSingular[perm.objectNameSingular]
: isDefined(perm.objectUniversalIdentifier)
? flatObjectMetadataMaps.idByUniversalIdentifier[
perm.objectUniversalIdentifier
]
: undefined;
const fieldMetadataId = isDefined(objectMetadataId)
? isDefined(perm.fieldName)
? Object.values(flatFieldMetadataMaps.byId).find(
(flatField) =>
isDefined(flatField) &&
flatField.objectMetadataId === objectMetadataId &&
flatField.name === perm.fieldName,
)?.id
: isDefined(perm.fieldUniversalIdentifier)
? Object.values(flatFieldMetadataMaps.byId).find(
(flatField) =>
isDefined(flatField) &&
flatField.objectMetadataId === objectMetadataId &&
flatField.universalIdentifier ===
perm.fieldUniversalIdentifier,
)?.id
: undefined
: undefined;
return {
...perm,
objectMetadataId,
fieldMetadataId,
};
})
.filter(
(
perm,
): perm is typeof perm & {
objectMetadataId: string;
fieldMetadataId: string;
} =>
isDefined(perm.objectMetadataId) && isDefined(perm.fieldMetadataId),
);
if (isDefined(formattedFieldPermissions)) {
await this.fieldPermissionService.upsertFieldPermissions({
workspaceId,
input: {
roleId: role.id,
fieldPermissions: applicationRole.fieldPermissions,
roleId,
fieldPermissions: formattedFieldPermissions,
},
});
}
}
if (
isDefined(applicationRole.permissionFlags) &&
applicationRole.permissionFlags.length > 0
) {
await this.permissionService.upsertPermissionFlags({
workspaceId,
input: {
roleId: role.id,
permissionFlagKeys: applicationRole.permissionFlags,
},
});
}
if (isDefined(role?.permissionFlags) && role.permissionFlags.length > 0) {
await this.permissionService.upsertPermissionFlags({
workspaceId,
input: {
roleId,
permissionFlagKeys: role.permissionFlags,
},
});
}
}
@@ -1036,7 +1100,7 @@ export class ApplicationSyncService {
id: triggerToUpdate.id,
update: {
path: triggerToSync.path,
httpMethod: triggerToSync.httpMethod,
httpMethod: triggerToSync.httpMethod as HTTPMethod,
isAuthRequired: triggerToSync.isAuthRequired,
},
};
@@ -1054,7 +1118,7 @@ export class ApplicationSyncService {
const createRouteTriggerInput = {
path: triggerToCreate.path,
httpMethod: triggerToCreate.httpMethod,
httpMethod: triggerToCreate.httpMethod as HTTPMethod,
isAuthRequired: triggerToCreate.isAuthRequired,
serverlessFunctionId,
};
@@ -1,4 +1,4 @@
import { ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import {
Column,
@@ -14,6 +14,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -60,6 +61,12 @@ export class ApplicationEntity {
@Column({ nullable: true, type: 'uuid' })
serverlessFunctionLayerId: string | null;
@Column({ nullable: true, type: 'uuid' })
defaultServerlessFunctionRoleId: string | null;
@Field(() => RoleDTO, { nullable: true })
defaultServerlessFunctionRole: RoleDTO | null;
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@@ -13,6 +13,7 @@ import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicatio
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@ObjectType('Application')
export class ApplicationDTO {
@@ -43,6 +44,15 @@ export class ApplicationDTO {
@IsBoolean()
canBeUninstalled: boolean;
@IsOptional()
@IsString()
@Field({ nullable: true })
defaultServerlessFunctionRoleId?: string;
@IsOptional()
@Field(() => RoleDTO, { nullable: true })
defaultServerlessFunctionRole?: RoleDTO;
@Field(() => [AgentDTO])
agents?: AgentDTO[];
@@ -13,6 +13,7 @@ export const AuthExceptionCode = appendCommonExceptionCode({
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND: 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND',
INVALID_INPUT: 'INVALID_INPUT',
FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION',
INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES',
@@ -63,6 +63,7 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { TwoFactorAuthenticationMethodEntity } from '../two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module';
@@ -87,6 +88,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UserEntity,
AppTokenEntity,
ApiKeyEntity,
ApplicationEntity,
FeatureFlagEntity,
WorkspaceSSOIdentityProviderEntity,
KeyValuePairEntity,
@@ -4,7 +4,10 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -24,6 +27,7 @@ describe('JwtAuthStrategy', () => {
let userWorkspaceRepository: any;
let userRepository: any;
let apiKeyRepository: any;
let applicationRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
@@ -49,6 +53,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(),
};
applicationRepository = {
findOne: jest.fn(),
};
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
@@ -66,7 +74,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
workspaceRepository.findOneBy.mockResolvedValue(null);
@@ -74,6 +82,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -91,7 +100,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -104,6 +113,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -121,7 +131,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -137,6 +147,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -154,7 +165,7 @@ describe('JwtAuthStrategy', () => {
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -170,6 +181,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -198,7 +210,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -210,6 +222,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -234,7 +247,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -248,6 +261,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -272,7 +286,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -290,6 +304,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -303,6 +318,44 @@ describe('JwtAuthStrategy', () => {
});
});
describe('APPLICATION token validation', () => {
it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => {
const validApplicationId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validApplicationId,
type: JwtTokenTypeEnum.APPLICATION,
applicationId: validApplicationId,
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
applicationRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('Application not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND);
}
});
});
describe('Impersonation validation', () => {
it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => {
const validUserId = randomUUID();
@@ -311,7 +364,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -335,6 +388,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -357,7 +411,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -380,6 +434,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -401,7 +456,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -427,6 +482,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -449,7 +505,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -485,6 +541,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -507,7 +564,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -537,6 +594,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -560,7 +618,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -607,6 +665,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -629,7 +688,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -676,6 +735,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -699,7 +759,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -746,6 +806,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -768,7 +829,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -809,6 +870,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -837,7 +899,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -874,6 +936,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -16,9 +16,11 @@ import {
import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationTokenJwtPayload,
type AuthContext,
type FileTokenJwtPayload,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -27,12 +29,16 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(UserWorkspaceEntity)
@@ -52,7 +58,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
>(rawJwtToken);
const appSecretBody =
decodedToken.type === 'WORKSPACE_AGNOSTIC'
decodedToken.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
? decodedToken.userId
: decodedToken.workspaceId;
@@ -270,7 +276,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private async validateWorkspaceAgnosticToken(
payload: WorkspaceAgnosticTokenJwtPayload,
) {
): Promise<AuthContext> {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
@@ -283,6 +289,39 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, authProvider: payload.authProvider };
}
private async validateApplicationToken(
payload: ApplicationTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
if (!isDefined(workspace)) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const applicationId = payload.sub ?? payload.applicationId;
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (!isDefined(application)) {
throw new AuthException(
'Application not found',
AuthExceptionCode.APPLICATION_NOT_FOUND,
);
}
return {
application,
workspace,
};
}
private isLegacyApiKeyPayload(
payload: JwtPayload,
): payload is ApiKeyTokenJwtPayload {
@@ -291,19 +330,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
async validate(payload: JwtPayload): Promise<AuthContext> {
// Support legacy api keys
if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) {
if (
payload.type === JwtTokenTypeEnum.API_KEY ||
this.isLegacyApiKeyPayload(payload)
) {
return await this.validateAPIKey(payload);
}
if (payload.type === 'WORKSPACE_AGNOSTIC') {
if (payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) {
return await this.validateWorkspaceAgnosticToken(payload);
}
// `!payload.type` is here to support legacy token
if (payload.type === 'ACCESS' || !payload.type) {
if (payload.type === JwtTokenTypeEnum.ACCESS) {
return await this.validateAccessToken(payload);
}
if (payload.type === JwtTokenTypeEnum.APPLICATION) {
return await this.validateApplicationToken(payload);
}
throw new AuthException(
'Invalid token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
@@ -265,10 +265,7 @@ describe('AccessTokenService', () => {
const result = await service.validateTokenByRequest(mockRequest);
expect(result).toEqual(mockAuthContext);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'ACCESS',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(service['jwtStrategy'].validate).toHaveBeenCalledWith(
mockDecodedToken,
@@ -154,7 +154,7 @@ export class AccessTokenService {
}
async validateToken(token: string): Promise<AuthContext> {
await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS);
await this.jwtWrapperService.verifyJwtToken(token);
const decoded = this.jwtWrapperService.decode<AccessTokenJwtPayload>(token);
@@ -0,0 +1,159 @@
import { Test, type TestingModule } from '@nestjs/testing';
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 { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
describe('ApplicationTokenService', () => {
let service: ApplicationTokenService;
let jwtWrapperService: JwtWrapperService;
let workspaceRepository: Repository<WorkspaceEntity>;
let applicationRepository: Repository<ApplicationEntity>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationTokenService,
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
},
},
{
provide: getRepositoryToken(ApplicationEntity),
useClass: Repository,
},
{
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
],
}).compile();
service = module.get<ApplicationTokenService>(ApplicationTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
applicationRepository = module.get<Repository<ApplicationEntity>>(
getRepositoryToken(ApplicationEntity),
);
workspaceRepository = module.get<Repository<WorkspaceEntity>>(
getRepositoryToken(WorkspaceEntity),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateApplicationToken', () => {
it('should generate an application token successfully', 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.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
}),
expect.any(Object),
);
});
it('should handle missing userId successfully', 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.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
workspaceId: workspaceId,
}),
expect.any(Object),
);
});
});
it('should throw an error if application is not found', async () => {
const workspaceId = 'workspace-id';
const mockWorkspace = { id: workspaceId };
jest.spyOn(applicationRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
await expect(
service.generateApplicationToken({
applicationId: 'non-existent-application',
workspaceId: 'workspace-id',
expiresInSeconds: 10,
}),
).rejects.toThrow(ApplicationException);
});
it('should throw an error if workspace is not found', async () => {
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.generateApplicationToken({
applicationId: 'application-id',
workspaceId: 'non-existent-workspace',
expiresInSeconds: 10,
}),
).rejects.toThrow(WorkspaceException);
});
});
@@ -0,0 +1,80 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { addMilliseconds } from 'date-fns';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
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,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { 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';
@Injectable()
export class ApplicationTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds,
}: Omit<ApplicationTokenJwtPayload, 'type' | 'sub'> & {
expiresInSeconds: number;
}): Promise<AuthToken> {
const expiresIn = `${expiresInSeconds}s`;
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
});
assertIsDefinedOrThrow(
application,
new ApplicationException(
'Application not found',
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
),
);
const jwtPayload: ApplicationTokenJwtPayload = {
sub: applicationId,
applicationId,
workspaceId,
type: JwtTokenTypeEnum.APPLICATION,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APPLICATION,
workspaceId,
),
expiresIn,
}),
expiresAt,
};
}
}
@@ -3,6 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
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 { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { LoginTokenService } from './login-token.service';
@@ -67,7 +68,7 @@ describe('LoginTokenService', () => {
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(twentyConfigService.get).toHaveBeenCalledWith(
@@ -77,7 +78,7 @@ describe('LoginTokenService', () => {
{
sub: email,
workspaceId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Password,
impersonatorUserId: undefined,
},
@@ -112,14 +113,14 @@ describe('LoginTokenService', () => {
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Impersonation,
impersonatorUserWorkspaceId,
},
@@ -143,10 +144,7 @@ describe('LoginTokenService', () => {
const result = await service.verifyLoginToken(mockToken);
expect(result).toEqual({ sub: mockEmail });
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
json: true,
});
@@ -52,10 +52,7 @@ export class LoginTokenService {
}
async verifyLoginToken(loginToken: string): Promise<LoginTokenJwtPayload> {
await this.jwtWrapperService.verifyJwtToken(
loginToken,
JwtTokenTypeEnum.LOGIN,
);
await this.jwtWrapperService.verifyJwtToken(loginToken);
return this.jwtWrapperService.decode(loginToken, {
json: true,
@@ -100,10 +100,7 @@ describe('RefreshTokenService', () => {
const result = await service.verifyRefreshToken(mockToken);
expect(result).toEqual({ user: mockUser, token: mockAppToken });
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'REFRESH',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
});
it('should throw an error if the token is malformed', async () => {
@@ -154,9 +151,9 @@ describe('RefreshTokenService', () => {
{
sub: userId,
workspaceId,
type: 'REFRESH',
type: JwtTokenTypeEnum.REFRESH,
userId: 'user-id',
targetedTokenType: 'ACCESS',
targetedTokenType: JwtTokenTypeEnum.ACCESS,
},
expect.objectContaining({
secret: 'mock-secret',
@@ -190,8 +187,8 @@ describe('RefreshTokenService', () => {
(jwtWrapperService.decode as jest.Mock).mockReturnValue({
sub: userId,
jti: tokenId,
type: 'REFRESH',
targetedTokenType: 'ACCESS',
type: JwtTokenTypeEnum.REFRESH,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
@@ -36,10 +36,7 @@ export class RefreshTokenService {
async verifyRefreshToken(refreshToken: string) {
const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN');
await this.jwtWrapperService.verifyJwtToken(
refreshToken,
JwtTokenTypeEnum.REFRESH,
);
await this.jwtWrapperService.verifyJwtToken(refreshToken);
const jwtPayload =
this.jwtWrapperService.decode<RefreshTokenJwtPayload>(refreshToken);
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
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 { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TransientTokenService } from './transient-token.service';
@@ -72,7 +73,7 @@ describe('TransientTokenService', () => {
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: workspaceMemberId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
userId,
workspaceId,
workspaceMemberId,
@@ -108,10 +109,7 @@ describe('TransientTokenService', () => {
userId: mockPayload.userId,
workspaceId: mockPayload.workspaceId,
});
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
});
@@ -53,10 +53,7 @@ export class TransientTokenService {
async verifyTransientToken(
transientToken: string,
): Promise<Omit<TransientTokenJwtPayload, 'type' | 'sub'>> {
await this.jwtWrapperService.verifyJwtToken(
transientToken,
JwtTokenTypeEnum.LOGIN,
);
await this.jwtWrapperService.verifyJwtToken(transientToken);
const { type: _type, ...payload } =
this.jwtWrapperService.decode<TransientTokenJwtPayload>(transientToken);
@@ -9,6 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
describe('WorkspaceAgnosticToken', () => {
let service: WorkspaceAgnosticTokenService;
@@ -95,7 +96,7 @@ describe('WorkspaceAgnosticToken', () => {
authProvider: AuthProviderEnum.Password,
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
},
expect.objectContaining({
secret: 'mocked-secret',
@@ -132,7 +133,7 @@ describe('WorkspaceAgnosticToken', () => {
const mockPayload = {
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
const mockUser = { id: userId };
@@ -177,7 +178,7 @@ describe('WorkspaceAgnosticToken', () => {
const mockPayload = {
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
@@ -16,6 +16,8 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } 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';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Module({
imports: [
@@ -26,6 +28,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
WorkspaceEntity,
UserWorkspaceEntity,
ApiKeyEntity,
ApplicationEntity,
]),
TypeORMModule,
DataSourceModule,
@@ -38,6 +41,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
LoginTokenService,
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
],
exports: [
RenewTokenService,
@@ -45,6 +49,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
LoginTokenService,
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
],
})
export class TokenModule {}
@@ -3,12 +3,14 @@ import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
export type AuthContext = {
user?: UserEntity | null | undefined;
apiKey?: ApiKeyEntity | null | undefined;
workspaceMemberId?: string;
workspace?: WorkspaceEntity;
application?: ApplicationEntity | null | undefined;
userWorkspaceId?: string;
userWorkspace?: UserWorkspaceEntity;
authProvider?: AuthProviderEnum;
@@ -28,6 +30,7 @@ export enum JwtTokenTypeEnum {
POSTGRES_PROXY = 'POSTGRES_PROXY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION = 'APPLICATION',
}
type CommonPropertiesJwtPayload = {
@@ -83,6 +86,12 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
jti?: string;
};
export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION;
workspaceId: string;
applicationId: string;
};
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.ACCESS;
workspaceId: string;
@@ -106,6 +115,7 @@ export type RemoteServerTokenJwtPayload = CommonPropertiesJwtPayload & {
export type JwtPayload =
| AccessTokenJwtPayload
| ApiKeyTokenJwtPayload
| ApplicationTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| LoginTokenJwtPayload
| TransientTokenJwtPayload
@@ -52,6 +52,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
});
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new AuthenticationError(exception);
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
@@ -30,6 +30,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
return 401;
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
@@ -4,10 +4,7 @@ import {
Injectable,
} from '@nestjs/common';
import {
type FileTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -28,7 +25,6 @@ export class FilePathGuard implements CanActivate {
try {
const payload = await this.jwtWrapperService.verifyJwtToken(
fileSignature,
JwtTokenTypeEnum.FILE,
ignoreExpirationToken ? { ignoreExpiration: true } : {},
);
@@ -17,13 +17,8 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type AccessTokenJwtPayload,
type FileTokenJwtPayload,
type JwtPayload,
JwtTokenTypeEnum,
type RefreshTokenJwtPayload,
type TransientTokenJwtPayload,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -54,16 +49,10 @@ export class JwtWrapperService {
verifyJwtToken(
token: string,
type: JwtTokenTypeEnum,
options?: JwtVerifyOptions,
isLegacyApiKey = false,
) {
const payload = this.decode<
| TransientTokenJwtPayload
| RefreshTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| AccessTokenJwtPayload
| FileTokenJwtPayload
>(token, {
const payload = this.decode<JwtPayload>(token, {
json: true,
});
@@ -71,37 +60,56 @@ export class JwtWrapperService {
throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED);
}
// @TODO: Migrate to use type from payload instead of parameter
type =
payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
? JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
: type;
const type = payload.type;
// TODO: check if this is really needed
if (type !== 'FILE' && !payload.sub) {
const appSecretBody =
'workspaceId' in payload
? payload.workspaceId
: 'userId' in payload
? payload.userId
: undefined;
if (!isDefined(appSecretBody)) {
throw new AuthException(
'No payload sub',
AuthExceptionCode.UNAUTHENTICATED,
'Invalid token type',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
try {
// TODO: Deprecate this once old API KEY tokens are no longer in use
if (!payload.type && !('workspaceId' in payload) && type === 'ACCESS') {
// Supporting old API KEY tokens
if (
!payload.type &&
!('workspaceId' in payload) &&
type === JwtTokenTypeEnum.API_KEY
) {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecretLegacy(),
});
}
const appSecretBody =
'workspaceId' in payload ? payload.workspaceId : payload.userId;
if (!isDefined(appSecretBody)) {
throw new AuthException(
'Invalid token type',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
// This is due to an unfortunate mistake in the secret generation of API_KEY
// tokens. We used to sign with ACCESS Jwt Token Type instead of API_KEY.
// Now we need to check both cases not to break the existing api keys
// See this PR for context -> https://github.com/twentyhq/twenty/pull/16504
// This code block can be deleted, but all api keys created before
// 12/12/2025 will be broken
if (type === JwtTokenTypeEnum.API_KEY && !isLegacyApiKey) {
try {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(type, appSecretBody),
});
} catch {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
appSecretBody,
),
});
}
}
return this.jwtService.verify(token, {
@@ -114,17 +122,17 @@ export class JwtWrapperService {
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else if (error instanceof jwt.JsonWebTokenError) {
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else {
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
}
@@ -99,10 +99,10 @@ export class OpenApiService {
}
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl(
this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
);
const baseUrl = getServerUrl({
serverUrlEnv: this.twentyConfigService.get('SERVER_URL'),
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
const tokenFromQuery = request.query.token;
const schema = baseSchema(
@@ -270,10 +270,10 @@ export class OpenApiService {
async generateMetaDataSchema(
request: Request,
): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl(
this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
);
const baseUrl = getServerUrl({
serverUrlEnv: this.twentyConfigService.get('SERVER_URL'),
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
const tokenFromQuery = request.query.token;
const schema = baseSchema(
@@ -18,9 +18,15 @@ export type ServerlessExecuteResult = {
// TODO refactor to be using FlatServerlessFunction
export interface ServerlessDriver {
delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult>;
execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult>;
}
@@ -27,7 +27,6 @@ import {
} from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies';
import { copyExecutor } from 'src/engine/core-modules/serverless/drivers/utils/copy-executor';
@@ -305,11 +304,17 @@ export class LambdaDriver implements ServerlessDriver {
.trim();
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult> {
async execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult> {
await this.build(serverlessFunction);
await this.waitFunctionUpdates(serverlessFunction);
@@ -348,7 +353,7 @@ export class LambdaDriver implements ServerlessDriver {
const executorPayload: LambdaDriverExecutorPayload = {
params: payload,
code: compiledCode,
env: buildEnvVar(serverlessFunction),
env: env ?? {},
handlerName: serverlessFunction.handlerName,
};
@@ -9,7 +9,6 @@ import {
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { SERVERLESS_TMPDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/serverless-tmpdir-folder';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies';
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
@@ -61,11 +60,17 @@ export class LocalDriver implements ServerlessDriver {
await this.createLayerIfNotExists(serverlessFunction);
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult> {
async execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult> {
await this.build(serverlessFunction);
const startTime = Date.now();
@@ -154,7 +159,7 @@ export class LocalDriver implements ServerlessDriver {
const { ok, result, error, stack, stdout, stderr } =
await this.runChildWithEnv({
runnerPath,
env: buildEnvVar(serverlessFunction),
env: env ?? {},
payload,
timeoutMs: 900_000, // timeout is handled by the serverless function service
});
@@ -16,11 +16,17 @@ export class ServerlessService implements ServerlessDriver {
return this.driver.delete(serverlessFunction);
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult> {
return this.driver.execute(serverlessFunction, payload, version);
async execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult> {
return this.driver.execute({ serverlessFunction, payload, version, env });
}
}