fix(core): Allow secrets manager secrets to be used in credentials (#13110)

This commit is contained in:
Marc Littlemore 2025-02-07 16:43:00 +00:00 committed by GitHub
parent 4577ce0846
commit cae98e733d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 75 additions and 5 deletions

View file

@ -4,6 +4,7 @@ import type { Response } from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core'; import { Cipher } from 'n8n-core';
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import nock from 'nock'; import nock from 'nock';
import { Time } from '@/constants'; import { Time } from '@/constants';
@ -19,8 +20,11 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import type { OAuthRequest } from '@/requests'; import type { OAuthRequest } from '@/requests';
import { SecretsHelper } from '@/secrets-helpers.ee'; import { SecretsHelper } from '@/secrets-helpers.ee';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
jest.mock('@/workflow-execute-additional-data');
describe('OAuth1CredentialController', () => { describe('OAuth1CredentialController', () => {
mockInstance(Logger); mockInstance(Logger);
mockInstance(ExternalHooks); mockInstance(ExternalHooks);
@ -28,6 +32,9 @@ describe('OAuth1CredentialController', () => {
mockInstance(VariablesService, { mockInstance(VariablesService, {
getAllCached: async () => [], getAllCached: async () => [],
}); });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
(WorkflowExecuteAdditionalData.getBase as jest.Mock).mockReturnValue(additionalData);
const cipher = mockInstance(Cipher); const cipher = mockInstance(Cipher);
const credentialsHelper = mockInstance(CredentialsHelper); const credentialsHelper = mockInstance(CredentialsHelper);
const credentialsRepository = mockInstance(CredentialsRepository); const credentialsRepository = mockInstance(CredentialsRepository);
@ -106,6 +113,14 @@ describe('OAuth1CredentialController', () => {
}), }),
); );
expect(cipher.encrypt).toHaveBeenCalledWith({ csrfSecret }); expect(cipher.encrypt).toHaveBeenCalledWith({ csrfSecret });
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
credential,
credential.type,
'internal',
undefined,
false,
);
}); });
}); });
@ -235,6 +250,14 @@ describe('OAuth1CredentialController', () => {
}), }),
); );
expect(res.render).toHaveBeenCalledWith('oauth-callback'); expect(res.render).toHaveBeenCalledWith('oauth-callback');
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
credential,
credential.type,
'internal',
undefined,
true,
);
}); });
}); });
}); });

View file

@ -4,6 +4,7 @@ import { type Response } from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core'; import { Cipher } from 'n8n-core';
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import nock from 'nock'; import nock from 'nock';
import { CREDENTIAL_BLANKING_VALUE, Time } from '@/constants'; import { CREDENTIAL_BLANKING_VALUE, Time } from '@/constants';
@ -19,14 +20,20 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import type { OAuthRequest } from '@/requests'; import type { OAuthRequest } from '@/requests';
import { SecretsHelper } from '@/secrets-helpers.ee'; import { SecretsHelper } from '@/secrets-helpers.ee';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
jest.mock('@/workflow-execute-additional-data');
describe('OAuth2CredentialController', () => { describe('OAuth2CredentialController', () => {
mockInstance(Logger); mockInstance(Logger);
mockInstance(SecretsHelper); mockInstance(SecretsHelper);
mockInstance(VariablesService, { mockInstance(VariablesService, {
getAllCached: async () => [], getAllCached: async () => [],
}); });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
(WorkflowExecuteAdditionalData.getBase as jest.Mock).mockReturnValue(additionalData);
const cipher = mockInstance(Cipher); const cipher = mockInstance(Cipher);
const externalHooks = mockInstance(ExternalHooks); const externalHooks = mockInstance(ExternalHooks);
const credentialsHelper = mockInstance(CredentialsHelper); const credentialsHelper = mockInstance(CredentialsHelper);
@ -108,6 +115,14 @@ describe('OAuth2CredentialController', () => {
type: 'oAuth2Api', type: 'oAuth2Api',
}), }),
); );
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
credential,
credential.type,
'internal',
undefined,
false,
);
}); });
}); });
@ -256,6 +271,14 @@ describe('OAuth2CredentialController', () => {
}), }),
); );
expect(res.render).toHaveBeenCalledWith('oauth-callback'); expect(res.render).toHaveBeenCalledWith('oauth-callback');
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
credential,
credential.type,
'internal',
undefined,
true,
);
}); });
it('merges oauthTokenData if it already exists', async () => { it('merges oauthTokenData if it already exists', async () => {

View file

@ -86,9 +86,30 @@ export abstract class AbstractOAuthController {
return await WorkflowExecuteAdditionalData.getBase(); return await WorkflowExecuteAdditionalData.getBase();
} }
protected async getDecryptedData( /**
* Allow decrypted data to evaluate expressions that include $secrets and apply overwrites
*/
protected async getDecryptedDataForAuthUri(
credential: ICredentialsDb, credential: ICredentialsDb,
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
) {
return await this.getDecryptedData(credential, additionalData, false);
}
/**
* Do not apply overwrites here because that removes the CSRF state, and breaks the oauth flow
*/
protected async getDecryptedDataForCallback(
credential: ICredentialsDb,
additionalData: IWorkflowExecuteAdditionalData,
) {
return await this.getDecryptedData(credential, additionalData, true);
}
private async getDecryptedData(
credential: ICredentialsDb,
additionalData: IWorkflowExecuteAdditionalData,
raw: boolean,
) { ) {
return await this.credentialsHelper.getDecrypted( return await this.credentialsHelper.getDecrypted(
additionalData, additionalData,
@ -96,7 +117,7 @@ export abstract class AbstractOAuthController {
credential.type, credential.type,
'internal', 'internal',
undefined, undefined,
true, raw,
); );
} }
@ -183,7 +204,10 @@ export abstract class AbstractOAuthController {
} }
const additionalData = await this.getAdditionalData(); const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); const decryptedDataOriginal = await this.getDecryptedDataForCallback(
credential,
additionalData,
);
const oauthCredentials = this.applyDefaultsAndOverwrites<T>( const oauthCredentials = this.applyDefaultsAndOverwrites<T>(
credential, credential,
decryptedDataOriginal, decryptedDataOriginal,

View file

@ -35,7 +35,7 @@ export class OAuth1CredentialController extends AbstractOAuthController {
async getAuthUri(req: OAuthRequest.OAuth1Credential.Auth): Promise<string> { async getAuthUri(req: OAuthRequest.OAuth1Credential.Auth): Promise<string> {
const credential = await this.getCredential(req); const credential = await this.getCredential(req);
const additionalData = await this.getAdditionalData(); const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth1CredentialData>( const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth1CredentialData>(
credential, credential,
decryptedDataOriginal, decryptedDataOriginal,

View file

@ -23,7 +23,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise<string> { async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise<string> {
const credential = await this.getCredential(req); const credential = await this.getCredential(req);
const additionalData = await this.getAdditionalData(); const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData);
// At some point in the past we saved hidden scopes to credentials (but shouldn't) // At some point in the past we saved hidden scopes to credentials (but shouldn't)
// Delete scope before applying defaults to make sure new scopes are present on reconnect // Delete scope before applying defaults to make sure new scopes are present on reconnect