From 7a5c2087f7e3c42c373ef397cb83df14d66a28a6 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 16 Jun 2026 11:33:39 +0200 Subject: [PATCH] feat: Show the workflow name on the OAuth consent screen (#32362) --- .../oauth-consent.controller.api.test.ts | 73 +++++++++++++++ .../__tests__/oauth-consent.service.test.ts | 89 +++++++++++++++++++ .../oauth-server/oauth-consent.controller.ts | 7 ++ .../oauth-server/oauth-consent.service.ts | 29 +++++- .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../@n8n/rest-api-client/src/api/consent.ts | 1 + .../editor-ui/src/app/stores/consent.store.ts | 10 ++- .../src/app/views/OAuthConsentView.test.ts | 40 +++++++++ .../src/app/views/OAuthConsentView.vue | 32 +++++-- 9 files changed, 274 insertions(+), 10 deletions(-) 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 b01d56466af..08abafea5e6 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 @@ -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'); 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 4db75326aee..004a29ef1d8 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 @@ -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; let oauthSessionService: jest.Mocked; let oauthClientRepository: jest.Mocked; let userConsentRepository: jest.Mocked; let authorizationCodeService: jest.Mocked; +let protectedResourceRegistry: jest.Mocked; let service: OAuthConsentService; describe('OAuthConsentService', () => { @@ -27,6 +32,9 @@ describe('OAuthConsentService', () => { UserConsentRepository, ) as jest.Mocked; authorizationCodeService = mockInstance(OAuthAuthorizationCodeService); + protectedResourceRegistry = mockInstance( + ProtectedResourceRegistry, + ) as jest.Mocked; 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({ id: 'client-123', name: 'Test Client' }); + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(client); + protectedResourceRegistry.getByResourceUrl.mockResolvedValue( + mock({ 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({ id: 'client-123', name: 'Test Client' }); + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(client); + protectedResourceRegistry.getByResourceUrl.mockResolvedValue( + mock({ 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({ 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', () => { 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 810e43d0250..cb1c1fbc081 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts @@ -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) { 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 a83be39ea73..936cfeb9aa9 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.service.ts @@ -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 { 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, }; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b0543a3e3c1..23cd024e8e4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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}", 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 1c3e6df6597..a1aae3c1aea 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,7 @@ import { makeRestApiRequest } from '../utils'; export interface ConsentDetails { clientName: string; clientId: string; + resourceName?: string; } export interface ConsentApprovalResponse { diff --git a/packages/frontend/editor-ui/src/app/stores/consent.store.ts b/packages/frontend/editor-ui/src/app/stores/consent.store.ts index f83fb4761c0..2e7e1f04b6e 100644 --- a/packages/frontend/editor-ui/src/app/stores/consent.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/consent.store.ts @@ -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(null); const isLoading = ref(false); const error = ref(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, }; }); 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 6c5ea68da76..87b3c176328 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue index ecb2008d0ee..7a4222f1423 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -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(() => consentStore.consentDetails); @@ -76,7 +84,14 @@ onMounted(async () => {
- + + {{ + i18n.baseText('oauth.consentView.headingWithWorkflow', { + interpolate: { clientName: clentDetails?.clientName ?? '', resourceName }, + }) + }} + + {{ i18n.baseText('oauth.consentView.heading', { interpolate: { clientName: clentDetails?.clientName ?? '' }, @@ -84,14 +99,21 @@ onMounted(async () => { }}
- + + {{ + i18n.baseText('oauth.consentView.descriptionWithWorkflow', { + interpolate: { clientName: clentDetails?.clientName ?? '' }, + }) + }} + + {{ i18n.baseText('oauth.consentView.description', { interpolate: { clientName: clentDetails?.clientName ?? '' }, }) }} -
    +
    • {{ i18n.baseText('oauth.consentView.action.listWorkflows') }}
    • {{ i18n.baseText('oauth.consentView.action.workflowDetails') }}
    • {{ i18n.baseText('oauth.consentView.action.executeWorkflows') }}
    • @@ -115,10 +137,10 @@ onMounted(async () => {