mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
7a40b4eafe
commit
0432bc926c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
json grantTypes
|
||||
varchar id
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
json redirectUris
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
json grantTypes
|
||||
varchar id
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
json redirectUris
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
json grantTypes
|
||||
varchar id
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
json redirectUris
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
json grantTypes
|
||||
varchar id
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
json redirectUris
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
TEXT grantTypes
|
||||
varchar id PK
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
TEXT redirectUris
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
TEXT grantTypes
|
||||
varchar id PK
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
TEXT redirectUris
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
TEXT grantTypes
|
||||
varchar id PK
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
TEXT redirectUris
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
TEXT grantTypes
|
||||
varchar id PK
|
||||
boolean isCimd
|
||||
boolean isFirstParty
|
||||
varchar_255_ name
|
||||
TEXT redirectUris
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export class OAuthClientsController {
|
|||
updatedAt: client.updatedAt.toISOString(),
|
||||
grantedAt: client.grantedAt,
|
||||
scopes: client.scopes,
|
||||
isCimd: client.isCimd,
|
||||
owner: client.owner,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user