mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
test(core): Add e2e for MCP trigger n8nOAuth2 credential-status gate (#32655)
This commit is contained in:
@@ -203,5 +203,24 @@ export class DynamicCredentialApiHelper {
|
||||
return result.data ?? result; // The OAuth2 provider authorization URL
|
||||
}
|
||||
|
||||
/**
|
||||
* GETs the n8n OAuth callback URL returned by the provider's authorization
|
||||
* flow, completing the connect: n8n exchanges the code and stores the user's
|
||||
* tokens for the resolver-keyed credential.
|
||||
*
|
||||
* The callback URL is absolute (the provider redirects to the instance host);
|
||||
* we GET its path+query via the api.request context so it targets this
|
||||
* context's baseURL (e.g. a specific main).
|
||||
*/
|
||||
async completeAuthorizationCallback(callbackUrl: string): Promise<void> {
|
||||
const parsed = new URL(callbackUrl);
|
||||
const response = await this.api.request.get(parsed.pathname + parsed.search);
|
||||
if (!response.ok()) {
|
||||
throw new TestError(
|
||||
`Failed to complete authorization callback: ${response.status()} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Revoke =====
|
||||
}
|
||||
|
||||
@@ -555,6 +555,7 @@ export class McpApiHelper {
|
||||
session: McpSession,
|
||||
path: string,
|
||||
message: unknown,
|
||||
options?: { headers?: Record<string, string> },
|
||||
): Promise<APIResponse> {
|
||||
if (session.transport !== 'streamableHttp') {
|
||||
throw new Error('Invalid Streamable HTTP session');
|
||||
@@ -566,6 +567,7 @@ export class McpApiHelper {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'mcp-session-id': session.sessionId,
|
||||
...options?.headers,
|
||||
},
|
||||
data: message,
|
||||
});
|
||||
@@ -628,6 +630,7 @@ export class McpApiHelper {
|
||||
path: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
options?: { headers?: Record<string, string> },
|
||||
): Promise<McpToolCallResponse> {
|
||||
const message = this.createMessage('tools/call', {
|
||||
name: toolName,
|
||||
@@ -638,7 +641,7 @@ export class McpApiHelper {
|
||||
// For SSE, response comes via the stream
|
||||
return await this.sseSendAndWait<McpToolCallResponse>(session, message);
|
||||
} else {
|
||||
const response = await this.streamableHttpSendMessage(session, path, message);
|
||||
const response = await this.streamableHttpSendMessage(session, path, message, options);
|
||||
return await this.parseResponse<McpToolCallResponse>(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,15 @@ export class McpOAuthApiHelper {
|
||||
return await this.api.request.get('/.well-known/oauth-authorization-server');
|
||||
}
|
||||
|
||||
async getProtectedResourceMetadata(): Promise<APIResponse> {
|
||||
return await this.api.request.get('/.well-known/oauth-protected-resource/mcp-server/http');
|
||||
/**
|
||||
* Fetches protected-resource metadata (RFC 9728). Defaults to the instance MCP
|
||||
* server resource; pass a `resourcePath` (e.g. `mcp/<trigger-path>`) to fetch
|
||||
* the per-resource document for a specific protected resource such as an
|
||||
* `n8nOAuth2` MCP Trigger workflow.
|
||||
*/
|
||||
async getProtectedResourceMetadata(resourcePath = 'mcp-server/http'): Promise<APIResponse> {
|
||||
const normalized = resourcePath.replace(/^\/+/, '');
|
||||
return await this.api.request.get(`/.well-known/oauth-protected-resource/${normalized}`);
|
||||
}
|
||||
|
||||
/** Dynamic client registration (RFC 7591). Unauthenticated. */
|
||||
@@ -230,11 +237,18 @@ export class McpOAuthApiHelper {
|
||||
clientName?: string;
|
||||
redirectUri?: string;
|
||||
basePath?: OAuthEndpointBasePath;
|
||||
/**
|
||||
* RFC 8707 resource indicator. Scopes the token to a specific protected
|
||||
* resource (e.g. an `n8nOAuth2` MCP Trigger workflow's resource URL). When
|
||||
* omitted, the instance MCP server resource is used.
|
||||
*/
|
||||
resource?: string;
|
||||
}): Promise<AuthorizationFlowResult> {
|
||||
const redirectUri = options?.redirectUri ?? 'https://example.com/callback';
|
||||
const state = randomBytes(16).toString('hex');
|
||||
const pkce = this.createPkcePair();
|
||||
const basePath = options?.basePath ?? DEFAULT_ENDPOINT_BASE_PATH;
|
||||
const resource = options?.resource;
|
||||
|
||||
const client = await this.registerClientOrFail(
|
||||
{
|
||||
@@ -251,6 +265,7 @@ export class McpOAuthApiHelper {
|
||||
redirectUri,
|
||||
challenge: pkce.challenge,
|
||||
state,
|
||||
resource,
|
||||
basePath,
|
||||
});
|
||||
if (authorizeResponse.status() !== 302) {
|
||||
@@ -270,6 +285,7 @@ export class McpOAuthApiHelper {
|
||||
clientId: client.client_id,
|
||||
codeVerifier: pkce.verifier,
|
||||
redirectUri,
|
||||
resource,
|
||||
basePath,
|
||||
});
|
||||
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import type { ServiceHelpers } from 'n8n-containers/services/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
import type { ApiHelpers } from '../../../services/api-helper';
|
||||
import type { McpSession } from '../../../services/mcp-api-helper';
|
||||
|
||||
/**
|
||||
* E2E for the eager pre-execution credential-status gate on the MCP Server
|
||||
* Trigger (IAM-802). When a tool is called over `n8nOAuth2` MCP, the calling
|
||||
* user's private-credential status is checked on the request-handling main
|
||||
* before enqueue. If a required private credential is not connected for that
|
||||
* user, the tool call returns the connection URL instead of executing.
|
||||
*
|
||||
* Two legs, both proven on multi-main (the gate runs on the
|
||||
* request-handling-main-before-enqueue path):
|
||||
* 1. Not-ready — unconnected private credential → gate response, no execution.
|
||||
* 2. Happy path — connect the credential via the gate's authorization URL,
|
||||
* retry, the workflow executes.
|
||||
*
|
||||
* All caller-token / PRM / tool-call traffic targets a specific main directly
|
||||
* (`createApiForMain(0)`) rather than the load balancer: minting the caller's
|
||||
* `n8nOAuth2` token needs the per-workflow protected resource to resolve
|
||||
* server-side (RFC 8707 resource indicator), which is registered asynchronously
|
||||
* after activation. We poll the per-resource metadata document on that main
|
||||
* until it is served, absorbing activation propagation.
|
||||
*
|
||||
* Requires the `dynamic-credentials` capability: it enables private credentials
|
||||
* (`N8N_ENV_FEAT_DYNAMIC_CREDENTIALS=true`, which seeds the `system-n8n`
|
||||
* resolver) and provides Keycloak as the credential's OAuth2 provider.
|
||||
*/
|
||||
test.use({
|
||||
capability: 'dynamic-credentials',
|
||||
ignoreHTTPSErrors: true, // Keycloak uses a self-signed certificate
|
||||
});
|
||||
|
||||
interface GatedWorkflowSetup {
|
||||
mainApi: ApiHelpers;
|
||||
workflowId: string;
|
||||
mcpPath: string;
|
||||
authHeaders: Record<string, string>;
|
||||
session: McpSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisions an active `n8nOAuth2` MCP-trigger workflow carrying an unconnected
|
||||
* resolvable OAuth2 credential, then mints a caller token scoped to the
|
||||
* workflow's protected resource and opens an authenticated MCP session — all
|
||||
* against a single confirmed-ready main. Each test calls this independently so
|
||||
* the legs stay parallel-safe (own credential, trigger path and caller token).
|
||||
*/
|
||||
async function provisionGatedWorkflow(
|
||||
api: ApiHelpers,
|
||||
services: ServiceHelpers,
|
||||
createApiForMain: (mainIndex: number) => Promise<ApiHelpers>,
|
||||
): Promise<GatedWorkflowSetup> {
|
||||
const keycloak = services.keycloak;
|
||||
const externalBase = keycloak.discoveryUrl.replace('/.well-known/openid-configuration', '');
|
||||
const internalBase = keycloak.internalDiscoveryUrl.replace(
|
||||
'/.well-known/openid-configuration',
|
||||
'',
|
||||
);
|
||||
|
||||
// A resolvable OAuth2 credential — resolved per-user by the seeded `system-n8n`
|
||||
// resolver. The caller has NOT connected it, so the gate reports it missing and
|
||||
// hands back the Keycloak authorization URL to connect it.
|
||||
const credential = await api.credentials.createCredential({
|
||||
name: `MCP Private OAuth2 ${nanoid()}`,
|
||||
type: 'oAuth2Api',
|
||||
data: {
|
||||
grantType: 'authorizationCode',
|
||||
authUrl: `${externalBase}/protocol/openid-connect/auth`,
|
||||
accessTokenUrl: `${internalBase}/protocol/openid-connect/token`,
|
||||
clientId: keycloak.clientId,
|
||||
clientSecret: keycloak.clientSecret,
|
||||
scope: 'openid',
|
||||
ignoreSSLIssues: true,
|
||||
},
|
||||
isResolvable: true,
|
||||
});
|
||||
|
||||
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
||||
'mcp-trigger/mcp-trigger-n8n-oauth2-private-cred.json',
|
||||
{
|
||||
transform: (wf) => {
|
||||
// Attach the resolvable credential to the Private API node so the
|
||||
// workflow carries an unconnected private credential.
|
||||
const privateApiNode = wf.nodes?.find((n) => n.name === 'Private API');
|
||||
if (privateApiNode) {
|
||||
privateApiNode.credentials = {
|
||||
oAuth2Api: { id: credential.id, name: credential.name },
|
||||
};
|
||||
}
|
||||
return wf;
|
||||
},
|
||||
},
|
||||
);
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mainApi = await createApiForMain(0);
|
||||
|
||||
// importWorkflowFromFile makes the MCP trigger path unique (appends a suffix
|
||||
// and sets the webhookId that isFullPath webhooks require), so read the
|
||||
// registered path back from the created workflow rather than the fixture value.
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const triggerPath = mcpNode?.parameters.path as string;
|
||||
const mcpPath = `mcp/${triggerPath}`;
|
||||
|
||||
// The per-workflow resource URL is the caller token's audience. The protected
|
||||
// resource is registered when the trigger's webhook is, which is asynchronous
|
||||
// after activation (notably multi-main), so poll the per-resource metadata
|
||||
// document until it is served.
|
||||
let resource = '';
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await mainApi.mcpOauth.getProtectedResourceMetadata(mcpPath);
|
||||
if (response.status() === 200) {
|
||||
resource = ((await response.json()) as { resource: string }).resource;
|
||||
}
|
||||
return response.status();
|
||||
},
|
||||
{ timeout: 20_000, intervals: [500, 1000, 2000] },
|
||||
)
|
||||
.toBe(200);
|
||||
expect(resource).toContain(triggerPath);
|
||||
|
||||
// Mint an n8n OAuth token scoped to this workflow (owner consents).
|
||||
const { tokens } = await mainApi.mcpOauth.completeAuthorizationCodeFlow({
|
||||
clientName: `mcp-gate e2e ${nanoid(8)}`,
|
||||
resource,
|
||||
});
|
||||
const authHeaders = { Authorization: `Bearer ${tokens.access_token}` };
|
||||
|
||||
const session = await mainApi.mcp.streamableHttpInitialize(mcpPath, { headers: authHeaders });
|
||||
|
||||
return { mainApi, workflowId, mcpPath, authHeaders, session };
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'MCP Trigger credential gate @capability:dynamic-credentials @licensed @mode:multi-main',
|
||||
{
|
||||
annotation: [{ type: 'owner', description: 'Identity & Access' }],
|
||||
},
|
||||
() => {
|
||||
test.beforeEach(async ({ api, mainUrls }) => {
|
||||
test.skip(mainUrls.length < 1, 'Requires a directly-addressable main');
|
||||
// The OAuth endpoints (register/authorize/token) require MCP access.
|
||||
await api.setMcpAccess(true);
|
||||
});
|
||||
|
||||
test('should return connection URLs instead of executing when a private credential is not connected @auth:owner', async ({
|
||||
api,
|
||||
services,
|
||||
createApiForMain,
|
||||
}) => {
|
||||
const { mainApi, workflowId, mcpPath, authHeaders, session } = await provisionGatedWorkflow(
|
||||
api,
|
||||
services,
|
||||
createApiForMain,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await mainApi.mcp.callTool(
|
||||
session,
|
||||
mcpPath,
|
||||
'echo',
|
||||
{ message: 'hi' },
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
|
||||
// The gate fired: an actionable error response, not the echoed message.
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.map((c) => c.text).join('\n');
|
||||
expect(text).toContain('not connected');
|
||||
expect(text).not.toContain('Echo: hi');
|
||||
|
||||
// And the workflow did not execute.
|
||||
const executions = await api.workflows.getExecutions(workflowId);
|
||||
expect(executions).toHaveLength(0);
|
||||
} finally {
|
||||
await api.workflows.deactivate(workflowId);
|
||||
}
|
||||
});
|
||||
|
||||
test('should execute the tool after the caller connects the private credential @auth:owner', async ({
|
||||
api,
|
||||
services,
|
||||
createApiForMain,
|
||||
}) => {
|
||||
const { mainApi, workflowId, mcpPath, authHeaders, session } = await provisionGatedWorkflow(
|
||||
api,
|
||||
services,
|
||||
createApiForMain,
|
||||
);
|
||||
|
||||
try {
|
||||
// First call hits the gate and returns the Keycloak authorization URL.
|
||||
// (The gate emits the provider URL directly — its CSRF state already
|
||||
// carries the caller identity + resolver — so no separate n8n authorize
|
||||
// step is needed before completing the Keycloak flow.)
|
||||
const gateResult = await mainApi.mcp.callTool(
|
||||
session,
|
||||
mcpPath,
|
||||
'echo',
|
||||
{ message: 'hi' },
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
expect(gateResult.isError).toBe(true);
|
||||
const gateText = gateResult.content.map((c) => c.text).join('\n');
|
||||
const authorizationUrl = gateText.split('\n').find((line) => line.startsWith('http'));
|
||||
expect(authorizationUrl).toBeTruthy();
|
||||
|
||||
// Complete the Keycloak authorization code flow, then GET the n8n callback
|
||||
// (on the same main) so n8n exchanges the code and stores the caller's
|
||||
// token for the resolver-keyed private credential.
|
||||
const n8nCallbackUrl = await services.keycloak.completeAuthorizationCodeFlow(
|
||||
authorizationUrl!,
|
||||
);
|
||||
await mainApi.dynamicCredentials.completeAuthorizationCallback(n8nCallbackUrl);
|
||||
|
||||
// Retry with the same caller token: the credential is now connected, the
|
||||
// gate passes, and the echo tool executes.
|
||||
const retryResult = await mainApi.mcp.callTool(
|
||||
session,
|
||||
mcpPath,
|
||||
'echo',
|
||||
{ message: 'hi' },
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
expect(retryResult.isError).toBeFalsy();
|
||||
const retryText = retryResult.content.map((c) => c.text).join('\n');
|
||||
expect(retryText).toContain('Echo: hi');
|
||||
|
||||
// And the workflow executed.
|
||||
await expect
|
||||
.poll(async () => (await api.workflows.getExecutions(workflowId)).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
} finally {
|
||||
await api.workflows.deactivate(workflowId);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "MCP Trigger n8n OAuth2 Private Credential Test",
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "n8nOAuth2",
|
||||
"path": "mcp-oauth-gate"
|
||||
},
|
||||
"id": "mcp-trigger-node",
|
||||
"name": "MCP Server Trigger",
|
||||
"type": "@n8n/n8n-nodes-langchain.mcpTrigger",
|
||||
"typeVersion": 2,
|
||||
"position": [300, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"name": "echo",
|
||||
"description": "Echoes the input message back to the caller",
|
||||
"specifyInputSchema": true,
|
||||
"jsonSchemaExample": "{\n\t\"message\": \"Hello, world!\"\n}",
|
||||
"jsCode": "return `Echo: ${query.message}`;"
|
||||
},
|
||||
"id": "echo-tool-node",
|
||||
"name": "Echo Tool",
|
||||
"type": "@n8n/n8n-nodes-langchain.toolCode",
|
||||
"typeVersion": 1.1,
|
||||
"position": [500, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "https://example.com/private-resource",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "oAuth2Api"
|
||||
},
|
||||
"id": "private-api-node",
|
||||
"name": "Private API",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [500, 200],
|
||||
"credentials": {}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Echo Tool": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "MCP Server Trigger",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"staticData": null,
|
||||
"meta": null,
|
||||
"pinData": {},
|
||||
"versionId": null,
|
||||
"triggerCount": 0,
|
||||
"tags": []
|
||||
}
|
||||
Reference in New Issue
Block a user