OAuth Client — Unified ApplicationRegistration, OAuth server, and frontend (#18267)

## Summary

Consolidates three separate PRs (#18260, #18261, #18262) into a single
unified branch with all review feedback addressed:

### New features
- **ApplicationRegistration entity** — server-level registration for
OAuth apps with encrypted server variables
- **OAuth 2.0 server** — authorization code, client credentials, refresh
token grants with PKCE support
- **OAuth discovery endpoint** —
`.well-known/oauth-authorization-server` metadata
- **Frontend UI** — app registration details page with credential
management, redirect URI editing, and server variable configuration
- **CLI integration** — `twenty dev` auto-registers apps and stores
OAuth credentials locally
- **Authorize consent screen** — OAuth consent page at `/authorize`
showing requested scopes

### Review feedback addressed

**Renames (PR #18260):**
- `appRegistration` → `applicationRegistration` (entity, tables, files,
imports, GraphQL types)
- `appRegistrationVariable` → `applicationRegistrationVariable`
- `clientId` → `oAuthClientId`, `clientSecretHash` →
`oAuthClientSecretHash`, `redirectUris` → `oAuthRedirectUris`, `scopes`
→ `oAuthScopes`

**Security fixes (PR #18261):**
- Fixed redirect URI validation bypass when `oAuthRedirectUris` is an
empty array
- Fixed workspace isolation in `clientCredentialsGrant` — now uses
`find()` with explicit handling for multiple installations
- Added error logging in refresh token `catch` block instead of silently
swallowing

**Code quality (PR #18262):**
- Split `VersionDistributionEntry` into its own file (one export per
file)
- Split GraphQL queries and mutations into individual files with a
shared fragment
- Removed unused `OAuth` entry from `AuthProviderEnum`
- Added loading state to `handleRotateSecret`
- Removed 27 narration-style comments from test files
- Added proper guards (`PublicEndpointGuard`, `NoPermissionGuard`) to
controllers and resolvers

## Test plan

- [ ] Verify `twenty dev` registers an app and stores OAuth credentials
- [ ] Test OAuth authorization code flow end-to-end (authorize → token →
API call)
- [ ] Test client credentials grant
- [ ] Verify redirect URI validation rejects requests when no URIs are
registered
- [ ] Verify app registration detail page renders correctly
- [ ] Test secret rotation with loading state
- [ ] Verify server variable editing and saving
- [ ] Run `npx nx database:reset twenty-server` to validate migration

Closes #18260, #18261, #18262


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-28 14:07:49 +01:00
committed by GitHub
parent 9fe2a07c55
commit 012d819557
242 changed files with 5717 additions and 1100 deletions
@@ -7,9 +7,9 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AdminAIModelsOutput } from 'src/engine/core-modules/client-config/client-config.entity';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto';
import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto';
import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto';
@@ -95,8 +95,8 @@ export class AdminPanelResolver {
}
@UseGuards(AdminPanelGuard)
@Query(() => ConfigVariablesOutput)
async getConfigVariablesGrouped(): Promise<ConfigVariablesOutput> {
@Query(() => ConfigVariablesDTO)
async getConfigVariablesGrouped(): Promise<ConfigVariablesDTO> {
return this.adminService.getConfigVariablesGrouped();
}
@@ -142,8 +142,8 @@ export class AdminPanelResolver {
}
@UseGuards(AdminPanelGuard)
@Query(() => AdminAIModelsOutput)
async getAdminAiModels(): Promise<AdminAIModelsOutput> {
@Query(() => AdminAIModelsDTO)
async getAdminAiModels(): Promise<AdminAIModelsDTO> {
const models = this.aiModelRegistryService
.getAllModelsWithStatus()
.map(({ modelConfig, isAvailable, isAdminEnabled }) => ({
@@ -9,7 +9,7 @@ import * as z from 'zod';
import { type ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { type ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
import { type ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { type ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { type UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
import { type VersionInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import {
@@ -112,7 +112,7 @@ export class AdminPanelService {
};
}
getConfigVariablesGrouped(): ConfigVariablesOutput {
getConfigVariablesGrouped(): ConfigVariablesDTO {
const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariableDTO[]>();
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
@ObjectType('ConfigVariablesOutput')
export class ConfigVariablesOutput {
@ObjectType('ConfigVariables')
export class ConfigVariablesDTO {
@Field(() => [ConfigVariablesGroupDataDTO])
groups: ConfigVariablesGroupDataDTO[];
}
@@ -3,8 +3,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceUrlsAndIdDTO } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
@ObjectType('ImpersonateOutput')
export class ImpersonateOutput {
@ObjectType('Impersonate')
export class ImpersonateDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
@ObjectType('UserInfo')
@@ -42,8 +42,8 @@ class WorkspaceInfoDTO {
@Field(() => [UserInfoDTO])
users: UserInfoDTO[];
@Field(() => [FeatureFlagEntity])
featureFlags: FeatureFlagEntity[];
@Field(() => [FeatureFlagDTO])
featureFlags: FeatureFlagDTO[];
}
@ObjectType('UserLookup')
@@ -6,10 +6,10 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto';
import { RevokeApiKeyInput } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.input';
import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.input';
import { RevokeApiKeyInput } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.input';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.input';
import {
ApiKeyException,
ApiKeyExceptionCode,
@@ -15,8 +15,8 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.input';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.input';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -89,5 +89,5 @@ export class AppTokenEntity {
}
@Column({ nullable: true, type: 'jsonb' })
context: { email: string } | null;
context: { email?: string; redirectUri?: string } | null;
}
@@ -0,0 +1,25 @@
import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
describe('OAuth Scopes', () => {
it('should have all scopes defined', () => {
expect(ALL_OAUTH_SCOPES).toContain('api');
expect(ALL_OAUTH_SCOPES).toContain('profile');
expect(ALL_OAUTH_SCOPES).toHaveLength(2);
});
it('should have descriptions for all scopes', () => {
for (const scope of ALL_OAUTH_SCOPES) {
expect(OAUTH_SCOPE_DESCRIPTIONS[scope]).toBeDefined();
expect(OAUTH_SCOPE_DESCRIPTIONS[scope].length).toBeGreaterThan(0);
}
});
it('should have consistent keys and values', () => {
expect(OAUTH_SCOPES.API).toBe('api');
expect(OAUTH_SCOPES.PROFILE).toBe('profile');
});
});
@@ -0,0 +1,74 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
@Entity({ name: 'applicationRegistrationVariable', schema: 'core' })
@ObjectType('ApplicationRegistrationVariable')
@Unique('IDX_APP_REG_VAR_KEY_APP_REGISTRATION_ID_UNIQUE', [
'key',
'applicationRegistrationId',
])
@Index('IDX_APP_REG_VAR_APP_REGISTRATION_ID', ['applicationRegistrationId'])
export class ApplicationRegistrationVariableEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'text' })
key: string;
@Column({ nullable: false, type: 'text', default: '' })
encryptedValue: string;
@Field()
@Column({ nullable: false, type: 'text', default: '' })
description: string;
@Field()
@Column({ nullable: false, type: 'boolean', default: true })
isSecret: boolean;
@Field()
@Column({ nullable: false, type: 'boolean', default: false })
isRequired: boolean;
@Field()
get isFilled(): boolean {
return this.encryptedValue !== '';
}
@Column({ nullable: false, type: 'uuid' })
applicationRegistrationId: string;
@ManyToOne(
() => ApplicationRegistrationEntity,
(applicationRegistration) => applicationRegistration.variables,
{ onDelete: 'CASCADE', nullable: false },
)
@JoinColumn({ name: 'applicationRegistrationId' })
applicationRegistration: Relation<ApplicationRegistrationEntity>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,165 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application-registration/application-registration.exception';
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ApplicationRegistrationVariableService {
constructor(
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly variableRepository: Repository<ApplicationRegistrationVariableEntity>,
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly encryptionService: SecretEncryptionService,
) {}
async findVariables(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationVariableEntity[]> {
return this.variableRepository.find({
where: { applicationRegistrationId },
order: { key: 'ASC' },
});
}
async createVariable(
input: CreateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
await this.assertRegistrationExists(input.applicationRegistrationId);
const encryptedValue = this.encryptionService.encrypt(input.value);
const variable = this.variableRepository.create({
applicationRegistrationId: input.applicationRegistrationId,
key: input.key,
encryptedValue,
description: input.description ?? '',
isSecret: input.isSecret ?? true,
});
return this.variableRepository.save(variable);
}
async updateVariable(
input: UpdateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
const { id, update } = input;
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new ApplicationRegistrationException(
`Variable with id ${id} not found`,
ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
const updateData: Record<string, unknown> = {};
if (isDefined(update.value)) {
updateData.encryptedValue = this.encryptionService.encrypt(update.value);
}
if (isDefined(update.description)) {
updateData.description = update.description;
}
if (Object.keys(updateData).length > 0) {
await this.variableRepository.update(id, updateData);
}
return this.variableRepository.findOneOrFail({ where: { id } });
}
async deleteVariable(id: string): Promise<boolean> {
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new ApplicationRegistrationException(
`Variable with id ${id} not found`,
ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
await this.variableRepository.delete(id);
return true;
}
// Syncs variable schemas from manifest: creates missing, updates metadata, removes stale
async syncVariableSchemas(
applicationRegistrationId: string,
serverVariables: ServerVariables,
): Promise<void> {
const declaredKeys = Object.keys(serverVariables);
const existingVariables = await this.variableRepository.find({
where: { applicationRegistrationId },
});
const existingByKey = new Map(
existingVariables.map((variable) => [variable.key, variable]),
);
for (const [key, schema] of Object.entries(serverVariables)) {
const existing = existingByKey.get(key);
if (existing) {
await this.variableRepository.update(existing.id, {
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
});
} else {
await this.variableRepository.save(
this.variableRepository.create({
applicationRegistrationId,
key,
encryptedValue: '',
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
}),
);
}
}
if (declaredKeys.length > 0) {
await this.variableRepository.delete({
applicationRegistrationId,
key: Not(In(declaredKeys)),
});
} else {
await this.variableRepository.delete({ applicationRegistrationId });
}
}
private async assertRegistrationExists(id: string): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
}
}
@@ -0,0 +1,113 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Entity({ name: 'applicationRegistration', schema: 'core' })
@ObjectType('ApplicationRegistration')
@Index(
'IDX_APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE',
['universalIdentifier'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
@Index(
'IDX_APPLICATION_REGISTRATION_OAUTH_CLIENT_ID_UNIQUE',
['oAuthClientId'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
export class ApplicationRegistrationEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Field()
@Column({ nullable: false, type: 'text' })
name: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
description: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
logoUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
author: string | null;
@Field()
@Column({ nullable: false, type: 'text' })
oAuthClientId: string;
@Column({ nullable: true, type: 'text' })
oAuthClientSecretHash: string | null;
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
oAuthRedirectUris: string[];
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
oAuthScopes: string[];
@Column({ nullable: true, type: 'uuid' })
createdByUserId: string | null;
@ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'createdByUserId' })
createdByUser: Relation<UserEntity> | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@OneToMany(
() => ApplicationRegistrationVariableEntity,
(variable) => variable.applicationRegistration,
{ onDelete: 'CASCADE' },
)
variables: Relation<ApplicationRegistrationVariableEntity[]>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,45 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum ApplicationRegistrationExceptionCode {
APPLICATION_REGISTRATION_NOT_FOUND = 'APPLICATION_REGISTRATION_NOT_FOUND',
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
INVALID_SCOPE = 'INVALID_SCOPE',
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
}
const getExceptionUserFriendlyMessage = (
code: ApplicationRegistrationExceptionCode,
) => {
switch (code) {
case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
return msg`Application registration not found.`;
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
return msg`This universal identifier is already claimed by another registration.`;
case ApplicationRegistrationExceptionCode.INVALID_SCOPE:
return msg`One or more requested scopes are invalid.`;
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
return msg`One or more redirect URIs are invalid.`;
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
return msg`Application registration variable not found.`;
default:
assertUnreachable(code);
}
};
export class ApplicationRegistrationException extends CustomException<ApplicationRegistrationExceptionCode> {
constructor(
message: string,
code: ApplicationRegistrationExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application-registration/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application-registration/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationRegistrationEntity,
ApplicationRegistrationVariableEntity,
ApplicationEntity,
AppTokenEntity,
UserWorkspaceEntity,
]),
SecretEncryptionModule,
PermissionsModule,
TokenModule,
ApplicationModule,
],
controllers: [OAuthTokenController, OAuthDiscoveryController],
providers: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
ApplicationRegistrationResolver,
OAuthService,
],
exports: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
],
})
export class ApplicationRegistrationModule {}
@@ -0,0 +1,181 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.dto';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
import { RotateClientSecretDTO } from 'src/engine/core-modules/application-registration/dtos/rotate-client-secret.dto';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(
AuthGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class ApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@Query(() => ApplicationRegistrationEntity, { nullable: true })
async findApplicationRegistrationByClientId(
@Args('clientId') clientId: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationService.findOneByClientId(clientId);
}
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
@Query(() => ApplicationRegistrationEntity, { nullable: true })
async findApplicationRegistrationByUniversalIdentifier(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [ApplicationRegistrationEntity])
async findManyApplicationRegistrations(): Promise<
ApplicationRegistrationEntity[]
> {
return this.applicationRegistrationService.findMany();
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => ApplicationRegistrationEntity)
async findOneApplicationRegistration(
@Args('id') id: string,
): Promise<ApplicationRegistrationEntity> {
return this.applicationRegistrationService.findOneById(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => ApplicationRegistrationStatsDTO)
async findApplicationRegistrationStats(
@Args('id') id: string,
): Promise<ApplicationRegistrationStatsDTO> {
return this.applicationRegistrationService.getStats(id);
}
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
@Mutation(() => CreateApplicationRegistrationDTO)
async createApplicationRegistration(
@Args('input') input: CreateApplicationRegistrationInput,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
): Promise<CreateApplicationRegistrationDTO> {
return this.applicationRegistrationService.create(input, user?.id ?? null);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApplicationRegistrationEntity)
async updateApplicationRegistration(
@Args('input') input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
return this.applicationRegistrationService.update(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteApplicationRegistration(
@Args('id') id: string,
): Promise<boolean> {
return this.applicationRegistrationService.delete(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => RotateClientSecretDTO)
async rotateApplicationRegistrationClientSecret(
@Args('id') id: string,
): Promise<RotateClientSecretDTO> {
const clientSecret =
await this.applicationRegistrationService.rotateClientSecret(id);
return { clientSecret };
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [ApplicationRegistrationVariableEntity])
async findApplicationRegistrationVariables(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<ApplicationRegistrationVariableEntity[]> {
return this.applicationRegistrationVariableService.findVariables(
applicationRegistrationId,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApplicationRegistrationVariableEntity)
async createApplicationRegistrationVariable(
@Args('input') input: CreateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
return this.applicationRegistrationVariableService.createVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApplicationRegistrationVariableEntity)
async updateApplicationRegistrationVariable(
@Args('input') input: UpdateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
return this.applicationRegistrationVariableService.updateVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteApplicationRegistrationVariable(
@Args('id') id: string,
): Promise<boolean> {
return this.applicationRegistrationVariableService.deleteVariable(id);
}
}
@@ -0,0 +1,264 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application-registration/application-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
export class ApplicationRegistrationService {
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async findMany(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
order: { createdAt: 'DESC' },
});
}
async findOneById(id: string): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOneByClientId(
clientId: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationRepository.findOne({
where: { oAuthClientId: clientId },
});
}
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationRepository.findOne({
where: { universalIdentifier },
});
}
async create(
input: CreateApplicationRegistrationInput,
createdByUserId: string | null,
): Promise<{
applicationRegistration: ApplicationRegistrationEntity;
clientSecret: string;
}> {
const universalIdentifier = input.universalIdentifier ?? v4();
const existingByUid =
await this.findOneByUniversalIdentifier(universalIdentifier);
if (existingByUid) {
throw new ApplicationRegistrationException(
`Universal identifier ${universalIdentifier} is already claimed`,
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
if (isDefined(input.oAuthRedirectUris)) {
this.validateRedirectUris(input.oAuthRedirectUris);
}
if (isDefined(input.oAuthScopes)) {
this.validateScopes(input.oAuthScopes);
}
const clientId = v4();
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
const applicationRegistration =
this.applicationRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
oAuthClientId: clientId,
oAuthClientSecretHash: clientSecretHash,
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
oAuthScopes: input.oAuthScopes ?? [],
createdByUserId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.applicationRegistrationRepository.save(
applicationRegistration,
);
return { applicationRegistration: saved, clientSecret };
}
async update(
input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
const { id, update } = input;
await this.findOneById(id);
if (isDefined(update.oAuthRedirectUris)) {
this.validateRedirectUris(update.oAuthRedirectUris);
}
if (isDefined(update.oAuthScopes)) {
this.validateScopes(update.oAuthScopes);
}
const updateData: Record<string, unknown> = {};
if (isDefined(update.name)) updateData.name = update.name;
if (isDefined(update.description))
updateData.description = update.description;
if (isDefined(update.logoUrl)) updateData.logoUrl = update.logoUrl;
if (isDefined(update.author)) updateData.author = update.author;
if (isDefined(update.oAuthRedirectUris))
updateData.oAuthRedirectUris = update.oAuthRedirectUris;
if (isDefined(update.oAuthScopes))
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
}
return this.findOneById(id);
}
async delete(id: string): Promise<boolean> {
await this.findOneById(id);
await this.applicationRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string): Promise<string> {
await this.findOneById(id);
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
await this.applicationRegistrationRepository.update(id, {
oAuthClientSecretHash: clientSecretHash,
});
return clientSecret;
}
async verifyClientSecret(
registration: ApplicationRegistrationEntity,
clientSecret: string,
): Promise<boolean> {
if (!registration.oAuthClientSecretHash) {
return false;
}
return bcrypt.compare(clientSecret, registration.oAuthClientSecretHash);
}
async getStats(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationStatsDTO> {
await this.findOneById(applicationRegistrationId);
const versionDistribution: { version: string; count: number }[] =
await this.applicationRepository
.createQueryBuilder('application')
.select("COALESCE(application.version, 'unknown')", 'version')
.addSelect('COUNT(*)::int', 'count')
.where(
'application."applicationRegistrationId" = :applicationRegistrationId',
{ applicationRegistrationId },
)
.andWhere('application."deletedAt" IS NULL')
.groupBy('version')
.orderBy('count', 'DESC')
.getRawMany();
const activeInstalls = versionDistribution.reduce(
(sum, entry) => sum + entry.count,
0,
);
const mostInstalledVersion = versionDistribution[0]?.version ?? null;
return {
activeInstalls,
mostInstalledVersion,
versionDistribution,
};
}
private async generateClientSecret(): Promise<{
clientSecret: string;
clientSecretHash: string;
}> {
const clientSecret = crypto.randomBytes(32).toString('hex');
const clientSecretHash = await bcrypt.hash(
clientSecret,
BCRYPT_SALT_ROUNDS,
);
return { clientSecret, clientSecretHash };
}
private validateRedirectUris(uris: string[]): void {
for (const uri of uris) {
const result = validateRedirectUri(uri);
if (!result.valid) {
throw new ApplicationRegistrationException(
result.reason,
ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI,
);
}
}
}
private validateScopes(scopes: string[]): void {
const validScopes: readonly string[] = ALL_OAUTH_SCOPES;
const invalidScopes = scopes.filter(
(scope) => !validScopes.includes(scope),
);
if (invalidScopes.length > 0) {
throw new ApplicationRegistrationException(
`Invalid scopes: ${invalidScopes.join(', ')}`,
ApplicationRegistrationExceptionCode.INVALID_SCOPE,
);
}
}
}
@@ -0,0 +1,16 @@
// Scopes are a thin consent boundary shown to the user during OAuth authorization.
// Actual permissions are enforced by the role assigned to the application at the
// workspace level (object, field, and row-level permissions).
export const OAUTH_SCOPES = {
API: 'api',
PROFILE: 'profile',
} as const;
export type OAuthScope = (typeof OAUTH_SCOPES)[keyof typeof OAUTH_SCOPES];
export const ALL_OAUTH_SCOPES: OAuthScope[] = Object.values(OAUTH_SCOPES);
export const OAUTH_SCOPE_DESCRIPTIONS: Record<OAuthScope, string> = {
[OAUTH_SCOPES.API]: 'Access workspace data according to the assigned role',
[OAUTH_SCOPES.PROFILE]: "Read the authenticated user's profile",
};
@@ -0,0 +1,32 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('.well-known')
export class OAuthDiscoveryController {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@Get('oauth-authorization-server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getAuthorizationServerMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return {
issuer: serverUrl,
authorization_endpoint: `${serverUrl}/authorize`,
token_endpoint: `${serverUrl}/oauth/token`,
scopes_supported: ALL_OAUTH_SCOPES,
response_types_supported: ['code'],
grant_types_supported: [
'authorization_code',
'client_credentials',
'refresh_token',
],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
};
}
}
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Post,
Res,
UseFilters,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { type Response } from 'express';
import { OAuthTokenInput } from 'src/engine/core-modules/application-registration/dtos/oauth-token.input';
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('oauth')
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthTokenController {
constructor(private readonly oauthService: OAuthService) {}
@Post('token')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe())
async token(
@Body() body: OAuthTokenInput,
@Res({ passthrough: true }) res: Response,
) {
let result: OAuthTokenResponse | OAuthErrorResponse;
switch (body.grant_type) {
case 'authorization_code':
result = await this.oauthService.exchangeAuthorizationCode({
authorizationCode: body.code ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
codeVerifier: body.code_verifier,
redirectUri: body.redirect_uri ?? '',
});
break;
case 'client_credentials':
result = await this.oauthService.clientCredentialsGrant({
clientId: body.client_id ?? '',
clientSecret: body.client_secret ?? '',
});
break;
case 'refresh_token':
result = await this.oauthService.refreshTokenGrant({
refreshToken: body.refresh_token ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
});
break;
default:
result = {
error: 'unsupported_grant_type',
error_description:
'The provided grant_type is not supported. Supported values: authorization_code, client_credentials, refresh_token',
};
break;
}
res.status('error' in result ? 400 : 200);
return result;
}
}
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('VersionDistributionEntry')
export class VersionDistributionEntryDTO {
@Field(() => String)
version: string;
@Field(() => Int)
count: number;
}
@ObjectType('ApplicationRegistrationStats')
export class ApplicationRegistrationStatsDTO {
@Field(() => Int)
activeInstalls: number;
@Field(() => String, { nullable: true })
mostInstalledVersion: string | null;
@Field(() => [VersionDistributionEntryDTO])
versionDistribution: VersionDistributionEntryDTO[];
}
@@ -0,0 +1,37 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@InputType()
export class CreateApplicationRegistrationVariableInput {
@Field()
@IsUUID()
applicationRegistrationId: string;
@Field()
@IsString()
@MaxLength(256)
key: string;
@Field()
@IsString()
@MaxLength(10000)
value: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isSecret?: boolean;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
@ObjectType('CreateApplicationRegistration')
export class CreateApplicationRegistrationDTO {
@Field(() => ApplicationRegistrationEntity)
applicationRegistration: ApplicationRegistrationEntity;
@Field()
clientSecret: string;
}
@@ -0,0 +1,69 @@
import { Field, InputType } from '@nestjs/graphql';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@InputType()
export class CreateApplicationRegistrationInput {
@Field()
@IsString()
@MaxLength(256)
name: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
universalIdentifier?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
@MaxLength(2048, { each: true })
@IsOptional()
oAuthRedirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@MaxLength(256, { each: true })
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
}
@@ -0,0 +1,37 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class OAuthTokenInput {
@IsString()
@MaxLength(50)
grant_type: string;
@IsOptional()
@IsString()
@MaxLength(512)
code?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
redirect_uri?: string;
@IsOptional()
@IsString()
@MaxLength(256)
client_id?: string;
@IsOptional()
@IsString()
@MaxLength(512)
client_secret?: string;
@IsOptional()
@IsString()
@MaxLength(512)
code_verifier?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
refresh_token?: string;
}
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('RotateClientSecret')
export class RotateClientSecretDTO {
@Field()
clientSecret: string;
}
@@ -0,0 +1,39 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
@InputType()
export class UpdateApplicationRegistrationVariablePayload {
@Field({ nullable: true })
@IsString()
@MaxLength(10000)
@IsOptional()
value?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
}
@InputType()
export class UpdateApplicationRegistrationVariableInput {
@IsNotEmpty()
@Field()
@IsUUID()
id: string;
@Type(() => UpdateApplicationRegistrationVariablePayload)
@ValidateNested()
@Field(() => UpdateApplicationRegistrationVariablePayload)
update: UpdateApplicationRegistrationVariablePayload;
}
@@ -0,0 +1,81 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
@InputType()
export class UpdateApplicationRegistrationPayload {
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
@MaxLength(2048, { each: true })
@IsOptional()
oAuthRedirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@MaxLength(256, { each: true })
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
}
@InputType()
export class UpdateApplicationRegistrationInput {
@IsNotEmpty()
@Field()
@IsUUID()
id: string;
@Type(() => UpdateApplicationRegistrationPayload)
@ValidateNested()
@Field(() => UpdateApplicationRegistrationPayload)
update: UpdateApplicationRegistrationPayload;
}
@@ -0,0 +1,395 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import ms from 'ms';
import { IsNull, Repository } from 'typeorm';
import { base64UrlEncode } from 'twenty-shared/utils';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly applicationTokenService: ApplicationTokenService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationService: ApplicationService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async exchangeAuthorizationCode(params: {
authorizationCode: string;
clientId: string;
clientSecret?: string;
codeVerifier?: string;
redirectUri: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const {
authorizationCode,
clientId,
clientSecret,
codeVerifier,
redirectUri,
} = params;
if (!authorizationCode) {
return this.errorResponse(
'invalid_request',
'Authorization code is required',
);
}
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const applicationRegistration = clientValidation;
if (clientSecret) {
const secretError = await this.validateClientSecret(
applicationRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
const authCodeToken = await this.appTokenRepository.findOne({
where: {
value: authorizationCode,
type: AppTokenType.AuthorizationCode,
revokedAt: IsNull(),
},
});
if (!authCodeToken) {
return this.errorResponse(
'invalid_grant',
'Authorization code not found',
);
}
if (authCodeToken.expiresAt.getTime() < Date.now()) {
return this.errorResponse('invalid_grant', 'Authorization code expired');
}
// RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request
const storedRedirectUri = authCodeToken.context?.redirectUri;
if (storedRedirectUri) {
if (!redirectUri) {
return this.errorResponse(
'invalid_request',
'redirect_uri is required',
);
}
if (redirectUri !== storedRedirectUri) {
return this.errorResponse(
'invalid_grant',
'redirect_uri does not match the one used in the authorization request',
);
}
}
if (codeVerifier) {
const pkceError = await this.validatePkce(codeVerifier, authCodeToken);
if (pkceError) {
return pkceError;
}
}
if (!clientSecret && !codeVerifier) {
return this.errorResponse(
'invalid_request',
'Either client_secret or code_verifier (PKCE) is required',
);
}
await this.appTokenRepository.update(authCodeToken.id, {
revokedAt: new Date(),
});
if (!authCodeToken.userId || !authCodeToken.workspaceId) {
return this.errorResponse(
'server_error',
'Authorization code is missing user or workspace context',
);
}
const application = await this.findOrInstallApplication(
applicationRegistration,
authCodeToken.workspaceId,
);
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId: authCodeToken.userId,
workspaceId: authCodeToken.workspaceId,
},
});
const { applicationAccessToken, applicationRefreshToken } =
await this.applicationTokenService.generateApplicationTokenPair({
workspaceId: authCodeToken.workspaceId,
applicationId: application.id,
userId: authCodeToken.userId,
userWorkspaceId: userWorkspace?.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: this.getAccessTokenExpiresInSeconds(),
refresh_token: applicationRefreshToken.token,
scope: applicationRegistration.oAuthScopes.join(' '),
};
}
async clientCredentialsGrant(params: {
clientId: string;
clientSecret: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const { clientId, clientSecret } = params;
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const applicationRegistration = clientValidation;
const secretError = await this.validateClientSecret(
applicationRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
const applications = await this.applicationRepository.find({
where: { applicationRegistrationId: applicationRegistration.id },
});
if (applications.length === 0) {
return this.errorResponse(
'server_error',
'No workspace installation found for this client. Install the app in a workspace first.',
);
}
if (applications.length > 1) {
return this.errorResponse(
'invalid_request',
'Multiple workspace installations found. Client credentials grant requires exactly one installation.',
);
}
const application = applications[0];
const applicationAccessToken =
await this.applicationTokenService.generateApplicationAccessToken({
workspaceId: application.workspaceId,
applicationId: application.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: this.getAccessTokenExpiresInSeconds(),
scope: applicationRegistration.oAuthScopes.join(' '),
};
}
async refreshTokenGrant(params: {
refreshToken: string;
clientId: string;
clientSecret?: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const { refreshToken, clientId, clientSecret } = params;
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const applicationRegistration = clientValidation;
if (clientSecret) {
const secretError = await this.validateClientSecret(
applicationRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
try {
const payload =
this.applicationTokenService.validateApplicationRefreshToken(
refreshToken,
);
const { applicationAccessToken, applicationRefreshToken } =
await this.applicationTokenService.renewApplicationTokens(payload);
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: this.getAccessTokenExpiresInSeconds(),
refresh_token: applicationRefreshToken.token,
scope: applicationRegistration.oAuthScopes.join(' '),
};
} catch (error) {
this.logger.error('Refresh token grant failed', error);
return this.errorResponse(
'invalid_grant',
'Invalid or expired refresh token',
);
}
}
private async validateClient(
clientId: string,
): Promise<ApplicationRegistrationEntity | OAuthErrorResponse> {
const applicationRegistration =
await this.applicationRegistrationService.findOneByClientId(clientId);
if (!applicationRegistration) {
return this.errorResponse('invalid_client', 'Client not found');
}
return applicationRegistration;
}
private async validateClientSecret(
applicationRegistration: ApplicationRegistrationEntity,
clientSecret: string,
): Promise<OAuthErrorResponse | null> {
const isValid =
await this.applicationRegistrationService.verifyClientSecret(
applicationRegistration,
clientSecret,
);
if (!isValid) {
return this.errorResponse('invalid_client', 'Invalid client secret');
}
return null;
}
private async validatePkce(
codeVerifier: string,
authCodeToken: AppTokenEntity,
): Promise<OAuthErrorResponse | null> {
const codeChallenge = base64UrlEncode(
crypto.createHash('sha256').update(codeVerifier).digest(),
);
const challengeToken = await this.appTokenRepository.findOne({
where: {
value: codeChallenge,
type: AppTokenType.CodeChallenge,
revokedAt: IsNull(),
...(authCodeToken.userId ? { userId: authCodeToken.userId } : {}),
},
});
if (!challengeToken) {
return this.errorResponse(
'invalid_grant',
'Code verifier does not match the code challenge',
);
}
if (challengeToken.expiresAt.getTime() < Date.now()) {
return this.errorResponse('invalid_grant', 'Code challenge expired');
}
await this.appTokenRepository.update(challengeToken.id, {
revokedAt: new Date(),
});
return null;
}
private async findOrInstallApplication(
applicationRegistration: ApplicationRegistrationEntity,
workspaceId: string,
): Promise<ApplicationEntity> {
const existingApplication = await this.applicationRepository.findOne({
where: {
applicationRegistrationId: applicationRegistration.id,
workspaceId,
},
});
if (existingApplication) {
return existingApplication;
}
this.logger.log(
`Auto-installing application "${applicationRegistration.name}" in workspace ${workspaceId}`,
);
// TODO: defaulting to version 0.0.0, build better system
return this.applicationService.create({
universalIdentifier: applicationRegistration.universalIdentifier,
name: applicationRegistration.name,
description: applicationRegistration.description,
version: '0.0.0',
sourcePath: 'oauth-install',
applicationRegistrationId: applicationRegistration.id,
workspaceId,
});
}
// OAuth RFC 6749 requires expires_in as seconds
private getAccessTokenExpiresInSeconds(): number {
const duration = this.twentyConfigService.get(
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
);
return Math.floor(ms(duration) / 1000);
}
private errorResponse(
error: string,
errorDescription: string,
): OAuthErrorResponse {
return { error, error_description: errorDescription };
}
}
@@ -0,0 +1,4 @@
export type OAuthErrorResponse = {
error: string;
error_description: string;
};
@@ -0,0 +1,7 @@
export type OAuthTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
};
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
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';
@@ -24,6 +25,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
@Module({
imports: [
TypeOrmModule.forFeature([FileEntity]),
ApplicationRegistrationModule,
ApplicationModule,
ApplicationVariableEntityModule,
TokenModule,
@@ -7,6 +7,7 @@ import {
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@@ -14,6 +15,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -94,6 +96,16 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@Column({ nullable: true, type: 'uuid' })
applicationRegistrationId: string | null;
@ManyToOne(() => ApplicationRegistrationEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'applicationRegistrationId' })
applicationRegistration: Relation<ApplicationRegistrationEntity> | null;
@OneToMany(() => AgentEntity, (agent) => agent.application, {
onDelete: 'CASCADE',
})
@@ -8,4 +8,5 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'applicationRegistration',
] as const satisfies (keyof ApplicationEntity)[];
@@ -1,6 +1,6 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateApplicationInput {
@@ -28,4 +28,9 @@ export class CreateApplicationInput {
@IsNotEmpty()
@Field()
sourcePath: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
applicationRegistrationId?: string;
}
@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
@ObjectType()
@ObjectType('WorkspaceMigration')
export class WorkspaceMigrationDTO {
@Field(() => String)
applicationUniversalIdentifier: string;
@@ -5,6 +5,8 @@ import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
@@ -38,6 +40,8 @@ export class ApplicationSyncService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fileStorageService: FileStorageService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
public async synchronizeFromManifest({
@@ -118,13 +122,41 @@ export class ApplicationSyncService {
},
);
const applicationRegistrationMetadata = {
name,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
};
const applicationRegistrationId =
await this.resolveApplicationRegistrationId(
application.applicationRegistrationId,
manifest.application.universalIdentifier,
applicationRegistrationMetadata,
);
await this.applicationRegistrationService.update({
id: applicationRegistrationId,
update: applicationRegistrationMetadata,
});
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
return await this.applicationService.update(application.id, {
name,
description: manifest.application.description,
version: packageJson.version,
packageJsonChecksum: manifest.application.packageJsonChecksum,
yarnLockChecksum: manifest.application.yarnLockChecksum,
//availablePackages: manifest.application.availablePackages, // TODO: compute available package in dev-mode-orchestrator
applicationRegistrationId,
});
}
@@ -204,4 +236,42 @@ export class ApplicationSyncService {
return validateAndBuildResult.workspaceMigration;
}
private async resolveApplicationRegistrationId(
existingId: string | null,
universalIdentifier: string,
metadata: {
name: string;
description?: string;
logoUrl?: string;
author?: string;
websiteUrl?: string;
termsUrl?: string;
},
): Promise<string> {
if (existingId) {
return existingId;
}
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (existingRegistration) {
return existingRegistration.id;
}
const { applicationRegistration: newRegistration } =
await this.applicationRegistrationService.create(
{ ...metadata, universalIdentifier },
null,
);
this.logger.log(
`Created app registration for ${metadata.name} (${universalIdentifier})`,
);
return newRegistration.id;
}
}
@@ -191,9 +191,8 @@ export class MarketplaceService {
const packageJson = JSON.parse(packageJsonContent) as PackageJson;
const { application } = manifest;
const marketplaceData = application.marketplaceData;
if (!marketplaceData?.author || !marketplaceData?.category) {
if (!application.author || !application.category) {
return null;
}
@@ -258,14 +257,14 @@ export class MarketplaceService {
description: application.description ?? '',
icon: application.icon ?? 'IconApps',
version: packageJson.version ?? '0.1.0',
author: marketplaceData.author,
category: marketplaceData.category,
logo: this.resolveAssetUrl(appPath, marketplaceData.logo),
screenshots: this.resolveAssetUrls(appPath, marketplaceData.screenshots),
aboutDescription: marketplaceData.aboutDescription ?? '',
providers: marketplaceData.providers ?? [],
websiteUrl: marketplaceData.websiteUrl,
termsUrl: marketplaceData.termsUrl,
author: application.author,
category: application.category,
logo: this.resolveAssetUrl(appPath, application.logoUrl),
screenshots: this.resolveAssetUrls(appPath, application.screenshots),
aboutDescription: application.aboutDescription ?? '',
providers: application.providers ?? [],
websiteUrl: application.websiteUrl,
termsUrl: application.termsUrl,
objects,
fields,
logicFunctions,
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
AuditModule,
SubdomainManagerModule,
DomainServerConfigModule,
ApplicationRegistrationModule,
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
@@ -24,7 +24,6 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { AuthResolver } from './auth.resolver';
import { AuthService } from './services/auth.service';
// import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service';
import { EmailVerificationTokenService } from './token/services/email-verification-token.service';
import { LoginTokenService } from './token/services/login-token.service';
@@ -139,10 +138,6 @@ describe('AuthResolver', () => {
}),
},
},
// {
// provide: OAuthService,
// useValue: {},
// },
],
})
.overrideGuard(CaptchaGuard)
@@ -11,17 +11,16 @@ import { Repository } from 'typeorm';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
import { AuthorizeAppOutput } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { EmailPasswordResetLinkOutput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { InvalidatePasswordOutput } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenOutput } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
@@ -31,12 +30,12 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
import { AvailableWorkspacesAndAccessTokensDTO } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.dto';
import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { VerifyEmailAndGetLoginTokenOutput } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output';
import { GetAuthorizationUrlForSSODTO } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.dto';
import { SignUpDTO } from 'src/engine/core-modules/auth/dto/sign-up.dto';
import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.dto';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
@@ -82,12 +81,12 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { LoginTokenOutput } from './dto/login-token.dto';
import { LoginTokenDTO } from './dto/login-token.dto';
import { SignUpInput } from './dto/sign-up.input';
import { UserCredentialsInput } from './dto/user-credentials.input';
import { CheckUserExistOutput } from './dto/user-exists.dto';
import { CheckUserExistDTO } from './dto/user-exists.dto';
import { EmailAndCaptchaInput } from './dto/user-exists.input';
import { WorkspaceInviteHashValidOutput } from './dto/workspace-invite-hash-valid.dto';
import { WorkspaceInviteHashValidDTO } from './dto/workspace-invite-hash-valid.dto';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
@@ -128,16 +127,16 @@ export class AuthResolver {
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
@Query(() => CheckUserExistOutput)
@Query(() => CheckUserExistDTO)
async checkUserExists(
@Args() checkUserExistsInput: EmailAndCaptchaInput,
): Promise<CheckUserExistOutput> {
): Promise<CheckUserExistDTO> {
return await this.authService.checkUserExists(
checkUserExistsInput.email.toLowerCase(),
);
}
@Mutation(() => GetAuthorizationUrlForSSOOutput)
@Mutation(() => GetAuthorizationUrlForSSODTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getAuthorizationUrlForSSO(
@Args('input') params: GetAuthorizationUrlForSSOInput,
@@ -148,11 +147,11 @@ export class AuthResolver {
);
}
@Query(() => WorkspaceInviteHashValidOutput)
@Query(() => WorkspaceInviteHashValidDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async checkWorkspaceInviteHashIsValid(
@Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput,
): Promise<WorkspaceInviteHashValidOutput> {
): Promise<WorkspaceInviteHashValidDTO> {
return await this.authService.checkWorkspaceInviteHashIsValid(
workspaceInviteHashValidInput.inviteHash,
);
@@ -168,13 +167,13 @@ export class AuthResolver {
);
}
@Mutation(() => LoginTokenOutput)
@Mutation(() => LoginTokenDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async getLoginTokenFromCredentials(
@Args()
getLoginTokenFromCredentialsInput: UserCredentialsInput,
@Args('origin') origin: string,
): Promise<LoginTokenOutput> {
): Promise<LoginTokenDTO> {
const workspace =
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
@@ -203,12 +202,12 @@ export class AuthResolver {
return { loginToken };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signIn(
@Args()
userCredentials: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
): Promise<AvailableWorkspacesAndAccessTokensDTO> {
const user =
await this.authService.validateLoginWithPassword(userCredentials);
@@ -241,7 +240,7 @@ export class AuthResolver {
};
}
@Mutation(() => VerifyEmailAndGetLoginTokenOutput)
@Mutation(() => VerifyEmailAndGetLoginTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async verifyEmailAndGetLoginToken(
@Args()
@@ -254,7 +253,10 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
if (appToken.context && appToken.context.email !== appToken.user.email) {
if (
appToken.context?.email &&
appToken.context.email !== appToken.user.email
) {
await this.userService.updateEmailFromVerificationToken(
appToken.user.id,
appToken.context.email,
@@ -283,7 +285,7 @@ export class AuthResolver {
return { loginToken, workspaceUrls };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async verifyEmailAndGetWorkspaceAgnosticToken(
@Args()
@@ -295,7 +297,10 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
if (appToken.context && appToken.context.email !== appToken.user.email) {
if (
appToken.context?.email &&
appToken.context.email !== appToken.user.email
) {
await this.userService.updateEmailFromVerificationToken(
appToken.user.id,
appToken.context.email,
@@ -373,11 +378,11 @@ export class AuthResolver {
return await this.authService.verify(email, workspace.id, authProvider);
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signUp(
@Args() signUpInput: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
): Promise<AvailableWorkspacesAndAccessTokensDTO> {
const user = await this.signInUpService.signUpWithoutWorkspace(
{
email: signUpInput.email,
@@ -426,12 +431,12 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
@Mutation(() => SignUpDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signUpInWorkspace(
@Args() signUpInput: SignUpInput,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
): Promise<SignUpDTO> {
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceInviteHash: signUpInput.workspaceInviteHash,
authProvider: AuthProviderEnum.Password,
@@ -500,12 +505,12 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
@Mutation(() => SignUpDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async signUpInNewWorkspace(
@AuthUser() currentUser: UserEntity,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
): Promise<SignUpDTO> {
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
);
@@ -525,12 +530,12 @@ export class AuthResolver {
};
}
@Mutation(() => TransientTokenOutput)
@Mutation(() => TransientTokenDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async generateTransientToken(
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<TransientTokenOutput | void> {
): Promise<TransientTokenDTO | void> {
const workspaceMember = await this.userService.loadWorkspaceMember(
user,
workspace,
@@ -771,13 +776,13 @@ export class AuthResolver {
};
}
@Mutation(() => AuthorizeAppOutput)
@Mutation(() => AuthorizeAppDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async authorizeApp(
@Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<AuthorizeAppOutput> {
): Promise<AuthorizeAppDTO> {
return await this.authService.generateAuthorizationCode(
authorizeAppInput,
user,
@@ -811,12 +816,12 @@ export class AuthResolver {
);
}
@Mutation(() => EmailPasswordResetLinkOutput)
@Mutation(() => EmailPasswordResetLinkDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async emailPasswordResetLink(
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
@Context() context: I18nContext,
): Promise<EmailPasswordResetLinkOutput> {
): Promise<EmailPasswordResetLinkDTO> {
const resetToken =
await this.resetPasswordService.generatePasswordResetToken(
emailPasswordResetInput.email,
@@ -830,12 +835,12 @@ export class AuthResolver {
);
}
@Mutation(() => InvalidatePasswordOutput)
@Mutation(() => InvalidatePasswordDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async updatePasswordViaResetToken(
@Args()
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
): Promise<InvalidatePasswordOutput> {
): Promise<InvalidatePasswordDTO> {
const { id } =
await this.resetPasswordService.validatePasswordResetToken(
passwordResetToken,
@@ -846,11 +851,11 @@ export class AuthResolver {
return await this.resetPasswordService.invalidatePasswordResetToken(id);
}
@Query(() => ValidatePasswordResetTokenOutput)
@Query(() => ValidatePasswordResetTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async validatePasswordResetToken(
@Args() args: ValidatePasswordResetTokenInput,
): Promise<ValidatePasswordResetTokenOutput> {
): Promise<ValidatePasswordResetTokenDTO> {
return this.resetPasswordService.validatePasswordResetToken(
args.passwordResetToken,
);
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AuthorizeAppOutput {
@ObjectType('AuthorizeApp')
export class AuthorizeAppDTO {
@Field(() => String)
redirectUrl: string;
}
@@ -1,11 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.dto';
import { AuthTokenPair } from './auth-token-pair.dto';
@ObjectType()
export class AvailableWorkspacesAndAccessTokensOutput {
@ObjectType('AvailableWorkspacesAndAccessTokens')
export class AvailableWorkspacesAndAccessTokensDTO {
@Field(() => AuthTokenPair)
tokens: AuthTokenPair;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class EmailPasswordResetLinkOutput {
@ObjectType('EmailPasswordResetLink')
export class EmailPasswordResetLinkDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class ExchangeAuthCodeOutput {
@ObjectType('ExchangeAuthCode')
export class ExchangeAuthCodeDTO {
@Field(() => AuthToken)
accessOrWorkspaceAgnosticToken: AuthToken;
@@ -1,15 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class ExchangeAuthCode {
@Field(() => AuthToken)
accessOrWorkspaceAgnosticToken: AuthToken;
@Field(() => AuthToken)
refreshToken: AuthToken;
@Field(() => AuthToken)
loginToken: AuthToken;
}
@@ -5,8 +5,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
@ObjectType()
export class GetAuthorizationUrlForSSOOutput {
@ObjectType('GetAuthorizationUrlForSSO')
export class GetAuthorizationUrlForSSODTO {
@Field(() => String)
authorizationURL: string;
@@ -1,7 +1,7 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class InvalidatePasswordOutput {
@ObjectType('InvalidatePassword')
export class InvalidatePasswordDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class LoginTokenOutput {
@ObjectType('LoginToken')
export class LoginTokenDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
}
@@ -4,8 +4,8 @@ import { WorkspaceUrlsAndIdDTO } from 'src/engine/core-modules/workspace/dtos/wo
import { AuthToken } from './auth-token.dto';
@ObjectType('SignUpOutput')
export class SignUpOutput {
@ObjectType('SignUp')
export class SignUpDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class TransientTokenOutput {
@ObjectType('TransientToken')
export class TransientTokenDTO {
@Field(() => AuthToken)
transientToken: AuthToken;
}
@@ -1,7 +1,7 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class UpdatePasswordOutput {
@ObjectType('UpdatePassword')
export class UpdatePasswordDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CheckUserExistOutput {
@ObjectType('CheckUserExist')
export class CheckUserExistDTO {
@Field(() => Boolean)
exists: boolean;
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType()
export class ValidatePasswordResetTokenOutput {
@ObjectType('ValidatePasswordResetToken')
export class ValidatePasswordResetTokenDTO {
@Field(() => UUIDScalarType)
id: string;
@@ -4,8 +4,8 @@ import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspa
import { AuthToken } from './auth-token.dto';
@ObjectType('VerifyEmailAndGetLoginTokenOutput')
export class VerifyEmailAndGetLoginTokenOutput {
@ObjectType('VerifyEmailAndGetLoginToken')
export class VerifyEmailAndGetLoginTokenDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class WorkspaceInviteHashValidOutput {
@ObjectType('WorkspaceInviteHashValid')
export class WorkspaceInviteHashValidDTO {
@Field(() => Boolean)
isValid: boolean;
}
@@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { AuthService } from './auth.service';
@@ -169,6 +170,10 @@ describe('AuthService', () => {
.mockResolvedValue(false),
},
},
{
provide: ApplicationRegistrationService,
useValue: {},
},
],
}).compile();
@@ -13,8 +13,7 @@ import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import {
AppTokenEntity,
AppTokenType,
@@ -30,12 +29,13 @@ import {
hashPassword,
} from 'src/engine/core-modules/auth/auth.util';
import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto';
import { type AuthorizeAppOutput } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { type AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { type AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { type UpdatePasswordOutput } from 'src/engine/core-modules/auth/dto/update-password.dto';
import { type UpdatePasswordDTO } from 'src/engine/core-modules/auth/dto/update-password.dto';
import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input';
import { type CheckUserExistOutput } from 'src/engine/core-modules/auth/dto/user-exists.dto';
import { type WorkspaceInviteHashValidOutput } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
import { type CheckUserExistDTO } from 'src/engine/core-modules/auth/dto/user-exists.dto';
import { type WorkspaceInviteHashValidDTO } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
@@ -94,6 +94,7 @@ export class AuthService {
private readonly appTokenRepository: Repository<AppTokenEntity>,
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -467,7 +468,7 @@ export class AuthService {
).flat(2).length;
}
async checkUserExists(email: string): Promise<CheckUserExistOutput> {
async checkUserExists(email: string): Promise<CheckUserExistDTO> {
const user = await this.userService.findUserByEmail(email);
const isUserExist = isDefined(user);
@@ -482,7 +483,7 @@ export class AuthService {
async checkWorkspaceInviteHashIsValid(
inviteHash: string,
): Promise<WorkspaceInviteHashValidOutput> {
): Promise<WorkspaceInviteHashValidDTO> {
const workspace = await this.workspaceRepository.findOneBy({
inviteHash,
});
@@ -494,51 +495,54 @@ export class AuthService {
authorizeAppInput: AuthorizeAppInput,
user: UserEntity,
workspace: WorkspaceEntity,
): Promise<AuthorizeAppOutput> {
// TODO: replace with db call to - third party app table
const apps = [
{
id: 'chrome',
name: 'Chrome Extension',
redirectUrl:
this.twentyConfigService.get('NODE_ENV') ===
NodeEnvironment.DEVELOPMENT
? authorizeAppInput.redirectUrl
: `https://${this.twentyConfigService.get(
'CHROME_EXTENSION_ID',
)}.chromiumapp.org/`,
},
];
): Promise<AuthorizeAppDTO> {
const { clientId, codeChallenge } = authorizeAppInput;
const client = apps.find((app) => app.id === clientId);
const applicationRegistration =
await this.applicationRegistrationService.findOneByClientId(clientId);
if (!client) {
if (!applicationRegistration) {
throw new AuthException(
`Client not found for '${clientId}'`,
AuthExceptionCode.CLIENT_NOT_FOUND,
);
}
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
if (!authorizeAppInput.redirectUrl) {
throw new AuthException(
`redirectUrl not found for '${clientId}'`,
`redirectUrl not provided for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
if (
!applicationRegistration.oAuthRedirectUris.includes(
authorizeAppInput.redirectUrl,
)
) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const redirectUriValidation = validateRedirectUri(
authorizeAppInput.redirectUrl,
);
if (!redirectUriValidation.valid) {
throw new AuthException(
redirectUriValidation.reason,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const authorizationCode = crypto.randomBytes(42).toString('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
const authCodeContext = { redirectUri: authorizeAppInput.redirectUrl };
if (codeChallenge) {
const tokens = this.appTokenRepository.create([
{
@@ -554,6 +558,7 @@ export class AuthService {
userId: user.id,
workspaceId: workspace.id,
expiresAt,
context: authCodeContext,
},
]);
@@ -565,22 +570,24 @@ export class AuthService {
userId: user.id,
workspaceId: workspace.id,
expiresAt,
context: authCodeContext,
});
await this.appTokenRepository.save(token);
}
const redirectUrl = `${
client.redirectUrl ? client.redirectUrl : authorizeAppInput.redirectUrl
}?authorizationCode=${authorizationCode}`;
redirectUriValidation.parsed.searchParams.set(
'authorizationCode',
authorizationCode,
);
return { redirectUrl };
return { redirectUrl: redirectUriValidation.parsed.toString() };
}
async updatePassword(
userId: string,
newPassword: string,
): Promise<UpdatePasswordOutput> {
): Promise<UpdatePasswordDTO> {
if (!userId) {
throw new AuthException(
'User ID is required',
@@ -1,157 +0,0 @@
// import { Injectable } from '@nestjs/common';
// import { InjectRepository } from '@nestjs/typeorm';
//
// import crypto from 'crypto';
//
// import { Repository } from 'typeorm';
//
// import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
// import {
// AuthException,
// AuthExceptionCode,
// } from 'src/engine/core-modules/auth/auth.exception';
// import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
// import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
// import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
// import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
// import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
// import { UserEntity } from 'src/engine/core-modules/user/user.entity';
// import { userValidator } from 'src/engine/core-modules/user/user.validate';
//
// @Injectable()
// export class OAuthService {
// constructor(
// @InjectRepository(UserEntity)
// private readonly userRepository: Repository<UserEntity>,
// @InjectRepository(AppTokenEntity)
// private readonly appTokenRepository: Repository<AppTokenEntity>,
// private readonly accessTokenService: AccessTokenService,
// private readonly refreshTokenService: RefreshTokenService,
// private readonly loginTokenService: LoginTokenService,
// ) {}
//
// async verifyAuthorizationCode(
// exchangeAuthCodeInput: ExchangeAuthCodeInput,
// ): Promise<ExchangeAuthCode> {
// const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
//
// if (!authorizationCode) {
// throw new AuthException(
// 'Authorization code not found',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// let userId = '';
//
// if (codeVerifier) {
// const authorizationCodeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: authorizationCode,
// },
// });
//
// if (!authorizationCodeAppToken) {
// throw new AuthException(
// 'Authorization code does not exist',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'Authorization code expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// const codeChallenge = crypto
// .createHash('sha256')
// .update(codeVerifier)
// .digest()
// .toString('base64')
// .replace(/\+/g, '-')
// .replace(/\//g, '_')
// .replace(/=/g, '');
//
// const codeChallengeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: codeChallenge,
// },
// });
//
// if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
// throw new AuthException(
// 'code verifier doesnt match the challenge',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'code challenge expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
// throw new AuthException(
// 'authorization code / code verifier was not created by same client',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.revokedAt) {
// throw new AuthException(
// 'Token has been revoked.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// await this.appTokenRepository.save({
// id: codeChallengeAppToken.id,
// revokedAt: new Date(),
// });
//
// userId = codeChallengeAppToken.userId;
// }
//
// const user = await this.userRepository.findOne({
// where: { id: userId },
// relations: ['defaultWorkspace'],
// });
//
// userValidator.assertIsDefinedOrThrow(
// user,
// new AuthException(
// 'User who generated the token does not exist',
// AuthExceptionCode.INVALID_INPUT,
// ),
// );
//
// if (!user.defaultWorkspace) {
// throw new AuthException(
// 'User does not have a default workspace',
// AuthExceptionCode.INVALID_DATA,
// );
// }
//
// const accessToken = await this.accessTokenService.generateAccessToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const refreshToken = await this.refreshTokenService.generateRefreshToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const loginToken = await this.loginTokenService.generateLoginToken(
// user.email,
// );
//
// return {
// accessToken,
// refreshToken,
// loginToken,
// };
// }
// }
@@ -25,10 +25,10 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type EmailPasswordResetLinkOutput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { type InvalidatePasswordOutput } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { type EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { type InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/password-reset-token.dto';
import { type ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { type ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -119,7 +119,7 @@ export class ResetPasswordService {
resetToken: PasswordResetToken,
email: string,
locale: keyof typeof APP_LOCALES,
): Promise<EmailPasswordResetLinkOutput> {
): Promise<EmailPasswordResetLinkDTO> {
const user = await this.userService.findUserByEmailOrThrow(
email,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
@@ -180,7 +180,7 @@ export class ResetPasswordService {
async validatePasswordResetToken(
resetToken: string,
): Promise<ValidatePasswordResetTokenOutput> {
): Promise<ValidatePasswordResetTokenDTO> {
const hashedResetToken = crypto
.createHash('sha256')
.update(resetToken)
@@ -216,7 +216,7 @@ export class ResetPasswordService {
async invalidatePasswordResetToken(
userId: string,
): Promise<InvalidatePasswordOutput> {
): Promise<InvalidatePasswordDTO> {
const user = await this.userService.findUserByIdOrThrow(
userId,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
@@ -259,9 +259,13 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('User not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
new AuthException(
'User or user workspace not found',
expect.any(String),
{
userFriendlyMessage: msg`User does not have access to this workspace`,
},
),
);
try {
@@ -301,15 +305,19 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspaceEntity not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
new AuthException(
'User or user workspace not found',
expect.any(String),
{
userFriendlyMessage: msg`User does not have access to this workspace`,
},
),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
}
});
@@ -145,17 +145,6 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
user = await this.userRepository.findOne({
where: { id: userId },
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!payload.userWorkspaceId) {
throw new AuthException(
'UserWorkspaceEntity not found',
@@ -163,29 +152,31 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: payload.userWorkspaceId },
relations: ['user', 'workspace'],
const userContext = await this.resolveUserContext({
userId,
userWorkspaceId: payload.userWorkspaceId,
});
assertIsDefinedOrThrow(
userWorkspace,
userContext,
new AuthException(
'UserWorkspaceEntity not found',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
'User or user workspace not found',
AuthExceptionCode.USER_NOT_FOUND,
{
userFriendlyMessage: msg`User does not have access to this workspace`,
},
),
);
user = userContext.user;
context = {
...context,
user,
workspace,
authProvider: payload.authProvider,
userWorkspace,
userWorkspaceId: userWorkspace.id,
userWorkspace: userContext.userWorkspace,
userWorkspaceId: userContext.userWorkspace.id,
workspaceMemberId: payload.workspaceMemberId,
};
@@ -225,6 +216,41 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
};
}
private async resolveUserContext(params: {
userId: string;
userWorkspaceId: string;
expectedWorkspaceId?: string;
}): Promise<{
user: UserEntity;
userWorkspace: UserWorkspaceEntity;
} | null> {
const user = await this.userRepository.findOne({
where: { id: params.userId },
});
if (!isDefined(user)) {
return null;
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: params.userWorkspaceId },
relations: ['user', 'workspace'],
});
if (!isDefined(userWorkspace)) {
return null;
}
if (
isDefined(params.expectedWorkspaceId) &&
userWorkspace.workspace.id !== params.expectedWorkspaceId
) {
return null;
}
return { user, userWorkspace };
}
private async validateImpersonation(payload: AccessTokenJwtPayload) {
// Validate required impersonation fields
if (
@@ -359,12 +385,23 @@ 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,
};
const context: AuthContext = { application, workspace };
if (payload.userId && payload.userWorkspaceId) {
const userContext = await this.resolveUserContext({
userId: payload.userId,
userWorkspaceId: payload.userWorkspaceId,
expectedWorkspaceId: workspace.id,
});
if (isDefined(userContext)) {
context.user = userContext.user;
context.userWorkspace = userContext.userWorkspace;
context.userWorkspaceId = userContext.userWorkspace.id;
}
}
return context;
}
private isLegacyApiKeyPayload(
@@ -12,6 +12,7 @@ import {
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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
@@ -43,6 +44,12 @@ describe('ApplicationTokenService', () => {
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockReturnValue('1h'),
},
},
],
}).compile();
@@ -79,7 +86,6 @@ describe('ApplicationTokenService', () => {
const result = await service.generateApplicationAccessToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
@@ -117,7 +123,6 @@ describe('ApplicationTokenService', () => {
applicationId,
userWorkspaceId,
userId,
expiresInSeconds: 10,
});
expect(result).toEqual({
@@ -151,7 +156,6 @@ describe('ApplicationTokenService', () => {
service.generateApplicationAccessToken({
applicationId: 'non-existent-application',
workspaceId: 'workspace-id',
expiresInSeconds: 10,
}),
).rejects.toThrow(ApplicationException);
});
@@ -163,7 +167,6 @@ describe('ApplicationTokenService', () => {
service.generateApplicationAccessToken({
applicationId: 'application-id',
workspaceId: 'non-existent-workspace',
expiresInSeconds: 10,
}),
).rejects.toThrow(WorkspaceException);
});
@@ -24,9 +24,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE =
'Application refresh token invalid or expired';
@@ -39,6 +38,7 @@ export class ApplicationTokenService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
async generateApplicationAccessToken({
@@ -46,23 +46,25 @@ export class ApplicationTokenService {
applicationId,
userWorkspaceId,
userId,
expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
}: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
expiresInSeconds?: number;
}): Promise<AuthToken> {
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
const expiresIn = this.twentyConfigService.get(
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
);
return this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresInSeconds,
expiresIn,
});
}
@@ -82,26 +84,30 @@ export class ApplicationTokenService {
}> {
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,
}),
],
const accessTokenExpiresIn = this.twentyConfigService.get(
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
);
const refreshTokenExpiresIn = this.twentyConfigService.get(
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
);
const applicationAccessToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresIn: accessTokenExpiresIn,
});
const applicationRefreshToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresIn: refreshTokenExpiresIn,
});
return { applicationAccessToken, applicationRefreshToken };
}
@@ -188,7 +194,7 @@ export class ApplicationTokenService {
userWorkspaceId,
userId,
tokenType,
expiresInSeconds,
expiresIn,
}: {
workspaceId: string;
applicationId: string;
@@ -197,9 +203,8 @@ export class ApplicationTokenService {
tokenType:
| JwtTokenTypeEnum.APPLICATION_ACCESS
| JwtTokenTypeEnum.APPLICATION_REFRESH;
expiresInSeconds: number;
expiresIn: string;
}): AuthToken {
const expiresIn = `${expiresInSeconds}s`;
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload:
@@ -0,0 +1,31 @@
// RFC 6749 redirect URI validation: must be absolute, HTTPS (except localhost), no fragments
export const validateRedirectUri = (
uri: string,
): { valid: true; parsed: URL } | { valid: false; reason: string } => {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return { valid: false, reason: `Invalid redirect URI: ${uri}` };
}
const isLocalhost =
parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
if (parsed.protocol !== 'https:' && !isLocalhost) {
return {
valid: false,
reason: `Redirect URIs must use HTTPS (except localhost): ${uri}`,
};
}
if (parsed.hash) {
return {
valid: false,
reason: `Redirect URIs must not contain fragments: ${uri}`,
};
}
return { valid: true, parsed };
};
@@ -10,11 +10,11 @@ import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entit
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
import { BillingEndTrialPeriodOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output';
import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
@@ -65,7 +65,7 @@ export class BillingResolver {
private readonly permissionsService: PermissionsService,
) {}
@Query(() => BillingSessionOutput)
@Query(() => BillingSessionDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -82,7 +82,7 @@ export class BillingResolver {
};
}
@Mutation(() => BillingSessionOutput)
@Mutation(() => BillingSessionDTO)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
async checkoutSession(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -143,7 +143,7 @@ export class BillingResolver {
}
}
@Mutation(() => BillingUpdateOutput)
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -165,7 +165,7 @@ export class BillingResolver {
};
}
@Mutation(() => BillingUpdateOutput)
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -185,7 +185,7 @@ export class BillingResolver {
};
}
@Mutation(() => BillingUpdateOutput)
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -205,7 +205,7 @@ export class BillingResolver {
};
}
@Mutation(() => BillingUpdateOutput)
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -229,7 +229,7 @@ export class BillingResolver {
};
}
@Mutation(() => BillingUpdateOutput)
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -255,22 +255,22 @@ export class BillingResolver {
};
}
@Query(() => [BillingPlanOutput])
@Query(() => [BillingPlanDTO])
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
async listPlans(): Promise<BillingPlanOutput[]> {
async listPlans(): Promise<BillingPlanDTO[]> {
const plans = await this.billingPlanService.listPlans();
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
}
@Mutation(() => BillingEndTrialPeriodOutput)
@Mutation(() => BillingEndTrialPeriodDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async endSubscriptionTrialPeriod(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingEndTrialPeriodOutput> {
): Promise<BillingEndTrialPeriodDTO> {
const result =
await this.billingSubscriptionService.endTrialPeriod(workspace);
@@ -295,14 +295,14 @@ export class BillingResolver {
};
}
@Query(() => [BillingMeteredProductUsageOutput])
@Query(() => [BillingMeteredProductUsageDTO])
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async getMeteredProductsUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageOutput[]> {
): Promise<BillingMeteredProductUsageDTO[]> {
const usageData =
await this.billingUsageService.getMeteredProductsUsage(workspace);
@@ -316,7 +316,7 @@ export class BillingResolver {
}));
}
@Mutation(() => BillingUpdateOutput)
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
@ObjectType()
export class BillingEndTrialPeriodOutput {
@ObjectType('BillingEndTrialPeriod')
export class BillingEndTrialPeriodDTO {
@Field(() => SubscriptionStatus, {
description: 'Updated subscription status',
nullable: true,
@@ -2,8 +2,8 @@ import { Field, Float, ObjectType } from '@nestjs/graphql';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
@ObjectType()
export class BillingMeteredProductUsageOutput {
@ObjectType('BillingMeteredProductUsage')
export class BillingMeteredProductUsageDTO {
@Field(() => BillingProductKey)
productKey: BillingProductKey;
@@ -8,8 +8,8 @@ import {
} from 'src/engine/core-modules/billing/dtos/billing-product.dto';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
@ObjectType()
export class BillingPlanOutput {
@ObjectType('BillingPlan')
export class BillingPlanDTO {
@Field(() => BillingPlanKey)
planKey: BillingPlanKey;
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@ObjectType()
export class BillingPriceOutput {
@ObjectType('BillingPrice')
export class BillingPriceDTO {
@Field(() => Number)
upTo: number;
@@ -2,8 +2,8 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class BillingSessionOutput {
@ObjectType('BillingSession')
export class BillingSessionDTO {
@Field(() => String, { nullable: true })
url: string;
}
@@ -7,7 +7,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
@ObjectType()
@ObjectType('BillingSubscriptionItem')
export class BillingSubscriptionItemDTO {
@IDField(() => UUIDScalarType)
id: string;
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ObjectType()
export class BillingUpdateOutput {
@ObjectType('BillingUpdate')
export class BillingUpdateDTO {
@Field(() => BillingSubscriptionEntity, {
description: 'Current billing subscription',
})
@@ -38,7 +38,7 @@ export type CancellationDetailsJson = {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output';
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-item.dto';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
@@ -10,7 +10,7 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
@@ -90,7 +90,7 @@ export class BillingUsageService {
async getMeteredProductsUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageOutput[]> {
): Promise<BillingMeteredProductUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
@@ -146,7 +146,7 @@ export class BillingUsageService {
>[number],
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageOutput> {
): Promise<BillingMeteredProductUsageDTO> {
const meterEventsSum =
await this.stripeBillingMeterEventService.sumMeterEvents(
item.stripeMeterId,
@@ -2,7 +2,7 @@
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { type BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
@@ -11,7 +11,7 @@ import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-displ
export const formatBillingDatabaseProductToGraphqlDTO = (
plan: BillingGetPlanResult,
): BillingPlanOutput => {
): BillingPlanDTO => {
return {
planKey: plan.planKey,
licensedProducts: plan.licensedProducts.map((product) => {
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { AuthProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { AuthProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.dto';
import {
InferenceProvider,
ModelFamily,
@@ -90,8 +90,8 @@ export class AdminAIModelConfig {
isRecommended?: boolean;
}
@ObjectType()
export class AdminAIModelsOutput {
@ObjectType('AdminAIModels')
export class AdminAIModelsDTO {
@Field(() => Boolean)
autoEnableNewModels: boolean;
@@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { ApplicationSyncModule } from 'src/engine/core-modules/application/application-sync.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
@@ -88,6 +89,7 @@ import { FileModule } from './file/file.module';
FileModule,
RowLevelPermissionModule,
OpenApiModule,
ApplicationRegistrationModule,
ApplicationModule,
ApplicationSyncModule,
AppTokenModule,
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean } from 'class-validator';
@ObjectType()
export class ResendEmailVerificationTokenOutput {
@ObjectType('ResendEmailVerificationToken')
export class ResendEmailVerificationTokenDTO {
@IsBoolean()
@Field(() => Boolean)
success: boolean;
@@ -4,7 +4,7 @@ import { Args, Context, Mutation } from '@nestjs/graphql';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResendEmailVerificationTokenInput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input';
import { ResendEmailVerificationTokenOutput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output';
import { ResendEmailVerificationTokenDTO } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.dto';
import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -26,14 +26,14 @@ export class EmailVerificationResolver {
) {}
// TODO: this should be an authenticated endpoint
@Mutation(() => ResendEmailVerificationTokenOutput)
@Mutation(() => ResendEmailVerificationTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async resendEmailVerificationToken(
@Args()
resendEmailVerificationTokenInput: ResendEmailVerificationTokenInput,
@Args('origin') origin: string,
@Context() context: I18nContext,
): Promise<ResendEmailVerificationTokenOutput> {
): Promise<ResendEmailVerificationTokenDTO> {
const workspace =
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
@@ -18,7 +18,7 @@ registerEnumType(EmailingDomainStatus, {
});
@ObjectType('EmailingDomain')
export class EmailingDomainDto {
export class EmailingDomainDTO {
@IDField(() => UUIDScalarType)
id: string;
@@ -5,7 +5,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { EmailingDomainDto } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -18,16 +18,16 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
@UsePipes(ResolverValidationPipe)
@MetadataResolver(() => EmailingDomainDto)
@MetadataResolver(() => EmailingDomainDTO)
export class EmailingDomainResolver {
constructor(private readonly emailingDomainService: EmailingDomainService) {}
@Mutation(() => EmailingDomainDto)
@Mutation(() => EmailingDomainDTO)
async createEmailingDomain(
@Args('domain') domain: string,
@Args('driver') driver: EmailingDomainDriver,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<EmailingDomainDto> {
): Promise<EmailingDomainDTO> {
const emailingDomain =
await this.emailingDomainService.createEmailingDomain(
domain,
@@ -48,11 +48,11 @@ export class EmailingDomainResolver {
return true;
}
@Mutation(() => EmailingDomainDto)
@Mutation(() => EmailingDomainDTO)
async verifyEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<EmailingDomainDto> {
): Promise<EmailingDomainDTO> {
const emailingDomain =
await this.emailingDomainService.verifyEmailingDomain(
currentWorkspace,
@@ -62,10 +62,10 @@ export class EmailingDomainResolver {
return emailingDomain;
}
@Query(() => [EmailingDomainDto])
@Query(() => [EmailingDomainDTO])
async getEmailingDomains(
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<EmailingDomainDto[]> {
): Promise<EmailingDomainDTO[]> {
const emailingDomains =
await this.emailingDomainService.getEmailingDomains(currentWorkspace);
@@ -21,7 +21,7 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
import { EventLogsService } from './event-logs.service';
import { EventLogQueryInput } from './dtos/event-log-query.input';
import { EventLogQueryResult } from './dtos/event-log-result.output';
import { EventLogQueryResult } from './dtos/event-log-result.dto';
@MetadataResolver()
@UseFilters(
@@ -23,7 +23,7 @@ import { EventLogQueryInput } from './dtos/event-log-query.input';
import {
EventLogQueryResult,
EventLogRecord,
} from './dtos/event-log-result.output';
} from './dtos/event-log-result.dto';
type ClickHouseEventRecord = {
event?: string;
@@ -4,7 +4,7 @@ import { Column } from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ObjectType('FeatureFlagDTO')
@ObjectType('FeatureFlag')
export class FeatureFlagDTO {
@Field(() => FeatureFlagKey)
@Column({ nullable: false, type: 'text' })
@@ -1,6 +1,5 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
@@ -10,23 +9,18 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'featureFlag', schema: 'core' })
@ObjectType('FeatureFlag')
@Unique('IDX_FEATURE_FLAG_KEY_WORKSPACE_ID_UNIQUE', ['key', 'workspaceId'])
export class FeatureFlagEntity extends WorkspaceRelatedEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field(() => FeatureFlagKey)
@Column({ nullable: false, type: 'text' })
key: FeatureFlagKey;
@Field()
@Column({ nullable: false })
value: boolean;
@@ -1,7 +1,5 @@
import { Module } from '@nestjs/common';
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@@ -12,11 +10,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
@Module({
imports: [
TypeORMModule,
NestjsQueryGraphQLModule.forFeature({
imports: [NestjsQueryTypeOrmModule.forFeature([FeatureFlagEntity])],
services: [],
resolvers: [],
}),
TypeOrmModule.forFeature([FeatureFlagEntity]),
WorkspaceFeatureFlagsMapCacheModule,
WorkspaceCacheModule,
],
@@ -5,7 +5,7 @@ import { Repository } from 'typeorm';
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { type FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { type FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import {
@@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('FileWithSignedUrl')
export class FileWithSignedUrlDto {
export class FileWithSignedUrlDTO {
@Field(() => UUIDScalarType)
id: string;
@@ -7,7 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -31,7 +31,7 @@ export class FileCorePictureResolver {
private readonly fileCorePictureService: FileCorePictureService,
) {}
@Mutation(() => FileWithSignedUrlDto)
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
@@ -40,7 +40,7 @@ export class FileCorePictureResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDto> {
): Promise<FileWithSignedUrlDTO> {
const buffer = await streamToBuffer(createReadStream());
return await this.fileCorePictureService.uploadWorkspacePicture({
@@ -50,13 +50,13 @@ export class FileCorePictureResolver {
});
}
@Mutation(() => FileWithSignedUrlDto)
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard)
async uploadWorkspaceMemberProfilePicture(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDto> {
): Promise<FileWithSignedUrlDTO> {
const buffer = await streamToBuffer(createReadStream());
return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture(
@@ -12,7 +12,7 @@ import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
@@ -88,7 +88,7 @@ export class FileCorePictureService {
file: Buffer;
filename: string;
workspace: WorkspaceEntity;
}): Promise<FileWithSignedUrlDto> {
}): Promise<FileWithSignedUrlDTO> {
const savedFile = await this.uploadCorePicture({
file,
filename,
@@ -130,7 +130,7 @@ export class FileCorePictureService {
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto> {
}): Promise<FileWithSignedUrlDTO> {
const savedFile = await this.uploadCorePicture({
file,
filename,
@@ -211,7 +211,7 @@ export class FileCorePictureService {
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto | undefined> {
}): Promise<FileWithSignedUrlDTO | undefined> {
const imageData = await this.fetchImageBufferFromUrl(imageUrl);
if (!isDefined(imageData)) {
@@ -265,7 +265,7 @@ export class FileCorePictureService {
targetWorkspaceId: string;
targetApplicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto> {
}): Promise<FileWithSignedUrlDTO> {
const sourceFile = await this.fileRepository.findOneOrFail({
where: {
id: sourceFileId,
@@ -7,7 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWorkflowService } from 'src/engine/core-modules/file/file-workflow/services/file-workflow.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -24,14 +24,14 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
export class FileWorkflowResolver {
constructor(private readonly fileWorkflowService: FileWorkflowService) {}
@Mutation(() => FileWithSignedUrlDto)
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadWorkflowFile(
@AuthWorkspace()
{ id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDto> {
): Promise<FileWithSignedUrlDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
@@ -27,7 +27,7 @@ export class FileWorkflowService {
file: Buffer;
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDto> {
}): Promise<FileWithSignedUrlDTO> {
const { mimeType, ext } = await extractFileInfo({
file,
filename,
@@ -7,7 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -24,7 +24,7 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
export class FilesFieldResolver {
constructor(private readonly filesFieldService: FilesFieldService) {}
@Mutation(() => FileWithSignedUrlDto)
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFilesFieldFile(
@AuthWorkspace()
@@ -37,7 +37,7 @@ export class FilesFieldResolver {
nullable: false,
})
fieldMetadataId: string,
): Promise<FileWithSignedUrlDto> {
): Promise<FileWithSignedUrlDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
@@ -49,7 +49,7 @@ export class FilesFieldResolver {
});
}
@Mutation(() => FileWithSignedUrlDto)
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFilesFieldFileByUniversalIdentifier(
@AuthWorkspace()
@@ -62,7 +62,7 @@ export class FilesFieldResolver {
nullable: false,
})
fieldMetadataUniversalIdentifier: string,
): Promise<FileWithSignedUrlDto> {
): Promise<FileWithSignedUrlDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
@@ -9,7 +9,7 @@ import { v4 } from 'uuid';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import {
FilesFieldException,
@@ -42,7 +42,7 @@ export class FilesFieldService {
workspaceId: string;
fieldMetadataId?: string;
fieldMetadataUniversalIdentifier?: string;
}): Promise<FileWithSignedUrlDto> {
}): Promise<FileWithSignedUrlDTO> {
if (!fieldMetadataId && !fieldMetadataUniversalIdentifier) {
throw new FilesFieldException(
'fieldMetadataId or fieldMetadataUniversalIdentifier must be provided',

Some files were not shown because too many files have changed in this diff Show More