mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
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:
parent
0ce820de73
commit
d06110ba9d
|
|
@ -105,6 +105,8 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
|
|||
'microsoftOAuth2Api',
|
||||
'highLevelOAuth2Api',
|
||||
'mcpOAuth2Api',
|
||||
'facebookGraphApiOAuth2Api',
|
||||
'facebookGraphAppOAuth2Api',
|
||||
'stravaOAuth2Api',
|
||||
'wordpressOAuth2Api',
|
||||
'figmaOAuth2Api',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(' ') + '"}}',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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"]');
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 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
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user