mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(Supabase Node): Add OAuth credentials and reuse them for MCP (#39979)
This commit is contained in:
parent
9adaa38f22
commit
8e339d7051
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
278
packages/nodes-base/nodes/Supabase/tests/SupabaseOAuth2.test.ts
Normal file
278
packages/nodes-base/nodes/Supabase/tests/SupabaseOAuth2.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user