From e3e70d6068a3d543b29b1bd24682101ecb2e641f Mon Sep 17 00:00:00 2001 From: Garrit Franke <32395585+garritfra@users.noreply.github.com> Date: Mon, 11 May 2026 13:30:49 +0200 Subject: [PATCH] feat(Figma Trigger Node): Add OAuth2 authentication support (#30079) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/cli/src/constants.ts | 1 + .../credentials/FigmaOAuth2Api.credentials.ts | 80 ++++++++++ .../test/FigmaOAuth2Api.credentials.test.ts | 142 ++++++++++++++++++ .../nodes/Figma/FigmaTrigger.node.ts | 30 ++++ .../nodes/Figma/GenericFunctions.ts | 10 +- .../Figma/__tests__/GenericFunctions.test.ts | 88 +++++++++++ packages/nodes-base/package.json | 1 + 7 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/credentials/FigmaOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/test/FigmaOAuth2Api.credentials.test.ts create mode 100644 packages/nodes-base/nodes/Figma/__tests__/GenericFunctions.test.ts diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 4c4215d78d2..54c48c50f77 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -107,6 +107,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'mcpOAuth2Api', 'stravaOAuth2Api', 'wordpressOAuth2Api', + 'figmaOAuth2Api', ]; export const ARTIFICIAL_TASK_DATA = { diff --git a/packages/nodes-base/credentials/FigmaOAuth2Api.credentials.ts b/packages/nodes-base/credentials/FigmaOAuth2Api.credentials.ts new file mode 100644 index 00000000000..0213e887649 --- /dev/null +++ b/packages/nodes-base/credentials/FigmaOAuth2Api.credentials.ts @@ -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(' ') + '"}}', + }, + ]; +} diff --git a/packages/nodes-base/credentials/test/FigmaOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/FigmaOAuth2Api.credentials.test.ts new file mode 100644 index 00000000000..185fcc3d440 --- /dev/null +++ b/packages/nodes-base/credentials/test/FigmaOAuth2Api.credentials.test.ts @@ -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) => { + 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'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Figma/FigmaTrigger.node.ts b/packages/nodes-base/nodes/Figma/FigmaTrigger.node.ts index 452c1af5e83..beef7472e86 100644 --- a/packages/nodes-base/nodes/Figma/FigmaTrigger.node.ts +++ b/packages/nodes-base/nodes/Figma/FigmaTrigger.node.ts @@ -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', diff --git a/packages/nodes-base/nodes/Figma/GenericFunctions.ts b/packages/nodes-base/nodes/Figma/GenericFunctions.ts index cbd56185580..6866d5951dc 100644 --- a/packages/nodes-base/nodes/Figma/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Figma/GenericFunctions.ts @@ -19,10 +19,9 @@ export async function figmaApiRequest( uri?: string, option: IDataObject = {}, ): Promise { - 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); diff --git a/packages/nodes-base/nodes/Figma/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Figma/__tests__/GenericFunctions.test.ts new file mode 100644 index 00000000000..6281193262e --- /dev/null +++ b/packages/nodes-base/nodes/Figma/__tests__/GenericFunctions.test.ts @@ -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); + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a97deb78892..ba966f2cbb6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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",