From f60d2f8968492fb0ebfb14e6ad388eaa0c6d0e02 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Wed, 24 Jun 2026 15:10:14 +0200 Subject: [PATCH] test(core): Add e2e for MCP trigger n8nOAuth2 credential-status gate (#32655) --- .../services/dynamic-credential-api-helper.ts | 19 ++ .../playwright/services/mcp-api-helper.ts | 5 +- .../services/mcp-oauth-api-helper.ts | 20 +- .../mcp-trigger-credential-gate.spec.ts | 246 ++++++++++++++++++ .../mcp-trigger-n8n-oauth2-private-cred.json | 66 +++++ 5 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 packages/testing/playwright/tests/e2e/dynamic-credentials/mcp-trigger-credential-gate.spec.ts create mode 100644 packages/testing/playwright/workflows/mcp-trigger/mcp-trigger-n8n-oauth2-private-cred.json diff --git a/packages/testing/playwright/services/dynamic-credential-api-helper.ts b/packages/testing/playwright/services/dynamic-credential-api-helper.ts index 6578b5d8582..7df276b7585 100644 --- a/packages/testing/playwright/services/dynamic-credential-api-helper.ts +++ b/packages/testing/playwright/services/dynamic-credential-api-helper.ts @@ -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 { + 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 ===== } diff --git a/packages/testing/playwright/services/mcp-api-helper.ts b/packages/testing/playwright/services/mcp-api-helper.ts index 1f2e7e51f06..474411787ad 100644 --- a/packages/testing/playwright/services/mcp-api-helper.ts +++ b/packages/testing/playwright/services/mcp-api-helper.ts @@ -555,6 +555,7 @@ export class McpApiHelper { session: McpSession, path: string, message: unknown, + options?: { headers?: Record }, ): Promise { 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, + options?: { headers?: Record }, ): Promise { 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(session, message); } else { - const response = await this.streamableHttpSendMessage(session, path, message); + const response = await this.streamableHttpSendMessage(session, path, message, options); return await this.parseResponse(response); } } diff --git a/packages/testing/playwright/services/mcp-oauth-api-helper.ts b/packages/testing/playwright/services/mcp-oauth-api-helper.ts index 4d301ace165..56d588681c8 100644 --- a/packages/testing/playwright/services/mcp-oauth-api-helper.ts +++ b/packages/testing/playwright/services/mcp-oauth-api-helper.ts @@ -69,8 +69,15 @@ export class McpOAuthApiHelper { return await this.api.request.get('/.well-known/oauth-authorization-server'); } - async getProtectedResourceMetadata(): Promise { - 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/`) 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 { + 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 { 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, }); diff --git a/packages/testing/playwright/tests/e2e/dynamic-credentials/mcp-trigger-credential-gate.spec.ts b/packages/testing/playwright/tests/e2e/dynamic-credentials/mcp-trigger-credential-gate.spec.ts new file mode 100644 index 00000000000..b1d29824d6f --- /dev/null +++ b/packages/testing/playwright/tests/e2e/dynamic-credentials/mcp-trigger-credential-gate.spec.ts @@ -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; + 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, +): Promise { + 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); + } + }); + }, +); diff --git a/packages/testing/playwright/workflows/mcp-trigger/mcp-trigger-n8n-oauth2-private-cred.json b/packages/testing/playwright/workflows/mcp-trigger/mcp-trigger-n8n-oauth2-private-cred.json new file mode 100644 index 00000000000..05a47b9c54e --- /dev/null +++ b/packages/testing/playwright/workflows/mcp-trigger/mcp-trigger-n8n-oauth2-private-cred.json @@ -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": [] +}