fix(core): Explain disabled MCP access on the OAuth authorize page

This commit is contained in:
Benny Neugebauer 2026-09-06 16:03:36 +02:00
parent 33eb5c196e
commit aecabb4567
No known key found for this signature in database
4 changed files with 275 additions and 7 deletions

View File

@ -8,7 +8,7 @@ import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { Response } from 'express';
import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { mock, mockDeep } from 'vitest-mock-extended';
import { AuthService } from '@/auth/auth.service';
import type { EventService } from '@/events/event.service';
@ -601,7 +601,8 @@ describe('OAuthServerService', () => {
resource: new URL(TEST_RESOURCE_URL),
};
const res = mock<Response>();
// Deep: the unavailable branch reads `res.req.accepts` for content negotiation.
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
@ -636,7 +637,8 @@ describe('OAuthServerService', () => {
codeChallenge: 'challenge-123',
};
const res = mock<Response>();
// Deep: the unavailable branch reads `res.req.accepts` for content negotiation.
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
@ -652,6 +654,44 @@ describe('OAuthServerService', () => {
expect(oauthSessionService.createSession).not.toHaveBeenCalled();
});
it('should render an explanatory page when a browser requests an unavailable resource', async () => {
const client = {
client_id: 'client-123',
client_name: 'Claude',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
logo_uri: undefined,
tos_uri: undefined,
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
resource: new URL(TEST_RESOURCE_URL),
};
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.req.accepts.mockReturnValue('html');
urlServiceMock.getInstanceBaseUrl.mockReturnValue('https://n8n.example.com/');
isResourceAvailable.mockResolvedValue(false);
await service.authorize(client, params, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.render).toHaveBeenCalledWith('oauth-resource-unavailable', {
clientName: 'Claude',
isInstanceMcp: true,
settingsUrl: 'https://n8n.example.com/settings/mcp',
});
expect(res.json).not.toHaveBeenCalled();
expect(oauthSessionService.createSession).not.toHaveBeenCalled();
});
describe('reusing a prior consent (auto-approval)', () => {
const client = {
client_id: 'client-123',

View File

@ -420,10 +420,7 @@ export class OAuthServerService implements OAuthServerProvider {
clientId: client.client_id,
resource: targetResource.getResourceUrl(),
});
res.status(400).json({
error: 'invalid_target',
error_description: 'Resource is not available for authorization',
});
this.respondResourceUnavailable(res, client, targetResource);
return;
}
@ -637,6 +634,34 @@ export class OAuthServerService implements OAuthServerProvider {
return !((await resource.isAvailable?.()) ?? true);
}
/**
* MCP clients open the authorize URL in the user's browser, so a browser gets
* a page that names the setting to change. Every other caller gets the
* RFC 8707 `invalid_target` error the endpoint answered with before.
*/
private respondResourceUnavailable(
res: Response,
client: OAuthClientInformationFull,
resource: ProtectedResource,
): void {
res.status(400);
if (res.req.accepts(['json', 'html']) === 'html') {
const baseUrl = this.urlService.getInstanceBaseUrl().replace(/\/$/, '');
res.render('oauth-resource-unavailable', {
clientName: client.client_name,
isInstanceMcp: resource.id === INSTANCE_MCP_RESOURCE_ID,
settingsUrl: `${baseUrl}/settings/mcp`,
});
return;
}
res.json({
error: 'invalid_target',
error_description: 'Resource is not available for authorization',
});
}
// Exact-match against a registered resource, as required by RFC 8707 §2.1.
// Prefix/wildcard matching would open the door to malicious-host or
// path-traversal indicators like ".../mcp-server/http/../admin".

View File

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
<link href='https://fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css' />
{{#if isInstanceMcp}}
<title>MCP access is turned off</title>
{{else}}
<title>Authorization unavailable</title>
{{/if}}
<style>
:root {
--font-family: 'Open Sans', sans-serif;
--font-weight-normal: 400;
--font-weight-bold: 600;
--font-size-body: 14px;
--font-size-header: 20px;
--color-background: #FBFCFE;
--color-card-bg: white;
--color-card-border: #DBDFE7;
--color-card-shadow: #634DFF0F;
--color-header: #525356;
--color-text: #7E8186;
/* Mirrors the design-system button (variant solid / ghost, size medium). */
--color-brand: oklch(69.96% 0.202 44.44);
--color-brand-hover: oklch(64.71% 0.2173 36.85);
--color-brand-active: oklch(55.42% 0.1922 35.48);
--color-ghost-hover: oklch(0% 0 0 / 0.05);
--color-ghost-active: oklch(0% 0 0 / 0.1);
--color-button-text: oklch(26.86% 0 89.88);
--color-focus-ring: oklch(55.9% 0.235 289.5 / 0.2);
--button-height: 32px;
--button-padding: 0 12px;
--button-radius: 4px;
--button-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15);
--padding-container-top: 48px;
--container-width: 448px;
--card-padding: 24px;
--card-border-radius: 8px;
}
*,
::after,
::before {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
font-weight: var(--font-weight-normal);
font-size: var(--font-size-body);
background-color: var(--color-background);
}
.container {
margin: auto;
padding-top: var(--padding-container-top);
max-width: var(--container-width);
}
.card {
padding: var(--card-padding);
background-color: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--card-border-radius);
box-shadow: 0px 4px 16px 0px var(--color-card-shadow);
}
h1 {
color: var(--color-header);
font-size: var(--font-size-header);
font-weight: var(--font-weight-bold);
padding-bottom: 12px;
}
p {
color: var(--color-text);
line-height: 1.5;
padding-bottom: 12px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.button {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--button-height);
padding: var(--button-padding);
border-radius: var(--button-radius);
font-size: var(--font-size-body);
font-weight: var(--font-weight-bold);
text-decoration: none;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
}
.button-solid {
background-color: var(--color-brand);
color: white;
box-shadow: inset 0 0 0 1px var(--color-brand), var(--button-shadow);
}
.button-solid:hover {
background-color: var(--color-brand-hover);
box-shadow: inset 0 0 0 1px var(--color-brand-hover), var(--button-shadow);
}
.button-solid:active {
background-color: var(--color-brand-active);
box-shadow: inset 0 0 0 1px var(--color-brand-active), var(--button-shadow);
}
.button-ghost {
color: var(--color-button-text);
}
.button-ghost:hover {
background-color: var(--color-ghost-hover);
}
.button-ghost:active {
background-color: var(--color-ghost-active);
}
.button-ghost svg {
width: 14px;
height: 14px;
}
</style>
</head>
<body>
<div class='container'>
<div class='card'>
{{#if isInstanceMcp}}
<h1>MCP access is turned off</h1>
<p>{{clientName}} could not connect because this n8n instance does not accept MCP connections yet.</p>
<p>An instance owner or admin can turn on MCP access in the instance settings. After that, start the connection again from your MCP client.</p>
<div class='actions'>
<a class='button button-ghost' href='https://docs.n8n.io/connect/connect-to-n8n-mcp-server#enabling-mcp-access' target='_blank' rel='noopener'>
Learn more
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' aria-hidden='true'>
<path d='M7 7h10v10' />
<path d='M7 17 17 7' />
</svg>
</a>
<a class='button button-solid' href='{{settingsUrl}}'>Open MCP settings</a>
</div>
{{else}}
<h1>Authorization unavailable</h1>
<p>{{clientName}} could not connect because the requested resource is not available on this n8n instance.</p>
<p>Start the connection again once the resource is available.</p>
{{/if}}
</div>
</div>
</body>
</html>

View File

@ -431,6 +431,33 @@ test.describe(
const body = await authorizeResponse.json();
expect(body.error).toBe('invalid_target');
});
// The authorize URL is opened in the user's browser, so a browser must get a
// page that names the setting rather than the machine-readable error.
test('should explain to a browser that MCP access is disabled', async ({ api }) => {
const client = await api.mcpOauth.registerClientOrFail({
client_name: `e2e OAuth client ${nanoid(8)}`,
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
});
const authorizeResponse = await api.request.get(
api.mcpOauth.buildAuthorizeUrl({
clientId: client.client_id,
redirectUri: 'https://example.com/callback',
challenge: api.mcpOauth.createPkcePair().challenge,
}),
{ headers: { accept: 'text/html' }, maxRedirects: 0 },
);
expect(authorizeResponse.status()).toBe(400);
expect(authorizeResponse.headers()['content-type']).toContain('text/html');
const html = await authorizeResponse.text();
expect(html).toContain('MCP access is turned off');
expect(html).toContain('/settings/mcp');
expect(html).toContain('connect-to-n8n-mcp-server#enabling-mcp-access');
});
});
test.describe('Consent screen', () => {