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:
+32
-4
@@ -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',
|
||||
}),
|
||||
|
||||
+11
-3
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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],
|
||||
|
||||
+8
-1
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user