diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-oauth2-client.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-oauth2-client.test.ts new file mode 100644 index 00000000000..cdce03bcc6e --- /dev/null +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-oauth2-client.test.ts @@ -0,0 +1,111 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IAllExecuteFunctions, INode, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; + +const mockGetToken = jest.fn(); +const mockSign = jest.fn(); +const mockCreateToken = jest.fn(); +const MockClientOAuth2 = jest.fn(); + +jest.mock('@n8n/client-oauth2', () => ({ + ClientOAuth2: MockClientOAuth2, +})); + +import { requestOAuth2 } from '../request-helper-functions'; + +describe('createOAuth2Client - scope handling', () => { + const mockThis = mockDeep(); + const mockNode = mockDeep(); + const mockAdditionalData = mockDeep(); + + const baseCredentials = { + clientId: 'client-id', + clientSecret: 'client-secret', + grantType: 'clientCredentials', + accessTokenUrl: 'https://auth.example.com/token', + authentication: 'body', + oauthTokenData: undefined, + }; + + beforeEach(() => { + jest.resetAllMocks(); + mockNode.name = 'test-node'; + mockNode.credentials = { testOAuth2: { id: 'cred-id', name: 'cred-name' } }; + + mockGetToken.mockResolvedValue({ data: { access_token: 'mock-token' } }); + mockSign.mockImplementation((opts: object) => ({ + ...opts, + headers: { Authorization: 'Bearer mock-token' }, + })); + mockCreateToken.mockReturnValue({ sign: mockSign, accessToken: 'mock-token' }); + + MockClientOAuth2.mockImplementation(() => ({ + credentials: { getToken: mockGetToken }, + createToken: mockCreateToken, + })); + + mockThis.helpers.httpRequest.mockResolvedValue({ success: true }); + }); + + const call = async () => + await requestOAuth2.call( + mockThis, + 'testOAuth2', + { method: 'GET', url: 'https://api.example.com/data' }, + mockNode, + mockAdditionalData, + undefined, + true, + ); + + test('should pass undefined scopes when scope is undefined', async () => { + mockThis.getCredentials.mockResolvedValue({ ...baseCredentials, scope: undefined }); + + await call(); + + expect(MockClientOAuth2).toHaveBeenCalledWith(expect.objectContaining({ scopes: undefined })); + }); + + test('should pass undefined scopes when scope is null', async () => { + mockThis.getCredentials.mockResolvedValue({ ...baseCredentials, scope: null }); + + await call(); + + expect(MockClientOAuth2).toHaveBeenCalledWith(expect.objectContaining({ scopes: undefined })); + }); + + test('should pass undefined scopes when scope is an empty string', async () => { + mockThis.getCredentials.mockResolvedValue({ ...baseCredentials, scope: '' }); + + await call(); + + expect(MockClientOAuth2).toHaveBeenCalledWith(expect.objectContaining({ scopes: undefined })); + }); + + test('should pass undefined scopes when scope contains only spaces', async () => { + mockThis.getCredentials.mockResolvedValue({ ...baseCredentials, scope: ' ' }); + + await call(); + + expect(MockClientOAuth2).toHaveBeenCalledWith(expect.objectContaining({ scopes: undefined })); + }); + + test('should pass a trimmed scopes array for a valid scope string', async () => { + mockThis.getCredentials.mockResolvedValue({ ...baseCredentials, scope: 'read write' }); + + await call(); + + expect(MockClientOAuth2).toHaveBeenCalledWith( + expect.objectContaining({ scopes: ['read', 'write'] }), + ); + }); + + test('should trim and filter extra whitespace between scopes', async () => { + mockThis.getCredentials.mockResolvedValue({ ...baseCredentials, scope: ' read write ' }); + + await call(); + + expect(MockClientOAuth2).toHaveBeenCalledWith( + expect.objectContaining({ scopes: ['read', 'write'] }), + ); + }); +}); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts index 0bc030867fa..71177375400 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts @@ -1396,6 +1396,134 @@ describe('Request Helper Functions', () => { }); }); + describe('requestOAuth2 - client credentials initial token fetch', () => { + const baseUrl = 'https://api.example.com'; + const tokenUrl = 'https://auth.example.com'; + const mockThis = mockDeep(); + const mockNode = mockDeep(); + const mockAdditionalData = mockDeep(); + + beforeEach(() => { + nock.cleanAll(); + jest.resetAllMocks(); + mockNode.name = 'test-node'; + mockNode.credentials = { + testOAuth2: { id: 'cred-id', name: 'cred-name' }, + }; + }); + + test('should not send scope parameter when scope is empty', async () => { + mockThis.getCredentials.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + grantType: 'clientCredentials', + accessTokenUrl: `${tokenUrl}/token`, + authentication: 'body', + scope: '', + oauthTokenData: undefined, + }); + + // Token endpoint must NOT receive scope in body + nock(tokenUrl) + .post('/token', (body) => !('scope' in body) || body.scope === undefined) + .reply( + 200, + { access_token: 'new-token', token_type: 'bearer' }, + { 'content-type': 'application/json' }, + ); + + nock(baseUrl).get('/data').reply(200, { success: true }); + + mockThis.helpers.httpRequest.mockResolvedValueOnce({ success: true }); + + await requestOAuth2.call( + mockThis, + 'testOAuth2', + { method: 'GET', url: `${baseUrl}/data` }, + mockNode, + mockAdditionalData, + undefined, + true, // isN8nRequest + ); + + expect(mockThis.helpers.httpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer new-token' }), + }), + ); + }); + + test('should send scope parameter when scope is set', async () => { + mockThis.getCredentials.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + grantType: 'clientCredentials', + accessTokenUrl: `${tokenUrl}/token`, + authentication: 'body', + scope: 'read write', + oauthTokenData: undefined, + }); + + nock(tokenUrl) + .post('/token', (body) => body.scope === 'read write') + .reply( + 200, + { access_token: 'scoped-token', token_type: 'bearer' }, + { 'content-type': 'application/json' }, + ); + + mockThis.helpers.httpRequest.mockResolvedValueOnce({ data: 'ok' }); + + await requestOAuth2.call( + mockThis, + 'testOAuth2', + { method: 'GET', url: `${baseUrl}/data` }, + mockNode, + mockAdditionalData, + undefined, + true, + ); + + expect(mockThis.helpers.httpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer scoped-token' }), + }), + ); + }); + + test('should throw ApplicationError with clear message when token acquisition fails', async () => { + mockThis.getCredentials.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'wrong-secret', + grantType: 'clientCredentials', + accessTokenUrl: `${tokenUrl}/token`, + authentication: 'body', + scope: '', + oauthTokenData: undefined, + }); + + nock(tokenUrl) + .post('/token') + .reply( + 400, + { error: 'invalid_client', error_description: 'Invalid client credentials' }, + { 'content-type': 'application/json' }, + ); + + await expect( + requestOAuth2.call( + mockThis, + 'testOAuth2', + { method: 'GET', url: `${baseUrl}/data` }, + mockNode, + mockAdditionalData, + undefined, + true, + ), + ).rejects.toThrow('Failed to acquire OAuth2 access token'); + }); + }); + describe('SSRF protection wiring', () => { const baseUrl = 'https://example.com'; const workflow = mock(); 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 d5a3d55cd1d..73c002ca5a7 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 @@ -713,11 +713,16 @@ export function applyPaginationRequestData( } function createOAuth2Client(credentials: OAuth2CredentialData): ClientOAuth2 { + // Split and trim scopes; empty scope tokens are not RFC 6749-compliant and may be rejected by authorization servers + const scopes = credentials.scope + ?.split(' ') + .map((s) => s.trim()) + .filter(Boolean); return new ClientOAuth2({ clientId: credentials.clientId, clientSecret: credentials.clientSecret, accessTokenUri: credentials.accessTokenUrl, - scopes: (credentials.scope ?? '').split(' '), + scopes: scopes?.length ? scopes : undefined, ignoreSSLIssues: credentials.ignoreSSLIssues, authentication: credentials.authentication ?? 'header', ...(credentials.additionalBodyProperties && { @@ -823,7 +828,16 @@ export async function requestOAuth2( Object.keys(oauthTokenData).length === 0 || oauthTokenData.access_token === '') // stub ) { - const { data } = await oAuthClient.credentials.getToken(); + let tokenResult: Awaited>; + try { + tokenResult = await oAuthClient.credentials.getToken(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ApplicationError(`Failed to acquire OAuth2 access token: ${message}`, { + cause: error, + }); + } + const { data } = tokenResult; // Find the credentials if (!node.credentials?.[credentialsType]) { throw new ApplicationError('Node does not have credential type', {