mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
fix(core): Explain disabled MCP access on the OAuth authorize page
This commit is contained in:
parent
33eb5c196e
commit
aecabb4567
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
176
packages/cli/templates/oauth-resource-unavailable.handlebars
Normal file
176
packages/cli/templates/oauth-resource-unavailable.handlebars
Normal 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>
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user