mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(Figma Trigger Node): Add OAuth2 authentication support (#30079)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
410b75c3d0
commit
e3e70d6068
|
|
@ -107,6 +107,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
|
|||
'mcpOAuth2Api',
|
||||
'stravaOAuth2Api',
|
||||
'wordpressOAuth2Api',
|
||||
'figmaOAuth2Api',
|
||||
];
|
||||
|
||||
export const ARTIFICIAL_TASK_DATA = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
const defaultScopes = ['webhooks:read', 'webhooks:write'];
|
||||
|
||||
export class FigmaOAuth2Api implements ICredentialType {
|
||||
name = 'figmaOAuth2Api';
|
||||
|
||||
extends = ['oAuth2Api'];
|
||||
|
||||
displayName = 'Figma OAuth2 API';
|
||||
|
||||
documentationUrl = 'figma';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'hidden',
|
||||
default: 'authorizationCode',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://www.figma.com/oauth',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://api.figma.com/v1/oauth/token',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'header',
|
||||
},
|
||||
{
|
||||
displayName: 'Custom Scopes',
|
||||
name: 'customScopes',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Define custom scopes',
|
||||
},
|
||||
{
|
||||
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: 'Scopes that should be enabled',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import nock from 'nock';
|
||||
|
||||
import { FigmaOAuth2Api } from '../FigmaOAuth2Api.credentials';
|
||||
|
||||
describe('FigmaOAuth2Api Credential', () => {
|
||||
const figmaOAuth2Api = new FigmaOAuth2Api();
|
||||
const defaultScopes = ['webhooks:read', 'webhooks:write'];
|
||||
|
||||
const baseUrl = 'https://api.figma.com';
|
||||
const authorizationUri = 'https://www.figma.com/oauth';
|
||||
const accessTokenUri = `${baseUrl}/v1/oauth/token`;
|
||||
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
|
||||
const clientId = 'test-client-id';
|
||||
const clientSecret = 'test-client-secret';
|
||||
|
||||
const createOAuthClient = (scopes: string[]) =>
|
||||
new ClientOAuth2({
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenUri,
|
||||
authorizationUri,
|
||||
redirectUri,
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenEndpoint = (code: string, responseScopes: string[]) => {
|
||||
nock(baseUrl)
|
||||
.post('/v1/oauth/token', (body: Record<string, unknown>) => {
|
||||
return (
|
||||
body.code === code &&
|
||||
body.grant_type === 'authorization_code' &&
|
||||
body.redirect_uri === redirectUri
|
||||
);
|
||||
})
|
||||
.reply(200, {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'test-refresh-token',
|
||||
scope: responseScopes.join(' '),
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(figmaOAuth2Api.name).toBe('figmaOAuth2Api');
|
||||
expect(figmaOAuth2Api.extends).toEqual(['oAuth2Api']);
|
||||
|
||||
const authUrlProperty = figmaOAuth2Api.properties.find((p) => p.name === 'authUrl');
|
||||
expect(authUrlProperty?.default).toBe('https://www.figma.com/oauth');
|
||||
|
||||
const tokenUrlProperty = figmaOAuth2Api.properties.find((p) => p.name === 'accessTokenUrl');
|
||||
expect(tokenUrlProperty?.default).toBe('https://api.figma.com/v1/oauth/token');
|
||||
|
||||
const enabledScopesProperty = figmaOAuth2Api.properties.find((p) => p.name === 'enabledScopes');
|
||||
expect(enabledScopesProperty?.default).toBe('webhooks:read webhooks:write');
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with default scopes', () => {
|
||||
it('should include default scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('webhooks');
|
||||
expect(authUri).toContain(`client_id=${clientId}`);
|
||||
expect(authUri).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with default scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, defaultScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('webhooks:read');
|
||||
expect(token.data.scope).toContain('webhooks:write');
|
||||
expect(token.data.refresh_token).toBe('test-refresh-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with custom scopes', () => {
|
||||
const customScopes = [
|
||||
'webhooks:read',
|
||||
'webhooks:write',
|
||||
'file_content:read',
|
||||
'file_comments:write',
|
||||
];
|
||||
|
||||
it('should include custom scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('file_content');
|
||||
expect(authUri).toContain('file_comments');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with custom scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, customScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('webhooks:read');
|
||||
expect(token.data.scope).toContain('webhooks:write');
|
||||
expect(token.data.scope).toContain('file_content:read');
|
||||
expect(token.data.scope).toContain('file_comments:write');
|
||||
});
|
||||
|
||||
it('should handle a minimal scope set distinct from defaults', async () => {
|
||||
const minimalScopes = ['webhooks:read'];
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, minimalScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(minimalScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('webhooks');
|
||||
expect(authUri).not.toContain('webhooks%3Awrite');
|
||||
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('webhooks:read');
|
||||
expect(token.data.scope).not.toContain('webhooks:write');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,20 @@ export class FigmaTrigger implements INodeType {
|
|||
{
|
||||
name: 'figmaApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['accessToken'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'figmaOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['oAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
|
|
@ -42,6 +56,22 @@ export class FigmaTrigger implements INodeType {
|
|||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Access Token',
|
||||
value: 'accessToken',
|
||||
},
|
||||
{
|
||||
name: 'OAuth2',
|
||||
value: 'oAuth2',
|
||||
},
|
||||
],
|
||||
default: 'accessToken',
|
||||
},
|
||||
{
|
||||
displayName: 'Team ID',
|
||||
name: 'teamId',
|
||||
|
|
|
|||
|
|
@ -19,10 +19,9 @@ export async function figmaApiRequest(
|
|||
uri?: string,
|
||||
option: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('figmaApi');
|
||||
const authentication = this.getNodeParameter('authentication', 0, 'accessToken') as string;
|
||||
|
||||
let options: IRequestOptions = {
|
||||
headers: { 'X-FIGMA-TOKEN': credentials.accessToken },
|
||||
method,
|
||||
body,
|
||||
uri: uri || `https://api.figma.com${resource}`,
|
||||
|
|
@ -32,7 +31,14 @@ export async function figmaApiRequest(
|
|||
if (Object.keys(options.body as IDataObject).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
try {
|
||||
if (authentication === 'oAuth2') {
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'figmaOAuth2Api', options);
|
||||
}
|
||||
|
||||
const credentials = await this.getCredentials('figmaApi');
|
||||
options.headers = { 'X-FIGMA-TOKEN': credentials.accessToken };
|
||||
return await this.helpers.request(options);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { figmaApiRequest } from '../GenericFunctions';
|
||||
|
||||
describe('Figma > GenericFunctions', () => {
|
||||
const mockFunctions: any = {
|
||||
helpers: {
|
||||
request: jest.fn(),
|
||||
requestWithAuthentication: jest.fn(),
|
||||
},
|
||||
getCredentials: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
getNodeParameter: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFunctions.getNodeParameter.mockReturnValue('accessToken');
|
||||
mockFunctions.getCredentials.mockResolvedValue({ accessToken: 'test-token' });
|
||||
});
|
||||
|
||||
describe('with access token authentication', () => {
|
||||
it('should send X-FIGMA-TOKEN header and use the figmaApi credential', async () => {
|
||||
mockFunctions.helpers.request.mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await figmaApiRequest.call(mockFunctions, 'GET', '/v2/teams/123/webhooks');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockFunctions.getCredentials).toHaveBeenCalledWith('figmaApi');
|
||||
expect(mockFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
uri: 'https://api.figma.com/v2/teams/123/webhooks',
|
||||
headers: { 'X-FIGMA-TOKEN': 'test-token' },
|
||||
}),
|
||||
);
|
||||
expect(mockFunctions.helpers.requestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NodeApiError on failure', async () => {
|
||||
mockFunctions.helpers.request.mockRejectedValue({ message: 'fail' });
|
||||
|
||||
await expect(
|
||||
figmaApiRequest.call(mockFunctions, 'GET', '/v2/teams/123/webhooks'),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with OAuth2 authentication', () => {
|
||||
beforeEach(() => {
|
||||
mockFunctions.getNodeParameter.mockReturnValue('oAuth2');
|
||||
mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('should route through requestWithAuthentication using the figmaOAuth2Api credential', async () => {
|
||||
const result = await figmaApiRequest.call(mockFunctions, 'POST', '/v2/webhooks', {
|
||||
event_type: 'FILE_UPDATE',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'figmaOAuth2Api',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
uri: 'https://api.figma.com/v2/webhooks',
|
||||
body: { event_type: 'FILE_UPDATE' },
|
||||
}),
|
||||
);
|
||||
expect(mockFunctions.helpers.request).not.toHaveBeenCalled();
|
||||
expect(mockFunctions.getCredentials).not.toHaveBeenCalledWith('figmaApi');
|
||||
});
|
||||
|
||||
it('should not attach the X-FIGMA-TOKEN header for OAuth2 requests', async () => {
|
||||
await figmaApiRequest.call(mockFunctions, 'GET', '/v2/teams/123/webhooks');
|
||||
|
||||
const callArgs = mockFunctions.helpers.requestWithAuthentication.mock.calls[0][1];
|
||||
expect(callArgs.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw NodeApiError on failure', async () => {
|
||||
mockFunctions.helpers.requestWithAuthentication.mockRejectedValue({ message: 'fail' });
|
||||
|
||||
await expect(
|
||||
figmaApiRequest.call(mockFunctions, 'GET', '/v2/teams/123/webhooks'),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -116,6 +116,7 @@
|
|||
"dist/credentials/FacebookGraphAppApi.credentials.js",
|
||||
"dist/credentials/FacebookLeadAdsOAuth2Api.credentials.js",
|
||||
"dist/credentials/FigmaApi.credentials.js",
|
||||
"dist/credentials/FigmaOAuth2Api.credentials.js",
|
||||
"dist/credentials/FileMaker.credentials.js",
|
||||
"dist/credentials/FilescanApi.credentials.js",
|
||||
"dist/credentials/FlowApi.credentials.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user