mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat: Adapt the OAuth consent screen for first-party clients (#36261)
This commit is contained in:
parent
bab26492bc
commit
2e97fa52bf
6
packages/@n8n/api-types/src/consent.ts
Normal file
6
packages/@n8n/api-types/src/consent.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export class OAuthConsentController {
|
|||
scopes: consentDetails.scopes,
|
||||
previousScopes: consentDetails.previousScopes,
|
||||
scopeTools: consentDetails.scopeTools,
|
||||
uiHints: consentDetails.uiHints,
|
||||
isFirstParty: consentDetails.isFirstParty,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user