feat(core): Add OAuth to MCP server (#21469)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-11-05 19:26:31 -05:00
committed by GitHub
co-authored by Milorad FIlipović
parent 74a0b51c46
commit cd167ac6db
56 changed files with 4580 additions and 543 deletions
@@ -35,6 +35,7 @@ export class ModuleRegistry {
'data-table',
'provisioning',
'breaking-changes',
'mcp',
];
private readonly activeModules: string[] = [];
@@ -82,7 +82,12 @@ type EntityName =
| 'DataTable'
| 'DataTableColumn'
| 'ChatHubSession'
| 'ChatHubMessage';
| 'ChatHubMessage'
| 'OAuthClient'
| 'AuthorizationCode'
| 'AccessToken'
| 'RefreshToken'
| 'UserConsent';
/**
* Truncate specific DB tables in a test DB.
@@ -0,0 +1,108 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateOAuthEntities1760116750277 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
// Create oauth_clients table
await createTable('oauth_clients').withColumns(
column('id').varchar().primary.notNull,
column('name').varchar(255).notNull,
column('redirectUris').json.notNull,
column('grantTypes').json.notNull,
column('clientSecret').varchar(255),
column('clientSecretExpiresAt').bigint,
column('tokenEndpointAuthMethod')
.varchar(255)
.notNull.default("'none'")
.comment('Possible values: none, client_secret_basic or client_secret_post'),
).withTimestamps;
// Create oauth_authorization_codes table
await createTable('oauth_authorization_codes')
.withColumns(
column('code').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('redirectUri').varchar(255).notNull,
column('codeChallenge').varchar(255).notNull,
column('codeChallengeMethod').varchar(255).notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
column('state').varchar(255), // Should be nullable
column('used').bool.notNull.default(false),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// Create oauth_access_tokens table
await createTable('oauth_access_tokens')
.withColumns(
column('token').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
});
// Create oauth_refresh_tokens table
await createTable('oauth_refresh_tokens')
.withColumns(
column('token').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// Create oauth_user_consents table
await createTable('oauth_user_consents')
.withColumns(
column('id').int.primary.autoGenerate2.notNull,
column('userId').uuid.notNull,
column('clientId').varchar().notNull,
column('grantedAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['userId', 'clientId']);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('oauth_user_consents');
await dropTable('oauth_refresh_tokens');
await dropTable('oauth_access_tokens');
await dropTable('oauth_authorization_codes');
await dropTable('oauth_clients');
}
}
@@ -106,6 +106,7 @@ import { ChangeValueTypesForInsights1759399811000 } from '../common/175939981100
import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables';
import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable';
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import type { Migration } from '../migration-types';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
@@ -221,4 +222,5 @@ export const mysqlMigrations: Migration[] = [
DropUnusedChatHubColumns1760965142113,
AddWorkflowVersionColumn1761047826451,
ChangeDependencyInfoToJson1761655473000,
CreateOAuthEntities1760116750277,
];
@@ -1,3 +1,4 @@
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
@@ -219,4 +220,5 @@ export const postgresMigrations: Migration[] = [
DropUnusedChatHubColumns1760965142113,
AddWorkflowVersionColumn1761047826451,
ChangeDependencyInfoToJson1761655473000,
CreateOAuthEntities1760116750277,
];
@@ -1,4 +1,3 @@
import { AddAudienceColumnToApiKeys1758731786132 } from './../common/1758731786132-AddAudienceColumnToApiKey';
import { InitialMigration1588102412422 } from './1588102412422-InitialMigration';
import { WebhookModel1592445003908 } from './1592445003908-WebhookModel';
import { CreateIndexStoppedAt1594825041918 } from './1594825041918-CreateIndexStoppedAt';
@@ -103,8 +102,10 @@ import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953
import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable';
import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables';
import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddAudienceColumnToApiKeys1758731786132 } from './../common/1758731786132-AddAudienceColumnToApiKey';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@@ -213,6 +214,7 @@ const sqliteMigrations: Migration[] = [
DropUnusedChatHubColumns1760965142113,
AddWorkflowVersionColumn1761047826451,
ChangeDependencyInfoToJson1761655473000,
CreateOAuthEntities1760116750277,
];
export { sqliteMigrations };
@@ -6,4 +6,4 @@ export { Middleware } from './middleware';
export { ControllerRegistryMetadata } from './controller-registry-metadata';
export { Licensed } from './licensed';
export { GlobalScope, ProjectScope } from './scoped';
export type { AccessScope, Controller, RateLimit } from './types';
export type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from './types';
@@ -1,7 +1,7 @@
import type { BooleanLicenseFeature } from '@n8n/constants';
import type { Constructable } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { RequestHandler } from 'express';
import type { RequestHandler, Router } from 'express';
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
@@ -40,8 +40,29 @@ export interface RouteMetadata {
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;
args: Arg[];
router?: Router;
}
/**
* Metadata for static routers mounted on a controller.
* Picks relevant fields from RouteMetadata and makes router required.
*/
export type StaticRouterMetadata = {
path: string;
router: Router;
} & Partial<
Pick<
RouteMetadata,
| 'skipAuth'
| 'allowSkipPreviewAuth'
| 'allowSkipMFA'
| 'middlewares'
| 'rateLimit'
| 'licenseFeature'
| 'accessScope'
>
>;
export interface ControllerMetadata {
basePath: `/${string}`;
// If true, the controller will be registered on the root path without the any prefix
+1
View File
@@ -86,6 +86,7 @@ export class AuthService {
// Skip browser ID check for type files
'/types/nodes.json',
'/types/credentials.json',
'/mcp-oauth/authorize/',
];
}
+74 -28
View File
@@ -4,7 +4,7 @@ import { GlobalConfig } from '@n8n/config';
import { type BooleanLicenseFeature } from '@n8n/constants';
import type { AuthenticatedRequest } from '@n8n/db';
import { ControllerRegistryMetadata } from '@n8n/decorators';
import type { AccessScope, Controller, RateLimit } from '@n8n/decorators';
import type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { Router } from 'express';
import type { Application, Request, Response, RequestHandler } from 'express';
@@ -19,7 +19,7 @@ import { AuthService } from '@/auth/auth.service';
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
import { License } from '@/license';
import { userHasScopes } from '@/permissions.ee/check-access';
import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file
import { send } from '@/response-helper';
@Service()
export class ControllerRegistry {
@@ -52,7 +52,23 @@ export class ControllerRegistry {
(handlerName) => controller[handlerName].bind(controller) as RequestHandler,
);
const staticRouters = (controllerClass as any).routers as StaticRouterMetadata[] | undefined;
if (staticRouters) {
for (const routerConfig of staticRouters) {
if (!routerConfig.router) {
throw new UnexpectedError(
`Router is undefined for path "${routerConfig.path}" in controller "${controllerClass.name}"`,
);
}
const middlewares = this.buildMiddlewares(routerConfig, controllerMiddlewares);
router.use(routerConfig.path, ...middlewares, routerConfig.router);
}
}
// Register regular routes
for (const [handlerName, route] of metadata.routes) {
// Original handler logic for non-router routes
const argTypes = Reflect.getMetadata(
'design:paramtypes',
controller,
@@ -63,7 +79,7 @@ export class ControllerRegistry {
const args: unknown[] = [req, res];
for (let index = 0; index < route.args.length; index++) {
const arg = route.args[index];
if (!arg) continue; // Skip args without any decorators
if (!arg) continue;
if (arg.type === 'param') args.push(req.params[arg.key]);
else if (['body', 'query'].includes(arg.type)) {
const paramType = argTypes[index] as ZodClass;
@@ -79,34 +95,64 @@ export class ControllerRegistry {
return await controller[handlerName](...args);
};
router[route.method](
route.path,
...(inProduction && route.rateLimit
? [this.createRateLimitMiddleware(route.rateLimit)]
: []),
const middlewares = this.buildMiddlewares(route, controllerMiddlewares);
const finalHandler = route.usesTemplates
? async (req: Request, res: Response) => {
await handler(req, res);
}
: send(handler);
...(route.skipAuth
? []
: ([
this.authService.createAuthMiddleware({
allowSkipMFA: route.allowSkipMFA,
allowSkipPreviewAuth: route.allowSkipPreviewAuth,
}),
this.lastActiveAtService.middleware.bind(this.lastActiveAtService),
] as RequestHandler[])),
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),
...(route.accessScope ? [this.createScopedMiddleware(route.accessScope)] : []),
...controllerMiddlewares,
...route.middlewares,
route.usesTemplates
? async (req, res) => {
// When using templates, intentionally drop the return value,
// since template rendering writes directly to the response.
await handler(req, res);
}
: send(handler),
router[route.method](route.path, ...middlewares, finalHandler);
}
}
/**
* Builds middleware array based on route configuration.
* Used for both static routers and inline router definitions.
*/
private buildMiddlewares(
route: {
skipAuth?: boolean;
allowSkipMFA?: boolean;
allowSkipPreviewAuth?: boolean;
rateLimit?: boolean | RateLimit;
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;
middlewares?: RequestHandler[];
},
controllerMiddlewares: RequestHandler[],
): RequestHandler[] {
const middlewares: RequestHandler[] = [];
if (inProduction && route.rateLimit) {
middlewares.push(this.createRateLimitMiddleware(route.rateLimit));
}
if (!route.skipAuth) {
middlewares.push(
this.authService.createAuthMiddleware({
allowSkipMFA: route.allowSkipMFA ?? false,
allowSkipPreviewAuth: route.allowSkipPreviewAuth ?? false,
}),
this.lastActiveAtService.middleware.bind(this.lastActiveAtService) as RequestHandler,
);
}
if (route.licenseFeature) {
middlewares.push(this.createLicenseMiddleware(route.licenseFeature));
}
if (route.accessScope) {
middlewares.push(this.createScopedMiddleware(route.accessScope));
}
middlewares.push(...controllerMiddlewares);
if (route.middlewares) {
middlewares.push(...route.middlewares);
}
return middlewares;
}
private createRateLimitMiddleware(rateLimit: true | RateLimit): RequestHandler {
@@ -0,0 +1,230 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { mock } from 'jest-mock-extended';
import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity';
import { AuthorizationCodeRepository } from '../database/repositories/oauth-authorization-code.repository';
import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service';
let authorizationCodeRepository: jest.Mocked<AuthorizationCodeRepository>;
let service: McpOAuthAuthorizationCodeService;
describe('McpOAuthAuthorizationCodeService', () => {
beforeAll(() => {
authorizationCodeRepository = mockInstance(AuthorizationCodeRepository);
service = new McpOAuthAuthorizationCodeService(authorizationCodeRepository);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('createAuthorizationCode', () => {
it('should generate and save authorization code with all parameters', async () => {
const clientId = 'client-123';
const userId = 'user-456';
const redirectUri = 'https://example.com/callback';
const codeChallenge = 'challenge-abc';
const state = 'state-xyz';
authorizationCodeRepository.insert.mockResolvedValue(mock());
const result = await service.createAuthorizationCode(
clientId,
userId,
redirectUri,
codeChallenge,
state,
);
expect(result).toHaveLength(64); // 32 bytes hex = 64 characters
expect(authorizationCodeRepository.insert).toHaveBeenCalledWith({
code: result,
clientId,
userId,
redirectUri,
codeChallenge,
codeChallengeMethod: 'S256',
state,
expiresAt: expect.any(Number),
used: false,
});
});
it('should handle null state', async () => {
authorizationCodeRepository.insert.mockResolvedValue(mock());
await service.createAuthorizationCode(
'client-123',
'user-456',
'https://example.com',
'challenge',
null,
);
expect(authorizationCodeRepository.insert).toHaveBeenCalledWith(
expect.objectContaining({
state: null,
}),
);
});
});
describe('findAndValidateAuthorizationCode', () => {
it('should return auth record when valid', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000, // Future expiry
used: false,
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
const result = await service.findAndValidateAuthorizationCode('code-123', 'client-123');
expect(result).toEqual(authRecord);
expect(authorizationCodeRepository.findOne).toHaveBeenCalledWith({
where: {
code: 'code-123',
clientId: 'client-123',
},
});
});
it('should throw error when authorization code not found', async () => {
authorizationCodeRepository.findOne.mockResolvedValue(null);
await expect(
service.findAndValidateAuthorizationCode('invalid-code', 'client-123'),
).rejects.toThrow('Invalid authorization code');
});
it('should throw error and remove when authorization code expired', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() - 1000, // Expired
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.remove.mockResolvedValue(authRecord);
await expect(
service.findAndValidateAuthorizationCode('code-123', 'client-123'),
).rejects.toThrow('Authorization code expired');
expect(authorizationCodeRepository.remove).toHaveBeenCalledWith(authRecord);
});
});
describe('validateAndConsumeAuthorizationCode', () => {
it('should mark code as used and return auth record', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.validateAndConsumeAuthorizationCode(
'code-123',
'client-123',
'https://example.com/callback',
);
expect(result).toEqual(authRecord);
expect(authRecord.used).toBe(true);
expect(authorizationCodeRepository.update).toHaveBeenCalledWith(
{ code: 'code-123', used: false },
{ used: true },
);
});
it('should throw error when code already used (atomic update fails)', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.update.mockResolvedValue({ affected: 0 } as any);
await expect(
service.validateAndConsumeAuthorizationCode('code-123', 'client-123'),
).rejects.toThrow('Authorization code already used');
});
it('should throw error when redirect URI mismatch', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
await expect(
service.validateAndConsumeAuthorizationCode(
'code-123',
'client-123',
'https://evil.com/callback',
),
).rejects.toThrow('Redirect URI mismatch');
});
it('should allow missing redirect URI parameter', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.validateAndConsumeAuthorizationCode('code-123', 'client-123');
expect(result).toEqual(authRecord);
expect(authorizationCodeRepository.update).toHaveBeenCalledWith(
{ code: 'code-123', used: false },
{ used: true },
);
});
});
describe('getCodeChallenge', () => {
it('should return code challenge from valid auth record', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
codeChallenge: 'challenge-abc',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
const result = await service.getCodeChallenge('code-123', 'client-123');
expect(result).toBe('challenge-abc');
});
it('should throw error when code invalid', async () => {
authorizationCodeRepository.findOne.mockResolvedValue(null);
await expect(service.getCodeChallenge('invalid-code', 'client-123')).rejects.toThrow(
'Invalid authorization code',
);
});
});
});
@@ -0,0 +1,246 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { Logger } from '@n8n/backend-common';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { mock } from 'jest-mock-extended';
import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service';
import { McpOAuthConsentService } from '../mcp-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';
let logger: jest.Mocked<Logger>;
let oauthSessionService: jest.Mocked<OAuthSessionService>;
let oauthClientRepository: jest.Mocked<OAuthClientRepository>;
let userConsentRepository: jest.Mocked<UserConsentRepository>;
let authorizationCodeService: jest.Mocked<McpOAuthAuthorizationCodeService>;
let service: McpOAuthConsentService;
describe('McpOAuthConsentService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
oauthSessionService = mockInstance(OAuthSessionService) as jest.Mocked<OAuthSessionService>;
oauthClientRepository = mockInstance(
OAuthClientRepository,
) as jest.Mocked<OAuthClientRepository>;
userConsentRepository = mockInstance(
UserConsentRepository,
) as jest.Mocked<UserConsentRepository>;
authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService);
service = new McpOAuthConsentService(
logger,
oauthSessionService,
oauthClientRepository,
userConsentRepository,
authorizationCodeService,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('getConsentDetails', () => {
it('should return client details from valid session token', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: 'state',
};
const client = mock<OAuthClient>({
id: 'client-123',
name: 'Test Client',
});
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
clientName: 'Test Client',
clientId: 'client-123',
});
expect(oauthSessionService.verifySession).toHaveBeenCalledWith(sessionToken);
expect(oauthClientRepository.findOne).toHaveBeenCalledWith({
where: { id: 'client-123' },
});
});
it('should return null when client not found', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'nonexistent-client',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: null,
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(null);
const result = await service.getConsentDetails(sessionToken);
expect(result).toBeNull();
});
it('should return null and log error when session verification fails', async () => {
const sessionToken = 'invalid-session-token';
oauthSessionService.verifySession.mockImplementation(() => {
throw new Error('Invalid session');
});
const result = await service.getConsentDetails(sessionToken);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith('Error getting consent details', {
error: expect.any(Error),
});
});
it('should return client details', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: null,
};
const client = mock<OAuthClient>({
id: 'client-123',
name: 'Test Client',
});
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
clientName: 'Test Client',
clientId: 'client-123',
});
});
});
describe('handleConsentDecision', () => {
it('should handle user denial', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: 'state-xyz',
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
const result = await service.handleConsentDecision(sessionToken, userId, false);
expect(result.redirectUrl).toContain('error=access_denied');
expect(result.redirectUrl).toContain(
'error_description=User+denied+the+authorization+request',
);
expect(result.redirectUrl).toContain('state=state-xyz');
expect(logger.info).toHaveBeenCalledWith('Consent denied', {
clientId: 'client-123',
userId: 'user-123',
});
expect(userConsentRepository.insert).not.toHaveBeenCalled();
});
it('should handle user approval and generate authorization code', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-abc',
state: 'state-xyz',
};
const authCode = 'generated-auth-code';
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
userConsentRepository.insert.mockResolvedValue(mock());
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
const result = await service.handleConsentDecision(sessionToken, userId, true);
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).toContain('state=state-xyz');
expect(userConsentRepository.insert).toHaveBeenCalledWith({
userId: 'user-123',
clientId: 'client-123',
grantedAt: expect.any(Number),
});
expect(authorizationCodeService.createAuthorizationCode).toHaveBeenCalledWith(
'client-123',
'user-123',
'https://example.com/callback',
'challenge-abc',
'state-xyz',
);
expect(logger.info).toHaveBeenCalledWith('Consent approved', {
clientId: 'client-123',
userId: 'user-123',
});
});
it('should handle approval without state parameter', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-abc',
state: null,
};
const authCode = 'generated-auth-code';
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
userConsentRepository.insert.mockResolvedValue(mock());
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
const result = await service.handleConsentDecision(sessionToken, userId, true);
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).not.toContain('state=');
});
it('should throw error when session verification fails', async () => {
const sessionToken = 'invalid-session-token';
const userId = 'user-123';
oauthSessionService.verifySession.mockImplementation(() => {
throw new Error('Invalid session');
});
await expect(service.handleConsentDecision(sessionToken, userId, true)).rejects.toThrow(
'Invalid or expired session',
);
});
it('should handle denial without state parameter', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: null,
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
const result = await service.handleConsentDecision(sessionToken, userId, false);
expect(result.redirectUrl).toContain('error=access_denied');
expect(result.redirectUrl).not.toContain('state=');
});
});
});
@@ -0,0 +1,547 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { mock } from 'jest-mock-extended';
import type { Response } from 'express';
import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service';
import { McpOAuthService, SUPPORTED_SCOPES } from '../mcp-oauth-service';
import { McpOAuthTokenService } from '../mcp-oauth-token.service';
import { OAuthSessionService } from '../oauth-session.service';
let logger: jest.Mocked<Logger>;
let oauthSessionService: jest.Mocked<OAuthSessionService>;
let oauthClientRepository: jest.Mocked<OAuthClientRepository>;
let tokenService: jest.Mocked<McpOAuthTokenService>;
let authorizationCodeService: jest.Mocked<McpOAuthAuthorizationCodeService>;
let service: McpOAuthService;
describe('McpOAuthService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
oauthSessionService = mockInstance(OAuthSessionService);
oauthClientRepository = mockInstance(OAuthClientRepository);
tokenService = mockInstance(McpOAuthTokenService);
authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService);
service = new McpOAuthService(
logger,
oauthSessionService,
oauthClientRepository,
tokenService,
authorizationCodeService,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('clientsStore', () => {
describe('getClient', () => {
it('should return client information when client exists', async () => {
const client = {
id: 'client-123',
name: 'Test Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
clientSecret: null,
clientSecretExpiresAt: null,
} as OAuthClient;
oauthClientRepository.findOneBy.mockResolvedValue(client);
const result = await service.clientsStore.getClient('client-123');
expect(result).toEqual({
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: SUPPORTED_SCOPES.join(' '),
});
});
it('should include client secret when present', async () => {
const client = {
id: 'client-123',
name: 'Test Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'client_secret_post',
clientSecret: 'secret-value',
clientSecretExpiresAt: 1234567890,
} as OAuthClient;
oauthClientRepository.findOneBy.mockResolvedValue(client);
const result = await service.clientsStore.getClient('client-123');
expect(result).toMatchObject({
client_secret: 'secret-value',
client_secret_expires_at: 1234567890,
});
});
it('should return undefined when client not found', async () => {
oauthClientRepository.findOneBy.mockResolvedValue(null);
const result = await service.clientsStore.getClient('nonexistent');
expect(result).toBeUndefined();
});
});
describe('registerClient', () => {
it('should save client with all required fields', async () => {
const clientInfo = {
client_id: 'new-client-123',
client_name: 'New Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read write',
};
oauthClientRepository.insert.mockResolvedValue({} as any);
const result = await service.clientsStore.registerClient!(clientInfo);
expect(oauthClientRepository.insert).toHaveBeenCalledWith({
id: 'new-client-123',
name: 'New Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code', 'refresh_token'],
clientSecret: null,
clientSecretExpiresAt: null,
tokenEndpointAuthMethod: 'none',
});
expect(result).toEqual(clientInfo);
});
it('should save client with client secret', async () => {
const clientInfo = {
client_id: 'new-client-123',
client_name: 'New Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'client_secret_post',
client_secret: 'secret-123',
client_secret_expires_at: 1234567890,
response_types: ['code'],
scope: 'read',
};
oauthClientRepository.insert.mockResolvedValue({} as any);
await service.clientsStore.registerClient!(clientInfo);
expect(oauthClientRepository.insert).toHaveBeenCalledWith({
id: 'new-client-123',
name: 'New Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
clientSecret: 'secret-123',
clientSecretExpiresAt: 1234567890,
tokenEndpointAuthMethod: 'client_secret_post',
});
});
it('should handle save errors gracefully', async () => {
const clientInfo = {
client_id: 'new-client-123',
client_name: 'New Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const error = new Error('Database error');
oauthClientRepository.insert.mockRejectedValue(error);
const result = await service.clientsStore.registerClient!(clientInfo);
expect(logger.error).toHaveBeenCalledWith('Error registering OAuth client', {
error,
clientId: 'new-client-123',
});
expect(result).toEqual(clientInfo);
});
});
});
describe('authorize', () => {
it('should create session and redirect to consent page', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read write',
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
state: 'state-xyz',
};
const res = mock<Response>();
await service.authorize(client, params, res);
expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
state: 'state-xyz',
});
expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
});
it('should handle null state parameter', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
};
const res = mock<Response>();
await service.authorize(client, params, res);
expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
state: null,
});
});
it('should handle errors and clear session', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
};
const res = mock<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
const error = new Error('Session creation failed');
oauthSessionService.createSession.mockImplementation(() => {
throw error;
});
await service.authorize(client, params, res);
expect(logger.error).toHaveBeenCalledWith('Error in authorize method', {
error,
clientId: 'client-123',
});
expect(oauthSessionService.clearSession).toHaveBeenCalledWith(res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'server_error',
error_description: 'Internal server error',
});
});
});
describe('challengeForAuthorizationCode', () => {
it('should return code challenge from authorization code service', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
authorizationCodeService.getCodeChallenge.mockResolvedValue('challenge-123');
const result = await service.challengeForAuthorizationCode(client, 'auth-code-123');
expect(authorizationCodeService.getCodeChallenge).toHaveBeenCalledWith(
'auth-code-123',
'client-123',
);
expect(result).toBe('challenge-123');
});
});
describe('exchangeAuthorizationCode', () => {
it('should validate code and return token pair', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const authRecord = {
userId: 'user-456',
clientId: 'client-123',
} as AuthorizationCode;
authorizationCodeService.validateAndConsumeAuthorizationCode.mockResolvedValue(authRecord);
tokenService.generateTokenPair.mockReturnValue({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
});
tokenService.saveTokenPair.mockResolvedValue();
const result = await service.exchangeAuthorizationCode(
client,
'auth-code-123',
'verifier-123',
'https://example.com/callback',
);
expect(authorizationCodeService.validateAndConsumeAuthorizationCode).toHaveBeenCalledWith(
'auth-code-123',
'client-123',
'https://example.com/callback',
);
expect(tokenService.generateTokenPair).toHaveBeenCalledWith('user-456', 'client-123');
expect(tokenService.saveTokenPair).toHaveBeenCalledWith(
'access-token-123',
'refresh-token-456',
'client-123',
'user-456',
);
expect(result).toEqual({
access_token: 'access-token-123',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'refresh-token-456',
});
});
it('should handle authorization code exchange without redirect URI', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const authRecord = {
userId: 'user-456',
clientId: 'client-123',
} as AuthorizationCode;
authorizationCodeService.validateAndConsumeAuthorizationCode.mockResolvedValue(authRecord);
tokenService.generateTokenPair.mockReturnValue({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
});
await service.exchangeAuthorizationCode(client, 'auth-code-123', 'verifier-123');
expect(authorizationCodeService.validateAndConsumeAuthorizationCode).toHaveBeenCalledWith(
'auth-code-123',
'client-123',
undefined,
);
});
});
describe('exchangeRefreshToken', () => {
it('should validate and rotate refresh token', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const newTokens = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token',
};
tokenService.validateAndRotateRefreshToken.mockResolvedValue(newTokens);
const result = await service.exchangeRefreshToken(client, 'old-refresh-token', ['read']);
expect(tokenService.validateAndRotateRefreshToken).toHaveBeenCalledWith(
'old-refresh-token',
'client-123',
);
expect(result).toEqual(newTokens);
});
});
describe('verifyAccessToken', () => {
it('should verify access token and return auth info', async () => {
const authInfo = {
token: 'access-token-123',
userId: 'user-123',
clientId: 'client-456',
scopes: ['read', 'write'],
};
tokenService.verifyAccessToken.mockResolvedValue(authInfo);
const result = await service.verifyAccessToken('access-token-123');
expect(tokenService.verifyAccessToken).toHaveBeenCalledWith('access-token-123');
expect(result).toEqual(authInfo);
});
});
describe('revokeToken', () => {
it('should revoke access token when type hint is access_token', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
token_type_hint: 'access_token',
});
expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled();
});
it('should revoke refresh token when type hint is refresh_token', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeRefreshToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
token_type_hint: 'refresh_token',
});
expect(tokenService.revokeAccessToken).not.toHaveBeenCalled();
expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123');
});
it('should try access token first when no type hint provided', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
});
expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled();
});
it('should try refresh token if access token revocation fails', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(false);
tokenService.revokeRefreshToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
});
expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123');
});
it('should silently succeed when token not found', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(false);
tokenService.revokeRefreshToken.mockResolvedValue(false);
await service.revokeToken(client, {
token: 'unknown-token',
});
expect(logger.debug).toHaveBeenCalledWith('Token revocation requested for unknown token', {
clientId: 'client-123',
});
});
});
});
@@ -0,0 +1,344 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
import type { AccessToken } from '../database/entities/oauth-access-token.entity';
import type { RefreshToken } from '../database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from '../database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository';
import { McpOAuthTokenService } from '../mcp-oauth-token.service';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let logger: jest.Mocked<Logger>;
let userRepository: jest.Mocked<UserRepository>;
let accessTokenRepository: jest.Mocked<AccessTokenRepository>;
let refreshTokenRepository: jest.Mocked<RefreshTokenRepository>;
let service: McpOAuthTokenService;
let mockTransactionManager: any;
describe('McpOAuthTokenService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
userRepository = mockInstance(UserRepository);
accessTokenRepository = mockInstance(
AccessTokenRepository,
) as jest.Mocked<AccessTokenRepository>;
refreshTokenRepository = mockInstance(
RefreshTokenRepository,
) as jest.Mocked<RefreshTokenRepository>;
mockTransactionManager = {
insert: jest.fn().mockResolvedValue(mock()),
remove: jest.fn().mockResolvedValue(mock()),
findOne: jest.fn(),
delete: jest.fn(),
};
const mockManager: any = {
transaction: jest.fn(async (cb: any) => await cb(mockTransactionManager)),
};
(accessTokenRepository as any).manager = mockManager;
(accessTokenRepository as any).target = 'AccessToken';
(refreshTokenRepository as any).manager = mockManager;
(refreshTokenRepository as any).target = 'RefreshToken';
service = new McpOAuthTokenService(
logger,
jwtService,
userRepository,
accessTokenRepository,
refreshTokenRepository,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('generateTokenPair', () => {
it('should generate JWT access token and opaque refresh token', () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken, refreshToken } = service.generateTokenPair(userId, clientId);
expect(accessToken).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); // JWT format
const decoded = jwtService.decode(accessToken);
expect(decoded.sub).toBe(userId);
expect(decoded.aud).toBe('mcp-server-api');
expect(decoded.client_id).toBe(clientId);
expect(decoded.meta.isOAuth).toBe(true);
expect(decoded.jti).toBeDefined();
expect(decoded.iat).toBeDefined();
expect(decoded.exp).toBeDefined();
expect(refreshToken).toHaveLength(64); // 32 bytes hex = 64 characters
expect(refreshToken).toMatch(/^[a-f0-9]{64}$/);
});
it('should generate different tokens on each call', () => {
const userId = 'user-123';
const clientId = 'client-456';
const pair1 = service.generateTokenPair(userId, clientId);
const pair2 = service.generateTokenPair(userId, clientId);
expect(pair1.accessToken).not.toBe(pair2.accessToken);
expect(pair1.refreshToken).not.toBe(pair2.refreshToken);
});
});
describe('saveTokenPair', () => {
it('should save both tokens in a transaction', async () => {
const accessToken = 'jwt-access-token';
const refreshToken = 'opaque-refresh-token';
const clientId = 'client-123';
const userId = 'user-456';
await service.saveTokenPair(accessToken, refreshToken, clientId, userId);
const mockManager = accessTokenRepository.manager as any;
expect(mockManager.transaction).toHaveBeenCalled();
expect(mockTransactionManager.insert).toHaveBeenCalledTimes(2);
expect(mockTransactionManager.insert).toHaveBeenCalledWith('AccessToken', {
token: accessToken,
clientId,
userId,
});
expect(mockTransactionManager.insert).toHaveBeenCalledWith('RefreshToken', {
token: refreshToken,
clientId,
userId,
expiresAt: expect.any(Number),
});
});
});
describe('validateAndRotateRefreshToken', () => {
it('should rotate refresh token and return new token pair in a transaction', async () => {
const refreshToken = 'old-refresh-token';
const clientId = 'client-123';
const refreshTokenRecord = mock<RefreshToken>({
token: refreshToken,
clientId,
userId: 'user-456',
expiresAt: Date.now() + 1000000, // Valid
});
mockTransactionManager.findOne.mockResolvedValue(refreshTokenRecord);
mockTransactionManager.delete.mockResolvedValue({ affected: 1 });
const result = await service.validateAndRotateRefreshToken(refreshToken, clientId);
expect(result).toEqual({
access_token: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
token_type: 'Bearer',
expires_in: 3600,
refresh_token: expect.stringMatching(/^[a-f0-9]{64}$/),
});
// Verify transaction was used
const mockManager = refreshTokenRepository.manager as any;
expect(mockManager.transaction).toHaveBeenCalled();
// Verify all operations happened inside the transaction
expect(mockTransactionManager.findOne).toHaveBeenCalled();
expect(mockTransactionManager.delete).toHaveBeenCalled();
expect(mockTransactionManager.insert).toHaveBeenCalledTimes(2);
});
it('should throw error when refresh token not found', async () => {
mockTransactionManager.findOne.mockResolvedValue(null);
await expect(
service.validateAndRotateRefreshToken('invalid-token', 'client-123'),
).rejects.toThrow('Invalid refresh token');
});
it('should throw error when refresh token expired (atomic delete fails)', async () => {
const refreshTokenRecord = mock<RefreshToken>({
token: 'expired-token',
clientId: 'client-123',
userId: 'user-456',
expiresAt: Date.now() - 1000, // Expired
});
mockTransactionManager.findOne.mockResolvedValue(refreshTokenRecord);
mockTransactionManager.delete.mockResolvedValue({ affected: 0 }); // Atomic delete fails due to expiry
await expect(
service.validateAndRotateRefreshToken('expired-token', 'client-123'),
).rejects.toThrow('Invalid refresh token');
});
});
describe('verifyAccessToken', () => {
it('should verify valid access token and return auth info', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
const accessTokenRecord = mock<AccessToken>({
token: accessToken,
clientId,
userId,
});
accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord);
const result = await service.verifyAccessToken(accessToken);
expect(result).toEqual({
token: accessToken,
clientId,
scopes: [],
extra: {
userId,
},
});
});
it('should throw error for invalid JWT signature', async () => {
const invalidToken = 'invalid.jwt.token';
await expect(service.verifyAccessToken(invalidToken)).rejects.toThrow(
'Invalid access token: JWT verification failed',
);
});
it('should throw error for wrong audience', async () => {
const wrongAudienceToken = jwtService.sign({
sub: 'user-123',
aud: 'wrong-audience', // Not 'mcp-server-api'
client_id: 'client-456',
});
await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow(
'Invalid access token: JWT verification failed',
);
});
it('should throw error when token not found in database', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
accessTokenRepository.findOne.mockResolvedValue(null);
await expect(service.verifyAccessToken(accessToken)).rejects.toThrow(
'Invalid access token: not found in database',
);
});
});
describe('verifyOAuthAccessToken', () => {
it('should verify token and return user', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
const accessTokenRecord = mock<AccessToken>({
token: accessToken,
clientId,
userId,
});
const user = mock<User>({ id: userId });
accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord);
userRepository.findOne.mockResolvedValue(user);
const result = await service.verifyOAuthAccessToken(accessToken);
expect(result).toEqual(user);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
relations: ['role'],
});
});
it('should return null for invalid token', async () => {
const invalidToken = 'invalid.jwt.token';
const result = await service.verifyOAuthAccessToken(invalidToken);
expect(result).toBeNull();
});
it('should return null when user not found', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
const accessTokenRecord = mock<AccessToken>({
token: accessToken,
clientId,
userId,
});
accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord);
userRepository.findOne.mockResolvedValue(null);
const result = await service.verifyOAuthAccessToken(accessToken);
expect(result).toBeNull();
});
});
describe('revokeAccessToken', () => {
it('should delete access token', async () => {
const token = 'access-token-123';
const clientId = 'client-456';
accessTokenRepository.delete.mockResolvedValue({ affected: 1 } as any);
const result = await service.revokeAccessToken(token, clientId);
expect(result).toBe(true);
expect(accessTokenRepository.delete).toHaveBeenCalledWith({ token, clientId });
expect(logger.info).toHaveBeenCalledWith('Access token revoked', { clientId });
});
it('should return false when token not found', async () => {
accessTokenRepository.delete.mockResolvedValue({ affected: 0 } as any);
const result = await service.revokeAccessToken('nonexistent-token', 'client-456');
expect(result).toBe(false);
});
});
describe('revokeRefreshToken', () => {
it('should delete refresh token', async () => {
const token = 'refresh-token-123';
const clientId = 'client-456';
refreshTokenRepository.delete.mockResolvedValue({ affected: 1 } as any);
const result = await service.revokeRefreshToken(token, clientId);
expect(result).toBe(true);
expect(refreshTokenRepository.delete).toHaveBeenCalledWith({ token, clientId });
expect(logger.info).toHaveBeenCalledWith('Refresh token revoked', { clientId });
});
it('should return false when token not found', async () => {
refreshTokenRepository.delete.mockResolvedValue({ affected: 0 } as any);
const result = await service.revokeRefreshToken('nonexistent-token', 'client-456');
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,151 @@
import { McpOAuthHelpers } from '../mcp-oauth.helpers';
describe('McpOAuthHelpers', () => {
describe('buildSuccessRedirectUrl', () => {
it('should build redirect URL with authorization code', () => {
const redirectUri = 'https://example.com/callback';
const code = 'auth-code-123';
const state = null;
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toBe('https://example.com/callback?code=auth-code-123');
});
it('should include state parameter when provided', () => {
const redirectUri = 'https://example.com/callback';
const code = 'auth-code-123';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toBe('https://example.com/callback?code=auth-code-123&state=state-xyz');
});
it('should preserve existing query parameters', () => {
const redirectUri = 'https://example.com/callback?foo=bar';
const code = 'auth-code-123';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toContain('foo=bar');
expect(result).toContain('code=auth-code-123');
expect(result).toContain('state=state-xyz');
});
it('should handle redirect URI with port', () => {
const redirectUri = 'http://localhost:3000/callback';
const code = 'auth-code-123';
const state = null;
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toBe('http://localhost:3000/callback?code=auth-code-123');
});
it('should URL-encode special characters in code', () => {
const redirectUri = 'https://example.com/callback';
const code = 'code+with/special=chars';
const state = null;
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toContain('code=code%2Bwith%2Fspecial%3Dchars');
});
});
describe('buildErrorRedirectUrl', () => {
it('should build redirect URL with error parameters', () => {
const redirectUri = 'https://example.com/callback';
const error = 'access_denied';
const errorDescription = 'User denied the authorization request';
const state = null;
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('error=access_denied');
expect(result).toContain('error_description=User+denied+the+authorization+request');
});
it('should include state parameter when provided', () => {
const redirectUri = 'https://example.com/callback';
const error = 'invalid_request';
const errorDescription = 'Missing required parameter';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('error=invalid_request');
expect(result).toContain('state=state-xyz');
});
it('should preserve existing query parameters', () => {
const redirectUri = 'https://example.com/callback?foo=bar';
const error = 'server_error';
const errorDescription = 'Internal server error';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('foo=bar');
expect(result).toContain('error=server_error');
expect(result).toContain('state=state-xyz');
});
it('should handle common OAuth error codes', () => {
const testCases = [
{ error: 'access_denied', description: 'User denied' },
{ error: 'invalid_request', description: 'Bad request' },
{ error: 'unauthorized_client', description: 'Client not authorized' },
{ error: 'invalid_scope', description: 'Invalid scope' },
{ error: 'server_error', description: 'Server error' },
{ error: 'temporarily_unavailable', description: 'Service unavailable' },
];
testCases.forEach(({ error, description }) => {
const result = McpOAuthHelpers.buildErrorRedirectUrl(
'https://example.com/callback',
error,
description,
null,
);
expect(result).toContain(`error=${error}`);
expect(result).toContain('error_description=');
});
});
it('should URL-encode special characters in error description', () => {
const redirectUri = 'https://example.com/callback';
const error = 'access_denied';
const errorDescription = 'User said "no thanks!"';
const state = null;
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('error_description=');
expect(result).not.toContain('"'); // Should be encoded
});
});
});
@@ -1,423 +0,0 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { ApiKeyRepository, UserRepository } from '@n8n/db';
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { McpServerApiKeyService } from '../mcp-api-key.service';
const mockReqWith = (authHeader: string | undefined) => {
const req = mockDeep<Request>();
req.header.mockImplementation((name: string) => {
if (name === 'authorization') return authHeader;
return undefined;
});
return req;
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let userRepository: jest.Mocked<UserRepository>;
let apiKeyRepository: jest.Mocked<ApiKeyRepository>;
let telemetry: jest.Mocked<Telemetry>;
let mcpServerApiKeyService: McpServerApiKeyService;
describe('McpServerApiKeyService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
beforeAll(() => {
userRepository = mockInstance(UserRepository);
apiKeyRepository = mockInstance(ApiKeyRepository);
telemetry = mockInstance(Telemetry);
mcpServerApiKeyService = new McpServerApiKeyService(
apiKeyRepository,
jwtService,
userRepository,
telemetry,
);
});
describe('getAuthMiddleware', () => {
it('should return 401 if authorization header is missing', async () => {
// Arrange
const req = mockReqWith(undefined);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should throw error if authorization header does not start with Bearer', async () => {
// Arrange
const req = mockReqWith('Basic sometoken');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should throw error if authorization header has invalid Bearer format', async () => {
// Arrange
const req = mockReqWith('Bearer');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if API key is not found in database', async () => {
// Arrange
const apiKey = jwtService.sign({
sub: randomUUID(),
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if JWT verification fails (invalid signature)', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const wrongJwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'wrong-key' }),
mock(),
);
const apiKey = wrongJwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should authenticate successfully with valid API key', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.send).not.toHaveBeenCalled();
// @ts-ignore
expect(req.user).toBeDefined();
// @ts-ignore
expect(req.user.id).toBe(userId);
});
it('should attach user with role information to request', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
// @ts-ignore
expect(req.user).toBeDefined();
// @ts-ignore
expect(req.user.role).toBeDefined();
});
it('should handle Bearer token with exact case matching', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should throw error with non-standard Bearer casing', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`BEARER ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if user is not found for valid JWT', async () => {
// Arrange
const apiKey = jwtService.sign({
sub: randomUUID(),
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 for malformed JWT', async () => {
// Arrange
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith('Bearer malformed.jwt.token');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should handle Bearer token with extra whitespace', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 if API key exists but user is deleted', async () => {
// Arrange
const apiKey = jwtService.sign({
sub: randomUUID(),
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should throw error with empty Bearer token', async () => {
// Arrange
const req = mockReqWith('Bearer ');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,305 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import type { Request, Response, NextFunction } from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { McpServerApiKeyService } from '../mcp-api-key.service';
import { McpOAuthTokenService } from '../mcp-oauth-token.service';
import { McpServerMiddlewareService } from '../mcp-server-middleware.service';
const mockReqWith = (authHeader: string | undefined, body?: any) => {
const req = mockDeep<Request>();
req.header.mockImplementation((name: string) => {
if (name === 'authorization') return authHeader;
return undefined;
});
req.body = body || {};
return req;
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let mcpServerApiKeyService: jest.Mocked<McpServerApiKeyService>;
let oauthTokenService: jest.Mocked<McpOAuthTokenService>;
let telemetry: jest.Mocked<Telemetry>;
let service: McpServerMiddlewareService;
describe('McpServerMiddlewareService', () => {
beforeAll(() => {
mcpServerApiKeyService = mockInstance(
McpServerApiKeyService,
) as jest.Mocked<McpServerApiKeyService>;
oauthTokenService = mockInstance(McpOAuthTokenService) as jest.Mocked<McpOAuthTokenService>;
telemetry = mockInstance(Telemetry);
service = new McpServerMiddlewareService(
mcpServerApiKeyService,
oauthTokenService,
jwtService,
telemetry,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('getUserForToken', () => {
it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
const result = await service.getUserForToken(oauthToken);
expect(result).toEqual(user);
expect(oauthTokenService.verifyOAuthAccessToken).toHaveBeenCalledWith(oauthToken);
expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
});
it('should return user for valid API key (no meta.isOAuth)', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
const result = await service.getUserForToken(apiKeyToken);
expect(result).toEqual(user);
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
it('should return user for valid API key (meta.isOAuth = false)', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: false },
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
const result = await service.getUserForToken(apiKeyToken);
expect(result).toEqual(user);
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
it('should return null for invalid JWT format', async () => {
const invalidToken = 'not-a-jwt-token';
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
const result = await service.getUserForToken(invalidToken);
expect(result).toBeNull();
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
it('should return null when OAuth token verification fails', async () => {
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
const result = await service.getUserForToken(oauthToken);
expect(result).toBeNull();
});
it('should return null when API key verification fails', async () => {
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
const result = await service.getUserForToken(apiKeyToken);
expect(result).toBeNull();
});
});
describe('getAuthMiddleware', () => {
it('should return 401 when authorization header is missing', async () => {
const req = mockReqWith(undefined);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: undefined,
client_version: undefined,
});
});
it('should throw error when authorization header does not start with Bearer', async () => {
const req = mockReqWith('Basic sometoken');
const res = mockDeep<Response>();
res.status.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should throw error when Bearer token is malformed', async () => {
const req = mockReqWith('Bearer');
const res = mockDeep<Response>();
res.status.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should authenticate with valid OAuth token and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
const next = jest.fn() as NextFunction;
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect((req as any).user).toEqual(user);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with valid API key and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
const req = mockReqWith(`Bearer ${apiKeyToken}`);
const res = mockDeep<Response>();
const next = jest.fn() as NextFunction;
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect((req as any).user).toEqual(user);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 when token validation fails', async () => {
const invalidToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const req = mockReqWith(`Bearer ${invalidToken}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should track telemetry with client info from request body', async () => {
const req = mockReqWith(undefined, {
params: {
clientInfo: {
name: 'test-client',
version: '1.0.0',
},
},
});
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: 'test-client',
client_version: '1.0.0',
});
});
it('should handle invalid token format gracefully', async () => {
const req = mockReqWith('Bearer invalid-token-format');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,433 @@
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { JwtService } from '@/services/jwt.service';
import { createOwner, createMember } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import type { OAuthSessionPayload } from '../oauth-session.service';
const testServer = setupTestServer({ endpointGroups: ['mcp'], modules: ['mcp'] });
let owner: User;
let member: User;
let jwtService: JwtService;
const createSessionToken = (payload: OAuthSessionPayload): string => {
return jwtService.sign(payload, { expiresIn: '10m' });
};
let oauthClientRepository: OAuthClientRepository;
beforeAll(async () => {
owner = await createOwner();
member = await createMember();
jwtService = Container.get(JwtService);
oauthClientRepository = Container.get(OAuthClientRepository);
});
afterEach(async () => {
await testDb.truncate(['OAuthClient', 'AuthorizationCode', 'UserConsent']);
});
describe('GET /rest/consent/details', () => {
test('should return consent details for valid session', async () => {
const client = await oauthClientRepository.save({
id: 'test-client-id',
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
};
const sessionToken = createSessionToken(sessionPayload);
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: 'test-client-id',
});
});
test('should return 400 when session cookie is missing', async () => {
const response = await testServer.authAgentFor(owner).get('/consent/details');
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should return 400 when session token is invalid', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', 'n8n-oauth-session=invalid-token');
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should return 400 when client does not exist', async () => {
const sessionPayload = {
clientId: 'non-existent-client',
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
};
const sessionToken = createSessionToken(sessionPayload);
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should clear session cookie on invalid token', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', 'n8n-oauth-session=invalid-token');
expect(response.statusCode).toBeGreaterThanOrEqual(400);
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should require authentication', async () => {
const response = await testServer.authlessAgent.get('/consent/details');
expect(response.statusCode).toBe(401);
});
test('should work for different users', async () => {
const client = await oauthClientRepository.save({
id: 'test-client-id-2',
name: 'Test Client 2',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
};
const sessionToken = createSessionToken(sessionPayload);
const ownerResponse = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(ownerResponse.statusCode).toBe(200);
expect(ownerResponse.body.data.clientName).toBe('Test Client 2');
const memberResponse = await testServer
.authAgentFor(member)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(memberResponse.statusCode).toBe(200);
expect(memberResponse.body.data.clientName).toBe('Test Client 2');
});
});
describe('POST /rest/consent/approve', () => {
let client: OAuthClient;
let sessionToken: string;
beforeEach(async () => {
client = await oauthClientRepository.save({
id: `test-client-${Date.now()}`,
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge-string-that-is-long-enough',
state: 'test-state',
};
sessionToken = createSessionToken(sessionPayload);
});
test('should handle consent approval and return redirect URL with authorization code', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({
status: 'success',
redirectUrl: expect.stringContaining('https://example.com/callback?code='),
});
const redirectUrl = new URL(response.body.data.redirectUrl);
expect(redirectUrl.searchParams.get('code')).toBeTruthy();
expect(redirectUrl.searchParams.get('code')?.length).toBeGreaterThan(32);
expect(redirectUrl.searchParams.get('state')).toBe('test-state');
});
test('should handle consent denial and return error redirect URL', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: false });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({
status: 'success',
redirectUrl: expect.stringContaining('https://example.com/callback?error=access_denied'),
});
const redirectUrl = new URL(response.body.data.redirectUrl);
expect(redirectUrl.searchParams.get('error')).toBe('access_denied');
expect(redirectUrl.searchParams.get('error_description')).toBeTruthy();
expect(redirectUrl.searchParams.get('state')).toBe('test-state');
});
test('should clear session cookie after processing consent', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(200);
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should return 400 when approved field is missing', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toMatchObject({
code: 'invalid_type',
expected: 'boolean',
path: ['approved'],
});
});
test('should return 400 when approved field is not boolean', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: 'yes' });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toMatchObject({
code: 'invalid_type',
expected: 'boolean',
received: 'string',
path: ['approved'],
});
});
test('should return 400 when session cookie is missing', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.send({ approved: true });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should return 400 when session token is invalid', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', 'n8n-oauth-session=invalid-token')
.send({ approved: true });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body.status).toBe('error');
});
test('should clear session cookie even on error', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', 'n8n-oauth-session=invalid-token')
.send({ approved: true });
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should require authentication', async () => {
const response = await testServer.authlessAgent
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(401);
});
test('should create user consent record on approval', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(200);
const { UserConsentRepository } = await import(
'../database/repositories/oauth-user-consent.repository'
);
const userConsentRepository = Container.get(UserConsentRepository);
const consent = await userConsentRepository.findOne({
where: { userId: owner.id, clientId: client.id },
});
expect(consent).toBeDefined();
expect(consent?.userId).toBe(owner.id);
expect(consent?.clientId).toBe(client.id);
expect(consent?.grantedAt).toBeDefined();
});
test('should not create user consent record on denial', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: false });
expect(response.statusCode).toBe(200);
const { UserConsentRepository } = await import(
'../database/repositories/oauth-user-consent.repository'
);
const userConsentRepository = Container.get(UserConsentRepository);
const consent = await userConsentRepository.findOne({
where: { userId: owner.id, clientId: client.id },
});
expect(consent).toBeNull();
});
test('should handle consent from different users', async () => {
const ownerResponse = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(ownerResponse.statusCode).toBe(200);
expect(ownerResponse.body.data.redirectUrl).toContain('code=');
const newSessionToken = createSessionToken({
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge-string-that-is-long-enough',
state: 'test-state-2',
});
const memberResponse = await testServer
.authAgentFor(member)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${newSessionToken}`)
.send({ approved: false });
expect(memberResponse.statusCode).toBe(200);
expect(memberResponse.body.data.redirectUrl).toContain('error=access_denied');
});
});
describe('Consent Flow - End-to-End', () => {
test('should complete full consent flow from details to approval', async () => {
const client = await oauthClientRepository.save({
id: 'e2e-test-client',
name: 'End-to-End Test Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'e2e-test-challenge-string-that-is-long-enough',
state: 'e2e-state',
};
const sessionToken = createSessionToken(sessionPayload);
const detailsResponse = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(detailsResponse.statusCode).toBe(200);
expect(detailsResponse.body.data.clientName).toBe('End-to-End Test Client');
const approvalResponse = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(approvalResponse.statusCode).toBe(200);
expect(approvalResponse.body.data.status).toBe('success');
expect(approvalResponse.body.data.redirectUrl).toContain('code=');
expect(approvalResponse.body.data.redirectUrl).toContain('state=e2e-state');
const setCookieHeader = approvalResponse.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
});
@@ -4,16 +4,16 @@ import { Container } from '@n8n/di';
import { mock, mockDeep } from 'jest-mock-extended';
// eslint-disable-next-line import-x/order
import { McpServerApiKeyService } from '../mcp-api-key.service';
import { McpServerMiddlewareService } from '../mcp-server-middleware.service';
const mockAuthMiddleware = jest.fn().mockImplementation(async (_req, _res, next) => {
next();
});
const mcpServerApiKeyService = mockDeep<McpServerApiKeyService>();
mcpServerApiKeyService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware);
const mcpServerMiddlewareService = mockDeep<McpServerMiddlewareService>();
mcpServerMiddlewareService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware);
// We need to mock the service before importing the controller as it's used in the middleware
Container.set(McpServerApiKeyService, mcpServerApiKeyService);
Container.set(McpServerMiddlewareService, mcpServerMiddlewareService);
import { McpController, type FlushableResponse } from '../mcp.controller';
import { McpService } from '../mcp.service';
@@ -0,0 +1,335 @@
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { SUPPORTED_SCOPES } from '../mcp-oauth-service';
const testServer = setupTestServer({ modules: ['mcp'], endpointGroups: ['mcp'] });
let owner: User;
beforeAll(async () => {
owner = await createOwner();
});
afterEach(async () => {
await testDb.truncate(['OAuthClient', 'AuthorizationCode', 'AccessToken', 'RefreshToken']);
});
describe('GET /.well-known/oauth-authorization-server', () => {
test('should return OAuth authorization server metadata', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
issuer: expect.any(String),
authorization_endpoint: expect.stringContaining('/mcp-oauth/authorize'),
token_endpoint: expect.stringContaining('/mcp-oauth/token'),
registration_endpoint: expect.stringContaining('/mcp-oauth/register'),
revocation_endpoint: expect.stringContaining('/mcp-oauth/revoke'),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: SUPPORTED_SCOPES,
});
});
test('should return metadata with correct base URL', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
const {
issuer,
authorization_endpoint,
token_endpoint,
registration_endpoint,
revocation_endpoint,
} = response.body;
expect(issuer).toMatch(/^https?:\/\//);
expect(authorization_endpoint).toBe(`${issuer}/mcp-oauth/authorize`);
expect(token_endpoint).toBe(`${issuer}/mcp-oauth/token`);
expect(registration_endpoint).toBe(`${issuer}/mcp-oauth/register`);
expect(revocation_endpoint).toBe(`${issuer}/mcp-oauth/revoke`);
});
test('should include all required OAuth 2.1 fields', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
const metadata = response.body;
expect(metadata.issuer).toBeDefined();
expect(metadata.authorization_endpoint).toBeDefined();
expect(metadata.token_endpoint).toBeDefined();
expect(metadata.response_types_supported).toBeDefined();
expect(metadata.grant_types_supported).toBeDefined();
expect(metadata.code_challenge_methods_supported).toContain('S256');
});
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
});
});
describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
test('should return protected resource metadata', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
resource: expect.stringContaining('/mcp-server/http'),
bearer_methods_supported: ['header'],
authorization_servers: [expect.any(String)],
scopes_supported: SUPPORTED_SCOPES,
});
});
test('should return metadata with correct resource URL', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
const { resource, authorization_servers } = response.body;
expect(resource).toMatch(/^https?:\/\//);
expect(resource).toContain('/mcp-server/http');
expect(authorization_servers).toHaveLength(1);
expect(authorization_servers[0]).toMatch(/^https?:\/\//);
});
test('should indicate Bearer token authentication via header', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
expect(response.body.bearer_methods_supported).toEqual(['header']);
});
test('should list supported scopes', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
expect(response.body.scopes_supported).toEqual(SUPPORTED_SCOPES);
expect(response.body.scopes_supported.length).toBeGreaterThan(0);
});
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
});
});
describe('POST /mcp-oauth/register', () => {
test('should register a new OAuth client with dynamic registration', async () => {
const clientData = {
client_name: 'Test MCP Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
};
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData);
expect(response.statusCode).toBe(201);
expect(response.body.client_id).toBeDefined();
expect(response.body.client_name).toBe('Test MCP Client');
expect(response.body.redirect_uris).toEqual(['https://example.com/callback']);
expect(response.body.grant_types).toEqual(['authorization_code', 'refresh_token']);
expect(response.body.token_endpoint_auth_method).toBe('none');
});
test('should generate unique client IDs for each registration', async () => {
const clientData = {
client_name: 'Test Client 1',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
};
const response1 = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData);
const response2 = await testServer.restlessAgent
.post('/mcp-oauth/register')
.send({ ...clientData, client_name: 'Test Client 2' });
expect(response1.statusCode).toBe(201);
expect(response2.statusCode).toBe(201);
expect(response1.body.client_id).toBeDefined();
expect(response2.body.client_id).toBeDefined();
expect(response1.body.client_id).not.toBe(response2.body.client_id);
});
test('should accept client registration without authentication', async () => {
const clientData = {
client_name: 'Public Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
};
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData);
expect(response.statusCode).toBe(201);
});
test('should validate required fields in client registration', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send({});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
});
});
describe('GET /mcp-oauth/authorize', () => {
test('should require authentication for authorization endpoint', async () => {
const response = await testServer.restlessAgent.get('/mcp-oauth/authorize').query({
client_id: 'test-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge',
code_challenge_method: 'S256',
});
expect([302, 400, 401, 403]).toContain(response.statusCode);
});
test('should accept valid authorization request parameters', async () => {
const registerResponse = await testServer.restlessAgent.post('/mcp-oauth/register').send({
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
});
const clientId = registerResponse.body.client_id;
const response = await testServer.authAgentFor(owner).get('/mcp-oauth/authorize').query({
client_id: clientId,
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'test-challenge-string-must-be-long-enough-for-validation',
code_challenge_method: 'S256',
state: 'random-state',
});
expect(response.statusCode).toBeGreaterThanOrEqual(200);
});
});
describe('POST /mcp-oauth/token', () => {
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({
grant_type: 'authorization_code',
code: 'invalid-code',
client_id: 'test-client',
});
expect(response.statusCode).not.toBe(401);
});
test('should return error for invalid authorization code', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({
grant_type: 'authorization_code',
code: 'invalid-authorization-code',
client_id: 'test-client',
redirect_uri: 'https://example.com/callback',
code_verifier: 'test-verifier',
});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body.error).toBeDefined();
});
test('should validate grant_type parameter', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({
grant_type: 'invalid_grant_type',
code: 'test-code',
client_id: 'test-client',
});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body.error).toBeDefined();
});
});
describe('POST /mcp-oauth/revoke', () => {
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({
token: 'test-token',
client_id: 'test-client',
});
expect(response.statusCode).not.toBe(401);
});
test('should accept token revocation request', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({
token: 'some-token-to-revoke',
client_id: 'test-client',
token_type_hint: 'access_token',
});
expect([200, 204, 400]).toContain(response.statusCode);
});
test('should handle revocation without token_type_hint', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({
token: 'some-token',
client_id: 'test-client',
});
expect(response.statusCode).toBeGreaterThanOrEqual(200);
});
});
describe('OAuth Discovery - Cross-validation', () => {
test('should have consistent URLs between authorization server and protected resource metadata', async () => {
const authServerResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-authorization-server',
);
const protectedResourceResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(authServerResponse.statusCode).toBe(200);
expect(protectedResourceResponse.statusCode).toBe(200);
const authServer = authServerResponse.body;
const protectedResource = protectedResourceResponse.body;
expect(protectedResource.authorization_servers).toContain(authServer.issuer);
});
test('should have consistent scopes between authorization server and protected resource', async () => {
const authServerResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-authorization-server',
);
const protectedResourceResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(authServerResponse.statusCode).toBe(200);
expect(protectedResourceResponse.statusCode).toBe(200);
const authServerScopes = authServerResponse.body.scopes_supported;
const protectedResourceScopes = protectedResourceResponse.body.scopes_supported;
expect(authServerScopes).toEqual(protectedResourceScopes);
});
});
@@ -0,0 +1,28 @@
import { User } from '@n8n/db';
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_access_tokens')
export class AccessToken {
@Column({ type: 'varchar', primary: true })
token: string;
@ManyToOne(
() => OAuthClient,
(client) => client.accessTokens,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
}
@@ -0,0 +1,47 @@
import { User, WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_authorization_codes')
export class AuthorizationCode extends WithTimestamps {
@Column({ type: 'varchar', primary: true })
code: string;
@ManyToOne(
() => OAuthClient,
(client) => client.authorizationCodes,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
@Column({ type: String })
redirectUri: string;
@Column({ type: String })
codeChallenge: string;
@Column({ type: String })
codeChallengeMethod: string;
@Column({ type: String, nullable: true })
state: string | null;
@Index()
@Column({ type: 'int' })
expiresAt: number;
@Column({ type: Boolean, default: false })
used: boolean;
}
@@ -0,0 +1,43 @@
import { JsonColumn, WithTimestamps } from '@n8n/db';
import { Column, Entity, OneToMany } from '@n8n/typeorm';
import type { AccessToken } from './oauth-access-token.entity';
import type { AuthorizationCode } from './oauth-authorization-code.entity';
import type { RefreshToken } from './oauth-refresh-token.entity';
import type { UserConsent } from './oauth-user-consent.entity';
@Entity('oauth_clients')
export class OAuthClient extends WithTimestamps {
@Column({ type: 'varchar', primary: true })
id: string;
@Column({ type: String })
name: string;
@JsonColumn()
redirectUris: string[];
@JsonColumn()
grantTypes: string[];
@Column({ type: String, default: 'none' })
tokenEndpointAuthMethod: string;
@OneToMany('AuthorizationCode', 'client')
authorizationCodes: AuthorizationCode[];
@OneToMany('AccessToken', 'client')
accessTokens: AccessToken[];
@OneToMany('RefreshToken', 'client')
refreshTokens: RefreshToken[];
@OneToMany('UserConsent', 'client')
userConsents: UserConsent[];
@Column({ type: String, nullable: true })
clientSecret: string | null;
@Column({ type: 'int', nullable: true })
clientSecretExpiresAt: number | null;
}
@@ -0,0 +1,32 @@
import { User, WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_refresh_tokens')
export class RefreshToken extends WithTimestamps {
@Column({ type: 'varchar', primary: true })
token: string;
@ManyToOne(
() => OAuthClient,
(client) => client.refreshTokens,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
@Index()
@Column({ type: 'int' })
expiresAt: number;
}
@@ -0,0 +1,32 @@
import { User } from '@n8n/db';
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_user_consents')
@Unique(['userId', 'clientId'])
export class UserConsent {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
@ManyToOne(
() => OAuthClient,
(client) => client.userConsents,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@Column({ type: 'bigint' })
grantedAt: number;
}
@@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { AccessToken } from '../entities/oauth-access-token.entity';
@Service()
export class AccessTokenRepository extends Repository<AccessToken> {
constructor(dataSource: DataSource) {
super(AccessToken, dataSource.manager);
}
}
@@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { AuthorizationCode } from '../entities/oauth-authorization-code.entity';
@Service()
export class AuthorizationCodeRepository extends Repository<AuthorizationCode> {
constructor(dataSource: DataSource) {
super(AuthorizationCode, dataSource.manager);
}
}
@@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { OAuthClient } from '../entities/oauth-client.entity';
@Service()
export class OAuthClientRepository extends Repository<OAuthClient> {
constructor(dataSource: DataSource) {
super(OAuthClient, dataSource.manager);
}
}
@@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { RefreshToken } from '../entities/oauth-refresh-token.entity';
@Service()
export class RefreshTokenRepository extends Repository<RefreshToken> {
constructor(dataSource: DataSource) {
super(RefreshToken, dataSource.manager);
}
}
@@ -0,0 +1,22 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { UserConsent } from '../entities/oauth-user-consent.entity';
@Service()
export class UserConsentRepository extends Repository<UserConsent> {
constructor(dataSource: DataSource) {
super(UserConsent, dataSource.manager);
}
/**
* Find all consents for a user with client information
*/
async findByUserWithClient(userId: string): Promise<UserConsent[]> {
return await this.find({
where: { userId },
relations: ['client'],
order: { grantedAt: 'DESC' },
});
}
}
@@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ApproveConsentRequestDto extends Z.class({
approved: z.boolean(),
}) {}
@@ -1,16 +1,12 @@
import { ApiKey, ApiKeyRepository, AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { ApiKey, ApiKeyRepository, User, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { EntityManager } from '@n8n/typeorm';
import { randomUUID } from 'crypto';
import { NextFunction, Response, Request } from 'express';
import { ApiKeyAudience } from 'n8n-workflow';
import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants';
import { getClientInfo } from './mcp.utils';
import { AuthError } from '@/errors/response-errors/auth.error';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
const API_KEY_AUDIENCE: ApiKeyAudience = 'mcp-server-api';
const API_KEY_ISSUER = 'n8n';
@@ -28,7 +24,7 @@ export class McpServerApiKeyService {
private readonly apiKeyRepository: ApiKeyRepository,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly telemetry: Telemetry,
private readonly accessTokenRepository: AccessTokenRepository,
) {}
async createMcpServerApiKey(user: User, trx?: EntityManager) {
@@ -69,7 +65,7 @@ export class McpServerApiKeyService {
return apiKey;
}
private async getUserForApiKey(apiKey: string) {
async getUserForApiKey(apiKey: string) {
return await this.userRepository.findOne({
where: {
apiKeys: {
@@ -81,6 +77,38 @@ export class McpServerApiKeyService {
});
}
async verifyApiKey(apiKey: string): Promise<User | null> {
try {
this.jwtService.verify(apiKey, {
issuer: API_KEY_ISSUER,
audience: API_KEY_AUDIENCE,
});
return await this.getUserForApiKey(apiKey);
} catch (error) {
return null;
}
}
async getUserForAccessToken(token: string) {
const accessToken = await this.accessTokenRepository.findOne({
where: {
token,
},
});
if (!accessToken) {
return null;
}
return await this.userRepository.findOne({
where: {
id: accessToken.userId,
},
relations: ['role'],
});
}
async deleteAllMcpApiKeysForUser(user: User, trx?: EntityManager) {
const manager = trx ?? this.apiKeyRepository.manager;
@@ -103,71 +131,6 @@ export class McpServerApiKeyService {
return redactedPart + visiblePart;
}
private extractAPIKeyFromHeader(headerValue: string) {
if (!headerValue.startsWith('Bearer')) {
throw new AuthError('Invalid authorization header format');
}
const apiKeyMatch = headerValue.match(/^Bearer\s+(.+)$/i);
if (apiKeyMatch) {
return apiKeyMatch[1];
}
throw new AuthError('Invalid authorization header format');
}
getAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authorizationHeader = req.header('authorization');
if (!authorizationHeader) {
this.responseWithUnauthorized(res, req);
return;
}
const apiKey = this.extractAPIKeyFromHeader(authorizationHeader);
if (!apiKey) {
this.responseWithUnauthorized(res, req);
return;
}
const user = await this.getUserForApiKey(apiKey);
if (!user) {
this.responseWithUnauthorized(res, req);
return;
}
try {
this.jwtService.verify(apiKey, {
issuer: API_KEY_ISSUER,
audience: API_KEY_AUDIENCE,
});
} catch (e) {
this.responseWithUnauthorized(res, req);
return;
}
(req as AuthenticatedRequest).user = user;
next();
};
}
private responseWithUnauthorized(res: Response, req: Request) {
this.trackUnauthorizedEvent(req);
res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE });
}
private trackUnauthorizedEvent(req: Request) {
const clientInfo = getClientInfo(req);
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, {
mcp_connection_status: 'error',
error: UNAUTHORIZED_ERROR_MESSAGE,
client_name: clientInfo?.name,
client_version: clientInfo?.version,
});
}
async getOrCreateApiKey(user: User) {
const apiKey = await this.apiKeyRepository.findOne({
where: {
@@ -0,0 +1,110 @@
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { randomBytes } from 'node:crypto';
import type { AuthorizationCode } from './database/entities/oauth-authorization-code.entity';
import { AuthorizationCodeRepository } from './database/repositories/oauth-authorization-code.repository';
/**
* Handles OAuth 2.1 authorization code lifecycle for MCP server
* Generates, validates, and consumes authorization codes with PKCE support
*/
@Service()
export class McpOAuthAuthorizationCodeService {
private readonly AUTHORIZATION_CODE_EXPIRY_MS = 10 * Time.minutes.toMilliseconds;
constructor(private readonly authorizationCodeRepository: AuthorizationCodeRepository) {}
/**
* Generate and save authorization code
* Returns the generated code string
*/
async createAuthorizationCode(
clientId: string,
userId: string,
redirectUri: string,
codeChallenge: string,
state: string | null,
): Promise<string> {
const code = randomBytes(32).toString('hex');
await this.authorizationCodeRepository.insert({
code,
clientId,
userId,
redirectUri,
codeChallenge,
codeChallengeMethod: 'S256',
state,
expiresAt: Date.now() + this.AUTHORIZATION_CODE_EXPIRY_MS,
used: false,
});
return code;
}
/**
* Find and validate authorization code (without consuming)
* Returns the auth record if valid, throws if invalid/expired
*/
async findAndValidateAuthorizationCode(
authorizationCode: string,
clientId: string,
): Promise<AuthorizationCode> {
const authRecord = await this.authorizationCodeRepository.findOne({
where: {
code: authorizationCode,
clientId,
},
});
if (!authRecord) {
throw new Error('Invalid authorization code');
}
if (authRecord.expiresAt < Date.now()) {
await this.authorizationCodeRepository.remove(authRecord);
throw new Error('Authorization code expired');
}
return authRecord;
}
/**
* Validate and consume authorization code
* Returns the auth record if valid, throws if invalid/expired/used
*/
async validateAndConsumeAuthorizationCode(
authorizationCode: string,
clientId: string,
redirectUri?: string,
): Promise<AuthorizationCode> {
const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId);
if (redirectUri && authRecord.redirectUri !== redirectUri) {
throw new Error('Redirect URI mismatch');
}
const result = await this.authorizationCodeRepository.update(
{ code: authorizationCode, used: false },
{ used: true },
);
const numAffected = result.affected ?? 0;
if (numAffected < 1) {
throw new Error('Authorization code already used');
}
authRecord.used = true;
return authRecord;
}
/**
* Get PKCE code challenge for authorization code
* Used by MCP SDK for PKCE verification
*/
async getCodeChallenge(authorizationCode: string, clientId: string): Promise<string> {
const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId);
return authRecord.codeChallenge;
}
}
@@ -0,0 +1,113 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { OAuthClientRepository } from './database/repositories/oauth-client.repository';
import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository';
import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service';
import { McpOAuthHelpers } from './mcp-oauth.helpers';
import { OAuthSessionService, type OAuthSessionPayload } from './oauth-session.service';
/**
* Manages OAuth consent flow for MCP server
* Handles user authorization decisions and generates authorization codes
*/
@Service()
export class McpOAuthConsentService {
constructor(
private readonly logger: Logger,
private readonly oauthSessionService: OAuthSessionService,
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userConsentRepository: UserConsentRepository,
private readonly authorizationCodeService: McpOAuthAuthorizationCodeService,
) {}
/**
* Get consent details from session cookie
* Verifies JWT session token and returns client information
*/
async getConsentDetails(sessionToken: string): Promise<{
clientName: string;
clientId: string;
} | null> {
try {
const sessionPayload = this.oauthSessionService.verifySession(sessionToken);
const client = await this.oauthClientRepository.findOne({
where: { id: sessionPayload.clientId },
});
if (!client) {
return null;
}
return {
clientName: client.name,
clientId: client.id,
};
} catch (error) {
this.logger.error('Error getting consent details', { error });
return null;
}
}
/**
* Handle consent approval/denial
* Uses JWT session token instead of database lookup
*/
async handleConsentDecision(
sessionToken: string,
userId: string,
approved: boolean,
): Promise<{ redirectUrl: string }> {
let sessionPayload: OAuthSessionPayload;
try {
sessionPayload = this.oauthSessionService.verifySession(sessionToken);
} catch (error) {
throw new UserError('Invalid or expired session');
}
if (!approved) {
const redirectUrl = McpOAuthHelpers.buildErrorRedirectUrl(
sessionPayload.redirectUri,
'access_denied',
'User denied the authorization request',
sessionPayload.state,
);
this.logger.info('Consent denied', {
clientId: sessionPayload.clientId,
userId,
});
return { redirectUrl };
}
await this.userConsentRepository.insert({
userId,
clientId: sessionPayload.clientId,
grantedAt: Date.now(),
});
const code = await this.authorizationCodeService.createAuthorizationCode(
sessionPayload.clientId,
userId,
sessionPayload.redirectUri,
sessionPayload.codeChallenge,
sessionPayload.state,
);
const successRedirectUrl = McpOAuthHelpers.buildSuccessRedirectUrl(
sessionPayload.redirectUri,
code,
sessionPayload.state,
);
this.logger.info('Consent approved', {
clientId: sessionPayload.clientId,
userId,
});
return { redirectUrl: successRedirectUrl };
}
}
@@ -0,0 +1,190 @@
import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
import {
AuthorizationParams,
OAuthServerProvider,
} from '@modelcontextprotocol/sdk/server/auth/provider';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import {
OAuthClientInformationFull,
OAuthTokens,
OAuthTokenRevocationRequest,
} from '@modelcontextprotocol/sdk/shared/auth';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { Response } from 'express';
import { OAuthClientRepository } from './database/repositories/oauth-client.repository';
import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service';
import { McpOAuthTokenService } from './mcp-oauth-token.service';
import { OAuthSessionService } from './oauth-session.service';
export const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
/**
* OAuth 2.1 server implementation for MCP
* Implements MCP SDK OAuthServerProvider interface for client registration, authorization, and token management
*/
@Service()
export class McpOAuthService implements OAuthServerProvider {
constructor(
private readonly logger: Logger,
private readonly oauthSessionService: OAuthSessionService,
private readonly oauthClientRepository: OAuthClientRepository,
private readonly tokenService: McpOAuthTokenService,
private readonly authorizationCodeService: McpOAuthAuthorizationCodeService,
) {}
get clientsStore(): OAuthRegisteredClientsStore {
return {
getClient: async (clientId: string): Promise<OAuthClientInformationFull | undefined> => {
const client = await this.oauthClientRepository.findOneBy({ id: clientId });
if (!client) {
return undefined;
}
return {
client_id: client.id,
client_name: client.name,
redirect_uris: client.redirectUris,
grant_types: client.grantTypes,
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
...(client.clientSecret && { client_secret: client.clientSecret }),
...(client.clientSecretExpiresAt && {
client_secret_expires_at: client.clientSecretExpiresAt,
}),
response_types: ['code'],
scope: SUPPORTED_SCOPES.join(' '),
};
},
registerClient: async (
client: OAuthClientInformationFull,
): Promise<OAuthClientInformationFull> => {
try {
await this.oauthClientRepository.insert({
id: client.client_id,
name: client.client_name,
redirectUris: client.redirect_uris,
grantTypes: client.grant_types,
clientSecret: client.client_secret ?? null,
clientSecretExpiresAt: client.client_secret_expires_at ?? null,
tokenEndpointAuthMethod: client.token_endpoint_auth_method ?? 'none',
});
} catch (error) {
this.logger.error('Error registering OAuth client', {
error,
clientId: client.client_id,
});
}
return client;
},
};
}
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response,
): Promise<void> {
this.logger.debug('Starting OAuth authorization', { clientId: client.client_id });
try {
this.oauthSessionService.createSession(res, {
clientId: client.client_id,
redirectUri: params.redirectUri,
codeChallenge: params.codeChallenge,
state: params.state ?? null,
});
res.redirect('/oauth/consent');
} catch (error) {
this.logger.error('Error in authorize method', { error, clientId: client.client_id });
this.oauthSessionService.clearSession(res);
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
}
}
async challengeForAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
): Promise<string> {
return await this.authorizationCodeService.getCodeChallenge(
authorizationCode,
client.client_id,
);
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
_codeVerifier?: string,
redirectUri?: string,
): Promise<OAuthTokens> {
const authRecord = await this.authorizationCodeService.validateAndConsumeAuthorizationCode(
authorizationCode,
client.client_id,
redirectUri,
);
const { accessToken, refreshToken } = this.tokenService.generateTokenPair(
authRecord.userId,
client.client_id,
);
await this.tokenService.saveTokenPair(
accessToken,
refreshToken,
client.client_id,
authRecord.userId,
);
this.logger.info('Authorization code exchanged for tokens', {
clientId: client.client_id,
userId: authRecord.userId,
});
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
};
}
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
_scopes?: string[],
): Promise<OAuthTokens> {
return await this.tokenService.validateAndRotateRefreshToken(refreshToken, client.client_id);
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
return await this.tokenService.verifyAccessToken(token);
}
async revokeToken(
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
const { token, token_type_hint } = request;
if (!token_type_hint || token_type_hint === 'access_token') {
const revoked = await this.tokenService.revokeAccessToken(token, client.client_id);
if (revoked) {
return;
}
}
if (!token_type_hint || token_type_hint === 'refresh_token') {
const revoked = await this.tokenService.revokeRefreshToken(token, client.client_id);
if (revoked) {
return;
}
}
this.logger.debug('Token revocation requested for unknown token', {
clientId: client.client_id,
});
}
}
@@ -0,0 +1,215 @@
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { User, UserRepository, withTransaction } from '@n8n/db';
import { Service } from '@n8n/di';
import { MoreThanOrEqual } from '@n8n/typeorm';
import { randomBytes, randomUUID } from 'node:crypto';
import { JwtService } from '@/services/jwt.service';
import { AccessToken } from './database/entities/oauth-access-token.entity';
import { RefreshToken } from './database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
/**
* Manages OAuth 2.1 token lifecycle for MCP server
* Generates, validates, rotates, and revokes access and refresh tokens
*/
@Service()
export class McpOAuthTokenService {
private readonly MCP_AUDIENCE = 'mcp-server-api';
private readonly ACCESS_TOKEN_EXPIRY_SECONDS = 1 * Time.hours.toSeconds;
private readonly REFRESH_TOKEN_EXPIRY_MS = 30 * Time.days.toMilliseconds;
constructor(
private readonly logger: Logger,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly accessTokenRepository: AccessTokenRepository,
private readonly refreshTokenRepository: RefreshTokenRepository,
) {}
generateTokenPair(
userId: string,
clientId: string,
): { accessToken: string; refreshToken: string } {
const accessToken = this.jwtService.sign({
sub: userId,
aud: this.MCP_AUDIENCE,
client_id: clientId,
jti: randomUUID(),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + this.ACCESS_TOKEN_EXPIRY_SECONDS,
meta: {
isOAuth: true,
},
});
const refreshToken = randomBytes(32).toString('hex');
return { accessToken, refreshToken };
}
async saveTokenPair(
accessToken: string,
refreshToken: string,
clientId: string,
userId: string,
): Promise<void> {
await this.accessTokenRepository.manager.transaction(async (transactionManager) => {
await transactionManager.insert(this.accessTokenRepository.target, {
token: accessToken,
clientId,
userId,
});
await transactionManager.insert(this.refreshTokenRepository.target, {
token: refreshToken,
clientId,
userId,
expiresAt: Date.now() + this.REFRESH_TOKEN_EXPIRY_MS,
});
});
}
async validateAndRotateRefreshToken(
refreshToken: string,
clientId: string,
): Promise<OAuthTokens> {
return await withTransaction(this.refreshTokenRepository.manager, undefined, async (trx) => {
const now = Date.now();
const refreshTokenRecord = await trx.findOne(RefreshToken, {
where: {
token: refreshToken,
clientId,
},
});
if (!refreshTokenRecord) {
throw new Error('Invalid refresh token');
}
const result = await trx.delete(RefreshToken, {
token: refreshToken,
clientId,
expiresAt: MoreThanOrEqual(now),
});
const numAffected = result.affected ?? 0;
if (numAffected < 1) {
throw new Error('Invalid refresh token');
}
const { accessToken, refreshToken: newRefreshToken } = this.generateTokenPair(
refreshTokenRecord.userId,
clientId,
);
await trx.insert(AccessToken, {
token: accessToken,
clientId,
userId: refreshTokenRecord.userId,
});
await trx.insert(RefreshToken, {
token: newRefreshToken,
clientId,
userId: refreshTokenRecord.userId,
expiresAt: now + this.REFRESH_TOKEN_EXPIRY_MS,
});
this.logger.info('Refresh token rotated and new access token issued', {
clientId,
userId: refreshTokenRecord.userId,
});
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: this.ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: newRefreshToken,
};
});
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
let decoded;
try {
decoded = this.jwtService.verify(token, { audience: this.MCP_AUDIENCE });
} catch (error) {
throw new Error('Invalid access token: JWT verification failed');
}
const accessTokenRecord = await this.accessTokenRepository.findOne({
where: { token },
});
if (!accessTokenRecord) {
throw new Error('Invalid access token: not found in database');
}
return {
token,
clientId: decoded.client_id,
scopes: [],
extra: {
userId: decoded.sub,
},
};
}
async verifyOAuthAccessToken(token: string): Promise<User | null> {
try {
const authInfo = await this.verifyAccessToken(token);
const userId = authInfo.extra?.userId as string;
if (!userId) {
return null;
}
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['role'],
});
return user;
} catch (error) {
return null;
}
}
async revokeAccessToken(token: string, clientId: string): Promise<boolean> {
const result = await this.accessTokenRepository.delete({
token,
clientId,
});
const revoked = (result.affected ?? 0) > 0;
if (revoked) {
this.logger.info('Access token revoked', { clientId });
}
return revoked;
}
async revokeRefreshToken(token: string, clientId: string): Promise<boolean> {
const result = await this.refreshTokenRepository.delete({
token,
clientId,
});
const revoked = (result.affected ?? 0) > 0;
if (revoked) {
this.logger.info('Refresh token revoked', { clientId });
}
return revoked;
}
}
@@ -0,0 +1,36 @@
/**
* Static utility functions for OAuth URL building
*/
export class McpOAuthHelpers {
/**
* Build success redirect URL with authorization code
* Used when user approves consent
*/
static buildSuccessRedirectUrl(redirectUri: string, code: string, state: string | null): string {
const targetUrl = new URL(redirectUri);
targetUrl.searchParams.set('code', code);
if (state) {
targetUrl.searchParams.set('state', state);
}
return targetUrl.toString();
}
/**
* Build error redirect URL
* Used when user denies consent or errors occur
*/
static buildErrorRedirectUrl(
redirectUri: string,
error: string,
errorDescription: string,
state: string | null,
): string {
const targetUrl = new URL(redirectUri);
targetUrl.searchParams.set('error', error);
targetUrl.searchParams.set('error_description', errorDescription);
if (state) {
targetUrl.searchParams.set('state', state);
}
return targetUrl.toString();
}
}
@@ -0,0 +1,107 @@
import { AuthenticatedRequest, User } from '@n8n/db';
import { Service } from '@n8n/di';
import { NextFunction, Response, Request } from 'express';
import { AuthError } from '@/errors/response-errors/auth.error';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { McpServerApiKeyService } from './mcp-api-key.service';
import { McpOAuthTokenService } from './mcp-oauth-token.service';
import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants';
import { getClientInfo } from './mcp.utils';
/**
* MCP Server Middleware Service
* Centralizes authentication for MCP server endpoints
* Supports both API key and OAuth token validation
*/
@Service()
export class McpServerMiddlewareService {
constructor(
private readonly mcpServerApiKeyService: McpServerApiKeyService,
private readonly mcpAuthTokenService: McpOAuthTokenService,
private readonly jwtService: JwtService,
private readonly telemetry: Telemetry,
) {}
/**
* Get user for a given token (API key or OAuth access token)
* Uses JWT metadata to determine token type and route to correct validation
*/
async getUserForToken(token: string): Promise<User | null> {
let decoded: { meta?: { isOAuth?: boolean } };
try {
decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token);
} catch (error) {
return null;
}
if (decoded?.meta?.isOAuth === true) {
return await this.mcpAuthTokenService.verifyOAuthAccessToken(token);
}
return await this.mcpServerApiKeyService.verifyApiKey(token);
}
/**
* Express middleware for MCP server authentication
* Validates Bearer token (OAuth or API key) and attaches user to request
*/
getAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authorizationHeader = req.header('authorization');
if (!authorizationHeader) {
this.responseWithUnauthorized(res, req);
return;
}
const token = this.extractBearerToken(authorizationHeader);
if (!token) {
this.responseWithUnauthorized(res, req);
return;
}
const user = await this.getUserForToken(token);
if (!user) {
this.responseWithUnauthorized(res, req);
return;
}
(req as AuthenticatedRequest).user = user;
next();
};
}
private extractBearerToken(headerValue: string): string | null {
if (!headerValue.startsWith('Bearer')) {
throw new AuthError('Invalid authorization header format');
}
const tokenMatch = headerValue.match(/^Bearer\s+(.+)$/i);
if (tokenMatch) {
return tokenMatch[1];
}
throw new AuthError('Invalid authorization header format');
}
private responseWithUnauthorized(res: Response, req: Request) {
this.trackUnauthorizedEvent(req);
res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE });
}
private trackUnauthorizedEvent(req: Request) {
const clientInfo = getClientInfo(req);
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, {
mcp_connection_status: 'error',
error: UNAUTHORIZED_ERROR_MESSAGE,
client_name: clientInfo?.name,
client_version: clientInfo?.version,
});
}
}
@@ -0,0 +1,106 @@
import { Logger } from '@n8n/backend-common';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Get, Post, RestController } from '@n8n/decorators';
import type { Response } from 'express';
import { ApproveConsentRequestDto } from './dto/approve-consent-request.dto';
import { McpOAuthConsentService } from './mcp-oauth-consent.service';
import { OAuthSessionService } from './oauth-session.service';
@RestController('/consent')
export class McpConsentController {
constructor(
private readonly logger: Logger,
private readonly consentService: McpOAuthConsentService,
private readonly oauthSessionService: OAuthSessionService,
) {}
@Get('/details', { usesTemplates: true })
async getConsentDetails(req: AuthenticatedRequest, res: Response) {
try {
const sessionToken = this.getAndValidateSessionToken(req, res);
if (!sessionToken) return;
const consentDetails = await this.consentService.getConsentDetails(sessionToken);
if (!consentDetails) {
this.sendInvalidSessionError(res, true);
return;
}
res.json({
data: {
clientName: consentDetails.clientName,
clientId: consentDetails.clientId,
},
});
} catch (error) {
this.logger.error('Failed to get consent details', { error });
this.oauthSessionService.clearSession(res);
this.sendErrorResponse(res, 500, 'Failed to load authorization details');
}
}
@Post('/approve', { usesTemplates: true })
async approveConsent(
req: AuthenticatedRequest,
res: Response,
@Body payload: ApproveConsentRequestDto,
) {
try {
const sessionToken = this.getAndValidateSessionToken(req, res);
if (!sessionToken) return;
const result = await this.consentService.handleConsentDecision(
sessionToken,
req.user.id,
payload.approved,
);
this.oauthSessionService.clearSession(res);
res.json({
data: {
status: 'success',
redirectUrl: result.redirectUrl,
},
});
} catch (error) {
this.logger.error('Failed to process consent', { error });
this.oauthSessionService.clearSession(res);
const message = error instanceof Error ? error.message : 'Failed to process authorization';
this.sendErrorResponse(res, 500, message);
}
}
private sendErrorResponse(res: Response, statusCode: number, message: string): void {
res.status(statusCode).json({
status: 'error',
message,
});
}
private sendInvalidSessionError(res: Response, clearCookie = false): void {
if (clearCookie) {
this.oauthSessionService.clearSession(res);
}
this.sendErrorResponse(res, 400, 'Invalid or expired authorization session');
}
private getAndValidateSessionToken(req: AuthenticatedRequest, res: Response): string | null {
const sessionToken = this.oauthSessionService.getSessionToken(req.cookies);
if (!sessionToken) {
this.sendInvalidSessionError(res);
return null;
}
try {
this.oauthSessionService.verifySession(sessionToken);
return sessionToken;
} catch (error) {
this.logger.debug('Invalid session token', { error });
this.sendInvalidSessionError(res, true);
return null;
}
}
}
+27 -4
View File
@@ -5,7 +5,9 @@ import { Container } from '@n8n/di';
import type { Response } from 'express';
import { ErrorReporter } from 'n8n-core';
import { McpServerApiKeyService } from './mcp-api-key.service';
import { Telemetry } from '@/telemetry';
import { McpServerMiddlewareService } from './mcp-server-middleware.service';
import {
USER_CONNECTED_TO_MCP_EVENT,
MCP_ACCESS_DISABLED_ERROR_MESSAGE,
@@ -17,11 +19,9 @@ import { isJSONRPCRequest } from './mcp.typeguards';
import type { UserConnectedToMCPEventPayload } from './mcp.types';
import { getClientInfo } from './mcp.utils';
import { Telemetry } from '@/telemetry';
export type FlushableResponse = Response & { flush: () => void };
const getAuthMiddleware = () => Container.get(McpServerApiKeyService).getAuthMiddleware();
const getAuthMiddleware = () => Container.get(McpServerMiddlewareService).getAuthMiddleware();
@RootLevelController('/mcp-server')
export class McpController {
@@ -32,6 +32,25 @@ export class McpController {
private readonly telemetry: Telemetry,
) {}
// Add CORS headers helper
private setCorsHeaders(res: Response) {
// Allow requests from Claude AI playground and other MCP clients
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Max-Age', '86400'); // 24 hours
}
// // Handle OPTIONS preflight requests
// @Option('/http', {
// skipAuth: true,
// })
// async handlePreflight(req: AuthenticatedRequest, res: Response) {
// this.setCorsHeaders(res);
// res.status(204).send();
// }
@Post('/http', {
rateLimit: { limit: 100 },
middlewares: [getAuthMiddleware()],
@@ -39,6 +58,9 @@ export class McpController {
usesTemplates: true,
})
async build(req: AuthenticatedRequest, res: FlushableResponse) {
// Set CORS headers for all responses
this.setCorsHeaders(res);
const body = req.body;
const isInitializationRequest = isJSONRPCRequest(body) ? body.method === 'initialize' : false;
const clientInfo = getClientInfo(req);
@@ -51,6 +73,7 @@ export class McpController {
// Deny if MCP access is disabled
const enabled = await this.mcpSettingsService.getEnabled();
if (!enabled) {
if (isInitializationRequest) {
this.trackConnectionEvent({
@@ -12,6 +12,8 @@ export class McpModule implements ModuleInterface {
async init() {
await import('./mcp.controller');
await import('./mcp.settings.controller');
await import('./mcp.oauth.controller');
await import('./mcp.auth.consent.controller');
// Initialize event relay to handle workflow deactivation
const { McpEventRelay } = await import('./mcp.event-relay');
@@ -29,6 +31,18 @@ export class McpModule implements ModuleInterface {
return { mcpAccessEnabled };
}
async entities() {
const { OAuthClient } = await import('./database/entities/oauth-client.entity');
const { AuthorizationCode } = await import(
'./database/entities/oauth-authorization-code.entity'
);
const { AccessToken } = await import('./database/entities/oauth-access-token.entity');
const { RefreshToken } = await import('./database/entities/oauth-refresh-token.entity');
const { UserConsent } = await import('./database/entities/oauth-user-consent.entity');
return [OAuthClient, AuthorizationCode, AccessToken, RefreshToken, UserConsent] as never;
}
@OnShutdown()
async shutdown() {}
}
@@ -0,0 +1,74 @@
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register.js';
import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
import { Get, RootLevelController, StaticRouterMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { Response, Request, Router } from 'express';
import { UrlService } from '@/services/url.service';
import { McpOAuthService, SUPPORTED_SCOPES } from './mcp-oauth-service';
const mcpOAuthService = Container.get(McpOAuthService);
@RootLevelController('/')
export class McpOAuthController {
constructor(private readonly urlService: UrlService) {}
static routers: StaticRouterMetadata[] = [
{
path: '/mcp-oauth/register',
router: clientRegistrationHandler({ clientsStore: mcpOAuthService.clientsStore }) as Router,
skipAuth: true,
},
{
path: '/mcp-oauth/authorize',
router: authorizationHandler({ provider: mcpOAuthService }) as Router,
skipAuth: true,
},
{
path: '/mcp-oauth/token',
router: tokenHandler({ provider: mcpOAuthService }) as Router,
skipAuth: true,
},
{
path: '/mcp-oauth/revoke',
router: revocationHandler({ provider: mcpOAuthService }) as Router,
skipAuth: true,
},
];
@Get('/.well-known/oauth-authorization-server', { skipAuth: true, usesTemplates: true })
metadata(_req: Request, res: Response) {
const baseUrl = this.urlService.getInstanceBaseUrl();
const metadata = {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/mcp-oauth/authorize`,
token_endpoint: `${baseUrl}/mcp-oauth/token`,
registration_endpoint: `${baseUrl}/mcp-oauth/register`,
revocation_endpoint: `${baseUrl}/mcp-oauth/revoke`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: SUPPORTED_SCOPES,
};
res.json(metadata);
}
@Get('/.well-known/oauth-protected-resource/mcp-server/http', {
skipAuth: true,
usesTemplates: true,
})
protectedResourceMetadata(_req: Request, res: Response) {
const baseUrl = this.urlService.getInstanceBaseUrl();
res.json({
resource: `${baseUrl}/mcp-server/http`,
bearer_methods_supported: ['header'],
authorization_servers: [baseUrl],
scopes_supported: SUPPORTED_SCOPES,
});
}
}
@@ -0,0 +1,61 @@
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { Response } from 'express';
import { JwtService } from '@/services/jwt.service';
export interface OAuthSessionPayload {
clientId: string;
redirectUri: string;
codeChallenge: string;
state: string | null;
}
const COOKIE_NAME = 'n8n-oauth-session';
const SESSION_EXPIRY_MS = 10 * Time.minutes.toMilliseconds; // 10 minutes
/**
* Manages OAuth authorization session state using JWT-based cookies
* Stores temporary session data during the authorization flow
*/
@Service()
export class OAuthSessionService {
constructor(private readonly jwtService: JwtService) {}
/**
* Create OAuth session token and set it as a cookie
*/
createSession(res: Response, payload: OAuthSessionPayload): void {
const sessionToken = this.jwtService.sign(payload, {
expiresIn: '10m',
});
res.cookie(COOKIE_NAME, sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_EXPIRY_MS,
});
}
/**
* Verify and decode OAuth session token
*/
verifySession(sessionToken: string): OAuthSessionPayload {
return this.jwtService.verify<OAuthSessionPayload>(sessionToken);
}
/**
* Clear OAuth session cookie
*/
clearSession(res: Response): void {
res.clearCookie(COOKIE_NAME);
}
/**
* Extract session token from request cookies
*/
getSessionToken(cookies: Record<string, string | undefined>): string | undefined {
return cookies[COOKIE_NAME];
}
}
+2 -2
View File
@@ -27,8 +27,8 @@ export class JwtService {
return jwt.sign(payload, this.jwtSecret, options);
}
decode(token: string) {
return jwt.decode(token) as JwtPayload;
decode<T = JwtPayload>(token: string) {
return jwt.decode(token) as T;
}
verify<T = JwtPayload>(token: string, options: jwt.VerifyOptions = {}) {
@@ -48,7 +48,7 @@ type EndpointGroup =
| 'third-party-licenses'
| 'mcp';
type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table';
type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table' | 'mcp';
export interface SetupProps {
endpointGroups?: EndpointGroup[];
@@ -0,0 +1,16 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#181818;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
</sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1,46 @@
<svg width="121" height="122" viewBox="0 0 121 122" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_572_319" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="121" height="122">
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.0687 0C55.9888 0 72.1373 16.1551 72.1373 36.0835V49.7975H84.141C104.061 49.7975 120.21 65.9526 120.21 85.8809C120.21 105.809 104.061 121.964 84.141 121.964H0V36.0835C0 16.1551 16.1485 0 36.0687 0Z" fill="url(#paint0_linear_572_319)"/>
</mask>
<g mask="url(#mask0_572_319)">
<g filter="url(#filter0_f_572_319)">
<ellipse cx="52.7381" cy="65.1011" rx="81.3729" ry="81.1923" fill="#4B73FF"/>
</g>
<g filter="url(#filter1_f_572_319)">
<ellipse cx="61.6734" cy="20.547" rx="104.216" ry="81.1923" fill="#FF66F4"/>
</g>
<g filter="url(#filter2_f_572_319)">
<ellipse cx="78.6659" cy="5.26802" rx="81.3729" ry="71.3042" fill="#FF0105"/>
</g>
<g filter="url(#filter3_f_572_319)">
<ellipse cx="63.121" cy="20.5275" rx="48.9374" ry="48.8288" fill="#FE7B02"/>
</g>
</g>
<defs>
<filter id="filter0_f_572_319" x="-65.0219" y="-52.4784" width="235.52" height="235.159" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="18.1936" result="effect1_foregroundBlur_572_319"/>
</filter>
<filter id="filter1_f_572_319" x="-78.9301" y="-97.0324" width="281.208" height="235.159" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="18.1936" result="effect1_foregroundBlur_572_319"/>
</filter>
<filter id="filter2_f_572_319" x="-39.0942" y="-102.423" width="235.52" height="215.383" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="18.1936" result="effect1_foregroundBlur_572_319"/>
</filter>
<filter id="filter3_f_572_319" x="-22.2036" y="-64.6884" width="170.649" height="170.432" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="18.1936" result="effect1_foregroundBlur_572_319"/>
</filter>
<linearGradient id="paint0_linear_572_319" x1="40.4527" y1="21.4331" x2="76.9327" y2="121.971" gradientUnits="userSpaceOnUse">
<stop offset="0.025" stop-color="#FF8E63"/>
<stop offset="0.56" stop-color="#FF7EB0"/>
<stop offset="0.95" stop-color="#4B73FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,9 +1,11 @@
import Anthropic from './custom/anthropic.svg';
import Binary from './custom/binary.svg';
import BoltFilled from './custom/bolt-filled.svg';
import Continue from './custom/continue.svg';
import EmptyOutput from './custom/empty-output.svg';
import FilledSquare from './custom/filled-square.svg';
import GripLinesVertical from './custom/grip-lines-vertical.svg';
import Lovable from './custom/lovable.svg';
import Mcp from './custom/mcp.svg';
import NodeDirty from './custom/node-dirty.svg';
import NodeEllipsis from './custom/node-ellipsis.svg';
@@ -430,6 +432,7 @@ export const deprecatedIconSet = {
export const updatedIconSet = {
// custom icons
// NOTE: ensure to replace any colors with "currentColor" in SVG
anthropic: Anthropic,
'bolt-filled': BoltFilled,
'filled-square': FilledSquare,
'grip-lines-vertical': GripLinesVertical,
@@ -464,6 +467,7 @@ export const updatedIconSet = {
'node-success': NodeSuccess,
'node-trash': NodeTrash,
mcp: Mcp,
lovable: Lovable,
// lucide
'align-right': IconLucideAlignRight,
@@ -40,6 +40,8 @@
"generic.annotations": "Annotations",
"generic.annotationData": "Highlighted data",
"generic.any": "Any",
"generic.allow": "Allow",
"generic.deny": "Deny",
"generic.cancel": "Cancel",
"generic.open": "Open",
"generic.openResource": "Open {resource}",
@@ -1744,6 +1746,15 @@
"nodeWebhooks.webhookUrls.mcpTrigger": "MCP URL",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"oauth.consentView.title": "oAuth access consent",
"oauth.consentView.heading": "{clientName} wants access to your n8n instance",
"oauth.consentView.description": "This will allow {clientName} to perform the following actions:",
"oauth.consentView.action.listWorkflows": "Get a list of your workflows",
"oauth.consentView.action.workflowDetails": "Get details for a specific workflow",
"oauth.consentView.readMore": "Read more <a href='{docsUrl}' target='_blank'>here</a>",
"oauth.consentView.error.deny": "Error denying access",
"oauth.consentView.error.allow": "Error allowing access",
"oauth.consentView.error.fetchDetails": "Error fetching client details",
"parameterInput.expressionResult": "e.g. {result}",
"parameterInput.tip": "Tip",
"parameterInput.dragTipBeforePill": "Drag an",
@@ -0,0 +1,23 @@
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export interface ConsentDetails {
clientName: string;
clientId: string;
}
export interface ConsentApprovalResponse {
status: string;
redirectUrl: string;
}
export async function getConsentDetails(context: IRestApiContext): Promise<ConsentDetails> {
return await makeRestApiRequest(context, 'GET', '/consent/details');
}
export async function approveConsent(
context: IRestApiContext,
approved: boolean,
): Promise<ConsentApprovalResponse> {
return await makeRestApiRequest(context, 'POST', '/consent/approve', { approved });
}
@@ -42,4 +42,5 @@ export const STORES = {
PERSONALIZED_TEMPLATES_V3: 'personalizedTemplatesV3',
TEMPLATES_DATA_QUALITY: 'templatesDataQuality',
BANNERS: 'banners',
CONSENT: 'consent',
} as const;
@@ -62,6 +62,7 @@ export const enum VIEWS {
ENTITY_NOT_FOUND = 'EntityNotFound',
ENTITY_UNAUTHORIZED = 'EntityUnAuthorized',
PRE_BUILT_AGENT_TEMPLATES = 'PreBuiltAgentTemplates',
OAUTH_CONSENT = 'OAuthConsent',
}
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
@@ -0,0 +1,60 @@
import { STORES } from '@n8n/stores';
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 { ConsentDetails } from '@n8n/rest-api-client/api/consent';
export const useConsentStore = defineStore(STORES.CONSENT, () => {
const consentDetails = ref<ConsentDetails | null>(null);
const isLoading = ref(false);
const error = ref<string | null>(null);
const rootStore = useRootStore();
const fetchConsentDetails = async () => {
isLoading.value = true;
error.value = null;
try {
consentDetails.value = await consentApi.getConsentDetails(rootStore.restApiContext);
return consentDetails.value;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load consent details';
throw err;
} finally {
isLoading.value = false;
}
};
const approveConsent = async (approved: boolean) => {
isLoading.value = true;
error.value = null;
try {
const response = await consentApi.approveConsent(rootStore.restApiContext, approved);
return response;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to process consent';
throw err;
} finally {
isLoading.value = false;
}
};
const resetState = () => {
consentDetails.value = null;
isLoading.value = false;
error.value = null;
};
return {
fetchConsentDetails,
approveConsent,
resetState,
consentDetails,
isLoading,
error,
};
});
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { useConsentStore } from '@/app/stores/consent.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useI18n } from '@n8n/i18n';
import { onMounted, computed } from 'vue';
import type { ConsentDetails } from '@n8n/rest-api-client/api/consent';
import { N8nButton, N8nHeading, N8nIcon, N8nLogo, N8nNotice, N8nText } from '@n8n/design-system';
import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants';
import { useToast } from '@/app/composables/useToast';
const ANTHROPIC_CLIENTS = ['claude', 'mcp inspector'];
const LOVABLE_CLIENTS = ['lovable'];
const consentStore = useConsentStore();
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const toast = useToast();
const error = computed(() => consentStore.error);
const loading = computed(() => consentStore.isLoading);
const clentDetails = computed<ConsentDetails | null>(() => consentStore.consentDetails);
const clientIcon = computed(() => {
const clientName = clentDetails.value?.clientName?.toLowerCase() ?? '';
if (ANTHROPIC_CLIENTS.some((name) => clientName.includes(name))) {
return 'anthropic';
} else if (LOVABLE_CLIENTS.some((name) => clientName.includes(name))) {
return 'lovable';
} else {
return 'mcp';
}
});
const handleAllow = async () => {
try {
const response = await consentStore.approveConsent(true);
window.location.href = response.redirectUrl;
} catch (err) {
toast.showError(err, i18n.baseText('oauth.consentView.error.allow'));
}
};
const handleDeny = async () => {
try {
const response = await consentStore.approveConsent(false);
window.location.href = response.redirectUrl;
} catch (err) {
toast.showError(err, i18n.baseText('oauth.consentView.error.deny'));
}
};
onMounted(async () => {
documentTitle.set(i18n.baseText('oauth.consentView.title'));
try {
await consentStore.fetchConsentDetails();
} catch (err) {
toast.showError(err, i18n.baseText('oauth.consentView.error.fetchDetails'));
}
});
</script>
<template>
<div :class="$style.overlay">
<div :class="$style['consent-dialog']">
<header :class="$style.header">
<div :class="[$style.logo, $style.n8n]">
<N8nLogo size="small" :collapsed="true" release-channel="stable" />
</div>
<div :class="$style.arrow">
<N8nIcon icon="arrow-right" size="large" color="text-light" />
</div>
<div :class="$style.logo">
<N8nIcon :icon="clientIcon" size="xlarge" color="text-dark" />
</div>
</header>
<div :class="$style.content">
<N8nHeading tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.heading', {
interpolate: { clientName: clentDetails?.clientName ?? '' },
})
}}
</N8nHeading>
<div :class="$style['text-content']">
<N8nText color="text-base" size="small">
{{
i18n.baseText('oauth.consentView.description', {
interpolate: { clientName: clentDetails?.clientName ?? '' },
})
}}
</N8nText>
<ul :class="$style['permission-list']">
<li>{{ i18n.baseText('oauth.consentView.action.listWorkflows') }}</li>
<li>{{ i18n.baseText('oauth.consentView.action.workflowDetails') }}</li>
</ul>
<p :class="$style['docs-link']">
<span
v-n8n-html="
i18n.baseText('oauth.consentView.readMore', {
interpolate: {
docsUrl: MCP_DOCS_PAGE_URL,
},
})
"
></span>
</p>
</div>
</div>
<footer :class="$style.footer">
<N8nNotice
v-if="error"
theme="danger"
:data-test-id="'consent-error-notice'"
:content="error"
></N8nNotice>
<div :class="$style['button-group']">
<N8nButton
type="tertiary"
:data-test-id="'consent-deny-button'"
:size="'large'"
:loading="loading"
:disabled="loading || error !== null"
@click="handleDeny"
>
{{ i18n.baseText('generic.deny') }}
</N8nButton>
<N8nButton
type="primary"
:data-test-id="'consent-allow-button'"
:size="'large'"
:loading="loading"
:disabled="loading || error !== null"
@click="handleAllow"
>
{{ i18n.baseText('generic.allow') }}
</N8nButton>
</div>
</footer>
</div>
</div>
</template>
<style module lang="scss">
.overlay {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(71, 69, 84, 0.75);
z-index: 1000;
}
.consent-dialog {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-width: 500px;
max-width: 70%;
padding: var(--spacing--lg);
background-color: var(--color--background--light-3);
border: var(--border);
border-radius: var(--radius--lg);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
.logo {
display: flex;
justify-content: center;
align-items: center;
width: var(--spacing--2xl);
height: var(--spacing--2xl);
border: var(--border);
border-radius: var(--radius);
&.n8n > div {
position: relative;
bottom: var(--spacing--5xs);
}
}
.content {
padding: var(--spacing--lg);
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.text-content {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.permission-list {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-left: var(--spacing--lg);
list-style-type: disc;
li {
color: var(--color--text);
font-size: var(--font-size--2xs);
}
}
.docs-link {
color: var(--color--text);
font-size: var(--font-size--2xs);
}
.footer {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--spacing--sm);
:global(.notice) {
margin: 0;
}
.button-group {
display: flex;
justify-content: center;
gap: var(--spacing--2xs);
}
}
</style>
+11
View File
@@ -34,6 +34,7 @@ const ChangePasswordView = async () =>
const ErrorView = async () => await import('@/app/views/ErrorView.vue');
const EntityNotFound = async () => await import('@/app/views/EntityNotFound.vue');
const EntityUnAuthorised = async () => await import('@/app/views/EntityUnAuthorised.vue');
const OAuthConsentView = async () => await import('@/app/views/OAuthConsentView.vue');
const ForgotMyPasswordView = async () =>
await import('@/features/core/auth/views/ForgotMyPasswordView.vue');
const MainHeader = async () => await import('@/app/components/MainHeader/MainHeader.vue');
@@ -472,6 +473,16 @@ export const routes: RouteRecordRaw[] = [
middleware: ['authenticated'],
},
},
{
path: '/oauth/consent',
name: VIEWS.OAUTH_CONSENT,
components: {
default: OAuthConsentView,
},
meta: {
middleware: ['authenticated'],
},
},
{
path: '/setup',
name: VIEWS.SETUP,
+3 -3
View File
@@ -29326,7 +29326,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.21
'@types/tough-cookie': 4.0.5
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.3.6)
camelcase: 6.3.0
debug: 4.4.3
dotenv: 16.6.1
@@ -29336,7 +29336,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.12.0)
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.1))
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@@ -33679,7 +33679,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.12.0):
retry-axios@2.6.0(axios@1.12.0(debug@4.4.1)):
dependencies:
axios: 1.12.0(debug@4.4.1)