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 <charlesBochet@users.noreply.github.com>
This commit is contained in:
Paul Rastoin
2026-05-11 17:16:12 +02:00
committed by GitHub
parent 93d83b2e36
commit 6c18bacb93
25 changed files with 884 additions and 74 deletions
@@ -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',
}),
@@ -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<ConnectedAccountEntity>,
) {}
@@ -248,9 +250,15 @@ export class ConnectionProviderOAuthFlowService {
visibility: 'user' | 'workspace';
reconnectingConnectedAccountId: string | null;
}): Promise<ConnectedAccountEntity> {
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,
@@ -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,
@@ -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],
@@ -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(),
},