diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts new file mode 100644 index 0000000000..43e3006946 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts @@ -0,0 +1,131 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; +import { + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ConnectedAccountTokenEncryptionService, +} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; + +const BACKFILL_BATCH_SIZE = 500; + +const ACCESS_TOKEN_CHECK_CONSTRAINT_NAME = + 'CHK_connectedAccount_accessToken_encrypted'; +const REFRESH_TOKEN_CHECK_CONSTRAINT_NAME = + 'CHK_connectedAccount_refreshToken_encrypted'; + +type ConnectedAccountTokenRow = { + id: string; + accessToken: string | null; + refreshToken: string | null; +}; + +@RegisteredInstanceCommand('2.5.0', 1798000004000, { type: 'slow' }) +export class EncryptConnectedAccountTokensSlowInstanceCommand + implements SlowInstanceCommand +{ + constructor( + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, + ) {} + + async runDataMigration(dataSource: DataSource): Promise { + // Cursor + prefix-filter on the SELECT makes the loop both bounded in + // memory and idempotent: re-runs after a partial failure skip rows that + // were already encrypted on a prior pass. + let cursor = '00000000-0000-0000-0000-000000000000'; + + while (true) { + const rows: ConnectedAccountTokenRow[] = await dataSource.query( + `SELECT id, "accessToken", "refreshToken" + FROM "core"."connectedAccount" + WHERE id > $1 + AND ( + ("accessToken" IS NOT NULL AND "accessToken" NOT LIKE $2) + OR ("refreshToken" IS NOT NULL AND "refreshToken" NOT LIKE $2) + ) + ORDER BY id + LIMIT $3`, + [ + cursor, + `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%`, + BACKFILL_BATCH_SIZE, + ], + ); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const sets: string[] = []; + const params: unknown[] = [row.id]; + if ( + row.accessToken !== null && + !row.accessToken.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX) + ) { + params.push( + this.connectedAccountTokenEncryptionService.encrypt( + row.accessToken, + ), + ); + sets.push(`"accessToken" = $${params.length}`); + } + + if ( + row.refreshToken !== null && + !row.refreshToken.startsWith( + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ) + ) { + params.push( + this.connectedAccountTokenEncryptionService.encrypt( + row.refreshToken, + ), + ); + sets.push(`"refreshToken" = $${params.length}`); + } + + if (sets.length === 0) { + continue; + } + + await dataSource.query( + `UPDATE "core"."connectedAccount" + SET ${sets.join(', ')} + WHERE id = $1`, + params, + ); + } + + cursor = rows[rows.length - 1].id; + } + } + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + ADD CONSTRAINT "${ACCESS_TOKEN_CHECK_CONSTRAINT_NAME}" + CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + ADD CONSTRAINT "${REFRESH_TOKEN_CHECK_CONSTRAINT_NAME}" + CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%')`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Deliberately do NOT decrypt rows on rollback — re-introducing plaintext + // tokens to the database would be a security regression. Dropping the + // CHECK constraints is enough; ConnectedAccountTokenEncryptionService can + // still read the encrypted columns whether or not the constraints exist. + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + DROP CONSTRAINT IF EXISTS "${REFRESH_TOKEN_CHECK_CONSTRAINT_NAME}"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + DROP CONSTRAINT IF EXISTS "${ACCESS_TOKEN_CHECK_CONSTRAINT_NAME}"`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts new file mode 100644 index 0000000000..67fece1356 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts @@ -0,0 +1,244 @@ +import { type DataSource } from 'typeorm'; + +import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens'; +import { + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + type ConnectedAccountTokenEncryptionService, +} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; + +type FakeRow = { + id: string; + accessToken: string | null; + refreshToken: string | null; +}; + +// In-memory stand-in that mimics the slow command's exact SELECT / UPDATE +// shape (LIKE filter, cursor, batch) — anything looser would let regressions +// in the SQL slip past these tests. +const buildFakeDataSource = ( + initialRows: FakeRow[], + { batchSize }: { batchSize: number } = { batchSize: 500 }, +): { + dataSource: DataSource; + rows: () => FakeRow[]; + queryCallCount: () => number; +} => { + const rows = [...initialRows].sort((a, b) => a.id.localeCompare(b.id)); + let queryCallCount = 0; + + const fakeDataSource = { + query: jest.fn(async (sql: string, params?: unknown[]) => { + queryCallCount++; + + if (sql.includes('SELECT id')) { + const cursor = params?.[0] as string; + const prefixPattern = params?.[1] as string; + const prefix = prefixPattern.replace(/%$/, ''); + + return rows + .filter((row) => row.id > cursor) + .filter( + (row) => + (row.accessToken !== null && + !row.accessToken.startsWith(prefix)) || + (row.refreshToken !== null && + !row.refreshToken.startsWith(prefix)), + ) + .slice(0, batchSize); + } + + if (sql.startsWith('UPDATE')) { + const id = params?.[0] as string; + const target = rows.find((row) => row.id === id); + + if (!target) { + return; + } + + // Mirror the SQL: SET "accessToken" = $N, "refreshToken" = $M WHERE id = $1 + const accessTokenMatch = sql.match(/"accessToken" = \$(\d+)/); + const refreshTokenMatch = sql.match(/"refreshToken" = \$(\d+)/); + + if (accessTokenMatch) { + target.accessToken = params?.[ + Number(accessTokenMatch[1]) - 1 + ] as string; + } + if (refreshTokenMatch) { + target.refreshToken = params?.[ + Number(refreshTokenMatch[1]) - 1 + ] as string; + } + + return; + } + + return; + }), + } as unknown as DataSource; + + return { + dataSource: fakeDataSource, + rows: () => rows, + queryCallCount: () => queryCallCount, + }; +}; + +describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => { + // Real AES round-trip is asserted in ConnectedAccountTokenEncryptionService's + // own spec; here we use a CIPHER(...) wrapper so assertions match exact strings. + const buildFakeTokenEncryptionService = + (): ConnectedAccountTokenEncryptionService => + ({ + encrypt: jest.fn( + (plaintext: string): string => + `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${plaintext})`, + ), + }) as unknown as ConnectedAccountTokenEncryptionService; + + const buildCommand = (): { + command: EncryptConnectedAccountTokensSlowInstanceCommand; + connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService; + } => { + const connectedAccountTokenEncryptionService = + buildFakeTokenEncryptionService(); + const command = new EncryptConnectedAccountTokensSlowInstanceCommand( + connectedAccountTokenEncryptionService, + ); + + return { command, connectedAccountTokenEncryptionService }; + }; + + describe('runDataMigration', () => { + it('should encrypt every legacy plaintext row and leave already-prefixed rows untouched', async () => { + const alreadyEncrypted = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}preexisting-ciphertext`; + + const { dataSource, rows } = buildFakeDataSource([ + { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + accessToken: 'plaintext-access-1', + refreshToken: 'plaintext-refresh-1', + }, + { + id: 'bbbbbbbb-0000-0000-0000-000000000002', + accessToken: alreadyEncrypted, + refreshToken: alreadyEncrypted, + }, + { + id: 'cccccccc-0000-0000-0000-000000000003', + accessToken: 'plaintext-access-3', + refreshToken: null, + }, + ]); + + const { command } = buildCommand(); + + await command.runDataMigration(dataSource); + + expect(rows()).toEqual([ + { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-access-1)`, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-refresh-1)`, + }, + { + id: 'bbbbbbbb-0000-0000-0000-000000000002', + accessToken: alreadyEncrypted, + refreshToken: alreadyEncrypted, + }, + { + id: 'cccccccc-0000-0000-0000-000000000003', + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-access-3)`, + refreshToken: null, + }, + ]); + }); + + // Regression guard: the SELECT filter is per-row (one column unencrypted is + // enough to fetch the row), so the loop body sees rows where one column is + // already prefixed and the other isn't. The per-cell prefix check inside + // the loop is what prevents the prefixed column from being double-encrypted + // into `enc:v1:CIPHER(enc:v1:...)`. If that check ever regresses, this is + // the test that should fail. + it('should only encrypt the plaintext column when a row mixes encrypted and plaintext tokens', async () => { + const alreadyEncryptedAccess = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}preexisting-access-cipher`; + + const { dataSource, rows } = buildFakeDataSource([ + { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + accessToken: alreadyEncryptedAccess, + refreshToken: 'plaintext-refresh-mixed', + }, + ]); + + const { command } = buildCommand(); + + await command.runDataMigration(dataSource); + + expect(rows()).toEqual([ + { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + accessToken: alreadyEncryptedAccess, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-refresh-mixed)`, + }, + ]); + }); + it('should be idempotent — re-running on already-migrated data leaves it unchanged', async () => { + const { dataSource, rows } = buildFakeDataSource([ + { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + accessToken: 'plaintext-token', + refreshToken: null, + }, + ]); + + const { command } = buildCommand(); + + const expectedFinalState = [ + { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-token)`, + refreshToken: null, + }, + ]; + + await command.runDataMigration(dataSource); + expect(rows()).toEqual(expectedFinalState); + + await command.runDataMigration(dataSource); + expect(rows()).toEqual(expectedFinalState); + }); + + it('should paginate through more rows than the batch size', async () => { + // 1100 rows + batch size 500 → at least 3 SELECT batches. + const initialRows: FakeRow[] = Array.from({ length: 1100 }, (_, idx) => ({ + // Lex-sortable hex IDs so the cursor advance works the way the SQL does. + id: `${idx.toString(16).padStart(12, '0')}-0000-0000-0000-000000000000`, + accessToken: `plaintext-${idx}`, + refreshToken: null, + })); + + const { dataSource, rows, queryCallCount } = buildFakeDataSource( + initialRows, + { batchSize: 500 }, + ); + + const { command } = buildCommand(); + + await command.runDataMigration(dataSource); + + // Every row got encrypted + expect( + rows().every((row) => + row.accessToken!.startsWith( + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ), + ), + ).toBe(true); + + // Sanity check: at least the expected number of SELECT batches happened + // (3 SELECTs for 500/500/100 + 1 final empty SELECT + 1100 UPDATEs) + expect(queryCallCount()).toBeGreaterThanOrEqual(1100 + 4); + }); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts index b35cc44420..2f212ef3f7 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { INSTANCE_COMMANDS } from 'src/database/commands/upgrade-version-command/instance-commands.constant'; +import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; @Module({ + imports: [ConnectedAccountTokenEncryptionModule], providers: [...INSTANCE_COMMANDS], }) export class InstanceCommandProviderModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 63e5b3d29c..ad9cdbe3cc 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -31,6 +31,7 @@ import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } fro import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings'; import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type'; import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain'; +import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -64,4 +65,5 @@ export const INSTANCE_COMMANDS = [ BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand, AddEmailGroupChannelTypeFastInstanceCommand, AddApplicationIdToPublicDomainFastInstanceCommand, + EncryptConnectedAccountTokensSlowInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts index 81b2ec0f1d..0e027cdc60 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts @@ -22,6 +22,10 @@ import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrap import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ConnectedAccountTokenEncryptionService, +} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; describe('ConnectionProviderOAuthFlowService', () => { let service: ConnectionProviderOAuthFlowService; @@ -110,6 +114,29 @@ describe('ConnectionProviderOAuthFlowService', () => { provide: getRepositoryToken(ConnectedAccountEntity), useValue: connectedAccountRepository, }, + { + // Real prefix/round-trip behavior is asserted in + // connected-account-token-encryption.service.spec.ts; here we use a + // CIPHER(...) wrapper so assertions can match exact ciphertext. + provide: ConnectedAccountTokenEncryptionService, + useValue: { + encryptTokenPair: jest.fn( + ({ + accessToken, + refreshToken, + }: { + accessToken: string; + refreshToken: string | null; + }) => ({ + encryptedAccessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${accessToken})`, + encryptedRefreshToken: + refreshToken === null + ? null + : `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${refreshToken})`, + }), + ), + }, + }, ], }).compile(); @@ -313,11 +340,12 @@ describe('ConnectionProviderOAuthFlowService', () => { expect(result.workspaceId).toBe('workspace-1'); expect(result.applicationId).toBe('app-1'); + // Encrypt-at-receipt: the entity must never hold the IDP plaintext. expect(connectedAccountRepository.create).toHaveBeenCalledWith( expect.objectContaining({ provider: ConnectedAccountProvider.APP, - accessToken: 'new_access', - refreshToken: 'new_refresh', + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_access)`, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_refresh)`, connectionProviderId: 'provider-1', applicationId: 'app-1', workspaceId: 'workspace-1', @@ -344,8 +372,8 @@ describe('ConnectionProviderOAuthFlowService', () => { expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: 'existing-account-id', workspaceId: 'workspace-1' }, expect.objectContaining({ - accessToken: 'new_access', - refreshToken: 'new_refresh', + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_access)`, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_refresh)`, authFailedAt: null, visibility: 'user', }), diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index 585e8443c6..4b66d634f4 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts @@ -6,8 +6,8 @@ import { Repository } from 'typeorm'; import { ConnectedAccountProvider } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; @@ -27,6 +27,7 @@ import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrap import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; const STATE_JWT_EXPIRES_IN = '10m'; @@ -61,6 +62,7 @@ export class ConnectionProviderOAuthFlowService { private readonly jwtWrapperService: JwtWrapperService, private readonly secureHttpClientService: SecureHttpClientService, private readonly twentyConfigService: TwentyConfigService, + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, ) {} @@ -248,9 +250,15 @@ export class ConnectionProviderOAuthFlowService { visibility: 'user' | 'workspace'; reconnectingConnectedAccountId: string | null; }): Promise { + const { encryptedAccessToken, encryptedRefreshToken } = + this.connectedAccountTokenEncryptionService.encryptTokenPair({ + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + }); + const sharedFields = { - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, scopes: tokenResponse.scopes ?? provider.oauthConfig.scopes, lastCredentialsRefreshedAt: new Date(), authFailedAt: null, diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.module.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.module.ts index 8d504040e1..dd7ff50c7b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.module.ts @@ -12,6 +12,7 @@ import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryptio import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module'; @Module({ @@ -27,6 +28,7 @@ import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-c SecureHttpClientModule, TwentyConfigModule, FlatConnectionProviderModule, + ConnectedAccountTokenEncryptionModule, ], providers: [ ConnectionProviderService, diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module.ts index 94cffa7c1d..ac921f7e86 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module.ts @@ -5,12 +5,14 @@ import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/appli import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service'; import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module'; import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; +import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; @Module({ imports: [ ConnectionProviderModule, ApplicationVariableEntityModule, SecureHttpClientModule, + ConnectedAccountTokenEncryptionModule, ], providers: [AppOAuthRefreshAccessTokenService, AppOAuthRevokeService], exports: [AppOAuthRefreshAccessTokenService, AppOAuthRevokeService], diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts index 70583b958e..e7caedcc0c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts @@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; @Injectable() export class AppOAuthRevokeService { @@ -13,6 +14,7 @@ export class AppOAuthRevokeService { constructor( private readonly connectionProviderService: ConnectionProviderService, private readonly secureHttpClientService: SecureHttpClientService, + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, ) {} // Best-effort: failures are logged but never block disconnect. @@ -41,6 +43,11 @@ export class AppOAuthRevokeService { } try { + const decryptedAccessToken = + this.connectedAccountTokenEncryptionService.decrypt( + connectedAccount.accessToken, + ); + const response = await this.secureHttpClientService.createSsrfSafeFetch()( revokeEndpoint, { @@ -49,7 +56,7 @@ export class AppOAuthRevokeService { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ - token: connectedAccount.accessToken, + token: decryptedAccessToken, token_type_hint: 'access_token', }).toString(), }, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index a40aa915b7..45c35da445 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -63,6 +63,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -125,6 +126,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; SecureHttpClientModule, EnterpriseModule, FileModule, + ConnectedAccountTokenEncryptionModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts index 0a9a6ced35..0eded98118 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts @@ -6,6 +6,7 @@ import { EntityManager, Repository } from 'typeorm'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -28,6 +29,7 @@ export type CreateConnectedAccountInput = { export class CreateConnectedAccountService { constructor( private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, ) {} @@ -85,14 +87,20 @@ export class CreateConnectedAccountService { const userWorkspaceId = userWorkspace.id; + const { encryptedAccessToken, encryptedRefreshToken } = + this.connectedAccountTokenEncryptionService.encryptTokenPair({ + accessToken, + refreshToken, + }); + await input.transactionManager .getRepository(ConnectedAccountEntity) .save({ id: connectedAccountId, handle, provider, - accessToken, - refreshToken, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, userWorkspaceId, scopes, workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts index f184a36696..8edee9421d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { EntityManager } from 'typeorm'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -19,6 +20,7 @@ export type UpdateConnectedAccountOnReconnectInput = { export class UpdateConnectedAccountOnReconnectService { constructor( private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, ) {} async updateConnectedAccountOnReconnect( @@ -32,6 +34,12 @@ export class UpdateConnectedAccountOnReconnectService { scopes, } = input; + const { encryptedAccessToken, encryptedRefreshToken } = + this.connectedAccountTokenEncryptionService.encryptTokenPair({ + accessToken, + refreshToken, + }); + const authContext = buildSystemAuthContext(workspaceId); await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { @@ -43,8 +51,8 @@ export class UpdateConnectedAccountOnReconnectService { workspaceId, }, { - accessToken, - refreshToken, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, scopes, authFailedAt: null, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts index 905da26aa9..b1323dc022 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts @@ -1,4 +1,5 @@ import { + Check, Column, CreateDateColumn, Entity, @@ -25,6 +26,14 @@ export type ConnectedAccountVisibility = 'user' | 'workspace'; @Entity({ name: 'connectedAccount', schema: 'core' }) @Index('IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID', ['connectionProviderId']) @Index('IDX_CONNECTED_ACCOUNT_APPLICATION_ID', ['applicationId']) +@Check( + 'CHK_connectedAccount_accessToken_encrypted', + `"accessToken" IS NULL OR "accessToken" LIKE 'enc:v1:%'`, +) +@Check( + 'CHK_connectedAccount_refreshToken_encrypted', + `"refreshToken" IS NULL OR "refreshToken" LIKE 'enc:v1:%'`, +) export class ConnectedAccountEntity extends WorkspaceRelatedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap new file mode 100644 index 0000000000..e17dd49d9c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectedAccountTokenEncryptionService encrypt should throw when given an already-prefixed value 1`] = `"ConnectedAccountTokenEncryptionService.encrypt received an already-prefixed value. This indicates a double-encryption bug — the caller is encrypting ciphertext."`; + +exports[`ConnectedAccountTokenEncryptionService encryptTokenPair should throw when accessToken is already encrypted 1`] = `"ConnectedAccountTokenEncryptionService.encrypt received an already-prefixed value. This indicates a double-encryption bug — the caller is encrypting ciphertext."`; diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts new file mode 100644 index 0000000000..cc4aa37d77 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts @@ -0,0 +1,151 @@ +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; +import { + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ConnectedAccountTokenEncryptionService, +} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; + +describe('ConnectedAccountTokenEncryptionService', () => { + const buildEncryptionService = (): ConnectedAccountTokenEncryptionService => { + const environmentConfigDriver = { + get: jest.fn().mockReturnValue('mock-app-secret-for-testing-12345678'), + } as unknown as EnvironmentConfigDriver; + + return new ConnectedAccountTokenEncryptionService( + new SecretEncryptionService(environmentConfigDriver), + ); + }; + + describe('encrypt', () => { + it('should produce a value that starts with the enc:v1: prefix and hides the plaintext', () => { + const service = buildEncryptionService(); + const plaintext = 'plaintext-token'; + + const ciphertext = service.encrypt(plaintext); + + expect( + ciphertext.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX), + ).toBe(true); + expect(ciphertext).not.toContain(plaintext); + }); + + it('should throw when given an already-prefixed value', () => { + const service = buildEncryptionService(); + + expect(() => + service.encrypt( + `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}already-encrypted`, + ), + ).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('encryptNullable', () => { + it('should pass null through unchanged', () => { + const service = buildEncryptionService(); + + expect(service.encryptNullable(null)).toBeNull(); + }); + + it('should encrypt non-null values like encrypt()', () => { + const service = buildEncryptionService(); + + const ciphertext = service.encryptNullable('plaintext'); + + expect(ciphertext).not.toBeNull(); + expect( + ciphertext!.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX), + ).toBe(true); + }); + }); + + describe('decrypt', () => { + it('should roundtrip cleanly with encrypt()', () => { + const service = buildEncryptionService(); + const plaintext = 'roundtrip-token-value'; + + expect(service.decrypt(service.encrypt(plaintext))).toBe(plaintext); + }); + + // v2.4.0 deployment-window tolerance. Should be patch to throw after v2.4.1 + it.failing( + 'should throw when given a value without the enc:v1: prefix', + () => { + const service = buildEncryptionService(); + + expect(() => + service.decrypt('raw-plaintext-without-prefix'), + ).toThrowErrorMatchingSnapshot(); + }, + ); + }); + + describe('decryptNullable', () => { + it('should pass null through unchanged', () => { + const service = buildEncryptionService(); + + expect(service.decryptNullable(null)).toBeNull(); + }); + + it('should decrypt non-null values like decrypt()', () => { + const service = buildEncryptionService(); + const plaintext = 'rt-value'; + const ciphertext = service.encrypt(plaintext); + + expect(service.decryptNullable(ciphertext)).toBe(plaintext); + }); + }); + + describe('encryptTokenPair', () => { + it('should encrypt both tokens and return them keyed as encrypted*', () => { + const service = buildEncryptionService(); + + const { encryptedAccessToken, encryptedRefreshToken } = + service.encryptTokenPair({ + accessToken: 'at-plaintext', + refreshToken: 'rt-plaintext', + }); + + expect( + encryptedAccessToken.startsWith( + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ), + ).toBe(true); + expect( + encryptedRefreshToken!.startsWith( + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ), + ).toBe(true); + expect(service.decrypt(encryptedAccessToken)).toBe('at-plaintext'); + expect(service.decrypt(encryptedRefreshToken!)).toBe('rt-plaintext'); + }); + + it('should pass a null refreshToken through unencrypted', () => { + const service = buildEncryptionService(); + + const { encryptedAccessToken, encryptedRefreshToken } = + service.encryptTokenPair({ + accessToken: 'at-plaintext', + refreshToken: null, + }); + + expect( + encryptedAccessToken.startsWith( + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ), + ).toBe(true); + expect(encryptedRefreshToken).toBeNull(); + }); + + it('should throw when accessToken is already encrypted', () => { + const service = buildEncryptionService(); + + expect(() => + service.encryptTokenPair({ + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}already-encrypted`, + refreshToken: 'rt-plaintext', + }), + ).toThrowErrorMatchingSnapshot(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module.ts new file mode 100644 index 0000000000..e97667a726 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; + +@Module({ + imports: [SecretEncryptionModule], + providers: [ConnectedAccountTokenEncryptionService], + exports: [ConnectedAccountTokenEncryptionService], +}) +export class ConnectedAccountTokenEncryptionModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts new file mode 100644 index 0000000000..53b75d9e72 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; + +export const CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX = 'enc:v1:'; + +@Injectable() +export class ConnectedAccountTokenEncryptionService { + private readonly logger = new Logger( + ConnectedAccountTokenEncryptionService.name, + ); + + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + encrypt(plaintext: string): string { + if (plaintext.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX)) { + throw new Error( + 'ConnectedAccountTokenEncryptionService.encrypt received an already-prefixed value. ' + + 'This indicates a double-encryption bug — the caller is encrypting ciphertext.', + ); + } + + return `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}${this.secretEncryptionService.encrypt(plaintext)}`; + } + + encryptNullable(plaintext: string | null): string | null { + if (plaintext === null) { + return null; + } + + return this.encrypt(plaintext); + } + + decrypt(ciphertext: string): string { + if (!ciphertext.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX)) { + // v2.4.0 deployment-window tolerance. Should be patch to throw after v2.4.1 + // throw new Error( + // 'ConnectedAccountTokenEncryptionService.decrypt received a value without the ' + + // `'${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}' prefix. ` + + // 'This indicates the column was written without going through encrypt(), ' + + // 'or the value was read from a source other than core.connectedAccount.', + // ); + + this.logger.warn( + 'Decrypted a legacy plaintext token. Expected during the 2.4.0 ' + + 'rollout window until the slow instance command finishes backfilling.', + ); + + return ciphertext; + } + + return this.secretEncryptionService.decrypt( + ciphertext.slice(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX.length), + ); + } + + decryptNullable(ciphertext: string | null): string | null { + if (ciphertext === null) { + return null; + } + + return this.decrypt(ciphertext); + } + + encryptTokenPair({ + accessToken, + refreshToken, + }: { + accessToken: string; + refreshToken: string | null; + }): { + encryptedAccessToken: string; + encryptedRefreshToken: string | null; + } { + return { + encryptedAccessToken: this.encrypt(accessToken), + encryptedRefreshToken: this.encryptNullable(refreshToken), + }; + } +} diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts index 85c706f501..5d8b41adb9 100644 --- a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts @@ -1,11 +1,12 @@ import { Logger, Module } from '@nestjs/common'; +import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service'; import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service'; import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; @Module({ - imports: [], + imports: [ConnectedAccountTokenEncryptionModule], providers: [ OAuth2ClientManagerService, GoogleOAuth2ClientManagerService, diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts index 00c5834a29..fade1ac2a8 100644 --- a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts @@ -4,16 +4,18 @@ import { type Client } from '@microsoft/microsoft-graph-client'; import { type Auth } from 'googleapis'; import { CustomError, isDefined } from 'twenty-shared/utils'; +import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service'; import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service'; import { OAuth2ClientManagerExceptionCode } from 'src/modules/connected-account/oauth2-client-manager/exceptions/oauth2-client-manager.exceptions'; -import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; @Injectable() export class OAuth2ClientManagerService { constructor( private readonly googleOAuth2ClientManagerService: GoogleOAuth2ClientManagerService, private readonly microsoftOAuth2ClientManagerService: MicrosoftOAuth2ClientManagerService, + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, ) {} public async getGoogleOAuth2Client( @@ -27,7 +29,9 @@ export class OAuth2ClientManagerService { } return this.googleOAuth2ClientManagerService.getOAuth2Client( - connectedAccount.refreshToken, + this.connectedAccountTokenEncryptionService.decrypt( + connectedAccount.refreshToken, + ), ); } @@ -42,7 +46,9 @@ export class OAuth2ClientManagerService { } return this.microsoftOAuth2ClientManagerService.getOAuth2Client( - connectedAccount.accessToken, + this.connectedAccountTokenEncryptionService.decrypt( + connectedAccount.accessToken, + ), ); } } diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts index 9f64997870..bd5d775f5f 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module'; import { MicrosoftAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/microsoft-api-refresh-access-token.module'; import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; @@ -15,6 +16,7 @@ import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-acco GoogleAPIRefreshAccessTokenModule, MicrosoftAPIRefreshAccessTokenModule, AppOAuthRefreshModule, + ConnectedAccountTokenEncryptionModule, ], providers: [ConnectedAccountRefreshTokensService], exports: [ConnectedAccountRefreshTokensService], diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts index db908fafc7..9db19fc6ae 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts @@ -5,6 +5,10 @@ import { ConnectedAccountProvider } from 'twenty-shared/types'; import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { + CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, + ConnectedAccountTokenEncryptionService, +} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; @@ -20,14 +24,65 @@ describe('ConnectedAccountRefreshTokensService', () => { let googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService; let microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService; let connectedAccountRepository: { update: jest.Mock }; + let connectedAccountTokenEncryptionService: { + decrypt: jest.Mock; + encryptTokenPair: jest.Mock; + }; const mockWorkspaceId = 'workspace-123'; const mockConnectedAccountId = 'account-456'; - const mockAccessToken = 'valid-access-token'; - const mockRefreshToken = 'valid-refresh-token'; - const mockNewAccessToken = 'new-access-token'; + + const mockAccessTokenPlaintext = 'valid-access-token'; + const mockRefreshTokenPlaintext = 'valid-refresh-token'; + const mockNewAccessTokenPlaintext = 'new-access-token'; + + const mockEncryptedAccessToken = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockAccessTokenPlaintext})`; + const mockEncryptedRefreshToken = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`; + + // Real prefix/round-trip invariants are asserted in + // connected-account-token-encryption.service.spec.ts. + const buildSymmetricEncryptionStub = (): { + decrypt: jest.Mock; + encryptTokenPair: jest.Mock; + } => { + const wrap = (value: string) => + `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${value})`; + + return { + decrypt: jest.fn((value: string) => { + const match = value.match( + new RegExp( + `^${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER\\((.*)\\)$`, + ), + ); + + if (match === null) { + throw new Error( + `fake encryption stub: decrypt called with a non-CIPHER value: ${value}`, + ); + } + + return match[1]; + }), + encryptTokenPair: jest.fn( + ({ + accessToken, + refreshToken, + }: { + accessToken: string; + refreshToken: string | null; + }) => ({ + encryptedAccessToken: wrap(accessToken), + encryptedRefreshToken: + refreshToken === null ? null : wrap(refreshToken), + }), + ), + }; + }; beforeEach(async () => { + connectedAccountTokenEncryptionService = buildSymmetricEncryptionStub(); + const module: TestingModule = await Test.createTestingModule({ providers: [ ConnectedAccountRefreshTokensService, @@ -64,6 +119,10 @@ describe('ConnectedAccountRefreshTokensService', () => { update: jest.fn(), }, }, + { + provide: ConnectedAccountTokenEncryptionService, + useValue: connectedAccountTokenEncryptionService, + }, ], }).compile(); @@ -88,12 +147,12 @@ describe('ConnectedAccountRefreshTokensService', () => { }); describe('refreshAndSaveTokens', () => { - it('should reuse valid access token without refreshing when lastCredentialsRefreshedAt is recent', async () => { + it('should reuse the cached token, decrypt before returning to the caller, and skip the refresh call entirely', async () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.MICROSOFT, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago } as ConnectedAccountEntity; @@ -103,27 +162,33 @@ describe('ConnectedAccountRefreshTokensService', () => { ); expect(result).toEqual({ - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }); + expect( + connectedAccountTokenEncryptionService.decrypt, + ).toHaveBeenCalledWith(mockEncryptedAccessToken); + expect( + connectedAccountTokenEncryptionService.decrypt, + ).toHaveBeenCalledWith(mockEncryptedRefreshToken); expect( microsoftAPIRefreshAccessTokenService.refreshTokens, ).not.toHaveBeenCalled(); expect(connectedAccountRepository.update).not.toHaveBeenCalled(); }); - it('should refresh and save new Microsoft token when expired (lastCredentialsRefreshedAt is old)', async () => { + it('should decrypt the stored refresh token before sending to Microsoft, then re-encrypt the rotated tokens before persisting', async () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.MICROSOFT, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago } as ConnectedAccountEntity; const newTokens = { - accessToken: mockNewAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockNewAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }; jest @@ -138,28 +203,29 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(result).toEqual(newTokens); expect( microsoftAPIRefreshAccessTokenService.refreshTokens, - ).toHaveBeenCalledWith(mockRefreshToken); + ).toHaveBeenCalledWith(mockRefreshTokenPlaintext); expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: mockConnectedAccountId, workspaceId: mockWorkspaceId }, expect.objectContaining({ - ...newTokens, + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, lastCredentialsRefreshedAt: expect.any(Date), }), ); }); - it('should refresh and save new Google token when expired (lastCredentialsRefreshedAt is old)', async () => { + it('should decrypt the stored refresh token before sending to Google, then re-encrypt the rotated tokens before persisting', async () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.GOOGLE, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago } as ConnectedAccountEntity; const newTokens = { - accessToken: mockNewAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockNewAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }; jest @@ -174,28 +240,29 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(result).toEqual(newTokens); expect( googleAPIRefreshAccessTokenService.refreshTokens, - ).toHaveBeenCalledWith(mockRefreshToken); + ).toHaveBeenCalledWith(mockRefreshTokenPlaintext); expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: mockConnectedAccountId, workspaceId: mockWorkspaceId }, expect.objectContaining({ - ...newTokens, + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, lastCredentialsRefreshedAt: expect.any(Date), }), ); }); - it('should refresh token when lastCredentialsRefreshedAt is null', async () => { + it('should treat null lastCredentialsRefreshedAt as expired and run the full decrypt → refresh → re-encrypt cycle', async () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.MICROSOFT, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: null, } as ConnectedAccountEntity; const newTokens = { - accessToken: mockNewAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockNewAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }; jest @@ -210,11 +277,12 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(result).toEqual(newTokens); expect( microsoftAPIRefreshAccessTokenService.refreshTokens, - ).toHaveBeenCalledWith(mockRefreshToken); + ).toHaveBeenCalledWith(mockRefreshTokenPlaintext); expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: mockConnectedAccountId, workspaceId: mockWorkspaceId }, expect.objectContaining({ - ...newTokens, + accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, + refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, lastCredentialsRefreshedAt: expect.any(Date), }), ); @@ -224,7 +292,7 @@ describe('ConnectedAccountRefreshTokensService', () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.GOOGLE, - accessToken: mockAccessToken, + accessToken: mockEncryptedAccessToken, refreshToken: null, lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), } as unknown as ConnectedAccountEntity; @@ -243,8 +311,8 @@ describe('ConnectedAccountRefreshTokensService', () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.MICROSOFT, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago } as ConnectedAccountEntity; @@ -271,8 +339,8 @@ describe('ConnectedAccountRefreshTokensService', () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.GOOGLE, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago } as ConnectedAccountEntity; @@ -368,12 +436,12 @@ describe('ConnectedAccountRefreshTokensService', () => { }); describe('refreshAndSaveTokens - OIDC/SAML', () => { - it('should reuse existing tokens for OIDC without attempting a refresh', async () => { + it('should decrypt and return existing tokens for OIDC without attempting a refresh', async () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.OIDC, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: null, } as unknown as ConnectedAccountEntity; @@ -383,8 +451,8 @@ describe('ConnectedAccountRefreshTokensService', () => { ); expect(result).toEqual({ - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }); expect( googleAPIRefreshAccessTokenService.refreshTokens, @@ -395,12 +463,12 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(connectedAccountRepository.update).not.toHaveBeenCalled(); }); - it('should reuse existing tokens for SAML without attempting a refresh', async () => { + it('should decrypt and return existing tokens for SAML without attempting a refresh', async () => { const connectedAccount = { id: mockConnectedAccountId, provider: ConnectedAccountProvider.SAML, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockEncryptedAccessToken, + refreshToken: mockEncryptedRefreshToken, lastCredentialsRefreshedAt: null, } as unknown as ConnectedAccountEntity; @@ -410,8 +478,8 @@ describe('ConnectedAccountRefreshTokensService', () => { ); expect(result).toEqual({ - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, + accessToken: mockAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }); expect( googleAPIRefreshAccessTokenService.refreshTokens, diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts index ca7fb9d30f..04a6fa56b4 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts @@ -7,14 +7,15 @@ import { Repository } from 'typeorm'; import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; -import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; +import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; +import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; export type ConnectedAccountTokens = { accessToken: string; @@ -34,6 +35,7 @@ export class ConnectedAccountRefreshTokensService { private readonly microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService, private readonly appOAuthRefreshAccessTokenService: AppOAuthRefreshAccessTokenService, private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, ) {} @@ -42,9 +44,12 @@ export class ConnectedAccountRefreshTokensService { connectedAccount: ConnectedAccountEntity, workspaceId: string, ): Promise { - const { refreshToken, accessToken } = connectedAccount; + const { + refreshToken: encryptedRefreshToken, + accessToken: encryptedAccessToken, + } = connectedAccount; - if (!refreshToken) { + if (!isDefined(encryptedRefreshToken)) { throw new ConnectedAccountRefreshAccessTokenException( `No refresh token found for connected account ${connectedAccount.id} in workspace ${workspaceId}`, ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND, @@ -58,7 +63,7 @@ export class ConnectedAccountRefreshTokensService { this.logger.debug( `Reusing valid access token for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`, ); - if (!isDefined(accessToken)) { + if (!isDefined(encryptedAccessToken)) { throw new ConnectedAccountRefreshAccessTokenException( `Access token is required for connected account ${connectedAccount.id} in workspace ${workspaceId}`, ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND, @@ -66,8 +71,13 @@ export class ConnectedAccountRefreshTokensService { } return { - accessToken, - refreshToken, + accessToken: + this.connectedAccountTokenEncryptionService.decrypt( + encryptedAccessToken, + ), + refreshToken: this.connectedAccountTokenEncryptionService.decrypt( + encryptedRefreshToken, + ), }; } @@ -75,19 +85,33 @@ export class ConnectedAccountRefreshTokensService { `Access token expired for connected account ${connectedAccount.id} in workspace ${workspaceId}, refreshing...`, ); + const decryptedRefreshTokenForRefreshCall = + this.connectedAccountTokenEncryptionService.decrypt( + encryptedRefreshToken, + ); + const connectedAccountTokens = await this.refreshTokens( connectedAccount, - refreshToken, + decryptedRefreshTokenForRefreshCall, workspaceId, ); + const { + encryptedAccessToken: reEncryptedAccessToken, + encryptedRefreshToken: reEncryptedRefreshToken, + } = this.connectedAccountTokenEncryptionService.encryptTokenPair({ + accessToken: connectedAccountTokens.accessToken, + refreshToken: connectedAccountTokens.refreshToken, + }); + const authContext = buildSystemAuthContext(workspaceId); await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { await this.connectedAccountRepository.update( { id: connectedAccount.id, workspaceId }, { - ...connectedAccountTokens, + accessToken: reEncryptedAccessToken, + refreshToken: reEncryptedRefreshToken, lastCredentialsRefreshedAt: new Date(), }, ); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts index 3ddcd55e1a..a7a03cc1a7 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts @@ -7,12 +7,13 @@ import { MessageFolderPendingSyncAction, } from 'twenty-shared/types'; -import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; +import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity'; import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service'; import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; -import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; -import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity'; import { microsoftGraphWithMessagesDeltaLink } from 'src/modules/messaging/message-import-manager/drivers/microsoft/mocks/microsoft-api-examples'; import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/microsoft/types/folders'; @@ -64,6 +65,7 @@ xdescribe('Microsoft dev tests : get message list service', () => { }, MicrosoftOAuth2ClientManagerService, ConfigService, + { provide: ConnectedAccountTokenEncryptionService, useValue: {} }, ], }).compile(); @@ -236,6 +238,7 @@ xdescribe('Microsoft dev tests : get message list service for folders', () => { }, MicrosoftOAuth2ClientManagerService, ConfigService, + { provide: ConnectedAccountTokenEncryptionService, useValue: {} }, ], }).compile(); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts index ed1b1a4775..e02235a53a 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts @@ -4,6 +4,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { ConnectedAccountProvider } from 'twenty-shared/types'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service'; import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; import { MicrosoftFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service'; @@ -35,6 +36,7 @@ xdescribe('Microsoft dev tests : get messages service', () => { MicrosoftOAuth2ClientManagerService, MicrosoftFetchByBatchService, ConfigService, + { provide: ConnectedAccountTokenEncryptionService, useValue: {} }, ], }).compile(); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts index 2b06b4c3d0..92cab8c9d0 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts @@ -8,6 +8,7 @@ import { } from 'twenty-shared/types'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service'; import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service'; import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; @@ -34,6 +35,7 @@ describe('Microsoft get messages service', () => { OAuth2ClientManagerService, GoogleOAuth2ClientManagerService, MicrosoftOAuth2ClientManagerService, + { provide: ConnectedAccountTokenEncryptionService, useValue: {} }, MicrosoftFetchByBatchService, ConfigService, {