diff --git a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-capabilities.test.ts b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-capabilities.test.ts index 3ef74afd1bf..1bb85daca3a 100644 --- a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-capabilities.test.ts +++ b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-capabilities.test.ts @@ -6,7 +6,9 @@ import { McpRegistryCapabilities } from '../mcp-registry-capabilities'; describe('McpRegistryCapabilities', () => { it.each([ ['default', undefined, true], + ['default', ['supabase-oauth2-credentials'], true], ['default', ['n8n-cloud'], false], + ['cloud', ['supabase-oauth2-credentials'], true], ['cloud', ['n8n-cloud'], true], ['cloud', ['n8n-cloud', 'unsupported-capability'], false], ])('checks capabilities for a %s deployment', (deploymentType, required, expected) => { diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-capabilities.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-capabilities.ts index 1fc4f7b8075..1edd8463ceb 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-capabilities.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-capabilities.ts @@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; // Add capabilities here when they do not depend on instance configuration. -const BASE_CAPABILITIES: readonly string[] = []; +const BASE_CAPABILITIES: readonly string[] = ['supabase-oauth2-credentials']; const N8N_CLOUD_CAPABILITY = 'n8n-cloud'; diff --git a/packages/nodes-base/credentials/SupabaseOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SupabaseOAuth2Api.credentials.ts new file mode 100644 index 00000000000..262f338f943 --- /dev/null +++ b/packages/nodes-base/credentials/SupabaseOAuth2Api.credentials.ts @@ -0,0 +1,60 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class SupabaseOAuth2Api implements ICredentialType { + name = 'supabaseOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Supabase OAuth2 API'; + + documentationUrl = 'supabase'; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://api.supabase.com/v1/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://api.supabase.com/v1/oauth/token', + required: true, + }, + // Scopes are specified when creating the OAuth app in Supabase + // including them from the client during auth does nothing + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Use PKCE', + name: 'usePkce', + type: 'hidden', + default: true, + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Supabase/GenericFunctions.ts b/packages/nodes-base/nodes/Supabase/GenericFunctions.ts index 0b9b4434a0e..0e2d33b6a7a 100644 --- a/packages/nodes-base/nodes/Supabase/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Supabase/GenericFunctions.ts @@ -1,18 +1,164 @@ import type { - ICredentialDataDecryptedObject, - ICredentialTestFunctions, IDataObject, IExecuteFunctions, + IHttpRequestOptions, ILoadOptionsFunctions, INodeProperties, IPairedItemData, JsonObject, IHttpRequestMethods, - IRequestOptions, } from 'n8n-workflow'; -import { NodeApiError, UserError } from 'n8n-workflow'; +import { NodeApiError, toPathSegment, UserError } from 'n8n-workflow'; import { createHash } from 'node:crypto'; +type SupabaseCredentials = { + host: string; + serviceRole: string; +}; + +export type SupabaseProject = { + name: string; + ref: string; +}; + +type SupabaseProjectApiKey = { + api_key?: string; + name?: string; + type?: string; +}; + +const N8N_SECRET_KEY_NAME = 'n8n_managed_data_api'; + +function getCredentialType(context: IExecuteFunctions | ILoadOptionsFunctions) { + const authentication = context.getNodeParameter('authentication', 0) as string; + return authentication === 'oAuth2' ? 'supabaseOAuth2Api' : 'supabaseApi'; +} + +function getProjectRef(context: IExecuteFunctions | ILoadOptionsFunctions, itemIndex = 0) { + const project = context.getNodeParameter('projectRef', itemIndex); + if (typeof project !== 'string' || !project) throw new UserError('Select a Supabase project'); + if (!/^[a-z0-9]+$/.test(project)) throw new UserError('The Supabase project ID is invalid'); + + return project; +} + +async function supabaseManagementApiRequest( + context: IExecuteFunctions | ILoadOptionsFunctions, + resource: string, + qs: IDataObject = {}, + method: IHttpRequestMethods = 'GET', + body: IDataObject = {}, +) { + try { + const options: IHttpRequestOptions = { + method, + url: `https://api.supabase.com/v1${resource}`, + qs, + body, + json: true, + }; + if (Object.keys(body).length === 0) delete options.body; + + return (await context.helpers.httpRequestWithAuthentication.call( + context, + 'supabaseOAuth2Api', + options, + )) as T; + } catch (error) { + throw new NodeApiError(context.getNode(), error as JsonObject); + } +} + +export async function getSupabaseProjects(this: ILoadOptionsFunctions) { + return await supabaseManagementApiRequest(this, '/projects'); +} + +const projectSecretKeyCache = new Map>(); + +function getOAuthCredentialId(context: IExecuteFunctions | ILoadOptionsFunctions) { + const credentialId = context.getNode().credentials?.supabaseOAuth2Api?.id; + return typeof credentialId === 'string' ? credentialId : undefined; +} + +function getProjectSecretKeyCacheKey( + context: IExecuteFunctions | ILoadOptionsFunctions, + projectRef: string, +) { + const credentialId = getOAuthCredentialId(context); + return credentialId ? `${credentialId}:${projectRef}` : undefined; +} + +async function resolveProjectSecretKey( + context: IExecuteFunctions | ILoadOptionsFunctions, + projectRef: string, +) { + const keys = await supabaseManagementApiRequest( + context, + `/projects/${toPathSegment(projectRef)}/api-keys`, + { reveal: true }, + ); + const secretKey = keys.find( + (key) => key.type === 'secret' && key.name === N8N_SECRET_KEY_NAME && key.api_key, + ); + + if (secretKey?.api_key) return secretKey.api_key; + + const createdKey = await supabaseManagementApiRequest( + context, + `/projects/${toPathSegment(projectRef)}/api-keys`, + { reveal: true }, + 'POST', + { + type: 'secret', + name: N8N_SECRET_KEY_NAME, + description: + "The n8n Supabase Node uses this key to access the Data API. Don't delete it if you want the node to keep working.", + }, + ); + + if (!createdKey.api_key) { + throw new UserError( + 'Supabase did not return the new secret key. Give the OAuth app Secrets Read and Write access, then reconnect the credential.', + ); + } + + return createdKey.api_key; +} + +async function getProjectSecretKey( + context: IExecuteFunctions | ILoadOptionsFunctions, + projectRef: string, +) { + const cacheKey = getProjectSecretKeyCacheKey(context, projectRef); + if (!cacheKey) return await resolveProjectSecretKey(context, projectRef); + + const cached = projectSecretKeyCache.get(cacheKey); + if (cached) return await cached; + + const request = resolveProjectSecretKey(context, projectRef); + projectSecretKeyCache.set(cacheKey, request); + try { + return await request; + } catch (error) { + if (projectSecretKeyCache.get(cacheKey) === request) projectSecretKeyCache.delete(cacheKey); + throw error; + } +} + +async function invalidateProjectSecretKey( + context: IExecuteFunctions | ILoadOptionsFunctions, + projectRef: string, + projectKey: string, +) { + const cacheKey = getProjectSecretKeyCacheKey(context, projectRef); + if (!cacheKey) return; + + const cached = projectSecretKeyCache.get(cacheKey); + if (cached && (await cached) === projectKey && projectSecretKeyCache.get(cacheKey) === cached) { + projectSecretKeyCache.delete(cacheKey); + } +} + export function getSchemaHeader( context: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, @@ -56,20 +202,36 @@ export async function supabaseApiRequest( qs: IDataObject = {}, uri?: string, headers: IDataObject = {}, + itemIndex = 0, ) { - const credentials = await this.getCredentials<{ - host: string; - serviceRole: string; - }>('supabaseApi'); + const credentialType = getCredentialType(this); + let host: string; + let projectKey: string | undefined; + let projectRef: string | undefined; - const options: IRequestOptions = { + if (credentialType === 'supabaseOAuth2Api') { + projectRef = getProjectRef(this, itemIndex); + host = `https://${projectRef}.supabase.co`; + projectKey = await getProjectSecretKey(this, projectRef); + } else { + const credentials = await this.getCredentials(credentialType); + host = credentials.host; + } + + const options: IHttpRequestOptions = { headers: { Prefer: 'return=representation', + ...(projectKey + ? { + apikey: projectKey, + Authorization: `Bearer ${projectKey}`, + } + : {}), }, method, qs, body, - uri: uri ?? `${credentials.host}/rest/v1${resource}`, + url: uri ?? `${host.replace(/\/$/, '')}/rest/v1${resource}`, json: true, }; @@ -78,7 +240,23 @@ export async function supabaseApiRequest( if (Object.keys(body).length === 0) { delete options.body; } - return await this.helpers.requestWithAuthentication.call(this, 'supabaseApi', options); + if (credentialType === 'supabaseOAuth2Api') { + try { + return await this.helpers.httpRequest(options); + } catch (error) { + if (error.response?.status !== 401 || !projectRef || !projectKey) throw error; + + await invalidateProjectSecretKey(this, projectRef, projectKey); + projectKey = await getProjectSecretKey(this, projectRef); + options.headers = { + ...options.headers, + apikey: projectKey, + Authorization: `Bearer ${projectKey}`, + }; + return await this.helpers.httpRequest(options); + } + } + return await this.helpers.httpRequestWithAuthentication.call(this, credentialType, options); } catch (error) { if (error.description) { error.message = `${error.message}: ${error.description}`; @@ -104,13 +282,17 @@ const apiDefinitionsInFlight = new Map>() export async function getApiDefinition( this: ILoadOptionsFunctions, ): Promise { - const { host, serviceRole } = await this.getCredentials<{ - host: string; - serviceRole: string; - }>('supabaseApi'); + const credentialType = getCredentialType(this); const header = getSchemaHeader(this, 'GET', 'loadOptions'); + let credentialIdentity: unknown; + if (credentialType === 'supabaseOAuth2Api') { + credentialIdentity = [getOAuthCredentialId(this), getProjectRef(this)]; + } else { + const { host, serviceRole } = await this.getCredentials(credentialType); + credentialIdentity = [host, serviceRole]; + } const key = createHash('sha256') - .update(JSON.stringify([host, serviceRole, header])) + .update(JSON.stringify([credentialType, credentialIdentity, header])) .digest('hex'); const inFlight = apiDefinitionsInFlight.get(key); @@ -466,29 +648,6 @@ export function appendFilterStringToEndpoint( return `${endpoint}?${applyFilterStringParameters(encodedTemplate, filterStringParameters)}`; } -export async function validateCredentials( - this: ICredentialTestFunctions, - decryptedCredentials: ICredentialDataDecryptedObject, -): Promise { - const credentials = decryptedCredentials; - - const { serviceRole } = credentials as { - serviceRole: string; - }; - - const options: IRequestOptions = { - headers: { - apikey: serviceRole, - Authorization: 'Bearer ' + serviceRole, - }, - method: 'GET', - uri: `${credentials.host}/rest/v1/`, - json: true, - }; - - return await this.helpers.request(options); -} - export function mapPairedItemsFrom(iterable: Iterable | ArrayLike): IPairedItemData[] { return Array.from(iterable, (_, i) => i).map((index) => { return { diff --git a/packages/nodes-base/nodes/Supabase/RowDescription.ts b/packages/nodes-base/nodes/Supabase/RowDescription.ts index c631b622f63..342c03dab6a 100644 --- a/packages/nodes-base/nodes/Supabase/RowDescription.ts +++ b/packages/nodes-base/nodes/Supabase/RowDescription.ts @@ -60,7 +60,7 @@ export const rowFields: INodeProperties[] = [ description: 'Choose from the list, or specify an ID using an expression', typeOptions: { - loadOptionsDependsOn: ['useCustomSchema', 'schema'], + loadOptionsDependsOn: ['projectRef', 'useCustomSchema', 'schema'], loadOptionsMethod: 'getTables', }, required: true, diff --git a/packages/nodes-base/nodes/Supabase/Supabase.node.ts b/packages/nodes-base/nodes/Supabase/Supabase.node.ts index 707cf6dac20..0f99000668b 100644 --- a/packages/nodes-base/nodes/Supabase/Supabase.node.ts +++ b/packages/nodes-base/nodes/Supabase/Supabase.node.ts @@ -1,11 +1,7 @@ import type { IExecuteFunctions, - ICredentialDataDecryptedObject, - ICredentialsDecrypted, - ICredentialTestFunctions, IDataObject, ILoadOptionsFunctions, - INodeCredentialTestResult, INodeExecutionData, INodePropertyOptions, INodeType, @@ -20,9 +16,9 @@ import { buildQuery, getApiDefinition, getSchemaHeader, + getSupabaseProjects, mapPairedItemsFrom, supabaseApiRequest, - validateCredentials, } from './GenericFunctions'; import { rowFields, rowOperations } from './RowDescription'; @@ -50,7 +46,20 @@ export class Supabase implements INodeType { { name: 'supabaseApi', required: true, - testedBy: 'supabaseApiCredentialTest', + displayOptions: { + show: { + authentication: ['secretKey'], + }, + }, + }, + { + name: 'supabaseOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, }, ], hints: [ @@ -64,6 +73,53 @@ export class Supabase implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Secret Key', + value: 'secretKey', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'secretKey', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + }, + ...rowOperations, + { + displayName: 'Project Name or ID', + name: 'projectRef', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + required: true, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + default: '', + }, { displayName: 'Use Custom Schema', name: 'useCustomSchema', @@ -82,26 +138,19 @@ export class Supabase implements INodeType { noDataExpression: false, displayOptions: { show: { useCustomSchema: [true] } }, }, - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Row', - value: 'row', - }, - ], - default: 'row', - }, - ...rowOperations, ...rowFields, ], }; methods = { loadOptions: { + async getProjects(this: ILoadOptionsFunctions): Promise { + const projects = await getSupabaseProjects.call(this); + return projects.map((project) => ({ + name: project.name, + value: project.ref, + })); + }, async getTables(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; const { paths } = await getApiDefinition.call(this); @@ -137,26 +186,6 @@ export class Supabase implements INodeType { return returnData; }, }, - credentialTest: { - async supabaseApiCredentialTest( - this: ICredentialTestFunctions, - credential: ICredentialsDecrypted, - ): Promise { - try { - await validateCredentials.call(this, credential.data as ICredentialDataDecryptedObject); - } catch (error) { - return { - status: 'Error', - message: 'The Service Key is invalid', - }; - } - - return { - status: 'OK', - message: 'Connection successful!', - }; - }, - }, }; async execute(this: IExecuteFunctions): Promise { @@ -272,6 +301,7 @@ export class Supabase implements INodeType { qs, undefined, header, + i, ); } catch (error) { if (this.continueOnFail()) { @@ -312,7 +342,16 @@ export class Supabase implements INodeType { } try { - rows = await supabaseApiRequest.call(this, 'GET', endpoint, {}, qs, undefined, header); + rows = await supabaseApiRequest.call( + this, + 'GET', + endpoint, + {}, + qs, + undefined, + header, + i, + ); } catch (error) { if (this.continueOnFail()) { const executionData = this.helpers.constructExecutionMetaData( @@ -387,6 +426,7 @@ export class Supabase implements INodeType { qs, undefined, header, + i, ); responseLength = newRows.length; rows = rows.concat(newRows); @@ -478,6 +518,7 @@ export class Supabase implements INodeType { qs, undefined, header, + i, ); const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(updatedRow as IDataObject[]), diff --git a/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts b/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts index fb597213c6c..8e303c2af5c 100644 --- a/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts +++ b/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts @@ -18,7 +18,7 @@ import { Supabase } from '../Supabase.node'; describe('Test Supabase Node', () => { const node = new Supabase(); const input = [{ json: {} }]; - const mockRequestWithAuthentication = vi.fn().mockResolvedValue([]); + const mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue([]); const mockGetCredentials = vi.fn().mockResolvedValue({ host: 'https://api.supabase.io', serviceRole: 'service_role', @@ -53,7 +53,7 @@ describe('Test Supabase Node', () => { continueOnFail: () => continueOnFail, getInputData: () => input, helpers: { - requestWithAuthentication: mockRequestWithAuthentication, + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, constructExecutionMetaData: ( _inputData: INodeExecutionData[], _options: { itemData: IPairedItemData | IPairedItemData[] }, @@ -72,7 +72,7 @@ describe('Test Supabase Node', () => { const context = mockDeep({ getCredentials, helpers: { - requestWithAuthentication: mockRequestWithAuthentication, + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, }, }); context.getNodeParameter.mockImplementation((name: string) => @@ -349,6 +349,7 @@ describe('Test Supabase Node', () => { expect.objectContaining({ order: 'id' }), undefined, {}, + 0, ); supabaseApiRequest.mockRestore(); @@ -397,6 +398,7 @@ describe('Test Supabase Node', () => { }, undefined, {}, + 0, ); supabaseApiRequest.mockRestore(); @@ -414,14 +416,14 @@ describe('Test Supabase Node', () => { await node.execute.call(fakeExecuteFunction); - expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( 'supabaseApi', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Prefer: 'return=representation', }), - uri: 'https://api.supabase.io/rest/v1/my_table', + url: 'https://api.supabase.io/rest/v1/my_table', }), ); }); @@ -438,7 +440,7 @@ describe('Test Supabase Node', () => { await node.execute.call(fakeExecuteFunction); - expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( 'supabaseApi', expect.objectContaining({ method: 'GET', @@ -446,7 +448,7 @@ describe('Test Supabase Node', () => { 'Accept-Profile': 'custom_schema', Prefer: 'return=representation', }), - uri: 'https://api.supabase.io/rest/v1/my_table', + url: 'https://api.supabase.io/rest/v1/my_table', }), ); }); @@ -467,7 +469,7 @@ describe('Test Supabase Node', () => { await node.execute.call(fakeExecuteFunction); - expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( 'supabaseApi', expect.objectContaining({ method: 'POST', @@ -475,7 +477,7 @@ describe('Test Supabase Node', () => { 'Content-Profile': 'custom_schema', Prefer: 'return=representation', }), - uri: 'https://api.supabase.io/rest/v1/my_table', + url: 'https://api.supabase.io/rest/v1/my_table', }), ); }); @@ -494,7 +496,7 @@ describe('Test Supabase Node', () => { }, }); - fakeExecuteFunction.helpers.requestWithAuthentication = vi.fn().mockRejectedValue({ + fakeExecuteFunction.helpers.httpRequestWithAuthentication = vi.fn().mockRejectedValue({ description: 'Something when wrong', message: 'error', }); @@ -538,6 +540,7 @@ describe('Test Supabase Node', () => { expect.anything(), undefined, expect.anything(), + 0, ); supabaseApiRequest.mockRestore(); }, @@ -716,7 +719,7 @@ describe('Test Supabase Node', () => { describe('getTables', () => { it('should return the tables and skip RPCs', async () => { const mockLoadOptionsFunctions = createMockLoadOptionsFunction(); - mockRequestWithAuthentication.mockResolvedValue({ + mockHttpRequestWithAuthentication.mockResolvedValue({ paths: { '/': { get: {}, @@ -741,7 +744,7 @@ describe('Test Supabase Node', () => { it('should return table columns with their types', async () => { const mockLoadOptionsFunctions = createMockLoadOptionsFunction(); mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue('users'); - mockRequestWithAuthentication.mockResolvedValue({ + mockHttpRequestWithAuthentication.mockResolvedValue({ definitions: { users: { properties: { @@ -766,7 +769,7 @@ describe('Test Supabase Node', () => { it('should return empty array when table definition has no properties', async () => { const mockLoadOptionsFunctions = createMockLoadOptionsFunction(); mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue('users'); - mockRequestWithAuthentication.mockResolvedValue({ + mockHttpRequestWithAuthentication.mockResolvedValue({ definitions: { users: {}, }, @@ -785,7 +788,7 @@ describe('Test Supabase Node', () => { return context; }; - mockRequestWithAuthentication.mockResolvedValue({ + mockHttpRequestWithAuthentication.mockResolvedValue({ definitions: { users: { properties: { @@ -811,7 +814,7 @@ describe('Test Supabase Node', () => { { name: 'email - (string)', value: 'email' }, ]); } - expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1); await Promise.all( Array.from( @@ -823,7 +826,7 @@ describe('Test Supabase Node', () => { ), ); - expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(2); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2); }); it('should not share a request between callers whose credentials differ', async () => { @@ -835,14 +838,14 @@ describe('Test Supabase Node', () => { context.getCurrentNodeParameter.mockReturnValue('users'); return context; }; - mockRequestWithAuthentication.mockResolvedValue({ definitions: { users: {} } }); + mockHttpRequestWithAuthentication.mockResolvedValue({ definitions: { users: {} } }); await Promise.all([ node.methods.loadOptions.getTableColumns.call(createColumnsContext('role-a')), node.methods.loadOptions.getTableColumns.call(createColumnsContext('role-b')), ]); - expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(2); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2); }); it('should issue a fresh request after a shared request fails', async () => { @@ -851,7 +854,7 @@ describe('Test Supabase Node', () => { context.getCurrentNodeParameter.mockReturnValue('users'); return context; }; - mockRequestWithAuthentication.mockRejectedValueOnce(new Error('schema unavailable')); + mockHttpRequestWithAuthentication.mockRejectedValueOnce(new Error('schema unavailable')); await expect( Promise.all([ @@ -859,12 +862,12 @@ describe('Test Supabase Node', () => { node.methods.loadOptions.getTableColumns.call(createColumnsContext()), ]), ).rejects.toThrow(); - expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1); - mockRequestWithAuthentication.mockResolvedValue({ definitions: { users: {} } }); + mockHttpRequestWithAuthentication.mockResolvedValue({ definitions: { users: {} } }); await node.methods.loadOptions.getTableColumns.call(createColumnsContext()); - expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(2); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2); }); }); }); diff --git a/packages/nodes-base/nodes/Supabase/tests/SupabaseOAuth2.test.ts b/packages/nodes-base/nodes/Supabase/tests/SupabaseOAuth2.test.ts new file mode 100644 index 00000000000..9958265d591 --- /dev/null +++ b/packages/nodes-base/nodes/Supabase/tests/SupabaseOAuth2.test.ts @@ -0,0 +1,278 @@ +import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow'; + +import { getApiDefinition, supabaseApiRequest } from '../GenericFunctions'; +import { Supabase } from '../Supabase.node'; + +const managedKey = (apiKey: string) => ({ + type: 'secret', + name: 'n8n_managed_data_api', + api_key: apiKey, +}); + +let contextId = 0; + +function createOAuthContext({ + credentialId = `credential${++contextId}`, + projectRef = `project${contextId}`, +}: { credentialId?: string; projectRef?: string | string[] } = {}) { + const managementRequest = vi.fn(); + const dataRequest = vi.fn(); + const context = { + getNodeParameter: vi.fn((name: string, itemIndex = 0) => { + if (name === 'authentication') return 'oAuth2'; + if (name === 'projectRef') { + return Array.isArray(projectRef) ? projectRef[itemIndex] : projectRef; + } + return undefined; + }), + getNode: vi.fn(() => ({ + credentials: { + supabaseOAuth2Api: { + id: credentialId, + name: 'Supabase OAuth2', + }, + }, + })), + helpers: { + httpRequestWithAuthentication: managementRequest, + httpRequest: dataRequest, + }, + } as unknown as IExecuteFunctions; + + return { context, managementRequest, dataRequest }; +} + +describe('Supabase OAuth2', () => { + it('should load projects through the Management API', async () => { + const node = new Supabase(); + const { context, managementRequest } = createOAuthContext(); + managementRequest.mockResolvedValue([ + { name: 'Project One', ref: 'projectone' }, + { name: 'Project Two', ref: 'projecttwo' }, + ]); + + const projects = await node.methods.loadOptions.getProjects.call( + context as unknown as ILoadOptionsFunctions, + ); + + expect(projects).toEqual([ + { name: 'Project One', value: 'projectone' }, + { name: 'Project Two', value: 'projecttwo' }, + ]); + expect(managementRequest).toHaveBeenCalledWith('supabaseOAuth2Api', { + method: 'GET', + url: 'https://api.supabase.com/v1/projects', + qs: {}, + json: true, + }); + }); + + it('should use only the n8n-managed key for Data API requests', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext({ + projectRef: 'projectone', + }); + managementRequest.mockResolvedValue([ + { type: 'secret', name: 'another_key', api_key: 'wrong-key' }, + managedKey('managed-key'), + ]); + dataRequest.mockResolvedValue([{ id: 1 }]); + + const result = await supabaseApiRequest.call(context, 'GET', '/users'); + + expect(result).toEqual([{ id: 1 }]); + expect(managementRequest).toHaveBeenCalledTimes(1); + expect(dataRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://projectone.supabase.co/rest/v1/users', + headers: expect.objectContaining({ + apikey: 'managed-key', + Authorization: 'Bearer managed-key', + }), + }), + ); + }); + + it('should create an n8n-managed key when none exists', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext({ + projectRef: 'projecttwo', + }); + managementRequest + .mockResolvedValueOnce([{ type: 'secret', name: 'another_key', api_key: 'wrong-key' }]) + .mockResolvedValueOnce({ api_key: 'created-key' }); + dataRequest.mockResolvedValue([]); + + await supabaseApiRequest.call(context, 'GET', '/users'); + + expect(managementRequest).toHaveBeenNthCalledWith(2, 'supabaseOAuth2Api', { + method: 'POST', + url: 'https://api.supabase.com/v1/projects/projecttwo/api-keys', + qs: { reveal: true }, + body: { + type: 'secret', + name: 'n8n_managed_data_api', + description: + "The n8n Supabase Node uses this key to access the Data API. Don't delete it if you want the node to keep working.", + }, + json: true, + }); + expect(dataRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ apikey: 'created-key' }), + }), + ); + }); + + it('should share one key lookup across concurrent requests', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext(); + managementRequest.mockResolvedValue([managedKey('shared-key')]); + dataRequest.mockResolvedValue([]); + + await Promise.all([ + supabaseApiRequest.call(context, 'GET', '/users'), + supabaseApiRequest.call(context, 'GET', '/teams'), + ]); + + expect(managementRequest).toHaveBeenCalledTimes(1); + expect(dataRequest).toHaveBeenCalledTimes(2); + }); + + it('should keep key caches separate by credential and project', async () => { + const first = createOAuthContext({ credentialId: 'credentiala', projectRef: 'projecta' }); + const otherProject = createOAuthContext({ + credentialId: 'credentiala', + projectRef: 'projectb', + }); + const otherCredential = createOAuthContext({ + credentialId: 'credentialb', + projectRef: 'projecta', + }); + first.managementRequest.mockResolvedValue([managedKey('key-a')]); + otherProject.managementRequest.mockResolvedValue([managedKey('key-b')]); + otherCredential.managementRequest.mockResolvedValue([managedKey('key-c')]); + first.dataRequest.mockResolvedValue([]); + otherProject.dataRequest.mockResolvedValue([]); + otherCredential.dataRequest.mockResolvedValue([]); + + await supabaseApiRequest.call(first.context, 'GET', '/users'); + await supabaseApiRequest.call(otherProject.context, 'GET', '/users'); + await supabaseApiRequest.call(otherCredential.context, 'GET', '/users'); + + expect(first.managementRequest).toHaveBeenCalledTimes(1); + expect(otherProject.managementRequest).toHaveBeenCalledTimes(1); + expect(otherCredential.managementRequest).toHaveBeenCalledTimes(1); + expect(otherProject.dataRequest).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.objectContaining({ apikey: 'key-b' }) }), + ); + expect(otherCredential.dataRequest).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.objectContaining({ apikey: 'key-c' }) }), + ); + }); + + it('should resolve the project for the current input item', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext({ + projectRef: ['firstproject', 'secondproject'], + }); + managementRequest.mockResolvedValue([managedKey('managed-key')]); + dataRequest.mockResolvedValue([]); + + await supabaseApiRequest.call(context, 'GET', '/users', {}, {}, undefined, {}, 1); + + expect(managementRequest).toHaveBeenCalledWith( + 'supabaseOAuth2Api', + expect.objectContaining({ + url: 'https://api.supabase.com/v1/projects/secondproject/api-keys', + }), + ); + expect(dataRequest).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://secondproject.supabase.co/rest/v1/users' }), + ); + }); + + it('should keep schema requests separate by OAuth credential', async () => { + const first = createOAuthContext({ + credentialId: 'schema-credential-a', + projectRef: 'sharedproject', + }); + const second = createOAuthContext({ + credentialId: 'schema-credential-b', + projectRef: 'sharedproject', + }); + first.managementRequest.mockResolvedValue([managedKey('key-a')]); + second.managementRequest.mockResolvedValue([managedKey('key-b')]); + first.dataRequest.mockResolvedValue({ definitions: { first: {} } }); + second.dataRequest.mockResolvedValue({ definitions: { second: {} } }); + + const [firstDefinition, secondDefinition] = await Promise.all([ + getApiDefinition.call(first.context as unknown as ILoadOptionsFunctions), + getApiDefinition.call(second.context as unknown as ILoadOptionsFunctions), + ]); + + expect(firstDefinition).toEqual({ definitions: { first: {} } }); + expect(secondDefinition).toEqual({ definitions: { second: {} } }); + expect(first.dataRequest).toHaveBeenCalledTimes(1); + expect(second.dataRequest).toHaveBeenCalledTimes(1); + }); + + it('should refresh the managed key and retry once after a 401 response', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext(); + const requestedKeys: unknown[] = []; + managementRequest + .mockResolvedValueOnce([managedKey('old-key')]) + .mockResolvedValueOnce([managedKey('new-key')]); + dataRequest + .mockImplementationOnce(async (options) => { + requestedKeys.push(options.headers.apikey); + throw Object.assign(new Error('Unauthorized'), { response: { status: 401 } }); + }) + .mockImplementationOnce(async (options) => { + requestedKeys.push(options.headers.apikey); + return [{ id: 1 }]; + }); + + const result = await supabaseApiRequest.call(context, 'GET', '/users'); + + expect(result).toEqual([{ id: 1 }]); + expect(managementRequest).toHaveBeenCalledTimes(2); + expect(dataRequest).toHaveBeenCalledTimes(2); + expect(requestedKeys).toEqual(['old-key', 'new-key']); + }); + + it('should not refresh the managed key for other errors', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext(); + managementRequest.mockResolvedValue([managedKey('managed-key')]); + dataRequest.mockRejectedValue({ message: 'Forbidden', response: { status: 403 } }); + + await expect(supabaseApiRequest.call(context, 'GET', '/users')).rejects.toThrow(); + + expect(managementRequest).toHaveBeenCalledTimes(1); + expect(dataRequest).toHaveBeenCalledTimes(1); + }); + + it('should retry a key lookup after an earlier lookup fails', async () => { + const { context, managementRequest, dataRequest } = createOAuthContext(); + managementRequest + .mockRejectedValueOnce(new Error('Management API unavailable')) + .mockResolvedValueOnce([managedKey('recovered-key')]); + dataRequest.mockResolvedValue([]); + + await expect(supabaseApiRequest.call(context, 'GET', '/users')).rejects.toThrow(); + await supabaseApiRequest.call(context, 'GET', '/users'); + + expect(managementRequest).toHaveBeenCalledTimes(2); + expect(dataRequest).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['', 'Select a Supabase project'], + ['invalid-project', 'The Supabase project ID is invalid'], + ['UPPERCASE', 'The Supabase project ID is invalid'], + ])('should reject project reference %j before making requests', async (projectRef, message) => { + const { context, managementRequest, dataRequest } = createOAuthContext({ projectRef }); + + await expect(supabaseApiRequest.call(context, 'GET', '/users')).rejects.toEqual( + expect.objectContaining({ message }), + ); + expect(managementRequest).not.toHaveBeenCalled(); + expect(dataRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index ee095ae2fe7..aea4209e176 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -383,6 +383,7 @@ "dist/credentials/StravaOAuth2Api.credentials.js", "dist/credentials/StripeApi.credentials.js", "dist/credentials/SupabaseApi.credentials.js", + "dist/credentials/SupabaseOAuth2Api.credentials.js", "dist/credentials/SurveyMonkeyApi.credentials.js", "dist/credentials/SurveyMonkeyOAuth2Api.credentials.js", "dist/credentials/SyncroMspApi.credentials.js",