feat: Show the workflow name on the OAuth consent screen (#32362)

This commit is contained in:
Andreas Fitzek
2026-06-16 11:33:39 +02:00
committed by GitHub
parent 029cf72b97
commit 7a5c2087f7
9 changed files with 274 additions and 10 deletions
@@ -3,6 +3,7 @@ import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { JwtService } from '@/services/jwt.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { createOwner, createMember } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
@@ -63,6 +64,78 @@ describe('GET /rest/consent/details', () => {
});
});
test('should include the resource name when the session resource resolves', async () => {
const client = await oauthClientRepository.save({
id: 'resource-client-id',
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const resourceUrl = 'https://n8n.example.com/mcp/named-workflow';
Container.get(ProtectedResourceRegistry).register({
id: 'test-named-resource',
displayName: 'My Named Workflow',
getResourceUrl: () => resourceUrl,
getAudiences: () => [resourceUrl],
scopes: [],
});
const sessionToken = createSessionToken({
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
resource: resourceUrl,
});
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({
clientName: 'Test OAuth Client',
clientId: 'resource-client-id',
resourceName: 'My Named Workflow',
});
});
test('should return 422 and clear the session when the resource cannot be resolved', async () => {
const client = await oauthClientRepository.save({
id: 'unresolvable-client-id',
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionToken = createSessionToken({
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
resource: 'https://n8n.example.com/mcp/does-not-exist',
});
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(response.statusCode).toBe(422);
expect(response.body).toEqual({
status: 'error',
message: 'Authorization target is no longer available',
});
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should return 400 when session cookie is missing', async () => {
const response = await testServer.authAgentFor(owner).get('/consent/details');
@@ -8,12 +8,17 @@ import { OAuthConsentService } from '../oauth-consent.service';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { OAuthSessionService } from '../oauth-session.service';
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
import {
ProtectedResourceRegistry,
type ProtectedResource,
} from '@/services/protected-resource.registry';
let logger: jest.Mocked<Logger>;
let oauthSessionService: jest.Mocked<OAuthSessionService>;
let oauthClientRepository: jest.Mocked<OAuthClientRepository>;
let userConsentRepository: jest.Mocked<UserConsentRepository>;
let authorizationCodeService: jest.Mocked<OAuthAuthorizationCodeService>;
let protectedResourceRegistry: jest.Mocked<ProtectedResourceRegistry>;
let service: OAuthConsentService;
describe('OAuthConsentService', () => {
@@ -27,6 +32,9 @@ describe('OAuthConsentService', () => {
UserConsentRepository,
) as jest.Mocked<UserConsentRepository>;
authorizationCodeService = mockInstance(OAuthAuthorizationCodeService);
protectedResourceRegistry = mockInstance(
ProtectedResourceRegistry,
) as jest.Mocked<ProtectedResourceRegistry>;
service = new OAuthConsentService(
logger,
@@ -34,6 +42,7 @@ describe('OAuthConsentService', () => {
oauthClientRepository,
userConsentRepository,
authorizationCodeService,
protectedResourceRegistry,
);
});
@@ -61,6 +70,7 @@ describe('OAuthConsentService', () => {
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
});
@@ -68,6 +78,7 @@ describe('OAuthConsentService', () => {
expect(oauthClientRepository.findOne).toHaveBeenCalledWith({
where: { id: 'client-123' },
});
expect(protectedResourceRegistry.getByResourceUrl).not.toHaveBeenCalled();
});
it('should return null when client not found', async () => {
@@ -121,10 +132,88 @@ describe('OAuthConsentService', () => {
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
});
});
it('should include the resource displayName as resourceName for a workflow resource', 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' });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(
mock<ProtectedResource>({ displayName: 'My Workflow' }),
);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
resourceName: 'My Workflow',
});
expect(protectedResourceRegistry.getByResourceUrl).toHaveBeenCalledWith(
'https://n8n.example.com/mcp/wf-123',
);
});
it('should omit resourceName for the instance MCP resource (no displayName)', 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-server/http',
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(
mock<ProtectedResource>({ id: 'instance-mcp', displayName: undefined }),
);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
ok: true,
clientName: 'Test Client',
clientId: 'client-123',
resourceName: undefined,
});
});
it('should report resource_unavailable when the resource cannot be resolved', 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/gone',
};
const client = mock<OAuthClient>({ id: 'client-123', name: 'Test Client' });
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(undefined);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({ ok: false, reason: 'resource_unavailable' });
});
});
describe('handleConsentDecision', () => {
@@ -28,10 +28,17 @@ export class OAuthConsentController {
return;
}
if (!consentDetails.ok) {
this.oauthSessionService.clearSession(res);
this.sendErrorResponse(res, 422, 'Authorization target is no longer available');
return;
}
res.json({
data: {
clientName: consentDetails.clientName,
clientId: consentDetails.clientId,
resourceName: consentDetails.resourceName,
},
});
} catch (error) {
@@ -7,6 +7,11 @@ import { UserConsentRepository } from './database/repositories/oauth-user-consen
import { OAuthAuthorizationCodeService } from './oauth-authorization-code.service';
import { OAuthSessionService, type OAuthSessionPayload } from './oauth-session.service';
import { OAuthHelpers } from './oauth.helpers';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
type ConsentDetailsResult =
| { ok: true; clientName: string; clientId: string; resourceName?: string }
| { ok: false; reason: 'resource_unavailable' };
/**
* Manages the consent flow for the shared OAuth server.
@@ -20,16 +25,14 @@ export class OAuthConsentService {
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userConsentRepository: UserConsentRepository,
private readonly authorizationCodeService: OAuthAuthorizationCodeService,
private readonly protectedResourceRegistry: ProtectedResourceRegistry,
) {}
/**
* Get consent details from session cookie
* Verifies JWT session token and returns client information
*/
async getConsentDetails(sessionToken: string): Promise<{
clientName: string;
clientId: string;
} | null> {
async getConsentDetails(sessionToken: string): Promise<ConsentDetailsResult | null> {
try {
const sessionPayload = this.oauthSessionService.verifySession(sessionToken);
@@ -41,7 +44,25 @@ export class OAuthConsentService {
return null;
}
if (sessionPayload.resource) {
const resource = await this.protectedResourceRegistry.getByResourceUrl(
sessionPayload.resource,
);
if (!resource) {
return { ok: false, reason: 'resource_unavailable' };
}
return {
ok: true,
clientName: client.name,
clientId: client.id,
resourceName: resource.displayName,
};
}
return {
ok: true,
clientName: client.name,
clientId: client.id,
};
@@ -2420,7 +2420,9 @@
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"oauth.consentView.title": "OAuth access consent",
"oauth.consentView.heading": "{clientName} wants access to your n8n instance",
"oauth.consentView.headingWithWorkflow": "{clientName} requests access to workflow {resourceName}",
"oauth.consentView.description": "This will allow {clientName} to perform the following actions:",
"oauth.consentView.descriptionWithWorkflow": "This will allow {clientName} to access this workflow on your behalf.",
"oauth.consentView.action.listWorkflows": "Get a list of your workflows",
"oauth.consentView.action.workflowDetails": "Get details for a specific workflow",
"oauth.consentView.action.executeWorkflows": "Execute workflows on your behalf",
@@ -2432,6 +2434,7 @@
"oauth.consentView.error.deny": "Error denying access",
"oauth.consentView.error.allow": "Error allowing access",
"oauth.consentView.error.fetchDetails": "Error fetching client details",
"oauth.consentView.error.resourceUnavailable": "This authorization can no longer be completed because the target is no longer available.",
"oauth.consentView.success.title": "Success",
"oauth.consentView.success.description": "You will soon be redirected back to the client.",
"parameterInput.expressionResult": "e.g. {result}",
@@ -4,6 +4,7 @@ import { makeRestApiRequest } from '../utils';
export interface ConsentDetails {
clientName: string;
clientId: string;
resourceName?: string;
}
export interface ConsentApprovalResponse {
@@ -3,24 +3,30 @@ import { defineStore } from 'pinia';
import { useRootStore } from '@n8n/stores/useRootStore';
import * as consentApi from '@n8n/rest-api-client/api/consent';
import { ref } from 'vue';
import { type Ref, ref } from 'vue';
import type { ConsentDetails } from '@n8n/rest-api-client/api/consent';
import { ResponseError } from '@n8n/rest-api-client/utils';
export const useConsentStore = defineStore(STORES.CONSENT, () => {
const consentDetails = ref<ConsentDetails | null>(null);
const isLoading = ref(false);
const error = ref<string | null>(null);
const errorCode: Ref<'resource_unavailable' | null> = ref(null);
const rootStore = useRootStore();
const fetchConsentDetails = async () => {
isLoading.value = true;
error.value = null;
errorCode.value = null;
try {
consentDetails.value = await consentApi.getConsentDetails(rootStore.restApiContext);
return consentDetails.value;
} catch (err) {
if (err instanceof ResponseError && err.httpStatusCode === 422) {
errorCode.value = 'resource_unavailable';
}
error.value = err instanceof Error ? err.message : 'Failed to load consent details';
throw err;
} finally {
@@ -47,6 +53,7 @@ export const useConsentStore = defineStore(STORES.CONSENT, () => {
consentDetails.value = null;
isLoading.value = false;
error.value = null;
errorCode.value = null;
};
return {
@@ -56,5 +63,6 @@ export const useConsentStore = defineStore(STORES.CONSENT, () => {
consentDetails,
isLoading,
error,
errorCode,
};
});
@@ -40,6 +40,46 @@ describe('OAuthConsentView', () => {
locationHrefSpy?.mockRestore();
});
it('should show the workflow name and hide the permission list when a resource is named', async () => {
consentStore.consentDetails = {
clientName: 'Test MCP Client',
clientId: 'test-client-id',
resourceName: 'My Workflow',
};
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails);
const { getByText, queryByText } = renderComponent();
await waitAllPromises();
expect(getByText('Test MCP Client requests access to workflow My Workflow')).toBeVisible();
expect(queryByText('Get a list of your workflows')).toBeNull();
});
it('should show the generic heading and permission list when no resource is named', async () => {
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails!);
const { getByText } = renderComponent();
await waitAllPromises();
expect(getByText('Test MCP Client wants access to your n8n instance')).toBeVisible();
expect(getByText('Get a list of your workflows')).toBeVisible();
});
it('should show the dedicated error and disable the buttons when the resource is unavailable', async () => {
consentStore.error = 'Authorization target is no longer available';
consentStore.errorCode = 'resource_unavailable';
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails!);
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('consent-error-notice')).toHaveTextContent(
'This authorization can no longer be completed because the target is no longer available.',
);
expect(getByTestId('consent-deny-button')).toBeDisabled();
expect(getByTestId('consent-allow-button')).toBeDisabled();
});
it('should redirect to home page when deny is clicked', async () => {
consentStore.approveConsent.mockResolvedValue({
status: 'denied',
@@ -19,6 +19,14 @@ const waitingForRedirect = ref(false);
const error = computed(() => consentStore.error);
const loading = computed(() => consentStore.isLoading);
const resourceName = computed(() => consentStore.consentDetails?.resourceName);
const errorMessage = computed(() => {
if (consentStore.errorCode === 'resource_unavailable') {
return i18n.baseText('oauth.consentView.error.resourceUnavailable');
}
return consentStore.error;
});
const clentDetails = computed<ConsentDetails | null>(() => consentStore.consentDetails);
@@ -76,7 +84,14 @@ onMounted(async () => {
</div>
<!-- Default content -->
<div v-else :class="$style.content" data-test-id="consent-content">
<N8nHeading tag="h2" size="large" :bold="true">
<N8nHeading v-if="resourceName" tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.headingWithWorkflow', {
interpolate: { clientName: clentDetails?.clientName ?? '', resourceName },
})
}}
</N8nHeading>
<N8nHeading v-else tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.heading', {
interpolate: { clientName: clentDetails?.clientName ?? '' },
@@ -84,14 +99,21 @@ onMounted(async () => {
}}
</N8nHeading>
<div :class="$style['text-content']">
<N8nText color="text-base" size="small">
<N8nText v-if="resourceName" color="text-base" size="small">
{{
i18n.baseText('oauth.consentView.descriptionWithWorkflow', {
interpolate: { clientName: clentDetails?.clientName ?? '' },
})
}}
</N8nText>
<N8nText v-else color="text-base" size="small">
{{
i18n.baseText('oauth.consentView.description', {
interpolate: { clientName: clentDetails?.clientName ?? '' },
})
}}
</N8nText>
<ul :class="$style['permission-list']">
<ul v-if="!resourceName" :class="$style['permission-list']">
<li>{{ i18n.baseText('oauth.consentView.action.listWorkflows') }}</li>
<li>{{ i18n.baseText('oauth.consentView.action.workflowDetails') }}</li>
<li>{{ i18n.baseText('oauth.consentView.action.executeWorkflows') }}</li>
@@ -115,10 +137,10 @@ onMounted(async () => {
</div>
<footer v-if="!waitingForRedirect" :class="$style.footer">
<N8nNotice
v-if="error"
v-if="errorMessage"
theme="danger"
:data-test-id="'consent-error-notice'"
:content="error"
:content="errorMessage"
></N8nNotice>
<div :class="$style['button-group']">
<N8nButton