feat: Adapt the OAuth consent screen for first-party clients (#36261)

This commit is contained in:
Andreas Fitzek 2026-08-13 21:02:36 +00:00 committed by GitHub
parent bab26492bc
commit 2e97fa52bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 213 additions and 9 deletions

View File

@ -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;
}

View File

@ -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';

View File

@ -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,
});
});

View File

@ -79,6 +79,7 @@ describe('OAuthConsentService', () => {
const client = mock<OAuthClient>({
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<OAuthClient>({
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<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({
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<OAuthClient>({ id: 'client-123', name: 'Test Client' });
const client = mock<OAuthClient>({
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<OAuthClient>({
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<User>({ 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<OAuthClient>({
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<User>({ 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 = {

View File

@ -53,6 +53,8 @@ export class OAuthConsentController {
scopes: consentDetails.scopes,
previousScopes: consentDetails.previousScopes,
scopeTools: consentDetails.scopeTools,
uiHints: consentDetails.uiHints,
isFirstParty: consentDetails.isFirstParty,
},
});
} catch (error) {

View File

@ -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<string, string[]>;
/** 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 });

View File

@ -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,

View File

@ -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,

View File

@ -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[] = [];

View File

@ -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`).

View File

@ -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",

View File

@ -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<string, string[]>;
/** 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 {

View File

@ -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',

View File

@ -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<ConsentDetails | null>(() => 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 () => {
<div :class="$style['consent-dialog']">
<header :class="$style.header">
<div :class="$style.logo">
<component :is="clientBrandIcon" v-if="clientBrandIcon" :class="$style['brand-icon']" />
<N8nIcon
v-if="clientDetails?.uiHints?.icon"
:icon="clientDetails.uiHints.icon"
size="large"
color="text-dark"
/>
<component
:is="clientBrandIcon"
v-else-if="clientBrandIcon"
:class="$style['brand-icon']"
/>
<N8nIcon v-else icon="mcp" size="large" color="text-dark" />
</div>
<!-- Pending-connection connector: a dashed SVG line marching toward the n8n tile
@ -196,7 +215,14 @@ onMounted(async () => {
</div>
<!-- Default content -->
<div v-else :class="$style.content" data-test-id="consent-content">
<N8nHeading v-if="resourceName" tag="h2" size="large" :bold="true">
<N8nHeading v-if="clientDetails?.isFirstParty" tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.firstParty.heading', {
interpolate: { resourceName: resourceName ?? '' },
})
}}
</N8nHeading>
<N8nHeading v-else-if="resourceName" tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.headingWithWorkflow', {
interpolate: { clientName: clientDetails?.clientName ?? '', resourceName },
@ -211,7 +237,14 @@ onMounted(async () => {
}}
</N8nHeading>
<div :class="$style['text-content']">
<N8nText v-if="resourceName" color="text-base" size="medium">
<N8nText v-if="clientDetails?.isFirstParty" color="text-base" size="medium">
{{
i18n.baseText('oauth.consentView.firstParty.description', {
interpolate: { resourceType: firstPartyResourceType },
})
}}
</N8nText>
<N8nText v-else-if="resourceName" color="text-base" size="medium">
{{
i18n.baseText('oauth.consentView.descriptionWithWorkflow', {
interpolate: { clientName: clientDetails?.clientName ?? '' },
@ -253,10 +286,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. -->
<!-- Third-party clients: the trust acknowledgment lives inside the warning itself so it
can't be missed: one block that says where access goes, shows the URL, asks for the check.
First-party clients skip this entirely — their redirect URI is the form itself. -->
<N8nCallout
v-if="!error && clientDetails?.redirectUri"
v-if="!error && !clientDetails?.isFirstParty && clientDetails?.redirectUri"
theme="warning"
data-test-id="consent-redirect-warning"
>