mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Add OAuth to MCP server (#21469)
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
co-authored by
Milorad FIlipović
parent
74a0b51c46
commit
cd167ac6db
@@ -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
|
||||
|
||||
@@ -86,6 +86,7 @@ export class AuthService {
|
||||
// Skip browser ID check for type files
|
||||
'/types/nodes.json',
|
||||
'/types/credentials.json',
|
||||
'/mcp-oauth/authorize/',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Generated
+3
-3
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user