diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 54c48c50f77..f3860cc69e9 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -105,6 +105,8 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'microsoftOAuth2Api', 'highLevelOAuth2Api', 'mcpOAuth2Api', + 'facebookGraphApiOAuth2Api', + 'facebookGraphAppOAuth2Api', 'stravaOAuth2Api', 'wordpressOAuth2Api', 'figmaOAuth2Api', diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 90ddb844b98..9fa6413d69f 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -1143,6 +1143,30 @@ describe('OauthService', () => { ); }); + it.each(['facebookGraphApiOAuth2Api', 'facebookGraphAppOAuth2Api'])( + 'should not delete scope for %s credentials', + async (credentialType) => { + const credential = mock({ id: '1', type: credentialType }); + const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' }; + const mockOAuthCredentials = { clientId: 'client-id', scope: 'custom-scope' }; + const mockAdditionalData = mock(); + + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + await service.getOAuthCredentials(credential); + + expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith( + mockAdditionalData, + { clientId: 'client-id', scope: 'custom-scope' }, + credentialType, + 'internal', + undefined, + undefined, + ); + }, + ); it('should not delete scope for wordpressOAuth2Api credentials', async () => { const credential = mock({ id: '1', diff --git a/packages/nodes-base/credentials/FacebookGraphApiOAuth2Api.credentials.ts b/packages/nodes-base/credentials/FacebookGraphApiOAuth2Api.credentials.ts new file mode 100644 index 00000000000..83d546e4661 --- /dev/null +++ b/packages/nodes-base/credentials/FacebookGraphApiOAuth2Api.credentials.ts @@ -0,0 +1,95 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +const defaultScopes = [ + 'public_profile', + 'email', + 'pages_show_list', + 'pages_read_engagement', + 'pages_read_user_content', + 'pages_manage_metadata', + 'pages_manage_posts', + 'business_management', +]; + +export class FacebookGraphApiOAuth2Api implements ICredentialType { + name = 'facebookGraphApiOAuth2Api'; + + displayName = 'Facebook Graph OAuth2 API'; + + extends = ['oAuth2Api']; + + documentationUrl = 'facebookgraph'; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://www.facebook.com/v25.0/dialog/oauth', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://graph.facebook.com/v25.0/oauth/access_token', + required: true, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + { + displayName: 'Custom Scopes', + name: 'customScopes', + type: 'boolean', + default: false, + description: 'Whether to define custom OAuth2 scopes instead of the defaults', + }, + { + displayName: + 'The default scopes needed for the node to work are already set. If you change these the node may not function correctly.', + name: 'customScopesNotice', + type: 'notice', + default: '', + displayOptions: { + show: { + customScopes: [true], + }, + }, + }, + { + displayName: 'Enabled Scopes', + name: 'enabledScopes', + type: 'string', + displayOptions: { + show: { + customScopes: [true], + }, + }, + default: defaultScopes.join(' '), + description: 'Space-separated list of OAuth2 scopes to request', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: + '={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}', + }, + ]; +} diff --git a/packages/nodes-base/credentials/FacebookGraphAppOAuth2Api.credentials.ts b/packages/nodes-base/credentials/FacebookGraphAppOAuth2Api.credentials.ts new file mode 100644 index 00000000000..73e2843ff5d --- /dev/null +++ b/packages/nodes-base/credentials/FacebookGraphAppOAuth2Api.credentials.ts @@ -0,0 +1,23 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class FacebookGraphAppOAuth2Api implements ICredentialType { + name = 'facebookGraphAppOAuth2Api'; + + displayName = 'Facebook Graph (App) OAuth2 API'; + + extends = ['facebookGraphApiOAuth2Api']; + + documentationUrl = 'facebookapp'; + + properties: INodeProperties[] = [ + { + displayName: 'App Secret', + name: 'appSecret', + type: 'string', + typeOptions: { password: true }, + default: '', + description: + '(Optional) When set, the node will verify incoming webhook payloads for added security', + }, + ]; +} diff --git a/packages/nodes-base/credentials/test/FacebookGraphApiOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/FacebookGraphApiOAuth2Api.credentials.test.ts new file mode 100644 index 00000000000..84446125507 --- /dev/null +++ b/packages/nodes-base/credentials/test/FacebookGraphApiOAuth2Api.credentials.test.ts @@ -0,0 +1,50 @@ +import { FacebookGraphApiOAuth2Api } from '../FacebookGraphApiOAuth2Api.credentials'; + +describe('FacebookGraphApiOAuth2Api Credential', () => { + const credential = new FacebookGraphApiOAuth2Api(); + const defaultScopes = [ + 'public_profile', + 'email', + 'pages_show_list', + 'pages_read_engagement', + 'pages_read_user_content', + 'pages_manage_metadata', + 'pages_manage_posts', + 'business_management', + ]; + + it('should have correct credential metadata', () => { + expect(credential.name).toBe('facebookGraphApiOAuth2Api'); + expect(credential.extends).toEqual(['oAuth2Api']); + }); + + it('should use the correct OAuth2 endpoints', () => { + const authUrlProp = credential.properties.find((p) => p.name === 'authUrl'); + expect(authUrlProp?.default).toBe('https://www.facebook.com/v25.0/dialog/oauth'); + + const tokenUrlProp = credential.properties.find((p) => p.name === 'accessTokenUrl'); + expect(tokenUrlProp?.default).toBe('https://graph.facebook.com/v25.0/oauth/access_token'); + }); + + it('should have custom scopes toggle defaulting to false', () => { + const customScopesProperty = credential.properties.find((p) => p.name === 'customScopes'); + expect(customScopesProperty?.default).toBe(false); + }); + + it('should embed all default scopes in the scope expression', () => { + const enabledScopesProp = credential.properties.find((p) => p.name === 'enabledScopes'); + expect(enabledScopesProp?.default).toBe(defaultScopes.join(' ')); + + const scopeProp = credential.properties.find((p) => p.name === 'scope'); + for (const scope of defaultScopes) { + expect(scopeProp?.default).toContain(scope); + } + }); + + it('should drive scope from enabledScopes when customScopes is true', () => { + const scopeProp = credential.properties.find((p) => p.name === 'scope'); + // The scope expression references $self["customScopes"] and $self["enabledScopes"] + expect(scopeProp?.default).toContain('$self["customScopes"]'); + expect(scopeProp?.default).toContain('$self["enabledScopes"]'); + }); +}); diff --git a/packages/nodes-base/credentials/test/FacebookGraphAppOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/FacebookGraphAppOAuth2Api.credentials.test.ts new file mode 100644 index 00000000000..7c77cc2e292 --- /dev/null +++ b/packages/nodes-base/credentials/test/FacebookGraphAppOAuth2Api.credentials.test.ts @@ -0,0 +1,17 @@ +import { FacebookGraphAppOAuth2Api } from '../FacebookGraphAppOAuth2Api.credentials'; + +describe('FacebookGraphAppOAuth2Api Credential', () => { + const credential = new FacebookGraphAppOAuth2Api(); + + it('should have correct credential metadata', () => { + expect(credential.name).toBe('facebookGraphAppOAuth2Api'); + expect(credential.extends).toEqual(['facebookGraphApiOAuth2Api']); + }); + + it('should have an optional appSecret property', () => { + const appSecretProperty = credential.properties.find((p) => p.name === 'appSecret'); + expect(appSecretProperty).toBeDefined(); + expect(appSecretProperty?.type).toBe('string'); + expect(appSecretProperty?.default).toBe(''); + }); +}); diff --git a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts index a84801f2a19..902f2e29c78 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -28,9 +28,40 @@ export class FacebookGraphApi implements INodeType { { name: 'facebookGraphApi', required: true, + displayOptions: { + show: { + authType: ['accessToken'], + }, + }, + }, + { + name: 'facebookGraphApiOAuth2Api', + required: true, + displayOptions: { + show: { + authType: ['oAuth2'], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authType', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The authentication method to use', + }, { displayName: 'Host URL', name: 'hostUrl', @@ -81,6 +112,14 @@ export class FacebookGraphApi implements INodeType { name: 'Default', value: '', }, + { + name: 'v25.0', + value: 'v25.0', + }, + { + name: 'v24.0', + value: 'v24.0', + }, { name: 'v23.0', value: 'v23.0', @@ -330,7 +369,16 @@ export class FacebookGraphApi implements INodeType { const returnItems: INodeExecutionData[] = []; for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - const graphApiCredentials = await this.getCredentials('facebookGraphApi'); + const authType = this.getNodeParameter('authType', itemIndex, 'accessToken') as string; + const qs: IDataObject = {}; + + let graphApiAccessToken: string | undefined; + if (authType === 'accessToken') { + const graphApiCredentials = await this.getCredentials('facebookGraphApi'); + graphApiAccessToken = graphApiCredentials.accessToken as string; + qs.access_token = graphApiAccessToken; + } + // OAuth2: token is injected automatically via requestWithAuthentication const hostUrl = this.getNodeParameter('hostUrl', itemIndex) as string; const httpRequestMethod = this.getNodeParameter( @@ -350,10 +398,6 @@ export class FacebookGraphApi implements INodeType { if (edge) { uri = `${uri}/${edge}`; } - - const qs: IDataObject = { - access_token: graphApiCredentials.accessToken, - }; const requestOptions: IRequestOptions = { headers: { accept: 'application/json,text/*;q=0.99', @@ -433,7 +477,15 @@ export class FacebookGraphApi implements INodeType { try { // Now that the options are all set make the actual http request - response = await this.helpers.request(requestOptions); + if (authType === 'oAuth2') { + response = await this.helpers.requestWithAuthentication.call( + this, + 'facebookGraphApiOAuth2Api', + requestOptions, + ); + } else { + response = await this.helpers.request(requestOptions); + } } catch (error) { if (!this.continueOnFail()) { throw new NodeApiError(this.getNode(), error as JsonObject); diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts index 8238b5da6bb..fa71edcf98b 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -35,6 +35,20 @@ export class FacebookTrigger implements INodeType { { name: 'facebookGraphAppApi', required: true, + displayOptions: { + show: { + authType: ['accessToken'], + }, + }, + }, + { + name: 'facebookGraphAppOAuth2Api', + required: true, + displayOptions: { + show: { + authType: ['oAuth2'], + }, + }, }, ], webhooks: [ @@ -52,6 +66,23 @@ export class FacebookTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authType', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The authentication method to use', + }, { displayName: 'APP ID', name: 'appId', @@ -267,7 +298,10 @@ export class FacebookTrigger implements INodeType { const res = this.getResponseObject(); const req = this.getRequestObject(); const headerData = this.getHeaderData() as IDataObject; - const credentials = await this.getCredentials('facebookGraphAppApi'); + const authType = this.getNodeParameter('authType', 0) as string; + const credentials = await this.getCredentials( + authType === 'oAuth2' ? 'facebookGraphAppOAuth2Api' : 'facebookGraphAppApi', + ); // Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) if (this.getWebhookName() === 'setup') { if (query['hub.challenge']) { diff --git a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts index 4c6663b4260..2ef3e5fae22 100644 --- a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts @@ -22,17 +22,25 @@ export async function facebookApiRequest( uri?: string, _option: IDataObject = {}, ): Promise { - let credentials; + const isTrigger = this.getNode().name.includes('Trigger'); + const authType = this.getNodeParameter('authType', 0, 'accessToken') as string; + const isOAuth2 = authType === 'oAuth2'; - if (this.getNode().name.includes('Trigger')) { - credentials = await this.getCredentials('facebookGraphAppApi'); - } else { - credentials = await this.getCredentials('facebookGraphApi'); + const credentialName = isTrigger + ? isOAuth2 + ? 'facebookGraphAppOAuth2Api' + : 'facebookGraphAppApi' + : isOAuth2 + ? 'facebookGraphApiOAuth2Api' + : 'facebookGraphApi'; + + const credentials = await this.getCredentials(credentialName); + + if (!isOAuth2) { + qs.access_token = credentials.accessToken; } - qs.access_token = credentials.accessToken; - - if (credentials.appSecret) { + if (!isOAuth2 && credentials.appSecret) { const appsecretTime = Math.floor(Date.now() / 1000); qs.appsecret_proof = createHmac('sha256', credentials.appSecret as string) .update(`${credentials.accessToken as string}|${appsecretTime}`) @@ -53,6 +61,9 @@ export async function facebookApiRequest( }; try { + if (isOAuth2) { + return await this.helpers.requestWithAuthentication.call(this, credentialName, options); + } return await this.helpers.request(options); } catch (error) { throw new NodeApiError(this.getNode(), error as JsonObject); diff --git a/packages/nodes-base/nodes/Facebook/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Facebook/__tests__/GenericFunctions.test.ts index 6815f3171e7..b77d35d6558 100644 --- a/packages/nodes-base/nodes/Facebook/__tests__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Facebook/__tests__/GenericFunctions.test.ts @@ -30,15 +30,21 @@ describe('Facebook GenericFunctions', () => { name: 'Facebook', typeVersion: 1, }), + getNodeParameter: jest.fn().mockReturnValue('accessToken'), getCredentials: jest.fn().mockImplementation(async (type) => { if (type === 'facebookGraphApi') { return { accessToken: 'test-access-token' }; } else if (type === 'facebookGraphAppApi') { return { accessToken: 'test-app-access-token' }; + } else if (type === 'facebookGraphApiOAuth2Api') { + return {}; + } else if (type === 'facebookGraphAppOAuth2Api') { + return {}; } }), helpers: { request: jest.fn(), + requestWithAuthentication: jest.fn(), }, }; }); @@ -156,6 +162,70 @@ describe('Facebook GenericFunctions', () => { expect.objectContaining({ uri: customUri }), ); }); + + describe('OAuth2 authentication', () => { + beforeEach(() => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('oAuth2'); + mockExecuteFunctions.helpers.requestWithAuthentication.mockResolvedValue({ + success: true, + }); + }); + + it('should use OAuth2 credential for regular nodes', async () => { + await utils.facebookApiRequest.call(mockExecuteFunctions, 'GET', '/me', {}, {}); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'facebookGraphApiOAuth2Api', + ); + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'facebookGraphApiOAuth2Api', + expect.objectContaining({ + method: 'GET', + uri: 'https://graph.facebook.com/v23.0/me', + }), + ); + expect(mockExecuteFunctions.helpers.request).not.toHaveBeenCalled(); + }); + + it('should use OAuth2 app credential for trigger nodes', async () => { + mockExecuteFunctions.getNode.mockReturnValue({ name: 'Facebook Trigger' }); + + await utils.facebookApiRequest.call(mockExecuteFunctions, 'GET', '/app', {}, {}); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'facebookGraphAppOAuth2Api', + ); + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'facebookGraphAppOAuth2Api', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('should not include access_token in qs when using OAuth2', async () => { + await utils.facebookApiRequest.call(mockExecuteFunctions, 'GET', '/me', {}, {}); + + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'facebookGraphApiOAuth2Api', + expect.objectContaining({ + qs: expect.not.objectContaining({ access_token: expect.anything() }), + }), + ); + }); + + it('should not compute appsecret_proof when using OAuth2', async () => { + mockExecuteFunctions.getNode.mockReturnValue({ name: 'Facebook Trigger' }); + mockExecuteFunctions.getCredentials.mockResolvedValue({ appSecret: 'some-secret' }); + + await utils.facebookApiRequest.call(mockExecuteFunctions, 'GET', '/app', {}, {}); + + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'facebookGraphAppOAuth2Api', + expect.objectContaining({ + qs: expect.not.objectContaining({ appsecret_proof: expect.anything() }), + }), + ); + }); + }); }); describe('getFields', () => { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 26ce3d753d1..04e1fc8a3dd 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -113,7 +113,9 @@ "dist/credentials/EventbriteOAuth2Api.credentials.js", "dist/credentials/F5BigIpApi.credentials.js", "dist/credentials/FacebookGraphApi.credentials.js", + "dist/credentials/FacebookGraphApiOAuth2Api.credentials.js", "dist/credentials/FacebookGraphAppApi.credentials.js", + "dist/credentials/FacebookGraphAppOAuth2Api.credentials.js", "dist/credentials/FacebookLeadAdsOAuth2Api.credentials.js", "dist/credentials/FigmaApi.credentials.js", "dist/credentials/FigmaOAuth2Api.credentials.js",