diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 0fa576aff16..a119745e181 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -35,6 +35,7 @@ export class ModuleRegistry { 'data-table', 'provisioning', 'breaking-changes', + 'mcp', ]; private readonly activeModules: string[] = []; diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 2cd55af2361..3a1a657ce79 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -82,7 +82,12 @@ type EntityName = | 'DataTable' | 'DataTableColumn' | 'ChatHubSession' - | 'ChatHubMessage'; + | 'ChatHubMessage' + | 'OAuthClient' + | 'AuthorizationCode' + | 'AccessToken' + | 'RefreshToken' + | 'UserConsent'; /** * Truncate specific DB tables in a test DB. diff --git a/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts b/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts new file mode 100644 index 00000000000..9f4a67f7012 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts @@ -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'); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index b89824e42c3..44ac9066c1b 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -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, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index b5eeff671f6..f08678f5639 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 94c661d0fcc..1816873044a 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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 }; diff --git a/packages/@n8n/decorators/src/controller/index.ts b/packages/@n8n/decorators/src/controller/index.ts index 879f4f5076d..f13c24f1f85 100644 --- a/packages/@n8n/decorators/src/controller/index.ts +++ b/packages/@n8n/decorators/src/controller/index.ts @@ -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'; diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index 7d127360576..455fe023653 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -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 diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index f122baf1167..77457a096ec 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -86,6 +86,7 @@ export class AuthService { // Skip browser ID check for type files '/types/nodes.json', '/types/credentials.json', + '/mcp-oauth/authorize/', ]; } diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index 148f53056c6..ccfe721d326 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -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 { diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-authorization-code.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-authorization-code.service.test.ts new file mode 100644 index 00000000000..3fb822a2f1f --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-authorization-code.service.test.ts @@ -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; +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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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', + ); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-consent.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-consent.service.test.ts new file mode 100644 index 00000000000..d0d069949f2 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-consent.service.test.ts @@ -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; +let oauthSessionService: jest.Mocked; +let oauthClientRepository: jest.Mocked; +let userConsentRepository: jest.Mocked; +let authorizationCodeService: jest.Mocked; +let service: McpOAuthConsentService; + +describe('McpOAuthConsentService', () => { + beforeAll(() => { + logger = mockInstance(Logger); + oauthSessionService = mockInstance(OAuthSessionService) as jest.Mocked; + oauthClientRepository = mockInstance( + OAuthClientRepository, + ) as jest.Mocked; + userConsentRepository = mockInstance( + UserConsentRepository, + ) as jest.Mocked; + 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({ + 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({ + 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='); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts new file mode 100644 index 00000000000..d05d35c20c1 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts @@ -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; +let oauthSessionService: jest.Mocked; +let oauthClientRepository: jest.Mocked; +let tokenService: jest.Mocked; +let authorizationCodeService: jest.Mocked; +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(); + + 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(); + + 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(); + 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', + }); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-token.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-token.service.test.ts new file mode 100644 index 00000000000..66d62b71b48 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-token.service.test.ts @@ -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({ encryptionKey: 'test-key' }); +const jwtService = new JwtService(instanceSettings, mock()); + +let logger: jest.Mocked; +let userRepository: jest.Mocked; +let accessTokenRepository: jest.Mocked; +let refreshTokenRepository: jest.Mocked; +let service: McpOAuthTokenService; +let mockTransactionManager: any; + +describe('McpOAuthTokenService', () => { + beforeAll(() => { + logger = mockInstance(Logger); + userRepository = mockInstance(UserRepository); + accessTokenRepository = mockInstance( + AccessTokenRepository, + ) as jest.Mocked; + refreshTokenRepository = mockInstance( + RefreshTokenRepository, + ) as jest.Mocked; + + 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({ + 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({ + 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({ + 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({ + token: accessToken, + clientId, + userId, + }); + + const user = mock({ 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({ + 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); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth.helpers.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth.helpers.test.ts new file mode 100644 index 00000000000..e74d5799d1f --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth.helpers.test.ts @@ -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 + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts deleted file mode 100644 index fa76ad3e2f1..00000000000 --- a/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts +++ /dev/null @@ -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(); - req.header.mockImplementation((name: string) => { - if (name === 'authorization') return authHeader; - return undefined; - }); - return req; -}; - -const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); - -let userRepository: jest.Mocked; -let apiKeyRepository: jest.Mocked; -let telemetry: jest.Mocked; -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(); - 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(); - 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(); - 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(); - 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(); - mockUser.id = userId; - - const wrongJwtService = new JwtService( - mock({ 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - }); - }); -}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts new file mode 100644 index 00000000000..a30802e48ea --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts @@ -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(); + req.header.mockImplementation((name: string) => { + if (name === 'authorization') return authHeader; + return undefined; + }); + req.body = body || {}; + return req; +}; + +const instanceSettings = mock({ encryptionKey: 'test-key' }); +const jwtService = new JwtService(instanceSettings, mock()); + +let mcpServerApiKeyService: jest.Mocked; +let oauthTokenService: jest.Mocked; +let telemetry: jest.Mocked; +let service: McpServerMiddlewareService; + +describe('McpServerMiddlewareService', () => { + beforeAll(() => { + mcpServerApiKeyService = mockInstance( + McpServerApiKeyService, + ) as jest.Mocked; + oauthTokenService = mockInstance(McpOAuthTokenService) as jest.Mocked; + 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({ 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({ 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({ 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(); + 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(); + 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(); + 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({ 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(); + 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({ id: 'user-123' }); + const apiKeyToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + }); + + const req = mockReqWith(`Bearer ${apiKeyToken}`); + const res = mockDeep(); + 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(); + 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(); + 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(); + 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(); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.auth.consent.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.auth.consent.controller.api.test.ts new file mode 100644 index 00000000000..f3f0ced81e7 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp.auth.consent.controller.api.test.ts @@ -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/); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts index dfa15c15b5b..cc523e41c87 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts @@ -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.getAuthMiddleware.mockReturnValue(mockAuthMiddleware); +const mcpServerMiddlewareService = mockDeep(); +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'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts new file mode 100644 index 00000000000..e9e7aa8ed42 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-access-token.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-access-token.entity.ts new file mode 100644 index 00000000000..e518eb3dba5 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-access-token.entity.ts @@ -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; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-authorization-code.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-authorization-code.entity.ts new file mode 100644 index 00000000000..7626716d031 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-authorization-code.entity.ts @@ -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; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-client.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-client.entity.ts new file mode 100644 index 00000000000..47aa98d2101 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-client.entity.ts @@ -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; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-refresh-token.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-refresh-token.entity.ts new file mode 100644 index 00000000000..7c19e5b4274 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-refresh-token.entity.ts @@ -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; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-user-consent.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-user-consent.entity.ts new file mode 100644 index 00000000000..27e0cfd1204 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-user-consent.entity.ts @@ -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; +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-access-token.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-access-token.repository.ts new file mode 100644 index 00000000000..d2f6aafe1b0 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-access-token.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(AccessToken, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-authorization-code.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-authorization-code.repository.ts new file mode 100644 index 00000000000..09ef581ce7e --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-authorization-code.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(AuthorizationCode, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-client.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-client.repository.ts new file mode 100644 index 00000000000..e34d5a56d6a --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-client.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(OAuthClient, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-refresh-token.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-refresh-token.repository.ts new file mode 100644 index 00000000000..fafa530341e --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-refresh-token.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(RefreshToken, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-user-consent.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-user-consent.repository.ts new file mode 100644 index 00000000000..a4d13c377cb --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-user-consent.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(UserConsent, dataSource.manager); + } + + /** + * Find all consents for a user with client information + */ + async findByUserWithClient(userId: string): Promise { + return await this.find({ + where: { userId }, + relations: ['client'], + order: { grantedAt: 'DESC' }, + }); + } +} diff --git a/packages/cli/src/modules/mcp/dto/approve-consent-request.dto.ts b/packages/cli/src/modules/mcp/dto/approve-consent-request.dto.ts new file mode 100644 index 00000000000..afc0de92987 --- /dev/null +++ b/packages/cli/src/modules/mcp/dto/approve-consent-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ApproveConsentRequestDto extends Z.class({ + approved: z.boolean(), +}) {} diff --git a/packages/cli/src/modules/mcp/mcp-api-key.service.ts b/packages/cli/src/modules/mcp/mcp-api-key.service.ts index 27387c5b7fa..55719b2ea55 100644 --- a/packages/cli/src/modules/mcp/mcp-api-key.service.ts +++ b/packages/cli/src/modules/mcp/mcp-api-key.service.ts @@ -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 { + 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: { diff --git a/packages/cli/src/modules/mcp/mcp-oauth-authorization-code.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-authorization-code.service.ts new file mode 100644 index 00000000000..1869bf4073b --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-authorization-code.service.ts @@ -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 { + 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 { + 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 { + 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 { + const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId); + return authRecord.codeChallenge; + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth-consent.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-consent.service.ts new file mode 100644 index 00000000000..5d35509819e --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-consent.service.ts @@ -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 }; + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth-service.ts b/packages/cli/src/modules/mcp/mcp-oauth-service.ts new file mode 100644 index 00000000000..419662b4eb8 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-service.ts @@ -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 => { + 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 => { + 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 { + 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 { + return await this.authorizationCodeService.getCodeChallenge( + authorizationCode, + client.client_id, + ); + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + _codeVerifier?: string, + redirectUri?: string, + ): Promise { + 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 { + return await this.tokenService.validateAndRotateRefreshToken(refreshToken, client.client_id); + } + + async verifyAccessToken(token: string): Promise { + return await this.tokenService.verifyAccessToken(token); + } + + async revokeToken( + client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest, + ): Promise { + 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, + }); + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts new file mode 100644 index 00000000000..560e8256b33 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth.helpers.ts b/packages/cli/src/modules/mcp/mcp-oauth.helpers.ts new file mode 100644 index 00000000000..0f025431f98 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth.helpers.ts @@ -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(); + } +} diff --git a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts new file mode 100644 index 00000000000..41c24646ea1 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/cli/src/modules/mcp/mcp.auth.consent.controller.ts b/packages/cli/src/modules/mcp/mcp.auth.consent.controller.ts new file mode 100644 index 00000000000..e27f080e229 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.auth.consent.controller.ts @@ -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; + } + } +} diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index f881fa300f7..185a67ec72c 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -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({ diff --git a/packages/cli/src/modules/mcp/mcp.module.ts b/packages/cli/src/modules/mcp/mcp.module.ts index a7e19112572..2cf18ba2061 100644 --- a/packages/cli/src/modules/mcp/mcp.module.ts +++ b/packages/cli/src/modules/mcp/mcp.module.ts @@ -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() {} } diff --git a/packages/cli/src/modules/mcp/mcp.oauth.controller.ts b/packages/cli/src/modules/mcp/mcp.oauth.controller.ts new file mode 100644 index 00000000000..40753397d30 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.oauth.controller.ts @@ -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, + }); + } +} diff --git a/packages/cli/src/modules/mcp/oauth-session.service.ts b/packages/cli/src/modules/mcp/oauth-session.service.ts new file mode 100644 index 00000000000..11809fa44c2 --- /dev/null +++ b/packages/cli/src/modules/mcp/oauth-session.service.ts @@ -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(sessionToken); + } + + /** + * Clear OAuth session cookie + */ + clearSession(res: Response): void { + res.clearCookie(COOKIE_NAME); + } + + /** + * Extract session token from request cookies + */ + getSessionToken(cookies: Record): string | undefined { + return cookies[COOKIE_NAME]; + } +} diff --git a/packages/cli/src/services/jwt.service.ts b/packages/cli/src/services/jwt.service.ts index 14e78a05b71..ffc64bb5d93 100644 --- a/packages/cli/src/services/jwt.service.ts +++ b/packages/cli/src/services/jwt.service.ts @@ -27,8 +27,8 @@ export class JwtService { return jwt.sign(payload, this.jwtSecret, options); } - decode(token: string) { - return jwt.decode(token) as JwtPayload; + decode(token: string) { + return jwt.decode(token) as T; } verify(token: string, options: jwt.VerifyOptions = {}) { diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 6490ab6df1e..9b4e1d495be 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -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[]; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/anthropic.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/anthropic.svg new file mode 100644 index 00000000000..135a8b9f33f --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/anthropic.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/lovable.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/lovable.svg new file mode 100644 index 00000000000..25f9bde660d --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/lovable.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index e4bb757904b..b3b9928c740 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -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, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index aed8cea3902..67e83c0c7cf 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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 here", + "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", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/consent.ts b/packages/frontend/@n8n/rest-api-client/src/api/consent.ts new file mode 100644 index 00000000000..1c3e6df6597 --- /dev/null +++ b/packages/frontend/@n8n/rest-api-client/src/api/consent.ts @@ -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 { + return await makeRestApiRequest(context, 'GET', '/consent/details'); +} + +export async function approveConsent( + context: IRestApiContext, + approved: boolean, +): Promise { + return await makeRestApiRequest(context, 'POST', '/consent/approve', { approved }); +} diff --git a/packages/frontend/@n8n/stores/src/constants.ts b/packages/frontend/@n8n/stores/src/constants.ts index 134ba9985fd..3f3e32ad60f 100644 --- a/packages/frontend/@n8n/stores/src/constants.ts +++ b/packages/frontend/@n8n/stores/src/constants.ts @@ -42,4 +42,5 @@ export const STORES = { PERSONALIZED_TEMPLATES_V3: 'personalizedTemplatesV3', TEMPLATES_DATA_QUALITY: 'templatesDataQuality', BANNERS: 'banners', + CONSENT: 'consent', } as const; diff --git a/packages/frontend/editor-ui/src/app/constants/navigation.ts b/packages/frontend/editor-ui/src/app/constants/navigation.ts index f412a5a150d..3963e99b9c8 100644 --- a/packages/frontend/editor-ui/src/app/constants/navigation.ts +++ b/packages/frontend/editor-ui/src/app/constants/navigation.ts @@ -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]; diff --git a/packages/frontend/editor-ui/src/app/stores/consent.store.ts b/packages/frontend/editor-ui/src/app/stores/consent.store.ts new file mode 100644 index 00000000000..f83fb4761c0 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/stores/consent.store.ts @@ -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(null); + const isLoading = ref(false); + const error = ref(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, + }; +}); diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue new file mode 100644 index 00000000000..8dc63445415 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/router.ts b/packages/frontend/editor-ui/src/router.ts index 5ea543507e2..672573ee4e5 100644 --- a/packages/frontend/editor-ui/src/router.ts +++ b/packages/frontend/editor-ui/src/router.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd151f5f28c..dbf0adea704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)