diff --git a/packages/@n8n/api-types/src/consent.ts b/packages/@n8n/api-types/src/consent.ts new file mode 100644 index 00000000000..a4bc37dfddf --- /dev/null +++ b/packages/@n8n/api-types/src/consent.ts @@ -0,0 +1,6 @@ +export interface ConsentUiHints { + /** design-system icon name (updatedIconSet) for the consent logo tile; omit to keep the client-brand / mcp fallback */ + icon?: string; + /** noun key inserted into first-party consent copy, e.g. 'form' (later 'webhook'); resolved to a localized word on the frontend */ + consentType?: string; +} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index ba1b8fa9b90..0ebbb9a8d81 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -5,6 +5,7 @@ export type * from './push'; export type * from './scaling'; export * from './frontend-settings'; export type * from './user'; +export type * from './consent'; export type * from './api-keys'; export type * from './community-node-types'; export type * from './quick-connect'; 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 b49e0706fde..84e2cc091f1 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 @@ -69,6 +69,7 @@ describe('GET /rest/consent/details', () => { scopeTools: expect.objectContaining({ 'workflow:read': expect.arrayContaining(['search_workflows']), }), + isFirstParty: false, }); }); @@ -111,6 +112,7 @@ describe('GET /rest/consent/details', () => { resourceName: 'My Named Workflow', redirectUri: 'https://example.com/callback', scopes: [], + isFirstParty: false, }); }); 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 e044d02b37f..0c1c0ad95dd 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', + isFirstParty: false, }); oauthSessionService.verifySession.mockReturnValue(sessionPayload); @@ -93,6 +94,7 @@ describe('OAuthConsentService', () => { redirectUri: 'https://example.com/callback', scopes: INSTANCE_SCOPES, previousScopes: undefined, + isFirstParty: false, }); expect(oauthSessionService.verifySession).toHaveBeenCalledWith(sessionToken); expect(oauthClientRepository.findOne).toHaveBeenCalledWith({ @@ -144,6 +146,7 @@ describe('OAuthConsentService', () => { const client = mock({ id: 'client-123', name: 'Test Client', + isFirstParty: false, }); oauthSessionService.verifySession.mockReturnValue(sessionPayload); @@ -158,6 +161,7 @@ describe('OAuthConsentService', () => { redirectUri: 'https://example.com/callback', scopes: INSTANCE_SCOPES, previousScopes: undefined, + isFirstParty: false, }); }); @@ -241,7 +245,11 @@ 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', + isFirstParty: false, + }); oauthSessionService.verifySession.mockReturnValue(sessionPayload); oauthClientRepository.findOne.mockResolvedValue(client); @@ -261,6 +269,7 @@ describe('OAuthConsentService', () => { redirectUri: 'https://example.com/callback', scopes: [], previousScopes: undefined, + isFirstParty: false, }); expect(protectedResourceRegistry.getByResourceUrl).toHaveBeenCalledWith( 'https://n8n.example.com/mcp/wf-123', @@ -276,7 +285,11 @@ 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', + isFirstParty: false, + }); oauthSessionService.verifySession.mockReturnValue(sessionPayload); oauthClientRepository.findOne.mockResolvedValue(client); @@ -297,9 +310,72 @@ describe('OAuthConsentService', () => { redirectUri: 'https://example.com/callback', scopes: INSTANCE_SCOPES, previousScopes: undefined, + isFirstParty: false, }); }); + it('should forward the resource uiHints and a first-party client flag', async () => { + const sessionToken = 'valid-session-token'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: null, + resource: 'https://n8n.example.com/form/wf-123', + }; + const client = mock({ + id: 'client-123', + name: 'Test Client', + isFirstParty: true, + }); + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(client); + protectedResourceRegistry.getByResourceUrl.mockResolvedValue({ + displayName: 'My Form', + scopes: [], + uiHints: { icon: 'square-pen', consentType: 'form' }, + authorize: async () => true, + } as unknown as ProtectedResource); + + const result = await service.getConsentDetails(sessionToken, mock({ id: 'user-1' })); + + expect(result).toMatchObject({ + ok: true, + isFirstParty: true, + uiHints: { icon: 'square-pen', consentType: 'form' }, + }); + }); + + it('should report a non-first-party client and omit uiHints when the resource has none', async () => { + const sessionToken = 'valid-session-token'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: null, + resource: 'https://n8n.example.com/mcp/wf-123', + }; + const client = mock({ + id: 'client-123', + name: 'Test Client', + isFirstParty: false, + }); + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(client); + protectedResourceRegistry.getByResourceUrl.mockResolvedValue({ + displayName: 'My Workflow', + scopes: [], + authorize: async () => true, + } as unknown as ProtectedResource); + + const result = await service.getConsentDetails(sessionToken, mock({ id: 'user-1' })); + + expect(result).toMatchObject({ ok: true, isFirstParty: false }); + expect((result as { uiHints?: unknown }).uiHints).toBeUndefined(); + }); + it('should report resource_unavailable when the resource cannot be resolved', async () => { const sessionToken = 'valid-session-token'; const sessionPayload = { 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..310a73043e5 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts @@ -53,6 +53,8 @@ export class OAuthConsentController { scopes: consentDetails.scopes, previousScopes: consentDetails.previousScopes, scopeTools: consentDetails.scopeTools, + uiHints: consentDetails.uiHints, + isFirstParty: consentDetails.isFirstParty, }, }); } catch (error) { 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 3874c1debc8..b6aeda005c1 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.service.ts @@ -1,3 +1,4 @@ +import type { ConsentUiHints } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -29,6 +30,10 @@ type ConsentDetailsResult = previousScopes?: string[]; /** Tool names each scope unlocks, shown per scope group in the picker. */ scopeTools?: Record; + /** Presentational hints for the consent screen, sourced from the target resource. */ + uiHints?: ConsentUiHints; + /** Whether the requesting client is a trusted first-party client. */ + isFirstParty: boolean; } | { ok: false; reason: 'resource_unavailable' } | { ok: false; reason: 'forbidden' }; @@ -91,6 +96,8 @@ export class OAuthConsentService { scopes, previousScopes: await this.previousScopes(user.id, client.id, scopes), scopeTools: resource.getScopeTools?.(), + uiHints: resource.uiHints, + isFirstParty: client.isFirstParty, }; } @@ -108,6 +115,8 @@ export class OAuthConsentService { scopes, previousScopes: await this.previousScopes(user.id, client.id, scopes), scopeTools: defaultResource?.getScopeTools?.(), + uiHints: defaultResource?.uiHints, + isFirstParty: client.isFirstParty, }; } catch (error) { this.logger.error('Error getting consent details', { error }); diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts index 40554ebaecc..9c8f1f21fba 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts @@ -12,6 +12,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { triggerResourceGate } from '../resource-gate'; import { + FORM_TRIGGER_CONSENT_HINTS, FORM_TRIGGER_SCOPES, resourceUrlToWebhookPath, trimSlashes, @@ -91,6 +92,7 @@ export class FormTriggerResourceResolver implements ProtectedResourceResolver { getAllowedRedirectUris: async () => [resourceUrl], scopes: FORM_TRIGGER_SCOPES, displayName: workflow.name, + uiHints: FORM_TRIGGER_CONSENT_HINTS, ...triggerResourceGate(this.workflowFinderService, { audiences, executeAccessWorkflowId: requireExecute ? workflow.id : undefined, diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts index c2ee03f3870..b621f25e3f3 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts @@ -11,6 +11,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { triggerResourceGate } from '../resource-gate'; import { + FORM_TRIGGER_CONSENT_HINTS, FORM_TRIGGER_SCOPES, resourceUrlToWebhookPath, trimSlashes, @@ -93,6 +94,7 @@ export class FormTriggerTestResourceResolver implements ProtectedResourceResolve getAllowedRedirectUris: async () => [resourceUrl], scopes: FORM_TRIGGER_SCOPES, displayName: workflowEntity.name, + uiHints: FORM_TRIGGER_CONSENT_HINTS, ...triggerResourceGate(this.workflowFinderService, { audiences, executeAccessWorkflowId: requireExecute ? workflowEntity.id : undefined, diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts index e7b78174a89..b26c7eaabf4 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts @@ -1,3 +1,5 @@ +import type { ConsentUiHints } from '@n8n/api-types'; + /** * Scopes advertised for per-workflow MCP trigger resources. Empty on purpose: * the gate enforces no scopes and tokens carry none, and an empty list makes @@ -8,6 +10,12 @@ export const WORKFLOW_MCP_TRIGGER_SCOPES: string[] = []; /** Scopes advertised for per-workflow Form trigger resources. Empty, like MCP triggers. */ export const FORM_TRIGGER_SCOPES: string[] = []; +/** Consent-screen presentation hints for per-workflow Form trigger resources. */ +export const FORM_TRIGGER_CONSENT_HINTS: ConsentUiHints = { + icon: 'square-pen', + consentType: 'form', +}; + /** Scopes advertised for per-workflow Webhook trigger resources. */ export const WEBHOOK_TRIGGER_SCOPES: string[] = []; diff --git a/packages/cli/src/services/protected-resource.registry.ts b/packages/cli/src/services/protected-resource.registry.ts index 75a52c99140..0170efd809c 100644 --- a/packages/cli/src/services/protected-resource.registry.ts +++ b/packages/cli/src/services/protected-resource.registry.ts @@ -1,3 +1,4 @@ +import type { ConsentUiHints } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -19,6 +20,9 @@ export interface ProtectedResource { /** Human readable name, for consent screen */ displayName?: string; + /** Presentational hints for the consent screen; omit for the default client-brand treatment. */ + uiHints?: ConsentUiHints; + /** * Canonical RFC 8707 resource URL used as the JWT `aud` claim and advertised * as the resource indicator (e.g. `https://instance.example/mcp-server/http`). diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b9c99279970..d7bd81443e3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2546,6 +2546,10 @@ "oauth.consentView.allow": "Allow access", "oauth.consentView.redirectWarning.title": "After you allow, access to this instance is sent to:", "oauth.consentView.redirectWarning.confirm": "I recognize and trust this URL", + "oauth.consentView.firstParty.heading": "\"{resourceName}\" wants to run using your n8n login", + "oauth.consentView.firstParty.description": "Running this {resourceType} executes its workflow using your account and any connected credentials. Only continue if you trust the creator of this {resourceType}.", + "oauth.consentView.firstParty.type.form": "form", + "oauth.consentView.firstParty.type.default": "resource", "oauth.consentView.allowDisabled.trust": "First confirm you recognize and trust the URL above", "oauth.consentView.allowDisabled.noScopes": "Select at least one scope to grant", "oauth.consentView.error.deny": "Error denying access", 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..9505170a071 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/consent.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/consent.ts @@ -1,3 +1,5 @@ +import type { ConsentUiHints } from '@n8n/api-types'; + import type { IRestApiContext } from '../types'; import { makeRestApiRequest } from '../utils'; @@ -15,6 +17,10 @@ export interface ConsentDetails { previousScopes?: string[]; /** Tool names each scope unlocks, shown per scope group in the picker. */ scopeTools?: Record; + /** Presentation hints from the resource (icon, consent noun) for first-party clients. */ + uiHints?: ConsentUiHints; + /** True only for first-party clients (e.g. form triggers) that skip the trust gate. */ + isFirstParty?: boolean; } export interface ConsentApprovalResponse { 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..09c8c4f6d76 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts @@ -177,6 +177,54 @@ describe('OAuthConsentView', () => { ); }); + describe('first-party consent', () => { + const firstPartyDetails = { + clientName: 'My Form', + clientId: 'c1', + redirectUri: 'https://instance.example/form/abc', + resourceName: 'Feedback workflow', + scopes: [], + isFirstParty: true, + uiHints: { icon: 'square-pen', consentType: 'form' }, + }; + + beforeEach(() => { + consentStore.consentDetails = firstPartyDetails; + consentStore.fetchConsentDetails.mockImplementation(async () => { + consentStore.consentDetails = firstPartyDetails; + return firstPartyDetails; + }); + }); + + it('should hide the trust checkbox and enable Allow without acknowledgement', async () => { + const { getByTestId, queryByTestId } = renderComponent(); + await waitAllPromises(); + + expect(queryByTestId('consent-redirect-confirm')).toBeNull(); + expect(getByTestId('consent-allow-button')).not.toBeDisabled(); + }); + + it('should not show the redirect URL or the warning callout', async () => { + const { queryByTestId } = renderComponent(); + await waitAllPromises(); + + expect(queryByTestId('consent-redirect-warning')).toBeNull(); + expect(queryByTestId('consent-redirect-uri')).toBeNull(); + }); + + it('should render the first-party heading and resource-driven description', async () => { + const { getByText } = renderComponent(); + await waitAllPromises(); + + expect(getByText('"Feedback workflow" wants to run using your n8n login')).toBeVisible(); + expect( + getByText( + 'Running this form executes its workflow using your account and any connected credentials. Only continue if you trust the creator of this form.', + ), + ).toBeVisible(); + }); + }); + describe('scope selection', () => { const scopedDetails = { clientName: 'Test MCP Client', diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue index 8f0bcf865fe..65532bf141a 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -2,6 +2,7 @@ import { useConsentStore } from '@/app/stores/consent.store'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { useI18n } from '@n8n/i18n'; +import type { BaseTextKey } from '@n8n/i18n'; import { onMounted, computed, ref, watch } from 'vue'; import type { ConsentDetails } from '@n8n/rest-api-client/api/consent'; import { @@ -49,9 +50,17 @@ const errorMessage = computed(() => { const clientDetails = computed(() => consentStore.consentDetails); // Known clients get their brand mark on the left tile; unknown ones fall back to the MCP glyph. const clientBrandIcon = computed(() => getClientBrand(clientDetails.value?.clientName ?? '').icon); +// Localized noun for first-party copy, driven by the resource's consentType hint. +const firstPartyResourceType = computed(() => + i18n.baseText( + `oauth.consentView.firstParty.type.${clientDetails.value?.uiHints?.consentType ?? 'default'}` as BaseTextKey, + ), +); const availableScopes = computed(() => clientDetails.value?.scopes ?? []); const hasScopes = computed(() => availableScopes.value.length > 0); -const trustRequired = computed(() => !!clientDetails.value?.redirectUri); +const trustRequired = computed( + () => !!clientDetails.value?.redirectUri && !clientDetails.value?.isFirstParty, +); const noScopesSelected = computed(() => hasScopes.value && selectedScopes.value.length === 0); const allowDisabled = computed( () => @@ -146,7 +155,17 @@ onMounted(async () => {
- + +
- + + {{ + i18n.baseText('oauth.consentView.firstParty.heading', { + interpolate: { resourceName: resourceName ?? '' }, + }) + }} + + {{ i18n.baseText('oauth.consentView.headingWithWorkflow', { interpolate: { clientName: clientDetails?.clientName ?? '', resourceName }, @@ -211,7 +237,14 @@ onMounted(async () => { }}
- + + {{ + i18n.baseText('oauth.consentView.firstParty.description', { + interpolate: { resourceType: firstPartyResourceType }, + }) + }} + + {{ i18n.baseText('oauth.consentView.descriptionWithWorkflow', { interpolate: { clientName: clientDetails?.clientName ?? '' }, @@ -253,10 +286,11 @@ onMounted(async () => {