feat(core): Support Client ID Metadata Documents on the MCP OAuth server

Recognize an HTTPS-URL client_id as a Client ID Metadata Document (CIMD): the
server fetches the client's OAuth metadata from that URL and uses it as the
verifiable client identity, instead of requiring Dynamic Client Registration.
DCR stays available and unchanged for clients that don't support CIMD.

Backend:
- New CimdMetadataHttpClient fetches the document. The client_id URL is
  attacker-influenced and fetched pre-auth, so the request is always routed
  through the SSRF filter regardless of N8N_SSRF_PROTECTION_ENABLED; the
  document is validated (HTTPS, redirect_uris present, client_id matches the
  URL) and cached.
- The clients store resolves a URL client_id from its cached document, which
  stays the source of truth; a row is persisted only to satisfy the
  consent/token foreign keys, marked isCimd and excluded from the
  registered-client cap (which bounds anonymous DCR).
- Per the CIMD trust model the requested redirect_uri is validated against the
  document's declared redirect_uris (by the SDK) rather than the instance
  redirect-URI allowlist.
- Gated behind N8N_OAUTH_SERVER_CIMD_ENABLED (default on).

Frontend:
- CIMD clients get a dedicated trust card on the consent screen: the verifiable
  domain, where the authorization code is sent (green check for loopback, a
  neutral host line otherwise), and an honest caveat that the name/icon are
  self-reported and unreviewed, with a link to the raw metadata document. They
  skip the manual redirect-trust acknowledgement DCR clients still require.
- The connected-clients settings show a "Verified" badge for CIMD clients.

Adds an isCimd column to oauth_clients (migration) and exposes isCimd on the
OAuth client and consent-details payloads.
This commit is contained in:
Ricardo Espinoza 2026-08-03 19:52:50 -04:00
parent 7a40b4eafe
commit 0432bc926c
No known key found for this signature in database
37 changed files with 898 additions and 24 deletions

View File

@ -88,7 +88,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.mcp_registry_server](public.mcp_registry_server.md) | 7 | | BASE TABLE |
| [public.oauth_access_tokens](public.oauth_access_tokens.md) | 3 | | BASE TABLE |
| [public.oauth_authorization_codes](public.oauth_authorization_codes.md) | 13 | | BASE TABLE |
| [public.oauth_clients](public.oauth_clients.md) | 10 | | BASE TABLE |
| [public.oauth_clients](public.oauth_clients.md) | 11 | | BASE TABLE |
| [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) | 7 | | BASE TABLE |
| [public.oauth_user_consents](public.oauth_user_consents.md) | 5 | | BASE TABLE |
| [public.processed_data](public.processed_data.md) | 5 | | BASE TABLE |
@ -1142,6 +1142,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
json grantTypes
varchar id
boolean isCimd
boolean isFirstParty
varchar_255_ name
json redirectUris

View File

@ -44,6 +44,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
json grantTypes
varchar id
boolean isCimd
boolean isFirstParty
varchar_255_ name
json redirectUris

View File

@ -72,6 +72,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
json grantTypes
varchar id
boolean isCimd
boolean isFirstParty
varchar_255_ name
json redirectUris

View File

@ -9,6 +9,7 @@
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| grantTypes | json | | false | | | |
| id | varchar | | false | [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) | | |
| isCimd | boolean | false | false | | | |
| isFirstParty | boolean | false | false | | | |
| name | varchar(255) | | false | | | |
| redirectUris | json | | false | | | |
@ -23,6 +24,7 @@
| oauth_clients_createdAt_not_null | n | NOT NULL "createdAt" |
| oauth_clients_grantTypes_not_null | n | NOT NULL "grantTypes" |
| oauth_clients_id_not_null | n | NOT NULL id |
| oauth_clients_isCimd_not_null | n | NOT NULL "isCimd" |
| oauth_clients_isFirstParty_not_null | n | NOT NULL "isFirstParty" |
| oauth_clients_name_not_null | n | NOT NULL name |
| oauth_clients_redirectUris_not_null | n | NOT NULL "redirectUris" |
@ -51,6 +53,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
json grantTypes
varchar id
boolean isCimd
boolean isFirstParty
varchar_255_ name
json redirectUris

View File

@ -56,6 +56,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
json grantTypes
varchar id
boolean isCimd
boolean isFirstParty
varchar_255_ name
json redirectUris

View File

@ -52,6 +52,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
json grantTypes
varchar id
boolean isCimd
boolean isFirstParty
varchar_255_ name
json redirectUris

View File

@ -88,7 +88,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [mcp_registry_server](mcp_registry_server.md) | 7 | | table |
| [oauth_access_tokens](oauth_access_tokens.md) | 3 | | table |
| [oauth_authorization_codes](oauth_authorization_codes.md) | 13 | | table |
| [oauth_clients](oauth_clients.md) | 10 | | table |
| [oauth_clients](oauth_clients.md) | 11 | | table |
| [oauth_refresh_tokens](oauth_refresh_tokens.md) | 7 | | table |
| [oauth_user_consents](oauth_user_consents.md) | 5 | | table |
| [processed_data](processed_data.md) | 5 | | table |
@ -1129,6 +1129,7 @@ erDiagram
datetime_3_ createdAt
TEXT grantTypes
varchar id PK
boolean isCimd
boolean isFirstParty
varchar_255_ name
TEXT redirectUris

View File

@ -53,6 +53,7 @@ erDiagram
datetime_3_ createdAt
TEXT grantTypes
varchar id PK
boolean isCimd
boolean isFirstParty
varchar_255_ name
TEXT redirectUris

View File

