Compare commits

...
Author SHA1 Message Date
Félix Malfait fd3fe6aa2d feat(server): OAuth 2.0 server and application sync improvements
Add OAuth 2.0 authorization server capabilities and improve application
sync to automatically keep AppRegistration metadata up to date.

OAuth server:
- Authorization Code flow with PKCE support
- Client Credentials flow for machine-to-machine auth
- Refresh Token flow for token renewal
- All flows issue application-scoped tokens via ApplicationTokenService
- OpenID Connect discovery endpoint (/.well-known/openid-configuration)
- REST token endpoint with proper validation decorators
- JWT strategy updated for optional user context in app tokens

Application sync:
- Auto-create AppRegistration during manifest sync if missing
- Sync name, description, logoUrl, author, websiteUrl, termsUrl from manifest
- Sync server variable schemas from manifest declarations
- Flatten marketplaceData onto ApplicationManifest (remove nesting)

Made-with: Cursor
2026-02-26 10:57:25 +01:00
Félix Malfait d025337ddb feat(server): add AppRegistration entity and server-level secrets
Introduce a server-level AppRegistration entity for managing application
identity, OAuth client credentials, and server-scoped encrypted variables.

- AppRegistration entity with clientId, clientSecretHash, redirectUris, scopes
- AppRegistrationVariable entity with AES-256-GCM encryption for secrets
- Full CRUD resolver secured with SettingsPermissionGuard
- Universal identifier guard service for collision detection
- Variable resolution service for runtime secret access
- Database migration creating both tables with proper indexes/FKs
- ApplicationEntity gains appRegistrationId FK (ON DELETE SET NULL)
- Shared types: ServerVariables, updated ApplicationManifest
- Unit tests for encryption, identifier guard, and service logic

