feat(Facebook Graph API Node): Add OAuth2 support (#27112)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Jon 2026-05-12 14:32:08 +01:00 committed by GitHub
parent 0ce820de73
commit d06110ba9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 395 additions and 15 deletions

View File

@ -105,6 +105,8 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
'microsoftOAuth2Api',
'highLevelOAuth2Api',
'mcpOAuth2Api',
'facebookGraphApiOAuth2Api',
'facebookGraphAppOAuth2Api',
'stravaOAuth2Api',
'wordpressOAuth2Api',
'figmaOAuth2Api',

View File

@ -1143,6 +1143,30 @@ describe('OauthService', () => {
);
});
it.each(['facebookGraphApiOAuth2Api', 'facebookGraphAppOAuth2Api'])(
'should not delete scope for %s credentials',
async (credentialType) => {
const credential = mock<CredentialsEntity>({ id: '1', type: credentialType });
const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' };
const mockOAuthCredentials = { clientId: 'client-id', scope: 'custom-scope' };
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();
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<CredentialsEntity>({
id: '1',

View File

@ -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(' ') + '"}}',
},
];
}

View File

@ -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',
},
];
}

View File

@ -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"]');
});
});

View File

@ -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('');
});
});

View File

@ -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);

View File

@ -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']) {

View File

@ -22,17 +22,25 @@ export async function facebookApiRequest(
uri?: string,
_option: IDataObject = {},
): Promise<any> {
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);

View File

@ -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', () => {

View File

@ -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",