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:
Garrit Franke 2026-05-11 13:30:49 +02:00 committed by GitHub
parent 410b75c3d0
commit e3e70d6068
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 350 additions and 2 deletions

View File

@ -107,6 +107,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
'mcpOAuth2Api',
'stravaOAuth2Api',
'wordpressOAuth2Api',
'figmaOAuth2Api',
];
export const ARTIFICIAL_TASK_DATA = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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