diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 13aa56c7d7c..0d91ed6d244 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -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 diff --git a/docs/generated/postgres-schema/public.oauth_access_tokens.md b/docs/generated/postgres-schema/public.oauth_access_tokens.md index 4a3c6947130..0b0883a54f0 100644 --- a/docs/generated/postgres-schema/public.oauth_access_tokens.md +++ b/docs/generated/postgres-schema/public.oauth_access_tokens.md @@ -44,6 +44,7 @@ erDiagram timestamp_3__with_time_zone createdAt json grantTypes varchar id + boolean isCimd boolean isFirstParty varchar_255_ name json redirectUris diff --git a/docs/generated/postgres-schema/public.oauth_authorization_codes.md b/docs/generated/postgres-schema/public.oauth_authorization_codes.md index 454fb742b73..e1c31761e2e 100644 --- a/docs/generated/postgres-schema/public.oauth_authorization_codes.md +++ b/docs/generated/postgres-schema/public.oauth_authorization_codes.md @@ -72,6 +72,7 @@ erDiagram timestamp_3__with_time_zone createdAt json grantTypes varchar id + boolean isCimd boolean isFirstParty varchar_255_ name json redirectUris diff --git a/docs/generated/postgres-schema/public.oauth_clients.md b/docs/generated/postgres-schema/public.oauth_clients.md index 60895179557..1a30cb49913 100644 --- a/docs/generated/postgres-schema/public.oauth_clients.md +++ b/docs/generated/postgres-schema/public.oauth_clients.md @@ -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 diff --git a/docs/generated/postgres-schema/public.oauth_refresh_tokens.md b/docs/generated/postgres-schema/public.oauth_refresh_tokens.md index edde80d3771..0c946c800a8 100644 --- a/docs/generated/postgres-schema/public.oauth_refresh_tokens.md +++ b/docs/generated/postgres-schema/public.oauth_refresh_tokens.md @@ -56,6 +56,7 @@ erDiagram timestamp_3__with_time_zone createdAt json grantTypes varchar id + boolean isCimd boolean isFirstParty varchar_255_ name json redirectUris diff --git a/docs/generated/postgres-schema/public.oauth_user_consents.md b/docs/generated/postgres-schema/public.oauth_user_consents.md index 00b7ab42fb6..47d2220d6c0 100644 --- a/docs/generated/postgres-schema/public.oauth_user_consents.md +++ b/docs/generated/postgres-schema/public.oauth_user_consents.md @@ -52,6 +52,7 @@ erDiagram timestamp_3__with_time_zone createdAt json grantTypes varchar id + boolean isCimd boolean isFirstParty varchar_255_ name json redirectUris diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 7182604a07c..dff6ce3a5c2 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -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 diff --git a/docs/generated/sqlite-schema/oauth_access_tokens.md b/docs/generated/sqlite-schema/oauth_access_tokens.md index 904d880aa81..d5a7799e130 100644 --- a/docs/generated/sqlite-schema/oauth_access_tokens.md +++ b/docs/generated/sqlite-schema/oauth_access_tokens.md @@ -53,6 +53,7 @@ erDiagram datetime_3_ createdAt TEXT grantTypes varchar id PK + boolean isCimd boolean isFirstParty varchar_255_ name TEXT redirectUris diff --git a/docs/generated/sqlite-schema/oauth_authorization_codes.md b/docs/generated/sqlite-schema/oauth_authorization_codes.md index 5bc4b4947f3..dd52be80e16 100644 --- a/docs/generated/sqlite-schema/oauth_authorization_codes.md +++ b/docs/generated/sqlite-schema/oauth_authorization_codes.md @@ -73,6 +73,7 @@ erDiagram datetime_3_ createdAt TEXT grantTypes varchar id PK + boolean isCimd boolean isFirstParty varchar_255_ name TEXT redirectUris diff --git a/docs/generated/sqlite-schema/oauth_clients.md b/docs/generated/sqlite-schema/oauth_clients.md index 69a9df541a1..742c74d1264 100644 --- a/docs/generated/sqlite-schema/oauth_clients.md +++ b/docs/generated/sqlite-schema/oauth_clients.md @@ -6,7 +6,7 @@ Table Definition ```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)) ``` @@ -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 diff --git a/docs/generated/sqlite-schema/oauth_refresh_tokens.md b/docs/generated/sqlite-schema/oauth_refresh_tokens.md index b4dc6c8a780..03e85c4d34f 100644 --- a/docs/generated/sqlite-schema/oauth_refresh_tokens.md +++ b/docs/generated/sqlite-schema/oauth_refresh_tokens.md @@ -61,6 +61,7 @@ erDiagram datetime_3_ createdAt TEXT grantTypes varchar id PK + boolean isCimd boolean isFirstParty varchar_255_ name TEXT redirectUris diff --git a/docs/generated/sqlite-schema/oauth_user_consents.md b/docs/generated/sqlite-schema/oauth_user_consents.md index 02ab80bae06..3c25a9e482a 100644 --- a/docs/generated/sqlite-schema/oauth_user_consents.md +++ b/docs/generated/sqlite-schema/oauth_user_consents.md @@ -57,6 +57,7 @@ erDiagram datetime_3_ createdAt TEXT grantTypes varchar id PK + boolean isCimd boolean isFirstParty varchar_255_ name TEXT redirectUris diff --git a/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts index e6a3b4085c3..5c84d4a6490 100644 --- a/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts +++ b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts @@ -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(), }; diff --git a/packages/@n8n/db/src/migrations/common/1785500900000-AddIsCimdToOAuthClients.ts b/packages/@n8n/db/src/migrations/common/1785500900000-AddIsCimdToOAuthClients.ts new file mode 100644 index 00000000000..7fe1e3fbddf --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1785500900000-AddIsCimdToOAuthClients.ts @@ -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, + }); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index cca7ebbc3f3..0e2783e0429 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/1785500900000-AddIsCimdToOAuthClients.ts b/packages/@n8n/db/src/migrations/sqlite/1785500900000-AddIsCimdToOAuthClients.ts new file mode 100644 index 00000000000..09ca2da07c8 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1785500900000-AddIsCimdToOAuthClients.ts @@ -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, + }); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index d9879b1b7c5..f161f8e0690 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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 }; diff --git a/packages/cli/src/modules/oauth-server/__tests__/cimd-metadata-http-client.test.ts b/packages/cli/src/modules/oauth-server/__tests__/cimd-metadata-http-client.test.ts new file mode 100644 index 00000000000..252fb5362ad --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/cimd-metadata-http-client.test.ts @@ -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(); + const cache = mock(); + const ssrfProtectionService = mock(); + const httpClient = mock(); + const outboundHttp = mock(); + + 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', + }); + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts index 5fef61081a5..720473dacd9 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts @@ -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: [], diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts index e8f96a69ddc..07fe05432e4 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts @@ -79,6 +79,7 @@ describe('OAuthConsentService', () => { const client = mock({ 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({ 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({ id: 'client-123', name: 'Test Client' }); + const client = mock({ 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({ id: 'client-123', name: 'Test Client' }); + const client = mock({ 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({ id: 'client-123', name: 'Test Client' }); + const client = mock({ 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({ id: 'client-123', name: 'Test Client' }); + const client = mock({ 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({ id: 'client-123', name: 'Test Client' }); + const client = mock({ 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({ id: 'client-123', name: 'Test Client' }); + const client = mock({ id: 'client-123', name: 'Test Client', isCimd: false }); oauthSessionService.verifySession.mockReturnValue(sessionPayload); oauthClientRepository.findOne.mockResolvedValue(client); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.config.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.config.test.ts index d8ef318e80d..13d6fcae60f 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.config.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.config.test.ts @@ -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); + }); }); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index b1315a48777..2217977829e 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -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; // Shared, immutable across tests: the base URLs gate the first-party client_id guard. const urlServiceMock = mock(); +// 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({ cimdEnabled: false }); +const cimdMetadataClientMock = mock(); + 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(); + + 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(), + oauthServerConfigMock, + cimdMetadataClientMock, ); }); @@ -251,6 +362,8 @@ describe('OAuthServerService', () => { mailer, urlServiceMock, mock(), + 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(), + oauthServerConfigMock, + cimdMetadataClientMock, ); expect( @@ -1447,6 +1570,8 @@ describe('OAuthServerService', () => { mailer, urlService, mock(), + oauthServerConfigMock, + cimdMetadataClientMock, ); }; diff --git a/packages/cli/src/modules/oauth-server/cimd-metadata-http-client.ts b/packages/cli/src/modules/oauth-server/cimd-metadata-http-client.ts new file mode 100644 index 00000000000..7cc080f010b --- /dev/null +++ b/packages/cli/src/modules/oauth-server/cimd-metadata-http-client.ts @@ -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; + +/** 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 { + const cacheKey = `${CACHE_PREFIX}:${clientId}`; + const cached = await this.cache.get(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; + } +} diff --git a/packages/cli/src/modules/oauth-server/database/entities/oauth-client.entity.ts b/packages/cli/src/modules/oauth-server/database/entities/oauth-client.entity.ts index 26b2e83609f..ce569dd6fef 100644 --- a/packages/cli/src/modules/oauth-server/database/entities/oauth-client.entity.ts +++ b/packages/cli/src/modules/oauth-server/database/entities/oauth-client.entity.ts @@ -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[]; diff --git a/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts b/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts index 42be5d206fa..ad5081b2e7c 100644 --- a/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts @@ -57,6 +57,7 @@ export class OAuthClientsController { updatedAt: client.updatedAt.toISOString(), grantedAt: client.grantedAt, scopes: client.scopes, + isCimd: client.isCimd, owner: client.owner, })); diff --git a/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts b/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts index f980075bd37..48fb030af20 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts @@ -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, diff --git a/packages/cli/src/modules/oauth-server/oauth-consent.service.ts b/packages/cli/src/modules/oauth-server/oauth-consent.service.ts index a88fc0cf3d0..a4d962c79a4 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.service.ts @@ -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), diff --git a/packages/cli/src/modules/oauth-server/oauth-server.config.ts b/packages/cli/src/modules/oauth-server/oauth-server.config.ts index ff0cd2d3400..7b154641744 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.config.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.config.ts @@ -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; } diff --git a/packages/cli/src/modules/oauth-server/oauth-server.service.ts b/packages/cli/src/modules/oauth-server/oauth-server.service.ts index b434f10402a..911102670a0 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.service.ts @@ -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 => { + // 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 { - 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 { - 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 { + 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', diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 894986cfcd2..aacbc7d5310 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/consent.ts b/packages/frontend/@n8n/rest-api-client/src/api/consent.ts index 4c545f19d55..008c0e69756 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/consent.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/consent.ts @@ -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; /** diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts index 8a0146d3900..71c05bb3eda 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts @@ -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'], }; diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue index 8f0bcf865fe..66a9cd79606 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -51,7 +51,37 @@ const clientDetails = computed(() => 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 () => { }) }} + +
+
+ {{ clientDetails.clientName }} + + {{ clientOrigin }} + +
+
    +
  • + + {{ + i18n.baseText('oauth.consentView.cimd.domainVerified', { + interpolate: { origin: clientOrigin }, + }) + }} +
  • +
  • + + + {{ + i18n.baseText( + isLoopbackRedirect + ? 'oauth.consentView.cimd.redirectLocal' + : 'oauth.consentView.cimd.redirectRemote', + { interpolate: { uri: clientDetails.redirectUri } }, + ) + }} +
  • +
+ +
+
+ + {{ i18n.baseText('oauth.consentView.cimd.unreviewed') }} +
+
+ {{ + i18n.baseText('oauth.consentView.cimd.identifiedVia') + }} + + {{ i18n.baseText('oauth.consentView.cimd.viewRawMetadata') }} + + +
+
+
{{ @@ -253,10 +348,11 @@ onMounted(async () => {