From 6c18bacb93de390018de21d6f01ce9b2248828b6 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 11 May 2026 17:16:12 +0200 Subject: [PATCH] Encrypt connected account `accessToken` and `refreshToken` (#20441) # Introduction Encrypt the `connectedAccount` `accessToken` and `refreshToken` using `APP_SECRET` in order to mitigate potential data leak or `core` table compromise ## Decrypt Temporary allow already plain text stored token to be retrieve without decryption until the slow instance has been passed Will uncomment the invariant check in a patch when the instance slow has fully be run ## Standards - Token are encrypted as quickly as possible - A token cannot be written in database non encrypted by mistake using a custom constraint ( `enc:` prefix ) ## What's next We should standardize not managing secret as is in the the services and layer, they should be encrypted on the flight the earliest and should never be logged Will create a dedicated pattern afterwards for `applicationVariables` secrets too --------- Co-authored-by: Charles Bochet --- ...004000-encrypt-connected-account-tokens.ts | 131 ++++++++++ ...0-encrypt-connected-account-tokens.spec.ts | 244 ++++++++++++++++++ .../instance-command-provider.module.ts | 2 + .../instance-commands.constant.ts | 2 + ...ection-provider-oauth-flow.service.spec.ts | 36 ++- .../connection-provider-oauth-flow.service.ts | 14 +- .../connection-provider.module.ts | 2 + .../refresh/app-oauth-refresh.module.ts | 2 + .../services/app-oauth-revoke.service.ts | 9 +- .../engine/core-modules/auth/auth.module.ts | 2 + .../create-connected-account.service.ts | 12 +- ...-connected-account-on-reconnect.service.ts | 12 +- .../entities/connected-account.entity.ts | 9 + ...ount-token-encryption.service.spec.ts.snap | 5 + ...d-account-token-encryption.service.spec.ts | 151 +++++++++++ ...nnected-account-token-encryption.module.ts | 11 + ...nected-account-token-encryption.service.ts | 82 ++++++ .../oauth2-client-manager.module.ts | 3 +- .../services/oauth2-client-manager.service.ts | 12 +- ...d-account-refresh-tokens-manager.module.ts | 2 + ...ted-account-refresh-tokens.service.spec.ts | 156 +++++++---- ...onnected-account-refresh-tokens.service.ts | 46 +++- ...osoft-get-message-list.service.dev.spec.ts | 9 +- ...microsoft-get-messages.service.dev.spec.ts | 2 + .../microsoft-get-messages.service.spec.ts | 2 + 25 files changed, 884 insertions(+), 74 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap create mode 100644 packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts 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, {