feat(Supabase Node): Add OAuth credentials and reuse them for MCP (#39979)

This commit is contained in:
RomanDavydchuk 2026-10-02 10:04:51 +00:00 committed by GitHub
parent 9adaa38f22
commit 8e339d7051
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 648 additions and 104 deletions

View File

@ -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) => {

View File

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

View File

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

View File

@ -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<T>(
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<SupabaseProject[]>(this, '/projects');
}
const projectSecretKeyCache = new Map<string, Promise<string>>();
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<SupabaseProjectApiKey[]>(
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<SupabaseProjectApiKey>(
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<SupabaseCredentials>(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<string, Promise<SupabaseApiDefinition>>()
export async function getApiDefinition(
this: ILoadOptionsFunctions,
): Promise<SupabaseApiDefinition> {
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<SupabaseCredentials>(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<any> {
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<T>(iterable: Iterable<T> | ArrayLike<T>): IPairedItemData[] {
return Array.from(iterable, (_, i) => i).map((index) => {
return {

View File

@ -60,7 +60,7 @@ export const rowFields: INodeProperties[] = [
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsDependsOn: ['useCustomSchema', 'schema'],
loadOptionsDependsOn: ['projectRef', 'useCustomSchema', 'schema'],
loadOptionsMethod: 'getTables',
},
required: true,

View File

@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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<INodePropertyOptions[]> {
const projects = await getSupabaseProjects.call(this);
return projects.map((project) => ({
name: project.name,
value: project.ref,
}));
},
async getTables(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
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<INodeCredentialTestResult> {
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<INodeExecutionData[][]> {
@ -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[]),

View File

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

View File

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

View File

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