mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
feat: Show the workflow name on the OAuth consent screen (#32362)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user