@ -73,6 +73,7 @@ erDiagram
datetime_3_ createdAt
TEXT grantTypes
varchar id PK
boolean isCimd
boolean isFirstParty
varchar_255_ name
TEXT redirectUris

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "oauth_clients" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "redirectUris" text NOT NULL, "grantTypes" text NOT NULL, "clientSecret" varchar(255), "clientSecretExpiresAt" bigint, "tokenEndpointAuthMethod" varchar(255) NOT NULL DEFAULT ('none'), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "isFirstParty" boolean NOT NULL DEFAULT (false))
CREATE TABLE "oauth_clients" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "redirectUris" text NOT NULL, "grantTypes" text NOT NULL, "clientSecret" varchar(255), "clientSecretExpiresAt" bigint, "tokenEndpointAuthMethod" varchar(255) NOT NULL DEFAULT ('none'), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "isFirstParty" boolean NOT NULL DEFAULT (false), "isCimd" boolean NOT NULL DEFAULT (false))
```
</details>
@ -20,6 +20,7 @@ CREATE TABLE "oauth_clients" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar(
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| grantTypes | TEXT | | false | | | |
| id | varchar | | false | [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) | | |
| isCimd | boolean | false | false | | | |
| isFirstParty | boolean | false | false | | | |
| name | varchar(255) | | false | | | |
| redirectUris | TEXT | | false | | | |
@ -55,6 +56,7 @@ erDiagram
datetime_3_ createdAt
TEXT grantTypes
varchar id PK
boolean isCimd
boolean isFirstParty
varchar_255_ name
TEXT redirectUris

View File

@ -61,6 +61,7 @@ erDiagram
datetime_3_ createdAt
TEXT grantTypes
varchar id PK
boolean isCimd
boolean isFirstParty
varchar_255_ name
TEXT redirectUris

View File

@ -57,6 +57,7 @@ erDiagram
datetime_3_ createdAt
TEXT grantTypes
varchar id PK
boolean isCimd
boolean isFirstParty
varchar_255_ name
TEXT redirectUris

View File

@ -23,6 +23,11 @@ const oauthClientShape = {
grantedAt: z.number(),
/** Scopes granted on the consent screen. */
scopes: z.array(z.string()),
/**
* True when the client was resolved from a Client ID Metadata Document, i.e.
* its `id` is the HTTPS URL that verifiably identifies it.
*/
isCimd: z.boolean(),
/** Consent owner; present only when listing with ownership=all. */
owner: oauthClientOwnerShape.optional(),
};

View File

@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const OAUTH_CLIENTS_TABLE = 'oauth_clients';
const OAUTH_IS_CIMD_COLUMN = 'isCimd';
export class AddIsCimdToOAuthClients1785500900000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(
OAUTH_CLIENTS_TABLE,
[column(OAUTH_IS_CIMD_COLUMN).bool.notNull.default(false)],
{
recreatesOnSqlite: true,
},
);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(OAUTH_CLIENTS_TABLE, [OAUTH_IS_CIMD_COLUMN], {
recreatesOnSqlite: true,
});
}
}

View File

@ -237,6 +237,7 @@ import { CrashStaleEnqueuedExecutions1785247194306 } from '../common/17852471943
import { AddMisfirePolicyToScheduler1785247194307 } from '../common/1785247194307-AddMisfirePolicyToScheduler';
import { CreateAgentChatAttachmentsTable1785255306000 } from '../common/1785255306000-CreateAgentChatAttachmentsTable';
import { AddSetupCompletedAtToAgents1785500832626 } from '../common/1785500832626-AddSetupCompletedAtToAgents';
import { AddIsCimdToOAuthClients1785500900000 } from '../common/1785500900000-AddIsCimdToOAuthClients';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -479,4 +480,5 @@ export const postgresMigrations: Migration[] = [
AddMisfirePolicyToScheduler1785247194307,
CreateAgentChatAttachmentsTable1785255306000,
AddSetupCompletedAtToAgents1785500832626,
AddIsCimdToOAuthClients1785500900000,
];

View File

@ -0,0 +1,27 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const OAUTH_CLIENTS_TABLE = 'oauth_clients';
const OAUTH_IS_CIMD_COLUMN = 'isCimd';
export class AddIsCimdToOAuthClients1785500900000 implements ReversibleMigration {
// oauth_clients has inbound ON DELETE CASCADE FKs (access/refresh tokens,
// authorization codes, user consents). Recreating the table on SQLite would
// fire those cascades and wipe the referencing rows, so disable FKs.
withFKsDisabled = true as const;
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(
OAUTH_CLIENTS_TABLE,
[column(OAUTH_IS_CIMD_COLUMN).bool.notNull.default(false)],
{
recreatesOnSqlite: true,
},
);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(OAUTH_CLIENTS_TABLE, [OAUTH_IS_CIMD_COLUMN], {
recreatesOnSqlite: true,
});
}
}

View File

@ -63,6 +63,7 @@ import { ChangeInstalledNodeVersionType1785162364000 } from './1785162364000-Cha
import { AddIsFirstPartyToOAuthClients1785162364001 } from './1785162364001-AddIsFirstPartyToOAuthClients';
import { AddMisfirePolicyToScheduler1785247194307 } from './1785247194307-AddMisfirePolicyToScheduler';
import { AddSetupCompletedAtToAgents1785500832626 } from './1785500832626-AddSetupCompletedAtToAgents';
import { AddIsCimdToOAuthClients1785500900000 } from './1785500900000-AddIsCimdToOAuthClients';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -461,6 +462,7 @@ const sqliteMigrations: Migration[] = [
AddMisfirePolicyToScheduler1785247194307,
CreateAgentChatAttachmentsTable1785255306000,
AddSetupCompletedAtToAgents1785500832626,
AddIsCimdToOAuthClients1785500900000,
];
export { sqliteMigrations };

View File

@ -0,0 +1,108 @@
import type { Logger } from '@n8n/backend-common';
import type { OutboundHttp, SsrfProtectionService, HttpRequestClient } from '@n8n/backend-network';
import type { IN8nHttpFullResponse } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
// Build a plain response object rather than a mock proxy: `mock()` would return
// mock functions for the document keys the schema reads but the fixture omits.
import { CimdMetadataError, CimdMetadataHttpClient } from '../cimd-metadata-http-client';
import type { CacheService } from '@/services/cache/cache.service';
const CLIENT_ID = 'https://client.example.com/metadata.json';
const validDocument = {
client_id: CLIENT_ID,
client_name: 'Example Client',
redirect_uris: ['https://client.example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
logo_uri: 'https://client.example.com/logo.png',
};
const response = (statusCode: number, body: unknown): IN8nHttpFullResponse =>
({ statusCode, body }) as unknown as IN8nHttpFullResponse;
describe('CimdMetadataHttpClient', () => {
const logger = mock<Logger>();
const cache = mock<CacheService>();
const ssrfProtectionService = mock<SsrfProtectionService>();
const httpClient = mock<HttpRequestClient>();
const outboundHttp = mock<OutboundHttp>();
let client: CimdMetadataHttpClient;
beforeEach(() => {
vi.clearAllMocks();
outboundHttp.requests.mockReturnValue(httpClient);
cache.get.mockResolvedValue(undefined);
client = new CimdMetadataHttpClient(logger, cache, outboundHttp, ssrfProtectionService);
});
it('always routes the fetch through the SSRF filter', () => {
// The client_id is client-controlled, so SSRF protection is passed
// unconditionally rather than gated on the global toggle.
expect(outboundHttp.requests).toHaveBeenCalledWith(
expect.objectContaining({ ssrf: ssrfProtectionService }),
);
});
it('fetches, validates and caches a valid document', async () => {
httpClient.request.mockResolvedValue(response(200, validDocument));
const result = await client.fetchMetadata(CLIENT_ID);
expect(result).toMatchObject({
client_name: 'Example Client',
redirect_uris: ['https://client.example.com/callback'],
});
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: CLIENT_ID, method: 'GET' }),
);
expect(cache.set).toHaveBeenCalledWith(
`cimd:metadata:${CLIENT_ID}`,
result,
expect.any(Number),
);
});
it('returns the cached document without an HTTP request', async () => {
cache.get.mockResolvedValue(validDocument);
const result = await client.fetchMetadata(CLIENT_ID);
expect(result).toEqual(validDocument);
expect(httpClient.request).not.toHaveBeenCalled();
});
it('throws on a non-200 response', async () => {
httpClient.request.mockResolvedValue(response(404, 'not found'));
await expect(client.fetchMetadata(CLIENT_ID)).rejects.toThrow(CimdMetadataError);
expect(cache.set).not.toHaveBeenCalled();
});
it('throws when the document fails schema validation', async () => {
httpClient.request.mockResolvedValue(response(200, { client_name: 'No redirect URIs' }));
await expect(client.fetchMetadata(CLIENT_ID)).rejects.toThrow(CimdMetadataError);
});
it('throws when the document declares a mismatched client_id', async () => {
httpClient.request.mockResolvedValue(
response(200, { ...validDocument, client_id: 'https://evil.example.com/metadata.json' }),
);
await expect(client.fetchMetadata(CLIENT_ID)).rejects.toThrow(/mismatched client_id/);
});
it('accepts a document that omits client_id', async () => {
const { client_id, ...withoutId } = validDocument;
httpClient.request.mockResolvedValue(response(200, withoutId));
await expect(client.fetchMetadata(CLIENT_ID)).resolves.toMatchObject({
client_name: 'Example Client',
});
});
});

View File

@ -64,6 +64,7 @@ describe('GET /rest/consent/details', () => {
expect(response.body.data).toEqual({
clientName: 'Test OAuth Client',
clientId: 'test-client-id',
isCimd: false,
redirectUri: 'https://example.com/callback',
scopes: supportedScopes,
scopeTools: expect.objectContaining({
@ -108,6 +109,7 @@ describe('GET /rest/consent/details', () => {
expect(response.body.data).toEqual({
clientName: 'Test OAuth Client',
clientId: 'resource-client-id',
isCimd: false,
resourceName: 'My Named Workflow',
redirectUri: 'https://example.com/callback',
scopes: [],

View File

@ -79,6 +79,7 @@ describe('OAuthConsentService', () => {
const client = mock<OAuthClient>({
id: 'client-123',
name: 'Test Client',
isCimd: false,
});
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
@ -90,6 +91,7 @@ describe('OAuthConsentService', () => {
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
isCimd: false,
redirectUri: 'https://example.com/callback',
scopes: INSTANCE_SCOPES,
previousScopes: undefined,
@ -144,6 +146,7 @@ describe('OAuthConsentService', () => {
const client = mock<OAuthClient>({
id: 'client-123',
name: 'Test Client',
isCimd: false,
});
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
@ -155,6 +158,7 @@ describe('OAuthConsentService', () => {
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
isCimd: false,
redirectUri: 'https://example.com/callback',
scopes: INSTANCE_SCOPES,
previousScopes: undefined,
@ -169,7 +173,7 @@ describe('OAuthConsentService', () => {
codeChallenge: 'challenge',
state: null,
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client', isCimd: false });
const scopeTools = { 'workflow:read': ['search_workflows', 'get_workflow_details'] };
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
@ -193,7 +197,7 @@ describe('OAuthConsentService', () => {
state: null,
requestedScopes: ['workflow:read', 'execution:read'],
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client', isCimd: false });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
@ -214,7 +218,7 @@ describe('OAuthConsentService', () => {
codeChallenge: 'challenge',
state: null,
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client', isCimd: false });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
@ -241,7 +245,7 @@ describe('OAuthConsentService', () => {
state: null,
resource: 'https://n8n.example.com/mcp/wf-123',
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client', isCimd: false });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
@ -257,6 +261,7 @@ describe('OAuthConsentService', () => {
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
isCimd: false,
resourceName: 'My Workflow',
redirectUri: 'https://example.com/callback',
scopes: [],
@ -276,7 +281,7 @@ describe('OAuthConsentService', () => {
state: null,
resource: 'https://n8n.example.com/mcp-server/http',
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client', isCimd: false });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
@ -293,6 +298,7 @@ describe('OAuthConsentService', () => {
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
isCimd: false,
resourceName: undefined,
redirectUri: 'https://example.com/callback',
scopes: INSTANCE_SCOPES,
@ -309,7 +315,7 @@ describe('OAuthConsentService', () => {
state: null,
resource: 'https://n8n.example.com/mcp/gone',
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client', isCimd: false });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);

View File

@ -13,6 +13,7 @@ describe('OAuthServerConfig', () => {
delete process.env.N8N_OAUTH_SERVER_TOKEN_RATE_LIMIT;
delete process.env.N8N_OAUTH_SERVER_REVOKE_RATE_LIMIT;
delete process.env.N8N_OAUTH_SERVER_WELL_KNOWN_RATE_LIMIT;
delete process.env.N8N_OAUTH_SERVER_CIMD_ENABLED;
});
it('applies the documented default limits', () => {
@ -59,4 +60,14 @@ describe('OAuthServerConfig', () => {
expect(config.rateLimitToken).toBe(20);
},
);
it('enables CIMD by default', () => {
expect(Container.get(OAuthServerConfig).cimdEnabled).toBe(true);
});
it('reads cimdEnabled from N8N_OAUTH_SERVER_CIMD_ENABLED', () => {
process.env.N8N_OAUTH_SERVER_CIMD_ENABLED = 'false';
expect(Container.get(OAuthServerConfig).cimdEnabled).toBe(false);
});
});

View File

@ -14,11 +14,13 @@ import { ProtectedResourceRegistry } from '@/services/protected-resource.registr
import type { UrlService } from '@/services/url.service';
import { UserManagementMailer } from '@/user-management/email';
import type { CimdMetadataHttpClient } from '../cimd-metadata-http-client';
import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
import { OAuthAuthorizationCodeService } from '../oauth-authorization-code.service';
import type { OAuthServerConfig } from '../oauth-server.config';
import { OAuthServerService } from '../oauth-server.service';
import { OAuthSessionService } from '../oauth-session.service';
import { OAuthTokenService } from '../oauth-token.service';
@ -40,6 +42,11 @@ let eventService: Mocked<EventService>;
// Shared, immutable across tests: the base URLs gate the first-party client_id guard.
const urlServiceMock = mock<UrlService>();
// CIMD is off by default so existing cases keep their DCR/virtual-client behavior;
// the CIMD suite flips `cimdEnabled` on and stubs `fetchMetadata` per test.
const oauthServerConfigMock = mock<OAuthServerConfig>({ cimdEnabled: false });
const cimdMetadataClientMock = mock<CimdMetadataHttpClient>();
describe('OAuthServerService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
@ -77,12 +84,114 @@ describe('OAuthServerService', () => {
mailer,
urlServiceMock,
eventService,
oauthServerConfigMock,
cimdMetadataClientMock,
);
});
beforeEach(() => {
vi.clearAllMocks();
getAllowedRedirectUris.mockResolvedValue([]);
oauthServerConfigMock.cimdEnabled = false;
});
describe('CIMD client resolution', () => {
const CIMD_CLIENT_ID = 'https://client.example.com/metadata.json';
const metadata = {
client_id: CIMD_CLIENT_ID,
client_name: 'Example CIMD Client',
redirect_uris: ['https://client.example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
logo_uri: 'https://client.example.com/logo.png',
};
beforeEach(() => {
oauthServerConfigMock.cimdEnabled = true;
cimdMetadataClientMock.fetchMetadata.mockResolvedValue(metadata);
oauthClientRepository.upsert.mockResolvedValue({} as never);
});
it('resolves an HTTPS client_id from its metadata document, not the DB row', async () => {
const result = await service.clientsStore.getClient(CIMD_CLIENT_ID);
expect(cimdMetadataClientMock.fetchMetadata).toHaveBeenCalledWith(CIMD_CLIENT_ID);
expect(oauthClientRepository.findOneBy).not.toHaveBeenCalled();
expect(result).toMatchObject({
client_id: CIMD_CLIENT_ID,
client_name: 'Example CIMD Client',
redirect_uris: ['https://client.example.com/callback'],
logo_uri: 'https://client.example.com/logo.png',
});
});
it('persists a cap-excluded row (isCimd) so the consent/token FKs resolve', async () => {
await service.clientsStore.getClient(CIMD_CLIENT_ID);
expect(oauthClientRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({ id: CIMD_CLIENT_ID, isCimd: true, isFirstParty: false }),
['id'],
);
});
it('returns undefined when the metadata document cannot be resolved', async () => {
cimdMetadataClientMock.fetchMetadata.mockRejectedValue(new Error('unreachable'));
const result = await service.clientsStore.getClient(CIMD_CLIENT_ID);
expect(result).toBeUndefined();
expect(oauthClientRepository.upsert).not.toHaveBeenCalled();
});
it('ignores an HTTPS client_id as CIMD when the feature is disabled', async () => {
oauthServerConfigMock.cimdEnabled = false;
oauthClientRepository.findOneBy.mockResolvedValue(null);
const result = await service.clientsStore.getClient(CIMD_CLIENT_ID);
expect(cimdMetadataClientMock.fetchMetadata).not.toHaveBeenCalled();
expect(oauthClientRepository.findOneBy).toHaveBeenCalledWith({ id: CIMD_CLIENT_ID });
expect(result).toBeUndefined();
});
it('does not treat a form-trigger URL as CIMD', async () => {
const formTriggerId = 'https://n8n.example.com/form/abc';
oauthClientRepository.findOneBy.mockResolvedValue(null);
await service.clientsStore.getClient(formTriggerId);
expect(cimdMetadataClientMock.fetchMetadata).not.toHaveBeenCalled();
expect(oauthClientRepository.findOneBy).toHaveBeenCalledWith({ id: formTriggerId });
});
it('skips the instance redirect-URI allowlist for CIMD clients', async () => {
// An allowlist that would reject the request for a non-CIMD client.
getAllowedRedirectUris.mockResolvedValue(['https://only-this.example.com/cb']);
const res = mock<Response>();
await service.authorize(
{
client_id: CIMD_CLIENT_ID,
client_name: 'Example CIMD Client',
redirect_uris: ['https://client.example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
logo_uri: undefined,
tos_uri: undefined,
},
{
redirectUri: 'https://client.example.com/callback',
codeChallenge: 'challenge',
state: 'state-xyz',
resource: new URL(TEST_RESOURCE_URL),
},
res,
);
expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
expect(res.status).not.toHaveBeenCalledWith(400);
});
});
describe('clientsStore', () => {
@ -182,6 +291,8 @@ describe('OAuthServerService', () => {
mailer,
urlServiceMock,
mock<EventService>(),
oauthServerConfigMock,
cimdMetadataClientMock,
);
});
@ -251,6 +362,8 @@ describe('OAuthServerService', () => {
mailer,
urlServiceMock,
mock<EventService>(),
oauthServerConfigMock,
cimdMetadataClientMock,
);
const result = await svc.clientsStore.getClient('https://evil.example.com/form/abc');
@ -267,7 +380,10 @@ describe('OAuthServerService', () => {
await service.isClientLimitReached();
expect(oauthClientRepository.countBy).toHaveBeenCalledWith({ isFirstParty: false });
expect(oauthClientRepository.countBy).toHaveBeenCalledWith({
isFirstParty: false,
isCimd: false,
});
});
it('counts only non-first-party clients for the instance stats', async () => {
@ -275,7 +391,10 @@ describe('OAuthServerService', () => {
await service.getInstanceClientStats();
expect(oauthClientRepository.countBy).toHaveBeenCalledWith({ isFirstParty: false });
expect(oauthClientRepository.countBy).toHaveBeenCalledWith({
isFirstParty: false,
isCimd: false,
});
});
});
@ -846,6 +965,8 @@ describe('OAuthServerService', () => {
mailer,
urlServiceMock,
eventService,
oauthServerConfigMock,
cimdMetadataClientMock,
);
const client = {
@ -1399,6 +1520,8 @@ describe('OAuthServerService', () => {
mailer,
urlServiceMock,
mock<EventService>(),
oauthServerConfigMock,
cimdMetadataClientMock,
);
expect(
@ -1447,6 +1570,8 @@ describe('OAuthServerService', () => {
mailer,
urlService,
mock<EventService>(),
oauthServerConfigMock,
cimdMetadataClientMock,
);
};

View File

@ -0,0 +1,113 @@
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService, type HttpRequestClient } from '@n8n/backend-network';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { OperationalError } from 'n8n-workflow';
import { z } from 'zod';
import { CacheService } from '@/services/cache/cache.service';
const REQUEST_TIMEOUT = 10 * Time.seconds.toMilliseconds;
const METADATA_CACHE_TTL = 1 * Time.hours.toMilliseconds;
const CACHE_PREFIX = 'cimd:metadata';
/**
* Client ID Metadata Document (CIMD): the OAuth client metadata a client
* publishes at its `client_id` URL. A superset of RFC 7591 client metadata; we
* validate only the fields we consume and keep the rest permissive.
*/
const cimdMetadataSchema = z
.object({
// When present it must equal the client_id URL the document was fetched
// from; validated in fetchMetadata rather than here.
client_id: z.string().url().optional(),
client_name: z.string().min(1).optional(),
redirect_uris: z.array(z.string().url()).min(1),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
token_endpoint_auth_method: z.string().optional(),
scope: z.string().optional(),
logo_uri: z.string().url().optional(),
client_uri: z.string().url().optional(),
})
.passthrough();
export type CimdMetadata = z.infer<typeof cimdMetadataSchema>;
/** Raised when a client_id URL does not resolve to a valid, matching CIMD. */
export class CimdMetadataError extends OperationalError {}
/**
* Fetches and caches Client ID Metadata Documents.
*
* The `client_id` URL is client-controlled and fetched before the user has
* authenticated the client, so the request is always routed through the SSRF
* filter — regardless of the global `N8N_SSRF_PROTECTION_ENABLED` flag — to keep
* a hostile `client_id` from reaching internal addresses. Self-hosters whose
* clients publish metadata on an internal host can allow it via
* `N8N_SSRF_ALLOWED_*`.
*/
@Service()
export class CimdMetadataHttpClient {
private readonly http: HttpRequestClient;
constructor(
private readonly logger: Logger,
private readonly cache: CacheService,
outboundHttp: OutboundHttp,
ssrfProtectionService: SsrfProtectionService,
) {
this.http = outboundHttp.requests({
ssrf: ssrfProtectionService,
timeout: REQUEST_TIMEOUT,
});
}
/**
* Fetch, validate and read-through cache the CIMD at `clientId`. Throws
* {@link CimdMetadataError} if the URL is unreachable, the response is not a
* valid metadata document, or its `client_id` disagrees with the URL.
*/
async fetchMetadata(clientId: string): Promise<CimdMetadata> {
const cacheKey = `${CACHE_PREFIX}:${clientId}`;
const cached = await this.cache.get<CimdMetadata>(cacheKey);
if (cached) {
return cached;
}
const response = await this.http.request({
url: clientId,
method: 'GET',
json: true,
returnFullResponse: true,
ignoreHttpStatusErrors: true,
});
if (response.statusCode !== 200) {
throw new CimdMetadataError(
`Client metadata document at ${clientId} returned status ${response.statusCode}`,
);
}
let metadata: CimdMetadata;
try {
metadata = cimdMetadataSchema.parse(response.body);
} catch (error) {
throw new CimdMetadataError(`Invalid client metadata document at ${clientId}`, {
cause: error,
});
}
// The document's self-asserted client_id, when present, must be the URL it
// was fetched from — otherwise a document could impersonate another client.
if (metadata.client_id !== undefined && metadata.client_id !== clientId) {
throw new CimdMetadataError(
`Client metadata document at ${clientId} declares a mismatched client_id`,
);
}
await this.cache.set(cacheKey, metadata, METADATA_CACHE_TTL);
this.logger.debug('Resolved CIMD client', { clientId });
return metadata;
}
}

View File

@ -26,6 +26,15 @@ export class OAuthClient extends WithTimestamps {
@Column({ type: Boolean, default: false })
isFirstParty: boolean;
/**
* True for clients resolved from a Client ID Metadata Document (the
* `client_id` is an HTTPS URL). The row is persisted only to satisfy the
* consent/token foreign keys and is excluded from the registered-client cap,
* which exists to bound anonymous DCR registrations.
*/
@Column({ type: Boolean, default: false })
isCimd: boolean;
@OneToMany('AuthorizationCode', 'client')
authorizationCodes: AuthorizationCode[];

View File

@ -57,6 +57,7 @@ export class OAuthClientsController {
updatedAt: client.updatedAt.toISOString(),
grantedAt: client.grantedAt,
scopes: client.scopes,
isCimd: client.isCimd,
owner: client.owner,
}));

View File

@ -48,6 +48,7 @@ export class OAuthConsentController {
data: {
clientName: consentDetails.clientName,
clientId: consentDetails.clientId,
isCimd: consentDetails.isCimd,
redirectUri: consentDetails.redirectUri,
resourceName: consentDetails.resourceName,
scopes: consentDetails.scopes,

View File

@ -17,6 +17,8 @@ type ConsentDetailsResult =
ok: true;
clientName: string;
clientId: string;
/** True when the client is identified by a Client ID Metadata Document (its id is a verifiable HTTPS URL). */
isCimd: boolean;
resourceName?: string;
redirectUri?: string;
/**
@ -86,6 +88,7 @@ export class OAuthConsentService {
ok: true,
clientName: client.name,
clientId: client.id,
isCimd: client.isCimd,
resourceName: resource.displayName,
redirectUri: sessionPayload.redirectUri,
scopes,
@ -104,6 +107,7 @@ export class OAuthConsentService {
ok: true,
clientName: client.name,
clientId: client.id,
isCimd: client.isCimd,
redirectUri: sessionPayload.redirectUri,
scopes,
previousScopes: await this.previousScopes(user.id, client.id, scopes),

View File

@ -31,4 +31,16 @@ export class OAuthServerConfig {
*/
@Env('N8N_OAUTH_SERVER_WELL_KNOWN_RATE_LIMIT', rateLimitSchema)
rateLimitWellKnown: number = 100;
/**
* Support Client ID Metadata Documents (CIMD): recognize an HTTPS-URL
* `client_id`, fetch the client's metadata document from that URL, and use
* that verifiable identity instead of requiring Dynamic Client Registration.
* On by default because clients such as Claude already present URL
* `client_id`s. The fetch is always SSRF-guarded regardless of
* `N8N_SSRF_PROTECTION_ENABLED`, since the URL is client-controlled and
* fetched before authentication.
*/
@Env('N8N_OAUTH_SERVER_CIMD_ENABLED')
cimdEnabled: boolean = true;
}

View File

@ -26,10 +26,12 @@ import { ProtectedResourceRegistry } from '@/services/protected-resource.registr
import { UrlService } from '@/services/url.service';
import { UserManagementMailer } from '@/user-management/email';
import { CimdMetadataHttpClient } from './cimd-metadata-http-client';
import { OAuthClient } from './database/entities/oauth-client.entity';
import { OAuthClientRepository } from './database/repositories/oauth-client.repository';
import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository';
import { OAuthAuthorizationCodeService } from './oauth-authorization-code.service';
import { OAuthServerConfig } from './oauth-server.config';
import { OAuthSessionService } from './oauth-session.service';
import { OAuthTokenService } from './oauth-token.service';
import { OAuthClientLimitReachedError } from './oauth.errors';
@ -104,11 +106,20 @@ export class OAuthServerService implements OAuthServerProvider {
private readonly mailer: UserManagementMailer,
private readonly urlService: UrlService,
private readonly eventService: EventService,
private readonly oauthServerConfig: OAuthServerConfig,
private readonly cimdMetadataClient: CimdMetadataHttpClient,
) {}
get clientsStore(): OAuthRegisteredClientsStore {
return {
getClient: async (clientId: string): Promise<OAuthClientInformationFull | undefined> => {
// A CIMD client_id (an HTTPS URL) always resolves from its metadata
// document (cached), which stays the source of truth over the row we
// persist for the FKs.
if (this.isCimdClientId(clientId)) {
return await this.resolveCimdClient(clientId);
}
const client = await this.oauthClientRepository.findOneBy({ id: clientId });
if (!client) {
return await this.resolveVirtualClient(clientId);
@ -158,9 +169,16 @@ export class OAuthServerService implements OAuthServerProvider {
};
}
/** Returns true when the instance is already at or above the registered-client cap. */
/**
* Returns true when the instance is already at or above the registered-client
* cap. Only counts DCR registrations: first-party virtual clients and CIMD
* clients (identified by a verifiable URL) don't consume registration slots.
*/
async isClientLimitReached(): Promise<boolean> {
const clientCount = await this.oauthClientRepository.countBy({ isFirstParty: false });
const clientCount = await this.oauthClientRepository.countBy({
isFirstParty: false,
isCimd: false,
});
return clientCount >= this.globalConfig.endpoints.mcpMaxRegisteredClients;
}
@ -169,7 +187,7 @@ export class OAuthServerService implements OAuthServerProvider {
limit: number;
atCapacity: boolean;
}> {
const count = await this.oauthClientRepository.countBy({ isFirstParty: false });
const count = await this.oauthClientRepository.countBy({ isFirstParty: false, isCimd: false });
const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients;
return { count, limit, atCapacity: count >= limit };
}
@ -183,7 +201,10 @@ export class OAuthServerService implements OAuthServerProvider {
* — matching the response shape of the pre-check guard at the route layer.
*/
private async enforceClientLimit(clientId: string): Promise<void> {
const clientCount = await this.oauthClientRepository.countBy({ isFirstParty: false });
const clientCount = await this.oauthClientRepository.countBy({
isFirstParty: false,
isCimd: false,
});
const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients;
if (clientCount > limit) {
await this.oauthClientRepository.delete({ id: clientId });
@ -251,6 +272,84 @@ export class OAuthServerService implements OAuthServerProvider {
.some((base) => clientId.startsWith(base));
}
/**
* Whether a client_id should be treated as a Client ID Metadata Document: CIMD
* is enabled, the id is an HTTPS URL, and it isn't a first-party form-trigger
* URL (which is handled as a virtual client).
*/
private isCimdClientId(clientId: string): boolean {
if (!this.oauthServerConfig.cimdEnabled) {
return false;
}
let url: URL;
try {
url = new URL(clientId);
} catch {
return false;
}
return url.protocol === 'https:' && !this.isFormTriggerClientId(clientId);
}
/**
* Resolve a CIMD client by fetching its metadata document from the client_id
* URL. The document (cached, SSRF-guarded) is the source of truth; a row is
* upserted only so the consent/token foreign keys resolve, marked `isCimd` so
* it stays out of the registered-client cap. Returns `undefined` when the
* document can't be fetched or fails validation, so the SDK answers
* `invalid_client` just as it would for an unknown id.
*/
private async resolveCimdClient(
clientId: string,
): Promise<OAuthClientInformationFull | undefined> {
let metadata;
try {
metadata = await this.cimdMetadataClient.fetchMetadata(clientId);
} catch (error) {
this.logger.warn('CIMD client resolution failed', {
clientId,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
const clientName = metadata.client_name ?? clientId;
const redirectUris = metadata.redirect_uris;
const grantTypes = metadata.grant_types ?? ['authorization_code'];
const tokenEndpointAuthMethod = metadata.token_endpoint_auth_method ?? 'none';
await this.oauthClientRepository.upsert(
{
id: clientId,
name: clientName,
redirectUris,
grantTypes,
tokenEndpointAuthMethod,
clientSecret: null,
clientSecretExpiresAt: null,
isFirstParty: false,
isCimd: true,
},
['id'],
);
// Advertise the instance's supported scopes, same as the DCR path; the user
// picks the effective scopes on the consent screen.
const supportedScopes = this.resourceRegistry.getAllScopes();
return {
client_id: clientId,
client_name: clientName,
redirect_uris: redirectUris,
grant_types: grantTypes,
token_endpoint_auth_method: tokenEndpointAuthMethod,
response_types: ['code'],
...(supportedScopes.length > 0 && { scope: supportedScopes.join(' ') }),
logo_uri: metadata.logo_uri,
tos_uri: undefined,
};
}
private validateClientRegistration(client: OAuthClientInformationFull): void {
if (!client.client_name) {
throw new Error('client_name is required');
@ -336,7 +435,13 @@ export class OAuthServerService implements OAuthServerProvider {
const targetResource = resource
? await this.resourceRegistry.getByResourceUrl(resource)
: this.resourceRegistry.getDefaultResource();
const allowedUris = (await targetResource?.getAllowedRedirectUris?.()) ?? [];
// CIMD clients: the requested redirect_uri was already validated against
// the document's declared redirect_uris by the SDK, and per the CIMD trust
// model (the identity is verifiable at its URL) we don't additionally
// constrain it to the instance's redirect-URI allowlist.
const allowedUris = this.isCimdClientId(client.client_id)
? []
: ((await targetResource?.getAllowedRedirectUris?.()) ?? []);
if (allowedUris.length > 0 && !this.isRedirectUriAllowed(allowedUris, params.redirectUri)) {
this.logger.warn(
'MCP OAuth authorization rejected: requested redirect URI is not in the configured allowlist',

View File

@ -2565,6 +2565,14 @@
"oauth.consentView.error.insufficientScope": "You do not have sufficient permissions to authorize this request.",
"oauth.consentView.success.title": "Success",
"oauth.consentView.success.description": "You will soon be redirected back to the client.",
"oauth.consentView.verified.badge": "Verified",
"oauth.consentView.verified.tooltip": "This client's identity was verified from its published metadata document.",
"oauth.consentView.cimd.domainVerified": "Domain verified — the app's metadata is served from {origin}",
"oauth.consentView.cimd.redirectLocal": "Sends the authorization code to {uri} on this machine",
"oauth.consentView.cimd.redirectRemote": "Sends the authorization code to {uri}",
"oauth.consentView.cimd.unreviewed": "The name and icon are self-reported. n8n hasn't reviewed this app.",
"oauth.consentView.cimd.identifiedVia": "Identified via published metadata document",
"oauth.consentView.cimd.viewRawMetadata": "View raw metadata",
"parameterInput.expressionResult": "e.g. {result}",
"parameterInput.dragTipBeforePill": "Drag an",
"parameterInput.inputField": "input field",
@ -3362,6 +3370,9 @@
"settings.mcp.oAuthClients.details.subtitleWithType": "{type} · Connected to this instance over MCP",
"settings.mcp.oAuthClients.details.connectedOn": "Connected on",
"settings.mcp.oAuthClients.details.access": "Access",
"settings.mcp.oAuthClients.details.clientUrl": "Client URL",
"settings.mcp.oAuthClients.verified.badge": "Verified",
"settings.mcp.oAuthClients.verified.tooltip": "This client's identity was verified from its published metadata document.",
"settings.mcp.oAuthClients.details.enablesTools": "Enables {count} tool | Enables {count} tools",
"settings.mcp.oAuthClients.details.badge.read": "Read",
"settings.mcp.oAuthClients.details.badge.write": "Write",

View File

@ -4,6 +4,11 @@ import { makeRestApiRequest } from '../utils';
export interface ConsentDetails {
clientName: string;
clientId: string;
/**
* True when the client is identified by a Client ID Metadata Document, i.e.
* `clientId` is the HTTPS URL that verifiably identifies it.
*/
isCimd: boolean;
redirectUri?: string;
resourceName?: string;
/**

View File

@ -21,6 +21,7 @@ describe('OAuthConsentView', () => {
const details = {
clientName: 'Test MCP Client',
clientId: 'test-client-id',
isCimd: false,
redirectUri: 'https://legitimate-client.com/callback',
scopes: [],
};
@ -51,6 +52,7 @@ describe('OAuthConsentView', () => {
consentStore.consentDetails = {
clientName: 'Test MCP Client',
clientId: 'test-client-id',
isCimd: false,
resourceName: 'My Workflow',
scopes: [],
};
@ -73,6 +75,47 @@ describe('OAuthConsentView', () => {
expect(getByText('Get a list of your workflows')).toBeVisible();
});
it('hides the verified-identity block for a non-CIMD client', async () => {
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails!);
const { queryByTestId } = renderComponent();
await waitAllPromises();
expect(queryByTestId('consent-verified-identity')).toBeNull();
});
it('shows the CIMD trust card with verified signals and no trust checkbox', async () => {
consentStore.consentDetails = {
clientName: 'Test MCP Client',
clientId: 'https://client.example.com/metadata.json',
isCimd: true,
redirectUri: 'http://localhost/callback',
scopes: [],
};
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails);
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
const card = getByTestId('consent-verified-identity');
expect(card).toBeVisible();
// Origin is the trust anchor; full document URL is the tooltip + raw-metadata link.
expect(getByTestId('consent-verified-url')).toHaveTextContent('client.example.com');
// Verifiable signals: domain + where the auth code is sent.
expect(card).toHaveTextContent('metadata is served from client.example.com');
expect(card).toHaveTextContent('Sends the authorization code to http://localhost/callback');
// Honest caveat: name/icon are self-asserted and unreviewed.
expect(card).toHaveTextContent("n8n hasn't reviewed this app");
// Raw metadata links to the document itself.
expect(getByTestId('consent-raw-metadata-link')).toHaveAttribute(
'href',
'https://client.example.com/metadata.json',
);
// Verified clients skip the DCR trust warning/checkbox.
expect(queryByTestId('consent-redirect-warning')).toBeNull();
expect(queryByTestId('consent-redirect-confirm')).toBeNull();
});
it('should show the dedicated error and a Close action when the resource is unavailable', async () => {
consentStore.error = 'Authorization target is no longer available';
consentStore.errorCode = 'resource_unavailable';
@ -181,6 +224,7 @@ describe('OAuthConsentView', () => {
const scopedDetails = {
clientName: 'Test MCP Client',
clientId: 'test-client-id',
isCimd: false,
redirectUri: 'https://legitimate-client.com/callback',
scopes: ['workflow:read', 'workflow:write', 'execution:read'],
};

View File

@ -51,7 +51,37 @@ const clientDetails = computed<ConsentDetails | null>(() => consentStore.consent
const clientBrandIcon = computed(() => getClientBrand(clientDetails.value?.clientName ?? '').icon);
const availableScopes = computed(() => clientDetails.value?.scopes ?? []);
const hasScopes = computed(() => availableScopes.value.length > 0);
const trustRequired = computed(() => !!clientDetails.value?.redirectUri);
// CIMD clients don't get the manual trust gate: their redirect is attested by the
// same verified metadata document as their identity, and it's surfaced in the CIMD
// trust card instead. DCR clients (self-asserted, unverified) still acknowledge it.
const trustRequired = computed(
() => !!clientDetails.value?.redirectUri && !clientDetails.value?.isCimd,
);
// The origin the CIMD metadata was fetched from is the trust anchor (the domain a
// user recognizes); the full document URL stays available via "View raw metadata".
const clientOrigin = computed(() => {
const id = clientDetails.value?.clientId;
if (!id) return '';
try {
return new URL(id).host;
} catch {
return id;
}
});
// A loopback redirect returns the code to the user's own machine; used to tailor
// the CIMD redirect signal ("on this machine").
const isLoopbackRedirect = computed(() => {
const uri = clientDetails.value?.redirectUri;
if (!uri) return false;
try {
const { hostname } = new URL(uri);
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
} catch {
return false;
}
});
const noScopesSelected = computed(() => hasScopes.value && selectedScopes.value.length === 0);
const allowDisabled = computed(
() =>
@ -210,6 +240,71 @@ onMounted(async () => {
})
}}
</N8nHeading>
<!-- CIMD clients are identified by an HTTPS URL the server fetched their
metadata from. The card surfaces the verifiable signals (domain, where the
code is sent) with an honest caveat that the name/icon are self-asserted,
plus a link to the raw document. -->
<div
v-if="clientDetails?.isCimd"
:class="$style['cimd-card']"
data-test-id="consent-verified-identity"
>
<div :class="$style['cimd-header']">
<N8nText :bold="true" color="text-dark">{{ clientDetails.clientName }}</N8nText>
<code
:class="$style['cimd-origin']"
:title="clientDetails.clientId"
data-test-id="consent-verified-url"
>
{{ clientOrigin }}
</code>
</div>
<ul :class="$style['cimd-signals']">
<li :class="$style['cimd-signal']">
<N8nIcon icon="circle-check" color="success" size="small" />
<span>{{
i18n.baseText('oauth.consentView.cimd.domainVerified', {
interpolate: { origin: clientOrigin },
})
}}</span>
</li>
<li v-if="clientDetails.redirectUri" :class="$style['cimd-signal']">
<N8nIcon v-if="isLoopbackRedirect" icon="circle-check" color="success" size="small" />
<N8nIcon v-else icon="circle" color="text-light" size="small" />
<span>{{
i18n.baseText(
isLoopbackRedirect
? 'oauth.consentView.cimd.redirectLocal'
: 'oauth.consentView.cimd.redirectRemote',
{ interpolate: { uri: clientDetails.redirectUri } },
)
}}</span>
</li>
</ul>
<!-- Below the divider: the honest caveat sits in the metadata strip next to the
provenance, so the checklist above is only what n8n can actually verify. -->
<div :class="$style['cimd-meta']">
<div :class="$style['cimd-caveat']">
<N8nIcon icon="info" color="text-light" size="small" />
<span>{{ i18n.baseText('oauth.consentView.cimd.unreviewed') }}</span>
</div>
<div :class="$style['cimd-provenance']">
<N8nText size="xsmall" color="text-light">{{
i18n.baseText('oauth.consentView.cimd.identifiedVia')
}}</N8nText>
<a
:href="clientDetails.clientId"
target="_blank"
rel="noopener noreferrer"
:class="$style['cimd-raw-link']"
data-test-id="consent-raw-metadata-link"
>
{{ i18n.baseText('oauth.consentView.cimd.viewRawMetadata') }}
<N8nIcon icon="arrow-up-right" size="xsmall" />
</a>
</div>
</div>
</div>
<div :class="$style['text-content']">
<N8nText v-if="resourceName" color="text-base" size="medium">
{{
@ -253,10 +348,11 @@ onMounted(async () => {
</div>
</div>
<footer v-if="!waitingForRedirect" :class="$style.footer">
<!-- The trust acknowledgment lives inside the warning itself so it can't be missed:
one block that says where access goes, shows the URL, and asks for the check. -->
<!-- DCR clients (self-asserted, unverified): the trust acknowledgment lives inside
the warning so it can't be missed. CIMD clients skip this — their redirect is
shown in the verified trust card above. -->
<N8nCallout
v-if="!error && clientDetails?.redirectUri"
v-if="!error && trustRequired"
theme="warning"
data-test-id="consent-redirect-warning"
>
@ -265,7 +361,7 @@ onMounted(async () => {
{{ i18n.baseText('oauth.consentView.redirectWarning.title') }}
</N8nText>
<code :class="$style['redirect-warning-url']" data-test-id="consent-redirect-uri">
{{ clientDetails.redirectUri }}
{{ clientDetails?.redirectUri }}
</code>
<N8nCheckbox
v-model="redirectUriTrusted"
@ -455,6 +551,74 @@ onMounted(async () => {
text-align: center;
}
.cimd-card {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
padding: var(--spacing--sm);
border: var(--border-width) solid var(--border-color--light);
border-radius: var(--radius--lg);
}
.cimd-header {
display: flex;
flex-direction: column;
min-width: 0;
}
.cimd-origin {
font-size: var(--font-size--2xs);
color: var(--text-color--subtle);
word-break: break-all;
}
.cimd-signals {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
margin: 0;
padding: 0;
list-style: none;
}
.cimd-signal {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
font-size: var(--font-size--2xs);
}
.cimd-meta {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-top: var(--spacing--xs);
border-top: var(--border-width) solid var(--border-color--light);
}
.cimd-caveat {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
font-size: var(--font-size--2xs);
color: var(--text-color--subtle);
}
.cimd-provenance {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.cimd-raw-link {
display: inline-flex;
align-items: center;
gap: var(--spacing--5xs);
font-size: var(--font-size--2xs);
color: var(--link--color--secondary);
}
.text-content {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { N8nBadge, N8nIcon, N8nTooltip } from '@n8n/design-system';
// Shown for CIMD clients: their identity was fetched from a published metadata
// document, so it's verifiable rather than self-asserted.
defineProps<{
label: string;
tooltip: string;
}>();
</script>
<template>
<N8nTooltip :content="tooltip">
<N8nBadge theme="success" data-test-id="cimd-verified-badge">
<span :class="$style.content">
<N8nIcon icon="badge-check" size="xsmall" />
{{ label }}
</span>
</N8nBadge>
</N8nTooltip>
</template>
<style lang="scss" module>
.content {
display: inline-flex;
align-items: center;
gap: var(--spacing--5xs);
}
</style>

View File

@ -17,6 +17,7 @@ import type { BaseTextKey } from '@n8n/i18n';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { getClientBrand, scopeLabel } from '../clients.utils';
import McpVerifiedBadge from './McpVerifiedBadge.vue';
const props = defineProps<{
client: OAuthClientResponseDto | null;
@ -70,6 +71,11 @@ function onRevoke() {
<N8nIcon v-else icon="mcp" :class="$style.icon" />
</span>
{{ client.name }}
<McpVerifiedBadge
v-if="client.isCimd"
:label="i18n.baseText('settings.mcp.oAuthClients.verified.badge')"
:tooltip="i18n.baseText('settings.mcp.oAuthClients.verified.tooltip')"
/>
</span>
</N8nDialogTitle>
<N8nDialogDescription>{{ subtitle }}</N8nDialogDescription>
@ -85,6 +91,20 @@ function onRevoke() {
</N8nText>
</template>
<template v-if="client.isCimd">
<N8nText color="text-light" size="small">
{{ i18n.baseText('settings.mcp.oAuthClients.details.clientUrl') }}
</N8nText>
<N8nText
color="text-dark"
size="small"
:class="$style['client-url']"
data-test-id="mcp-client-details-client-url"
>
{{ client.id }}
</N8nText>
</template>
<N8nText color="text-light" size="small">
{{ i18n.baseText('settings.mcp.oAuthClients.details.connectedOn') }}
</N8nText>
@ -173,4 +193,8 @@ function onRevoke() {
gap: var(--spacing--3xs);
min-width: 0;
}
.client-url {
word-break: break-all;
}
</style>

View File

@ -29,6 +29,7 @@ import {
} from '../../clients.utils';
import type { OAuthClientFilters } from '../../clients.utils';
import McpEmptyStateCard from '../McpEmptyStateCard.vue';
import McpVerifiedBadge from '../McpVerifiedBadge.vue';
import OAuthClientDetailsModal from '../OAuthClientDetailsModal.vue';
import OAuthClientOwnerCell from './OAuthClientOwnerCell.vue';
import OAuthClientsFilters from './OAuthClientsFilters.vue';
@ -311,9 +312,20 @@ function onRevoke(item: OAuthClientResponseDto) {
<N8nIcon v-else icon="mcp" :class="$style['client-icon']" />
</span>
<div :class="$style['client-name']">
<N8nText data-test-id="mcp-client-name" color="text-dark">
{{ item.name }}
</N8nText>
<span :class="$style['client-name-row']">
<N8nText
data-test-id="mcp-client-name"
color="text-dark"
:class="$style['client-name-text']"
>
{{ item.name }}
</N8nText>
<McpVerifiedBadge
v-if="item.isCimd"
:label="i18n.baseText('settings.mcp.oAuthClients.verified.badge')"
:tooltip="i18n.baseText('settings.mcp.oAuthClients.verified.tooltip')"
/>
</span>
<N8nText
v-if="clientTypeLabel(item)"
data-test-id="mcp-client-type"
@ -433,6 +445,20 @@ function onRevoke(item: OAuthClientResponseDto) {
.client-name {
display: flex;
flex-direction: column;
min-width: 0;
}
.client-name-row {
display: inline-flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
}
.client-name-text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.empty-state {

View File

@ -66,5 +66,6 @@ export const createOAuthClient = (
tokenEndpointAuthMethod: 'client_secret_basic',
grantedAt: new Date('2025-09-09T14:14:04.155Z').getTime(),
scopes: ['workflow:read', 'execution:read'],
isCimd: false,
...overrides,
});