diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 89b84b14f2..19915b1d31 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -1,5 +1,6 @@ import { CredentialsEntity, type CredentialsRepository } from '@n8n/db'; import { EntityNotFoundError } from '@n8n/typeorm'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { IAuthenticateGeneric, @@ -9,8 +10,10 @@ import type { INode, INodeProperties, INodeTypes, + INodeCredentialsDetails, } from 'n8n-workflow'; import { deepCopy, Workflow } from 'n8n-workflow'; +import { type InstanceSettings, Cipher } from 'n8n-core'; import { CredentialTypes } from '@/credential-types'; import { CredentialsHelper } from '@/credentials-helper'; @@ -22,6 +25,10 @@ describe('CredentialsHelper', () => { const mockNodesAndCredentials = mock(); const credentialsRepository = mock(); + // Setup cipher for testing + const cipher = new Cipher(mock({ encryptionKey: 'test_key_for_testing' })); + Container.set(Cipher, cipher); + const credentialsHelper = new CredentialsHelper( new CredentialTypes(mockNodesAndCredentials), mock(), @@ -283,4 +290,95 @@ describe('CredentialsHelper', () => { }); } }); + + describe('updateCredentialsOauthTokenData', () => { + test('only updates oauthTokenData field while preserving other credential fields', async () => { + const nodeCredentials: INodeCredentialsDetails = { + id: 'cred-123', + name: 'Test OAuth2 Credential', + }; + + const existingCredentialData = { + clientId: 'existing-client-id', + clientSecret: 'existing-client-secret', + scope: 'read write', + customField: 'custom-value', + oauthTokenData: { + access_token: 'old-access-token', + refresh_token: 'old-refresh-token', + expires_in: 3600, + }, + }; + + const newOauthTokenData = { + oauthTokenData: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 7200, + token_type: 'Bearer', + }, + }; + + const mockCredentialEntity = { + id: 'cred-123', + name: 'Test OAuth2 Credential', + type: 'oAuth2Api', + data: cipher.encrypt(existingCredentialData), + }; + + credentialsRepository.findOneByOrFail.mockResolvedValue( + mockCredentialEntity as CredentialsEntity, + ); + + const beforeUpdateTime = new Date(); + await credentialsHelper.updateCredentialsOauthTokenData( + nodeCredentials, + 'oAuth2Api', + newOauthTokenData, + ); + + expect(credentialsRepository.update).toHaveBeenCalledWith( + { id: 'cred-123', type: 'oAuth2Api' }, + expect.objectContaining({ + id: 'cred-123', + name: 'Test OAuth2 Credential', + type: 'oAuth2Api', + data: expect.any(String), + updatedAt: expect.any(Date), + }), + ); + + const updateCall = credentialsRepository.update.mock.calls[0]; + const updatedCredentialData = updateCall[1]; + const updatedAt = updatedCredentialData.updatedAt as Date; + + expect(updatedAt).toBeInstanceOf(Date); + expect(updatedAt.getTime()).toBeGreaterThanOrEqual(beforeUpdateTime.getTime()); + + const decryptedUpdatedData = cipher.decrypt(updatedCredentialData.data as string); + const parsedUpdatedData = JSON.parse(decryptedUpdatedData); + + expect(parsedUpdatedData).toEqual({ + clientId: 'existing-client-id', + clientSecret: 'existing-client-secret', + scope: 'read write', + customField: 'custom-value', + oauthTokenData: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 7200, + token_type: 'Bearer', + }, + }); + + expect(parsedUpdatedData.clientId).toBe('existing-client-id'); + expect(parsedUpdatedData.clientSecret).toBe('existing-client-secret'); + expect(parsedUpdatedData.scope).toBe('read write'); + expect(parsedUpdatedData.customField).toBe('custom-value'); + expect(parsedUpdatedData.oauthTokenData.access_token).toBe('new-access-token'); + expect(parsedUpdatedData.oauthTokenData.refresh_token).toBe('new-refresh-token'); + expect(parsedUpdatedData.oauthTokenData.expires_in).toBe(7200); + expect(parsedUpdatedData.oauthTokenData.token_type).toBe('Bearer'); + }); + }); }); diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 2726659c42..45609a3daf 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -457,6 +457,32 @@ export class CredentialsHelper extends ICredentialsHelper { await this.credentialsRepository.update(findQuery, newCredentialsData); } + /** + * Updates credential's oauth token data in the database + */ + async updateCredentialsOauthTokenData( + nodeCredentials: INodeCredentialsDetails, + type: string, + data: ICredentialDataDecryptedObject, + ): Promise { + const credentials = await this.getCredentials(nodeCredentials, type); + + credentials.updateData({ oauthTokenData: data.oauthTokenData }); + const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialsData.updatedAt = new Date(); + + // Save the credentials in DB + const findQuery = { + id: credentials.id, + type, + }; + + // @ts-ignore CAT-957 + await this.credentialsRepository.update(findQuery, newCredentialsData); + } + async credentialCanUseExternalSecrets(nodeCredential: INodeCredentialsDetails): Promise { if (!nodeCredential.id) { return false; diff --git a/packages/core/nodes-testing/credentials-helper.ts b/packages/core/nodes-testing/credentials-helper.ts index 6aa096ecb6..dd5151ef3f 100644 --- a/packages/core/nodes-testing/credentials-helper.ts +++ b/packages/core/nodes-testing/credentials-helper.ts @@ -74,4 +74,10 @@ export class CredentialsHelper extends ICredentialsHelper { _type: string, _data: ICredentialDataDecryptedObject, ): Promise {} + + async updateCredentialsOauthTokenData( + _nodeCredentials: INodeCredentialsDetails, + _type: string, + _data: ICredentialDataDecryptedObject, + ): Promise {} } diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts index cac810c229..c52efef5e6 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts @@ -980,7 +980,7 @@ export async function requestOAuth2( credentials.oauthTokenData = data; // Save the refreshed token - await additionalData.credentialsHelper.updateCredentials( + await additionalData.credentialsHelper.updateCredentialsOauthTokenData( nodeCredentials, credentialsType, credentials as unknown as ICredentialDataDecryptedObject, @@ -1061,7 +1061,7 @@ export async function requestOAuth2( }); } const nodeCredentials = node.credentials[credentialsType]; - await additionalData.credentialsHelper.updateCredentials( + await additionalData.credentialsHelper.updateCredentialsOauthTokenData( nodeCredentials, credentialsType, credentials as unknown as ICredentialDataDecryptedObject, @@ -1141,7 +1141,7 @@ export async function requestOAuth2( const nodeCredentials = node.credentials[credentialsType]; // Save the refreshed token - await additionalData.credentialsHelper.updateCredentials( + await additionalData.credentialsHelper.updateCredentialsOauthTokenData( nodeCredentials, credentialsType, credentials as unknown as ICredentialDataDecryptedObject, diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 69619a40bb..a79e59b93d 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -223,6 +223,12 @@ export abstract class ICredentialsHelper { data: ICredentialDataDecryptedObject, ): Promise; + abstract updateCredentialsOauthTokenData( + nodeCredentials: INodeCredentialsDetails, + type: string, + data: ICredentialDataDecryptedObject, + ): Promise; + abstract getCredentialsProperties(type: string): INodeProperties[]; }