Made-with: Cursor
2026-02-26 10:56:45 +01:00
40 changed files with 2519 additions and 230 deletions
@@ -0,0 +1,111 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAppRegistration1772000000000 implements MigrationInterface {
name = 'CreateAppRegistration1772000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "core"."appRegistration" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"universalIdentifier" uuid NOT NULL,
"name" text NOT NULL,
"description" text,
"logoUrl" text,
"author" text,
"clientId" text NOT NULL,
"clientSecretHash" text,
"redirectUris" text[] NOT NULL DEFAULT '{}',
"scopes" text[] NOT NULL DEFAULT '{}',
"createdByUserId" uuid,
"websiteUrl" text,
"termsUrl" text,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_app_registration" PRIMARY KEY ("id"),
CONSTRAINT "FK_app_registration_created_by_user" FOREIGN KEY ("createdByUserId") REFERENCES "core"."user"("id") ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"
ON "core"."appRegistration" ("universalIdentifier")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE"
ON "core"."appRegistration" ("clientId")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REGISTRATION_CREATED_BY_USER_ID"
ON "core"."appRegistration" ("createdByUserId")
`);
await queryRunner.query(`
CREATE TABLE "core"."appRegistrationVariable" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" text NOT NULL,
"encryptedValue" text NOT NULL DEFAULT '',
"description" text NOT NULL DEFAULT '',
"isSecret" boolean NOT NULL DEFAULT true,
"isRequired" boolean NOT NULL DEFAULT false,
"appRegistrationId" uuid NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_app_registration_variable" PRIMARY KEY ("id"),
CONSTRAINT "FK_app_registration_variable_app_registration" FOREIGN KEY ("appRegistrationId") REFERENCES "core"."appRegistration"("id") ON DELETE CASCADE,
CONSTRAINT "IDX_APP_REGISTRATION_VARIABLE_KEY_APP_REGISTRATION_ID_UNIQUE" UNIQUE ("key", "appRegistrationId")
)
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID"
ON "core"."appRegistrationVariable" ("appRegistrationId")
`);
await queryRunner.query(
`ALTER TABLE "core"."application" ADD "appRegistrationId" uuid`,
);
await queryRunner.query(`
ALTER TABLE "core"."application"
ADD CONSTRAINT "FK_application_app_registration"
FOREIGN KEY ("appRegistrationId") REFERENCES "core"."appRegistration"("id") ON DELETE SET NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" DROP CONSTRAINT "FK_application_app_registration"`,
);
await queryRunner.query(
`ALTER TABLE "core"."application" DROP COLUMN "appRegistrationId"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID"`,
);
await queryRunner.query(
`DROP TABLE "core"."appRegistrationVariable"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_CREATED_BY_USER_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"`,
);
await queryRunner.query(`DROP TABLE "core"."appRegistration"`);
}
}
@@ -0,0 +1,58 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('AppRegistrationEncryptionService', () => {
let service: AppRegistrationEncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationEncryptionService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockReturnValue('test-app-secret-32-chars-long!!!'),
},
},
],
}).compile();
service = module.get(AppRegistrationEncryptionService);
});
it('should encrypt and decrypt a value', () => {
const plaintext = 'my-secret-api-key';
const encrypted = service.encrypt(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertexts for the same plaintext', () => {
const plaintext = 'same-value';
const encrypted1 = service.encrypt(plaintext);
const encrypted2 = service.encrypt(plaintext);
expect(encrypted1).not.toBe(encrypted2);
expect(service.decrypt(encrypted1)).toBe(plaintext);
expect(service.decrypt(encrypted2)).toBe(plaintext);
});
it('should handle empty strings', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('should return empty string when decrypting empty input', () => {
expect(service.decrypt('')).toBe('');
});
});
@@ -0,0 +1,116 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationIdentifierGuardService } from 'src/engine/core-modules/app-registration/app-registration-identifier-guard.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
describe('AppRegistrationIdentifierGuardService', () => {
let service: AppRegistrationIdentifierGuardService;
let appRegistrationRepository: jest.Mocked<
Repository<AppRegistrationEntity>
>;
let applicationRepository: jest.Mocked<Repository<ApplicationEntity>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationIdentifierGuardService,
{
provide: getRepositoryToken(AppRegistrationEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get(AppRegistrationIdentifierGuardService);
appRegistrationRepository = module.get(
getRepositoryToken(AppRegistrationEntity),
);
applicationRepository = module.get(
getRepositoryToken(ApplicationEntity),
);
});
it('should allow when application has no appRegistrationId (grandfather policy)', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: null,
} as ApplicationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'some-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should allow when identifier is not claimed by any registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue(null);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'unclaimed-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should allow when identifier is claimed by the same registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
universalIdentifier: 'my-uid',
name: 'My App',
} as AppRegistrationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'my-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should reject when identifier is claimed by a different registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
name: 'My App',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-2',
universalIdentifier: 'stolen-uid',
name: 'Other App',
} as AppRegistrationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'stolen-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('claimed by');
});
});
@@ -0,0 +1,324 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationExceptionCode } from 'src/engine/core-modules/app-registration/app-registration.exception';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
describe('AppRegistrationService', () => {
let service: AppRegistrationService;
let appRegistrationRepository: jest.Mocked<
Repository<AppRegistrationEntity>
>;
let variableRepository: jest.Mocked<
Repository<AppRegistrationVariableEntity>
>;
let encryptionService: jest.Mocked<AppRegistrationEncryptionService>;
const mockUserId = 'user-123';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationService,
{
provide: getRepositoryToken(AppRegistrationEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
},
},
{
provide: getRepositoryToken(AppRegistrationVariableEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
findOneOrFail: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: AppRegistrationEncryptionService,
useValue: {
encrypt: jest.fn((value: string) => `enc_${value}`),
decrypt: jest.fn((value: string) =>
value.replace('enc_', ''),
),
},
},
],
}).compile();
service = module.get(AppRegistrationService);
appRegistrationRepository = module.get(
getRepositoryToken(AppRegistrationEntity),
);
variableRepository = module.get(
getRepositoryToken(AppRegistrationVariableEntity),
);
encryptionService = module.get(AppRegistrationEncryptionService);
});
describe('create', () => {
it('should always generate a client secret', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
appRegistrationRepository.create.mockImplementation(
(entity) =>
({
...entity,
id: 'reg-1',
}) as AppRegistrationEntity,
);
appRegistrationRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationEntity,
);
const result = await service.create(
{ name: 'Test App' },
mockUserId,
);
expect(result.appRegistration.name).toBe('Test App');
expect(result.clientSecret).toBeDefined();
expect(result.clientSecret.length).toBeGreaterThan(0);
expect(appRegistrationRepository.save).toHaveBeenCalled();
});
it('should accept null createdByUserId for CLI-created registrations', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
appRegistrationRepository.create.mockImplementation(
(entity) =>
({
...entity,
id: 'reg-1',
}) as AppRegistrationEntity,
);
appRegistrationRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationEntity,
);
const result = await service.create({ name: 'CLI App' }, null);
expect(result.appRegistration.createdByUserId).toBeNull();
});
it('should reject duplicate universal identifiers', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'existing',
} as AppRegistrationEntity);
await expect(
service.create(
{
name: 'Dupe App',
universalIdentifier: 'existing-uid',
},
mockUserId,
),
).rejects.toMatchObject({
code: AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
});
});
it('should reject invalid scopes', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
await expect(
service.create(
{
name: 'Bad Scopes App',
scopes: ['api', 'invalid:scope'],
},
mockUserId,
),
).rejects.toMatchObject({
code: AppRegistrationExceptionCode.INVALID_SCOPE,
});
});
});
describe('delete', () => {
it('should soft delete a registration', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
const result = await service.delete('reg-1');
expect(result).toBe(true);
expect(appRegistrationRepository.softDelete).toHaveBeenCalledWith(
'reg-1',
);
});
});
describe('rotateClientSecret', () => {
it('should generate a new secret', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
const secret = await service.rotateClientSecret('reg-1');
expect(secret).toBeDefined();
expect(secret.length).toBeGreaterThan(0);
expect(appRegistrationRepository.update).toHaveBeenCalledWith(
'reg-1',
expect.objectContaining({ clientSecretHash: expect.any(String) }),
);
});
});
describe('createVariable', () => {
it('should encrypt variable value', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.createVariable({
appRegistrationId: 'reg-1',
key: 'API_KEY',
value: 'secret-value',
});
expect(encryptionService.encrypt).toHaveBeenCalledWith(
'secret-value',
);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
encryptedValue: 'enc_secret-value',
}),
);
});
});
describe('syncVariableSchemas', () => {
it('should create variables for new keys', async () => {
variableRepository.findOne.mockResolvedValue(null);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.syncVariableSchemas('reg-1', {
NOTION_CLIENT_ID: {
description: 'Notion OAuth Client ID',
isSecret: false,
isRequired: true,
},
NOTION_CLIENT_SECRET: {
description: 'Notion OAuth Client Secret',
isSecret: true,
isRequired: true,
},
});
expect(variableRepository.save).toHaveBeenCalledTimes(2);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'NOTION_CLIENT_ID',
encryptedValue: '',
isSecret: false,
isRequired: true,
}),
);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'NOTION_CLIENT_SECRET',
isSecret: true,
isRequired: true,
}),
);
});
it('should update metadata for existing keys without overwriting values', async () => {
variableRepository.findOne.mockResolvedValue({
id: 'var-1',
key: 'API_KEY',
encryptedValue: 'enc_existing_value',
isSecret: true,
isRequired: false,
} as AppRegistrationVariableEntity);
await service.syncVariableSchemas('reg-1', {
API_KEY: {
description: 'Updated description',
isSecret: false,
isRequired: true,
},
});
expect(variableRepository.update).toHaveBeenCalledWith('var-1', {
description: 'Updated description',
isSecret: false,
isRequired: true,
});
expect(variableRepository.save).not.toHaveBeenCalled();
});
it('should delete variables not in schema', async () => {
variableRepository.findOne.mockResolvedValue(null);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.syncVariableSchemas('reg-1', {
KEPT_KEY: { description: 'stays' },
});
expect(variableRepository.delete).toHaveBeenCalledWith(
expect.objectContaining({
appRegistrationId: 'reg-1',
}),
);
});
});
describe('findOneByClientId', () => {
it('should return null when client not found', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
const result = await service.findOneByClientId('nonexistent');
expect(result).toBeNull();
});
it('should return registration by client ID', async () => {
const mockRegistration = {
id: 'reg-1',
clientId: 'chrome',
} as AppRegistrationEntity;
appRegistrationRepository.findOne.mockResolvedValue(
mockRegistration,
);
const result = await service.findOneByClientId('chrome');
expect(result).toEqual(mockRegistration);
});
});
});
@@ -0,0 +1,25 @@
import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/app-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,63 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const HKDF_CONTEXT = 'app-registration-variable';
@Injectable()
export class AppRegistrationEncryptionService {
private encryptionKey: Buffer;
constructor(private readonly twentyConfigService: TwentyConfigService) {
const appSecret = this.twentyConfigService.get('APP_SECRET');
this.encryptionKey = Buffer.from(
crypto.hkdfSync('sha256', appSecret, '', HKDF_CONTEXT, 32),
);
}
encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, this.encryptionKey, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Format: base64(iv + authTag + ciphertext)
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
decrypt(encryptedBase64: string): string {
if (!encryptedBase64) {
return '';
}
const data = Buffer.from(encryptedBase64, 'base64');
const iv = data.subarray(0, IV_LENGTH);
const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(
ALGORITHM,
this.encryptionKey,
iv,
{ authTagLength: AUTH_TAG_LENGTH },
);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class AppRegistrationIdentifierGuardService {
constructor(
@InjectRepository(AppRegistrationEntity)
private readonly appRegistrationRepository: Repository<AppRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
// Validates that a universalIdentifier being created/used by an application
// is not claimed by a different AppRegistration.
// Grandfather policy: applications without appRegistrationId are not checked.
async validateUniversalIdentifierOwnership(params: {
universalIdentifier: string;
applicationId: string;
}): Promise<{ isValid: boolean; errorMessage?: string }> {
const application = await this.applicationRepository.findOne({
where: { id: params.applicationId },
});
if (!isDefined(application?.appRegistrationId)) {
return { isValid: true };
}
const ownerRegistration = await this.appRegistrationRepository.findOne({
where: { universalIdentifier: params.universalIdentifier },
});
if (!isDefined(ownerRegistration)) {
return { isValid: true };
}
if (ownerRegistration.id !== application.appRegistrationId) {
return {
isValid: false,
errorMessage:
`Universal identifier '${params.universalIdentifier}' is claimed by ` +
`app registration '${ownerRegistration.name}' (${ownerRegistration.id}), ` +
`but application '${application.name}' is linked to a different registration`,
};
}
return { isValid: true };
}
}
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
// Resolution order: workspace-level override > server-level default
@Injectable()
export class AppRegistrationVariableResolutionService {
constructor(
@InjectRepository(ApplicationVariableEntity)
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
@InjectRepository(AppRegistrationVariableEntity)
private readonly appRegistrationVariableRepository: Repository<AppRegistrationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly appRegistrationEncryption: AppRegistrationEncryptionService,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async resolveVariable(
applicationId: string,
key: string,
): Promise<string | undefined> {
const workspaceVariable = await this.applicationVariableRepository.findOne({
where: { applicationId, key },
});
if (isDefined(workspaceVariable)) {
if (workspaceVariable.isSecret) {
return this.secretEncryptionService.decrypt(workspaceVariable.value);
}
return workspaceVariable.value;
}
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (!isDefined(application?.appRegistrationId)) {
return undefined;
}
const serverVariable = await this.appRegistrationVariableRepository.findOne(
{
where: {
appRegistrationId: application.appRegistrationId,
key,
},
},
);
if (!isDefined(serverVariable)) {
return undefined;
}
return this.appRegistrationEncryption.decrypt(
serverVariable.encryptedValue,
);
}
async resolveAllVariables(
applicationId: string,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (isDefined(application?.appRegistrationId)) {
const serverVariables = await this.appRegistrationVariableRepository.find(
{
where: { appRegistrationId: application.appRegistrationId },
},
);
for (const variable of serverVariables) {
result[variable.key] = this.appRegistrationEncryption.decrypt(
variable.encryptedValue,
);
}
}
const workspaceVariables = await this.applicationVariableRepository.find({
where: { applicationId },
});
for (const variable of workspaceVariables) {
if (variable.isSecret) {
result[variable.key] = this.secretEncryptionService.decrypt(
variable.value,
);
} else {
result[variable.key] = variable.value;
}
}
return result;
}
}
@@ -0,0 +1,76 @@
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 { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
@Entity({ name: 'appRegistrationVariable', schema: 'core' })
@ObjectType('AppRegistrationVariable')
@Unique('IDX_APP_REGISTRATION_VARIABLE_KEY_APP_REGISTRATION_ID_UNIQUE', [
'key',
'appRegistrationId',
])
@Index('IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID', [
'appRegistrationId',
])
export class AppRegistrationVariableEntity {
@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' })
appRegistrationId: string;
@ManyToOne(
() => AppRegistrationEntity,
(appRegistration) => appRegistration.variables,
{ onDelete: 'CASCADE', nullable: false },
)
@JoinColumn({ name: 'appRegistrationId' })
appRegistration: Relation<AppRegistrationEntity>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,105 @@
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 { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Entity({ name: 'appRegistration', schema: 'core' })
@ObjectType('AppRegistration')
@Index('IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE', ['universalIdentifier'], {
unique: true,
where: '"deletedAt" IS NULL',
})
@Index('IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE', ['clientId'], {
unique: true,
where: '"deletedAt" IS NULL',
})
@Index('IDX_APP_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
export class AppRegistrationEntity {
@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' })
clientId: string;
@Column({ nullable: true, type: 'text' })
clientSecretHash: string | null;
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
redirectUris: string[];
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
scopes: 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(
() => AppRegistrationVariableEntity,
(variable) => variable.appRegistration,
{ onDelete: 'CASCADE' },
)
variables: Relation<AppRegistrationVariableEntity[]>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,53 @@
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 AppRegistrationExceptionCode {
APP_REGISTRATION_NOT_FOUND = 'APP_REGISTRATION_NOT_FOUND',
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
CLIENT_ID_ALREADY_EXISTS = 'CLIENT_ID_ALREADY_EXISTS',
INVALID_SCOPE = 'INVALID_SCOPE',
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
INVALID_INPUT = 'INVALID_INPUT',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
}
const getExceptionUserFriendlyMessage = (
code: AppRegistrationExceptionCode,
) => {
switch (code) {
case AppRegistrationExceptionCode.APP_REGISTRATION_NOT_FOUND:
return msg`App registration not found.`;
case AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
return msg`This universal identifier is already claimed by another registration.`;
case AppRegistrationExceptionCode.CLIENT_ID_ALREADY_EXISTS:
return msg`This client ID already exists.`;
case AppRegistrationExceptionCode.INVALID_SCOPE:
return msg`One or more requested scopes are invalid.`;
case AppRegistrationExceptionCode.INVALID_REDIRECT_URI:
return msg`One or more redirect URIs are invalid.`;
case AppRegistrationExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
case AppRegistrationExceptionCode.VARIABLE_NOT_FOUND:
return msg`App registration variable not found.`;
default:
assertUnreachable(code);
}
};
export class AppRegistrationException extends CustomException<AppRegistrationExceptionCode> {
constructor(
message: string,
code: AppRegistrationExceptionCode,
{
userFriendlyMessage,
}: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationIdentifierGuardService } from 'src/engine/core-modules/app-registration/app-registration-identifier-guard.service';
import { AppRegistrationVariableResolutionService } from 'src/engine/core-modules/app-registration/app-registration-variable-resolution.service';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationResolver } from 'src/engine/core-modules/app-registration/app-registration.resolver';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([
AppRegistrationEntity,
AppRegistrationVariableEntity,
ApplicationEntity,
ApplicationVariableEntity,
]),
SecretEncryptionModule,
PermissionsModule,
],
providers: [
AppRegistrationService,
AppRegistrationEncryptionService,
AppRegistrationVariableResolutionService,
AppRegistrationIdentifierGuardService,
AppRegistrationResolver,
],
exports: [
AppRegistrationService,
AppRegistrationEncryptionService,
AppRegistrationVariableResolutionService,
AppRegistrationIdentifierGuardService,
],
})
export class AppRegistrationModule {}
@@ -0,0 +1,172 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { AppRegistrationStatsOutput } from 'src/engine/core-modules/app-registration/dtos/app-registration-stats.output';
import { CreateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.input';
import { CreateAppRegistrationOutput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.output';
import { CreateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration-variable.input';
import { RotateClientSecretOutput } from 'src/engine/core-modules/app-registration/dtos/rotate-client-secret.output';
import { UpdateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration.input';
import { UpdateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/update-app-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 { 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 AppRegistrationResolver {
constructor(
private readonly appRegistrationService: AppRegistrationService,
) {}
@Query(() => AppRegistrationEntity, { nullable: true })
async findAppRegistrationByClientId(
@Args('clientId') clientId: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationService.findOneByClientId(clientId);
}
@UseGuards(WorkspaceAuthGuard)
@Query(() => AppRegistrationEntity, { nullable: true })
async findAppRegistrationByUniversalIdentifier(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [AppRegistrationEntity])
async findManyAppRegistrations(): Promise<AppRegistrationEntity[]> {
return this.appRegistrationService.findMany();
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => AppRegistrationEntity)
async findOneAppRegistration(
@Args('id') id: string,
): Promise<AppRegistrationEntity> {
return this.appRegistrationService.findOneById(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => AppRegistrationStatsOutput)
async findAppRegistrationStats(
@Args('id') id: string,
): Promise<AppRegistrationStatsOutput> {
return this.appRegistrationService.getStats(id);
}
@UseGuards(WorkspaceAuthGuard)
@Mutation(() => CreateAppRegistrationOutput)
async createAppRegistration(
@Args('input') input: CreateAppRegistrationInput,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
): Promise<CreateAppRegistrationOutput> {
return this.appRegistrationService.create(input, user?.id ?? null);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationEntity)
async updateAppRegistration(
@Args('input') input: UpdateAppRegistrationInput,
): Promise<AppRegistrationEntity> {
return this.appRegistrationService.update(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteAppRegistration(
@Args('id') id: string,
): Promise<boolean> {
return this.appRegistrationService.delete(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => RotateClientSecretOutput)
async rotateAppRegistrationClientSecret(
@Args('id') id: string,
): Promise<RotateClientSecretOutput> {
const clientSecret =
await this.appRegistrationService.rotateClientSecret(id);
return { clientSecret };
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [AppRegistrationVariableEntity])
async findAppRegistrationVariables(
@Args('appRegistrationId') appRegistrationId: string,
): Promise<AppRegistrationVariableEntity[]> {
return this.appRegistrationService.findVariables(appRegistrationId);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationVariableEntity)
async createAppRegistrationVariable(
@Args('input') input: CreateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
return this.appRegistrationService.createVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationVariableEntity)
async updateAppRegistrationVariable(
@Args('input') input: UpdateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
return this.appRegistrationService.updateVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteAppRegistrationVariable(
@Args('id') id: string,
): Promise<boolean> {
return this.appRegistrationService.deleteVariable(id);
}
}
@@ -0,0 +1,349 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import crypto from 'crypto';
import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import {
AppRegistrationException,
AppRegistrationExceptionCode,
} from 'src/engine/core-modules/app-registration/app-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
import { type AppRegistrationStatsOutput } from 'src/engine/core-modules/app-registration/dtos/app-registration-stats.output';
import { type CreateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.input';
import { type CreateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration-variable.input';
import { type UpdateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration.input';
import { type UpdateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration-variable.input';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
export class AppRegistrationService {
constructor(
@InjectRepository(AppRegistrationEntity)
private readonly appRegistrationRepository: Repository<AppRegistrationEntity>,
@InjectRepository(AppRegistrationVariableEntity)
private readonly variableRepository: Repository<AppRegistrationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly encryptionService: AppRegistrationEncryptionService,
) {}
async findMany(): Promise<AppRegistrationEntity[]> {
return this.appRegistrationRepository.find({
order: { createdAt: 'DESC' },
});
}
async findOneById(id: string): Promise<AppRegistrationEntity> {
const registration = await this.appRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new AppRegistrationException(
`App registration with id ${id} not found`,
AppRegistrationExceptionCode.APP_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOneByClientId(
clientId: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationRepository.findOne({
where: { clientId },
});
}
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationRepository.findOne({
where: { universalIdentifier },
});
}
async create(
input: CreateAppRegistrationInput,
createdByUserId: string | null,
): Promise<{ appRegistration: AppRegistrationEntity; clientSecret: string }> {
const universalIdentifier = input.universalIdentifier ?? v4();
const existingByUid =
await this.findOneByUniversalIdentifier(universalIdentifier);
if (existingByUid) {
throw new AppRegistrationException(
`Universal identifier ${universalIdentifier} is already claimed`,
AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
if (isDefined(input.scopes)) {
this.validateScopes(input.scopes);
}
const clientId = v4();
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
const appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
clientId,
clientSecretHash,
redirectUris: input.redirectUris ?? [],
scopes: input.scopes ?? [],
createdByUserId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.appRegistrationRepository.save(appRegistration);
return { appRegistration: saved, clientSecret };
}
async update(
input: UpdateAppRegistrationInput,
): Promise<AppRegistrationEntity> {
await this.findOneById(input.id);
if (isDefined(input.scopes)) {
this.validateScopes(input.scopes);
}
const updateData: QueryDeepPartialEntity<AppRegistrationEntity> = {};
if (isDefined(input.name)) updateData.name = input.name;
if (isDefined(input.description)) updateData.description = input.description;
if (isDefined(input.logoUrl)) updateData.logoUrl = input.logoUrl;
if (isDefined(input.author)) updateData.author = input.author;
if (isDefined(input.redirectUris)) updateData.redirectUris = input.redirectUris;
if (isDefined(input.scopes)) updateData.scopes = input.scopes;
if (isDefined(input.websiteUrl)) updateData.websiteUrl = input.websiteUrl;
if (isDefined(input.termsUrl)) updateData.termsUrl = input.termsUrl;
await this.appRegistrationRepository.update(input.id, updateData);
return this.findOneById(input.id);
}
async delete(id: string): Promise<boolean> {
await this.findOneById(id);
await this.appRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string): Promise<string> {
await this.findOneById(id);
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
await this.appRegistrationRepository.update(id, { clientSecretHash });
return clientSecret;
}
async verifyClientSecret(
registration: AppRegistrationEntity,
clientSecret: string,
): Promise<boolean> {
if (!registration.clientSecretHash) {
return false;
}
return bcrypt.compare(clientSecret, registration.clientSecretHash);
}
// Variable operations
async findVariables(
appRegistrationId: string,
): Promise<AppRegistrationVariableEntity[]> {
return this.variableRepository.find({
where: { appRegistrationId },
order: { key: 'ASC' },
});
}
async createVariable(
input: CreateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
await this.findOneById(input.appRegistrationId);
const encryptedValue = this.encryptionService.encrypt(input.value);
const variable = this.variableRepository.create({
appRegistrationId: input.appRegistrationId,
key: input.key,
encryptedValue,
description: input.description ?? '',
isSecret: input.isSecret ?? true,
});
return this.variableRepository.save(variable);
}
async updateVariable(
input: UpdateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
const variable = await this.variableRepository.findOne({
where: { id: input.id },
});
if (!variable) {
throw new AppRegistrationException(
`Variable with id ${input.id} not found`,
AppRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
const updateData: QueryDeepPartialEntity<AppRegistrationVariableEntity> = {};
if (isDefined(input.value)) {
updateData.encryptedValue = this.encryptionService.encrypt(input.value);
}
if (isDefined(input.description)) {
updateData.description = input.description;
}
await this.variableRepository.update(input.id, updateData);
return this.variableRepository.findOneOrFail({ where: { id: input.id } });
}
async deleteVariable(id: string): Promise<boolean> {
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new AppRegistrationException(
`Variable with id ${id} not found`,
AppRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
await this.variableRepository.delete(id);
return true;
}
// Syncs variable schemas from manifest: creates missing, updates metadata, removes stale
async syncVariableSchemas(
appRegistrationId: string,
serverVariables: ServerVariables,
): Promise<void> {
const declaredKeys = Object.keys(serverVariables);
for (const [key, schema] of Object.entries(serverVariables)) {
const existing = await this.variableRepository.findOne({
where: { appRegistrationId, 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({
appRegistrationId,
key,
encryptedValue: '',
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
}),
);
}
}
if (declaredKeys.length > 0) {
await this.variableRepository.delete({
appRegistrationId,
key: Not(In(declaredKeys)),
});
} else {
await this.variableRepository.delete({ appRegistrationId });
}
}
async getStats(
appRegistrationId: string,
): Promise<AppRegistrationStatsOutput> {
await this.findOneById(appRegistrationId);
const installs = await this.applicationRepository.find({
where: { appRegistrationId },
select: ['version'],
});
const versionCounts = new Map<string, number>();
for (const install of installs) {
const version = install.version ?? 'unknown';
versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1);
}
const versionDistribution = Array.from(versionCounts.entries())
.map(([version, count]) => ({ version, count }))
.sort((a, b) => b.count - a.count);
const latestVersion = versionDistribution[0]?.version ?? null;
return {
activeInstalls: installs.length,
latestVersion,
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 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 AppRegistrationException(
`Invalid scopes: ${invalidScopes.join(', ')}`,
AppRegistrationExceptionCode.INVALID_SCOPE,
);
}
}
}
@@ -0,0 +1,17 @@
// 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,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class VersionDistributionEntry {
@Field()
version: string;
@Field(() => Int)
count: number;
}
@ObjectType()
export class AppRegistrationStatsOutput {
@Field(() => Int)
activeInstalls: number;
@Field(() => String, { nullable: true })
latestVersion: string | null;
@Field(() => [VersionDistributionEntry])
versionDistribution: VersionDistributionEntry[];
}
@@ -0,0 +1,28 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateAppRegistrationVariableInput {
@Field()
@IsUUID()
appRegistrationId: string;
@Field()
@IsString()
key: string;
@Field()
@IsString()
value: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isSecret?: boolean;
}
@@ -0,0 +1,52 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateAppRegistrationInput {
@Field()
@IsString()
name: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
universalIdentifier?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
scopes?: string[];
@Field({ nullable: true })
@IsString()
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
termsUrl?: string;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
@ObjectType()
export class CreateAppRegistrationOutput {
@Field(() => AppRegistrationEntity)
appRegistration: AppRegistrationEntity;
@Field()
clientSecret: string;
}
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class RotateClientSecretOutput {
@Field()
clientSecret: string;
}
@@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class UpdateAppRegistrationVariableInput {
@Field()
@IsUUID()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
value?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
}
@@ -0,0 +1,57 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsArray,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
@InputType()
export class UpdateAppRegistrationInput {
@Field()
@IsUUID()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
scopes?: string[];
@Field({ nullable: true })
@IsString()
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
termsUrl?: string;
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-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]),
AppRegistrationModule,
ApplicationModule,
ApplicationVariableEntityModule,
TokenModule,
@@ -7,6 +7,7 @@ import {
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@@ -14,6 +15,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-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';
@@ -91,6 +93,16 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@Column({ nullable: true, type: 'uuid' })
appRegistrationId: string | null;
@ManyToOne(() => AppRegistrationEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'appRegistrationId' })
appRegistration: Relation<AppRegistrationEntity> | null;
@OneToMany(() => AgentEntity, (agent) => agent.application, {
onDelete: 'CASCADE',
})
@@ -8,4 +8,5 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'appRegistration',
] 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 })
appRegistrationId?: string;
}
@@ -5,6 +5,7 @@ import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
@@ -38,6 +39,7 @@ export class ApplicationSyncService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fileStorageService: FileStorageService,
private readonly appRegistrationService: AppRegistrationService,
) {}
public async synchronizeFromManifest({
@@ -118,14 +120,65 @@ export class ApplicationSyncService {
},
);
return await this.applicationService.update(application.id, {
const updateData: Partial<ApplicationEntity> = {
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
};
let appRegistrationId = application.appRegistrationId;
const appRegistrationMetadata = {
name,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
};
if (!appRegistrationId) {
const existingRegistration =
await this.appRegistrationService.findOneByUniversalIdentifier(
manifest.application.universalIdentifier,
);
if (existingRegistration) {
appRegistrationId = existingRegistration.id;
} else {
const { appRegistration: newRegistration } =
await this.appRegistrationService.create(
{
...appRegistrationMetadata,
universalIdentifier: manifest.application.universalIdentifier,
},
null,
);
appRegistrationId = newRegistration.id;
this.logger.log(
`Created app registration for ${name} (${manifest.application.universalIdentifier})`,
);
}
updateData.appRegistrationId = appRegistrationId;
}
await this.appRegistrationService.update({
id: appRegistrationId,
...appRegistrationMetadata,
});
if (manifest.application.serverVariables) {
await this.appRegistrationService.syncVariableSchemas(
appRegistrationId,
manifest.application.serverVariables,
);
}
return await this.applicationService.update(application.id, updateData);
}
public async uninstallApplication({
@@ -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 { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-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';
@@ -13,6 +14,8 @@ import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controlle
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
import { OAuthDiscoveryController } from 'src/engine/core-modules/auth/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/auth/controllers/oauth-token.controller';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
@@ -71,6 +74,7 @@ import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-
import { AuthResolver } from './auth.resolver';
import { AuthService } from './services/auth.service';
import { OAuthService } from './services/oauth.service';
import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
@Module({
@@ -116,6 +120,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
AuditModule,
SubdomainManagerModule,
DomainServerConfigModule,
AppRegistrationModule,
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
@@ -127,6 +132,8 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
GoogleAPIsAuthController,
MicrosoftAPIsAuthController,
SSOAuthController,
OAuthTokenController,
OAuthDiscoveryController,
],
providers: [
SignInUpService,
@@ -153,6 +160,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UpdateConnectedAccountOnReconnectService,
TransientTokenService,
AuthSsoService,
OAuthService,
],
exports: [
AccessTokenService,
@@ -0,0 +1,36 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
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)
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,85 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
export class OAuthTokenRequestDto {
@IsString()
grant_type: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
redirect_uri?: string;
@IsOptional()
@IsString()
client_id?: string;
@IsOptional()
@IsString()
client_secret?: string;
@IsOptional()
@IsString()
code_verifier?: string;
@IsOptional()
@IsString()
refresh_token?: string;
}
@Controller('oauth')
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthTokenController {
constructor(private readonly oauthService: OAuthService) {}
@Post('token')
@HttpCode(HttpStatus.OK)
@UseGuards(PublicEndpointGuard)
async token(@Body() body: OAuthTokenRequestDto) {
switch (body.grant_type) {
case 'authorization_code':
return this.oauthService.exchangeAuthorizationCode({
authorizationCode: body.code ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
codeVerifier: body.code_verifier,
redirectUri: body.redirect_uri ?? '',
});
case 'client_credentials':
return this.oauthService.clientCredentialsGrant({
clientId: body.client_id ?? '',
clientSecret: body.client_secret ?? '',
});
case 'refresh_token':
return this.oauthService.refreshTokenGrant({
refreshToken: body.refresh_token ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
});
default:
return {
error: 'unsupported_grant_type',
error_description: `Grant type '${body.grant_type}' is not supported`,
};
}
}
}
@@ -13,8 +13,9 @@ 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 { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import {
AppTokenEntity,
AppTokenType,
@@ -94,6 +95,7 @@ export class AuthService {
private readonly appTokenRepository: Repository<AppTokenEntity>,
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly appRegistrationService: AppRegistrationService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -495,40 +497,28 @@ export class AuthService {
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/`,
},
];
const { clientId, codeChallenge } = authorizeAppInput;
const client = apps.find((app) => app.id === clientId);
const appRegistration =
await this.appRegistrationService.findOneByClientId(clientId);
if (!client) {
if (!appRegistration) {
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 (
!appRegistration.redirectUris.includes(authorizeAppInput.redirectUrl)
) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
@@ -570,9 +560,7 @@ export class AuthService {
await this.appTokenRepository.save(token);
}
const redirectUrl = `${
client.redirectUrl ? client.redirectUrl : authorizeAppInput.redirectUrl
}?authorizationCode=${authorizationCode}`;
const redirectUrl = `${authorizeAppInput.redirectUrl}?authorizationCode=${authorizationCode}`;
return { redirectUrl };
}
@@ -1,157 +1,362 @@
// 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,
// };
// }
// }
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
const OAUTH_ACCESS_TOKEN_EXPIRES_IN = 1800;
type OAuthTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
};
type OAuthErrorResponse = {
error: string;
error_description: string;
};
@Injectable()
export class OAuthService {
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 appRegistrationService: AppRegistrationService,
) {}
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 appRegistration = clientValidation;
if (
redirectUri &&
appRegistration.redirectUris.length > 0 &&
!appRegistration.redirectUris.includes(redirectUri)
) {
return this.errorResponse(
'invalid_grant',
'Redirect URI does not match any registered redirect URI',
);
}
if (clientSecret) {
const secretError = await this.validateClientSecret(
appRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
const authCodeToken = await this.appTokenRepository.findOne({
where: {
value: authorizationCode,
type: AppTokenType.AuthorizationCode,
},
});
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',
);
}
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.findApplicationByRegistrationAndWorkspace(
appRegistration.id,
authCodeToken.workspaceId,
);
if (!application) {
return this.errorResponse(
'server_error',
'No workspace installation found for this client',
);
}
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: OAUTH_ACCESS_TOKEN_EXPIRES_IN,
refresh_token: applicationRefreshToken.token,
scope: appRegistration.scopes.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 appRegistration = clientValidation;
const secretError = await this.validateClientSecret(
appRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
const application = await this.applicationRepository.findOne({
where: { appRegistrationId: appRegistration.id },
});
if (!application) {
return this.errorResponse(
'server_error',
'No workspace installation found for this client. Install the app in a workspace first.',
);
}
const applicationAccessToken =
await this.applicationTokenService.generateApplicationAccessToken({
workspaceId: application.workspaceId,
applicationId: application.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: OAUTH_ACCESS_TOKEN_EXPIRES_IN,
scope: appRegistration.scopes.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 appRegistration = clientValidation;
if (clientSecret) {
const secretError = await this.validateClientSecret(
appRegistration,
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: OAUTH_ACCESS_TOKEN_EXPIRES_IN,
refresh_token: applicationRefreshToken.token,
scope: appRegistration.scopes.join(' '),
};
} catch {
return this.errorResponse(
'invalid_grant',
'Invalid or expired refresh token',
);
}
}
private async validateClient(
clientId: string,
): Promise<AppRegistrationEntity | OAuthErrorResponse> {
const appRegistration =
await this.appRegistrationService.findOneByClientId(clientId);
if (!appRegistration) {
return this.errorResponse('invalid_client', 'Client not found');
}
return appRegistration;
}
private async validateClientSecret(
appRegistration: AppRegistrationEntity,
clientSecret: string,
): Promise<OAuthErrorResponse | null> {
const isValid = await this.appRegistrationService.verifyClientSecret(
appRegistration,
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 = crypto
.createHash('sha256')
.update(codeVerifier)
.digest()
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
const challengeToken = await this.appTokenRepository.findOne({
where: {
value: codeChallenge,
type: AppTokenType.CodeChallenge,
...(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 findApplicationByRegistrationAndWorkspace(
appRegistrationId: string,
workspaceId: string,
): Promise<ApplicationEntity | null> {
return this.applicationRepository.findOne({
where: { appRegistrationId, workspaceId },
});
}
private errorResponse(
error: string,
errorDescription: string,
): OAuthErrorResponse {
return { error, error_description: errorDescription };
}
}
@@ -359,12 +359,33 @@ 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 };
// When the token carries user context (e.g. OAuth authorization code flow),
// populate user fields so downstream resolvers can identify the acting user.
if (payload.userId) {
const user = await this.userRepository.findOne({
where: { id: payload.userId },
});
if (isDefined(user)) {
context.user = user;
}
}
if (payload.userWorkspaceId) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: payload.userWorkspaceId },
relations: ['user', 'workspace'],
});
if (isDefined(userWorkspace)) {
context.userWorkspace = userWorkspace;
context.userWorkspaceId = userWorkspace.id;
}
}
return context;
}
private isLegacyApiKeyPayload(
@@ -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 { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-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,
AppRegistrationModule,
ApplicationModule,
ApplicationSyncModule,
AppTokenModule,
@@ -5,20 +5,18 @@
"displayName": "Data Enrichment",
"description": "Enrich your data easily. Choose your provider.",
"icon": "IconSparkles",
"marketplaceData": {
"author": "Cosmos Labs",
"category": "Data",
"logo": "assets/logo.png",
"screenshots": [
"assets/screenshot-1.png",
"assets/screenshot-2.png",
"assets/screenshot-3.png"
],
"aboutDescription": "Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.",
"providers": ["Clearbit", "Apollo", "Hunter.io"],
"websiteUrl": "https://google.com",
"termsUrl": "https://google.com"
}
"author": "Cosmos Labs",
"category": "Data",
"logoUrl": "assets/logo.png",
"screenshots": [
"assets/screenshot-1.png",
"assets/screenshot-2.png",
"assets/screenshot-3.png"
],
"aboutDescription": "Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.",
"providers": ["Clearbit", "Apollo", "Hunter.io"],
"websiteUrl": "https://google.com",
"termsUrl": "https://google.com"
},
"entities": {
"objects": [],
@@ -4,4 +4,5 @@ export enum AuthProviderEnum {
Password = 'password',
SSO = 'sso',
Impersonation = 'impersonation',
OAuth = 'oauth',
}
@@ -1,17 +1,7 @@
import { type ApplicationVariables } from './applicationVariablesType';
import { type ServerVariables } from './serverVariablesType';
import { type SyncableEntityOptions } from './syncableEntityOptionsType';
export type ApplicationMarketplaceData = {
author?: string;
category?: string;
logo?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
};
export type ApplicationManifest = SyncableEntityOptions & {
defaultRoleUniversalIdentifier: string;
postInstallLogicFunctionUniversalIdentifier?: string;
@@ -19,7 +9,15 @@ export type ApplicationManifest = SyncableEntityOptions & {
description: string;
icon?: string;
applicationVariables?: ApplicationVariables;
marketplaceData?: ApplicationMarketplaceData;
serverVariables?: ServerVariables;
author?: string;
logoUrl?: string;
category?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
packageJsonChecksum: string | null;
yarnLockChecksum: string | null;
apiClientChecksum: string | null;
@@ -7,10 +7,7 @@
* |___/
*/
export type {
ApplicationMarketplaceData,
ApplicationManifest,
} from './applicationType';
export type { ApplicationManifest } from './applicationType';
export type { ApplicationVariables } from './applicationVariablesType';
export type { AssetManifest } from './assetManifestType';
export { API_CLIENT_DIR } from './constants/ApiClientDirectory';
@@ -52,6 +49,7 @@ export type {
FieldPermissionManifest,
RoleManifest,
} from './roleManifestType';
export type { ServerVariables } from './serverVariablesType';
export type { SkillManifest } from './skillManifestType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
export type {
@@ -0,0 +1,7 @@
type ServerVariableSchema = {
description?: string;
isSecret?: boolean;
isRequired?: boolean;
};
export type ServerVariables = Record<string, ServerVariableSchema>;