mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
chore(core): Introduce CORS settings for dynamic credential interface (#24324)
This commit is contained in:
@@ -6,4 +6,11 @@ export { Middleware } from './middleware';
|
||||
export { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
export { Licensed } from './licensed';
|
||||
export { GlobalScope, ProjectScope } from './scoped';
|
||||
export type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from './types';
|
||||
export type {
|
||||
AccessScope,
|
||||
Controller,
|
||||
CorsOptions,
|
||||
Method,
|
||||
RateLimit,
|
||||
StaticRouterMetadata,
|
||||
} from './types';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Container } from '@n8n/di';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller, Method, RateLimit } from './types';
|
||||
import type { Controller, CorsOptions, Method, RateLimit } from './types';
|
||||
|
||||
interface RouteOptions {
|
||||
middlewares?: RequestHandler[];
|
||||
@@ -16,6 +16,8 @@ interface RouteOptions {
|
||||
rateLimit?: boolean | RateLimit;
|
||||
/** When this flag is set to true, the endpoint is protected by API key */
|
||||
apiKeyAuth?: boolean;
|
||||
/** CORS options for the route, this does not handle preflight requests */
|
||||
cors?: Partial<CorsOptions> | true;
|
||||
}
|
||||
|
||||
const RouteFactory =
|
||||
@@ -35,6 +37,7 @@ const RouteFactory =
|
||||
routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false;
|
||||
routeMetadata.apiKeyAuth = options.apiKeyAuth ?? false;
|
||||
routeMetadata.rateLimit = options.rateLimit;
|
||||
routeMetadata.cors = options.cors;
|
||||
};
|
||||
|
||||
export const Get = RouteFactory('get');
|
||||
|
||||
@@ -7,6 +7,14 @@ export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'opt
|
||||
|
||||
export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string };
|
||||
|
||||
export interface CorsOptions {
|
||||
allowedOrigins: string[];
|
||||
allowedMethods: Method[];
|
||||
allowedHeaders: string[];
|
||||
allowCredentials?: boolean;
|
||||
maxAge?: number;
|
||||
}
|
||||
|
||||
export interface RateLimit {
|
||||
/**
|
||||
* The maximum number of requests to allow during the `window` before rate limiting the client.
|
||||
@@ -36,6 +44,7 @@ export interface RouteMetadata {
|
||||
allowSkipPreviewAuth: boolean;
|
||||
allowSkipMFA: boolean;
|
||||
apiKeyAuth: boolean;
|
||||
cors?: Partial<CorsOptions> | true;
|
||||
rateLimit?: boolean | RateLimit;
|
||||
licenseFeature?: BooleanLicenseFeature;
|
||||
accessScope?: AccessScope;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.e
|
||||
import { License } from '@/license';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
import { send } from '@/response-helper';
|
||||
import { CorsService } from './services/cors-service';
|
||||
|
||||
@Service()
|
||||
export class ControllerRegistry {
|
||||
@@ -76,6 +77,12 @@ export class ControllerRegistry {
|
||||
) as unknown[];
|
||||
|
||||
const handler = async (req: Request, res: Response) => {
|
||||
if (route.cors) {
|
||||
const corsService = Container.get(CorsService);
|
||||
const corsOptions = route.cors === true ? {} : route.cors;
|
||||
corsService.applyCorsHeaders(req, res, corsOptions);
|
||||
}
|
||||
|
||||
const args: unknown[] = [req, res];
|
||||
for (let index = 0; index < route.args.length; index++) {
|
||||
const arg = route.args[index];
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { CredentialsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ICredentialResolver } from '@n8n/decorators';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
import * as utils from '@test-integration/utils';
|
||||
|
||||
import { DynamicCredentialsConfig } from '../dynamic-credentials.config';
|
||||
import { DynamicCredentialResolverRegistry } from '../services';
|
||||
import { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository';
|
||||
|
||||
// Enable dynamic credentials feature flag
|
||||
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
|
||||
|
||||
// Mock license
|
||||
const licenseMock = mock<LicenseState>();
|
||||
licenseMock.isLicensed.mockReturnValue(true);
|
||||
Container.set(LicenseState, licenseMock);
|
||||
|
||||
// Mock DynamicCredentialsConfig before test server is created
|
||||
mockInstance(DynamicCredentialsConfig, {
|
||||
corsOrigin: 'https://app.example.com',
|
||||
corsAllowCredentials: false,
|
||||
});
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['credentials'],
|
||||
enabledFeatures: ['feat:externalSecrets'],
|
||||
modules: ['dynamic-credentials'],
|
||||
});
|
||||
|
||||
let credentialsRepository: CredentialsRepository;
|
||||
let resolverRepository: DynamicCredentialResolverRepository;
|
||||
let cipher: Cipher;
|
||||
let oauthService: OauthService;
|
||||
|
||||
// Mock resolver for testing
|
||||
const mockResolver: ICredentialResolver = {
|
||||
metadata: {
|
||||
name: 'test-resolver',
|
||||
description: 'Test resolver for CORS integration tests',
|
||||
},
|
||||
async setSecret() {
|
||||
return;
|
||||
},
|
||||
async getSecret() {
|
||||
return { token: 'test-token', refreshToken: 'test-refresh-token' };
|
||||
},
|
||||
async deleteSecret() {},
|
||||
async validateIdentity() {},
|
||||
validateOptions: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
credentialsRepository = Container.get(CredentialsRepository);
|
||||
resolverRepository = Container.get(DynamicCredentialResolverRepository);
|
||||
cipher = Container.get(Cipher);
|
||||
oauthService = Container.get(OauthService);
|
||||
|
||||
// Mock OAuth service to avoid actual OAuth flow
|
||||
oauthService.generateAOauth2AuthUri = jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://oauth.example.com/authorize');
|
||||
mockInstance(EnterpriseCredentialsService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['CredentialsEntity', 'DynamicCredentialResolver']);
|
||||
});
|
||||
|
||||
function randomId() {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
describe('POST /credentials/:id/authorize - CORS Integration', () => {
|
||||
let credentialId: string;
|
||||
let resolverId: string;
|
||||
|
||||
async function setupTestData() {
|
||||
// Create test credential
|
||||
const credential = await credentialsRepository.save(
|
||||
credentialsRepository.create({
|
||||
id: randomId(),
|
||||
name: 'Test OAuth2 Credential',
|
||||
type: 'oAuth2Api',
|
||||
data: cipher.encrypt({ clientId: 'test-client-id' }),
|
||||
}),
|
||||
);
|
||||
credentialId = credential.id;
|
||||
|
||||
// Create test resolver
|
||||
const resolver = await resolverRepository.save({
|
||||
id: randomId(),
|
||||
name: 'Test Resolver',
|
||||
type: 'test-resolver',
|
||||
config: cipher.encrypt(JSON.stringify({ apiKey: 'test-api-key' })),
|
||||
});
|
||||
resolverId = resolver.id;
|
||||
|
||||
// Register mock resolver - access private property using array notation
|
||||
const registry = Container.get(DynamicCredentialResolverRegistry);
|
||||
registry['resolverMap'].set('test-resolver', mockResolver);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTestData();
|
||||
});
|
||||
|
||||
test('should return 204 on OPTIONS preflight with allowed origin', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.options(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
|
||||
.set('Origin', 'https://app.example.com')
|
||||
.set('Access-Control-Request-Method', 'POST')
|
||||
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('POST');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
|
||||
expect(response.headers['access-control-allow-headers']).toBeDefined();
|
||||
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
|
||||
expect(response.headers['access-control-allow-headers']).toContain('Content-Type');
|
||||
expect(response.headers['access-control-allow-headers']).toContain('X-Requested-With');
|
||||
expect(response.headers['access-control-max-age']).toBe('86400');
|
||||
});
|
||||
|
||||
test('should set CORS headers on POST request with allowed origin', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.post(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
|
||||
.set('Origin', 'https://app.example.com')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send();
|
||||
|
||||
// Note: The test doesn't verify the full OAuth2 flow, just CORS headers
|
||||
// A 400 may occur if validateIdentity fails, but we still check CORS headers
|
||||
expect([200, 400]).toContain(response.status);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('POST');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
|
||||
});
|
||||
|
||||
test('should succeed without CORS headers when Origin header is absent', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.post(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send();
|
||||
// Explicitly NOT setting Origin header
|
||||
|
||||
// Request should succeed
|
||||
expect([200, 400]).toContain(response.status);
|
||||
|
||||
// CORS headers should not be present when no Origin header is sent
|
||||
expect(response.headers['access-control-allow-origin']).toBeUndefined();
|
||||
expect(response.headers['access-control-allow-methods']).toBeUndefined();
|
||||
expect(response.headers['access-control-allow-headers']).toBeUndefined();
|
||||
expect(response.headers['access-control-max-age']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return 404 on OPTIONS preflight with disallowed origin', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.options(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
|
||||
.set('Origin', 'https://evil.com')
|
||||
.set('Access-Control-Request-Method', 'POST')
|
||||
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
|
||||
|
||||
// Disallowed origin should result in 404 (route not matched by custom-cors middleware)
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers['access-control-allow-origin']).toBeUndefined();
|
||||
expect(response.headers['access-control-allow-methods']).toBeUndefined();
|
||||
expect(response.headers['access-control-allow-headers']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not set CORS headers with disallowed origin', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.post(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
|
||||
.set('Origin', 'https://evil.com')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send();
|
||||
|
||||
// With disallowed origin, request may succeed but CORS headers should not be set
|
||||
// The request reaches the actual handler since POST doesn't require preflight match
|
||||
expect([200, 400]).toContain(response.status);
|
||||
|
||||
// No CORS headers should be present for disallowed origin
|
||||
expect(response.headers['access-control-allow-origin']).toBeUndefined();
|
||||
expect(response.headers['access-control-allow-methods']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /credentials/:id/revoke - CORS Integration', () => {
|
||||
let credentialId: string;
|
||||
let resolverId: string;
|
||||
|
||||
async function setupTestData() {
|
||||
// Create test credential
|
||||
const credential = await credentialsRepository.save(
|
||||
credentialsRepository.create({
|
||||
id: randomId(),
|
||||
name: 'Test OAuth2 Credential',
|
||||
type: 'oAuth2Api',
|
||||
data: cipher.encrypt({ clientId: 'test-client-id' }),
|
||||
}),
|
||||
);
|
||||
credentialId = credential.id;
|
||||
|
||||
// Create test resolver
|
||||
const resolver = await resolverRepository.save({
|
||||
id: randomId(),
|
||||
name: 'Test Resolver',
|
||||
type: 'test-resolver',
|
||||
config: cipher.encrypt(JSON.stringify({ apiKey: 'test-api-key' })),
|
||||
});
|
||||
resolverId = resolver.id;
|
||||
|
||||
// Register mock resolver
|
||||
const registry = Container.get(DynamicCredentialResolverRegistry);
|
||||
registry['resolverMap'].set('test-resolver', mockResolver);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTestData();
|
||||
});
|
||||
|
||||
test('should handle CORS preflight correctly', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.options(`/credentials/${credentialId}/revoke?resolverId=${resolverId}`)
|
||||
.set('Origin', 'https://app.example.com')
|
||||
.set('Access-Control-Request-Method', 'DELETE')
|
||||
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('DELETE');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
|
||||
expect(response.headers['access-control-allow-methods']).not.toContain('POST');
|
||||
expect(response.headers['access-control-allow-headers']).toBeDefined();
|
||||
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
|
||||
expect(response.headers['access-control-allow-headers']).toContain('Content-Type');
|
||||
expect(response.headers['access-control-allow-headers']).toContain('X-Requested-With');
|
||||
expect(response.headers['access-control-max-age']).toBe('86400');
|
||||
});
|
||||
|
||||
test('should set CORS headers on DELETE request', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.delete(`/credentials/${credentialId}/revoke?resolverId=${resolverId}`)
|
||||
.set('Origin', 'https://app.example.com')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
// Note: The test doesn't verify the full deletion flow, just CORS headers
|
||||
// A 400 may occur if validation fails, but we still check CORS headers
|
||||
expect([204, 400]).toContain(response.status);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('DELETE');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflows/:workflowId/execution-status - CORS Integration', () => {
|
||||
let workflowId: string;
|
||||
|
||||
async function setupTestData() {
|
||||
// Create a simple test workflow ID
|
||||
workflowId = randomId();
|
||||
|
||||
// Register mock resolver
|
||||
const registry = Container.get(DynamicCredentialResolverRegistry);
|
||||
registry['resolverMap'].set('test-resolver', mockResolver);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTestData();
|
||||
});
|
||||
|
||||
test('should handle CORS preflight correctly', async () => {
|
||||
const response = await testServer.authlessAgent
|
||||
.options(`/workflows/${workflowId}/execution-status`)
|
||||
.set('Origin', 'https://app.example.com')
|
||||
.set('Access-Control-Request-Method', 'GET')
|
||||
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('GET');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
|
||||
expect(response.headers['access-control-allow-methods']).not.toContain('POST');
|
||||
expect(response.headers['access-control-allow-methods']).not.toContain('DELETE');
|
||||
expect(response.headers['access-control-allow-headers']).toBeDefined();
|
||||
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
|
||||
expect(response.headers['access-control-allow-headers']).toContain('Content-Type');
|
||||
expect(response.headers['access-control-allow-headers']).toContain('X-Requested-With');
|
||||
expect(response.headers['access-control-max-age']).toBe('86400');
|
||||
});
|
||||
|
||||
test('should set CORS headers on GET request', async () => {
|
||||
// Mock the workflow status service to return a valid response
|
||||
const { CredentialResolverWorkflowService } = await import(
|
||||
'../services/credential-resolver-workflow.service'
|
||||
);
|
||||
const workflowService = Container.get(CredentialResolverWorkflowService);
|
||||
jest.spyOn(workflowService, 'getWorkflowStatus').mockResolvedValue([
|
||||
{
|
||||
credentialId: 'cred-123',
|
||||
resolverId: 'resolver-123',
|
||||
credentialName: 'Test Credential',
|
||||
status: 'configured',
|
||||
credentialType: 'oAuth2Api',
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await testServer.authlessAgent
|
||||
.get(`/workflows/${workflowId}/execution-status`)
|
||||
.set('Origin', 'https://app.example.com')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('GET');
|
||||
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
|
||||
expect(response.body.data).toHaveProperty('workflowId', workflowId);
|
||||
expect(response.body.data).toHaveProperty('readyToExecute');
|
||||
expect(response.body.data).toHaveProperty('credentials');
|
||||
});
|
||||
});
|
||||
+13
-1
@@ -2,6 +2,7 @@ import { mock } from 'jest-mock-extended';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { DynamicCredentialCorsService } from '../services/dynamic-credential-cors.service';
|
||||
import { WorkflowStatusController } from '../workflow-status.controller';
|
||||
import type { CredentialResolverWorkflowService } from '../services/credential-resolver-workflow.service';
|
||||
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
|
||||
@@ -13,6 +14,7 @@ describe('WorkflowStatusController', () => {
|
||||
let mockService: jest.Mocked<CredentialResolverWorkflowService>;
|
||||
let mockUrlService: jest.Mocked<UrlService>;
|
||||
let mockGlobalConfig: jest.Mocked<GlobalConfig>;
|
||||
let mockDynamicCredentialCorsService: jest.Mocked<DynamicCredentialCorsService>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -31,7 +33,17 @@ describe('WorkflowStatusController', () => {
|
||||
},
|
||||
} as unknown as jest.Mocked<GlobalConfig>;
|
||||
|
||||
controller = new WorkflowStatusController(mockService, mockUrlService, mockGlobalConfig);
|
||||
mockDynamicCredentialCorsService = {
|
||||
applyCorsHeadersIfEnabled: jest.fn(),
|
||||
preflightHandler: jest.fn(),
|
||||
} as unknown as jest.Mocked<DynamicCredentialCorsService>;
|
||||
|
||||
controller = new WorkflowStatusController(
|
||||
mockService,
|
||||
mockUrlService,
|
||||
mockGlobalConfig,
|
||||
mockDynamicCredentialCorsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('checkWorkflowForExecution', () => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Config, Env } from '@n8n/config';
|
||||
|
||||
@Config
|
||||
export class DynamicCredentialsConfig {
|
||||
/**
|
||||
* Comma-separated list of allowed CORS origins for dynamic credentials endpoints.
|
||||
* Example: 'https://app.example.com,https://admin.example.com'
|
||||
* When empty CORS is disabled and endpoints return 404 for preflight requests.
|
||||
*/
|
||||
@Env('N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN')
|
||||
corsOrigin: string = '';
|
||||
|
||||
/**
|
||||
* Whether to allow credentials (cookies, authorization headers) in CORS requests.
|
||||
* Must be false when using wildcard (*) in N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN.
|
||||
* Default: false
|
||||
*/
|
||||
@Env('N8N_DYNAMIC_CREDENTIALS_CORS_ALLOW_CREDENTIALS')
|
||||
corsAllowCredentials: boolean = false;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Delete, Post, RestController } from '@n8n/decorators';
|
||||
import { Delete, Options, Post, RestController } from '@n8n/decorators';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
|
||||
@@ -11,6 +11,7 @@ import { DynamicCredentialResolverRegistry } from './services';
|
||||
import { getBearerToken } from './utils';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
|
||||
|
||||
@RestController('/credentials')
|
||||
export class DynamicCredentialsController {
|
||||
@@ -20,6 +21,7 @@ export class DynamicCredentialsController {
|
||||
private readonly resolverRepository: DynamicCredentialResolverRepository,
|
||||
private readonly resolverRegistry: DynamicCredentialResolverRegistry,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly dynamicCredentialCorsService: DynamicCredentialCorsService,
|
||||
) {}
|
||||
|
||||
private async findCredentialToUse(credentialId: string): Promise<CredentialsEntity> {
|
||||
@@ -57,8 +59,19 @@ export class DynamicCredentialsController {
|
||||
return { resolver, resolverEntity };
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /credentials/:id/revoke
|
||||
*
|
||||
* Handles CORS preflight requests
|
||||
*/
|
||||
@Options('/:id/revoke', { skipAuth: true })
|
||||
handlePreflightCredentialRevoke(req: Request, res: Response): void {
|
||||
this.dynamicCredentialCorsService.preflightHandler(req, res, ['delete', 'options']);
|
||||
}
|
||||
|
||||
@Delete('/:id/revoke', { skipAuth: true })
|
||||
async revokeCredential(req: Request, res: Response): Promise<void> {
|
||||
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['delete', 'options']);
|
||||
const token = getBearerToken(req);
|
||||
const credential = await this.findCredentialToUse(req.params.id);
|
||||
|
||||
@@ -87,8 +100,19 @@ export class DynamicCredentialsController {
|
||||
res.status(204).send(); // 204 No Content indicates successful deletion
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /credentials/:id/authorize
|
||||
*
|
||||
* Handles CORS preflight requests
|
||||
*/
|
||||
@Options('/:id/authorize', { skipAuth: true })
|
||||
handlePreflightCredentialAuthorize(req: Request, res: Response): void {
|
||||
this.dynamicCredentialCorsService.preflightHandler(req, res, ['post', 'options']);
|
||||
}
|
||||
|
||||
@Post('/:id/authorize', { skipAuth: true })
|
||||
async authorizeCredential(req: Request, _res: Response): Promise<string> {
|
||||
async authorizeCredential(req: Request, res: Response): Promise<string> {
|
||||
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['post', 'options']);
|
||||
const token = getBearerToken(req);
|
||||
const credential = await this.findCredentialToUse(req.params.id);
|
||||
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type { CorsService } from '@/services/cors-service';
|
||||
|
||||
import type { DynamicCredentialsConfig } from '../../dynamic-credentials.config';
|
||||
import { DynamicCredentialCorsService } from '../dynamic-credential-cors.service';
|
||||
|
||||
describe('DynamicCredentialCorsService', () => {
|
||||
let mockCorsService: jest.Mocked<CorsService>;
|
||||
let mockConfig: jest.Mocked<DynamicCredentialsConfig>;
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let statusSpy: jest.Mock;
|
||||
let endSpy: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCorsService = {
|
||||
applyCorsHeaders: jest.fn(),
|
||||
} as unknown as jest.Mocked<CorsService>;
|
||||
|
||||
mockConfig = {
|
||||
corsOrigin: '',
|
||||
corsAllowCredentials: false,
|
||||
};
|
||||
|
||||
statusSpy = jest.fn().mockReturnThis();
|
||||
endSpy = jest.fn();
|
||||
|
||||
mockRequest = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: statusSpy,
|
||||
end: endSpy,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('should disable CORS when corsOrigin is null, empty, or whitespace', () => {
|
||||
// Test 1: corsOrigin = '' → defaultOptions = null
|
||||
mockConfig.corsOrigin = '';
|
||||
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service2['defaultOptions']).toBeNull();
|
||||
|
||||
// Test 2: corsOrigin = ' ' → defaultOptions = null
|
||||
mockConfig.corsOrigin = ' ';
|
||||
const service3 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service3['defaultOptions']).toBeNull();
|
||||
|
||||
// Test 3: corsOrigin = ' , , ' → defaultOptions = null
|
||||
mockConfig.corsOrigin = ' , , ';
|
||||
const service4 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service4['defaultOptions']).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse and trim comma-separated origins correctly', () => {
|
||||
// Test 1: 'https://app.com' → ['https://app.com']
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service1['defaultOptions']?.allowedOrigins).toEqual(['https://app.com']);
|
||||
expect(service1['defaultOptions']?.allowedHeaders).toEqual([
|
||||
'Authorization',
|
||||
'Content-Type',
|
||||
'X-Requested-With',
|
||||
]);
|
||||
|
||||
// Test 2: 'https://app.com,https://admin.com' → ['https://app.com', 'https://admin.com']
|
||||
mockConfig.corsOrigin = 'https://app.com,https://admin.com';
|
||||
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service2['defaultOptions']?.allowedOrigins).toEqual([
|
||||
'https://app.com',
|
||||
'https://admin.com',
|
||||
]);
|
||||
|
||||
// Test 3: ' https://app.com , https://admin.com ' → trimmed and filtered
|
||||
mockConfig.corsOrigin = ' https://app.com , https://admin.com ';
|
||||
const service3 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service3['defaultOptions']?.allowedOrigins).toEqual([
|
||||
'https://app.com',
|
||||
'https://admin.com',
|
||||
]);
|
||||
expect(service3['defaultOptions']?.allowedHeaders).toEqual([
|
||||
'Authorization',
|
||||
'Content-Type',
|
||||
'X-Requested-With',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set allowCredentials from config', () => {
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
|
||||
// Test 1: corsAllowCredentials = true → defaultOptions.allowCredentials = true
|
||||
mockConfig.corsAllowCredentials = true;
|
||||
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service1['defaultOptions']?.allowCredentials).toBe(true);
|
||||
|
||||
// Test 2: corsAllowCredentials = false → defaultOptions.allowCredentials = false
|
||||
mockConfig.corsAllowCredentials = false;
|
||||
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service2['defaultOptions']?.allowCredentials).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error when wildcard used with allowCredentials true', () => {
|
||||
// Test: corsOrigin = '*', corsAllowCredentials = true
|
||||
mockConfig.corsOrigin = '*';
|
||||
mockConfig.corsAllowCredentials = true;
|
||||
|
||||
// Expected: throws error with message about wildcard + credentials
|
||||
expect(() => new DynamicCredentialCorsService(mockCorsService, mockConfig)).toThrow(
|
||||
'N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN cannot use wildcard (*) when ' +
|
||||
'N8N_DYNAMIC_CREDENTIALS_CORS_ALLOW_CREDENTIALS is true. Specify explicit origins instead.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow wildcard when allowCredentials is false', () => {
|
||||
// Test: corsOrigin = '*', corsAllowCredentials = false
|
||||
mockConfig.corsOrigin = '*';
|
||||
mockConfig.corsAllowCredentials = false;
|
||||
|
||||
// Expected: no error, defaultOptions set with wildcard
|
||||
const service = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
expect(service['defaultOptions']?.allowedOrigins).toEqual(['*']);
|
||||
expect(service['defaultOptions']?.allowCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflightHandler()', () => {
|
||||
it('should return 204 when CORS enabled and origin allowed, 404 otherwise', () => {
|
||||
// Test 1: CORS disabled (corsOrigin="") → 404
|
||||
mockConfig.corsOrigin = '';
|
||||
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
service1.preflightHandler(mockRequest as Request, mockResponse as Response, [
|
||||
'post',
|
||||
'options',
|
||||
]);
|
||||
expect(statusSpy).toHaveBeenCalledWith(404);
|
||||
expect(endSpy).toHaveBeenCalled();
|
||||
|
||||
// Test 2: CORS enabled, origin not allowed → 404
|
||||
jest.clearAllMocks();
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
mockCorsService.applyCorsHeaders.mockReturnValue(false);
|
||||
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
service2.preflightHandler(mockRequest as Request, mockResponse as Response, [
|
||||
'post',
|
||||
'options',
|
||||
]);
|
||||
expect(statusSpy).toHaveBeenCalledWith(404);
|
||||
expect(endSpy).toHaveBeenCalled();
|
||||
|
||||
// Test 3: CORS enabled, origin allowed → 204
|
||||
jest.clearAllMocks();
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
mockCorsService.applyCorsHeaders.mockReturnValue(true);
|
||||
const service3 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
service3.preflightHandler(mockRequest as Request, mockResponse as Response, [
|
||||
'post',
|
||||
'options',
|
||||
]);
|
||||
expect(statusSpy).toHaveBeenCalledWith(204);
|
||||
expect(endSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass allowedMethods to CORS service', () => {
|
||||
// Test: call with ['post', 'options']
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
mockConfig.corsAllowCredentials = true;
|
||||
mockCorsService.applyCorsHeaders.mockReturnValue(true);
|
||||
|
||||
const service = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
service.preflightHandler(mockRequest as Request, mockResponse as Response, [
|
||||
'post',
|
||||
'options',
|
||||
]);
|
||||
|
||||
// Verify: corsService.applyCorsHeaders called with allowedMethods
|
||||
expect(mockCorsService.applyCorsHeaders).toHaveBeenCalledWith(mockRequest, mockResponse, {
|
||||
allowedOrigins: ['https://app.com'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
|
||||
allowCredentials: true,
|
||||
allowedMethods: ['post', 'options'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCorsHeadersIfEnabled()', () => {
|
||||
it('should return false when CORS disabled or origin not allowed', () => {
|
||||
// Test 1: CORS disabled → returns false, corsService not called
|
||||
mockConfig.corsOrigin = '';
|
||||
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
const result1 = service1.applyCorsHeadersIfEnabled(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
['get', 'options'],
|
||||
);
|
||||
expect(result1).toBe(false);
|
||||
expect(mockCorsService.applyCorsHeaders).not.toHaveBeenCalled();
|
||||
|
||||
// Test 2: origin not allowed → corsService returns false, method returns false
|
||||
jest.clearAllMocks();
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
mockCorsService.applyCorsHeaders.mockReturnValue(false);
|
||||
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
const result2 = service2.applyCorsHeadersIfEnabled(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
['get', 'options'],
|
||||
);
|
||||
expect(result2).toBe(false);
|
||||
expect(mockCorsService.applyCorsHeaders).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return true and merge options when origin allowed', () => {
|
||||
// Test: CORS enabled, origin allowed, allowedMethods=['get', 'options']
|
||||
mockConfig.corsOrigin = 'https://app.com';
|
||||
mockConfig.corsAllowCredentials = true;
|
||||
mockCorsService.applyCorsHeaders.mockReturnValue(true);
|
||||
|
||||
const service = new DynamicCredentialCorsService(mockCorsService, mockConfig);
|
||||
const result = service.applyCorsHeadersIfEnabled(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
['get', 'options'],
|
||||
);
|
||||
|
||||
// Verify: corsService called with merged options (defaultOptions + allowedMethods)
|
||||
expect(mockCorsService.applyCorsHeaders).toHaveBeenCalledWith(mockRequest, mockResponse, {
|
||||
allowedOrigins: ['https://app.com'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
|
||||
allowCredentials: true,
|
||||
allowedMethods: ['get', 'options'],
|
||||
});
|
||||
// returns true
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { CorsService } from '@/services/cors-service';
|
||||
import type { CorsOptions, Method } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { Request, Response } from 'express';
|
||||
import { DynamicCredentialsConfig } from '../dynamic-credentials.config';
|
||||
|
||||
@Service()
|
||||
export class DynamicCredentialCorsService {
|
||||
private readonly defaultOptions: Partial<CorsOptions> | null;
|
||||
constructor(
|
||||
private readonly corsService: CorsService,
|
||||
private readonly dynamicCredentialConfig: DynamicCredentialsConfig,
|
||||
) {
|
||||
if (this.dynamicCredentialConfig.corsOrigin === null) {
|
||||
this.defaultOptions = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const corsOriginConfig = this.dynamicCredentialConfig.corsOrigin?.trim();
|
||||
|
||||
if (!corsOriginConfig) {
|
||||
this.defaultOptions = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedOrigins = corsOriginConfig
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
// Add this check:
|
||||
if (allowedOrigins.length === 0) {
|
||||
this.defaultOptions = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dynamicCredentialConfig.corsAllowCredentials && allowedOrigins.includes('*')) {
|
||||
throw new Error(
|
||||
'N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN cannot use wildcard (*) when ' +
|
||||
'N8N_DYNAMIC_CREDENTIALS_CORS_ALLOW_CREDENTIALS is true. Specify explicit origins instead.',
|
||||
);
|
||||
}
|
||||
|
||||
this.defaultOptions = {
|
||||
allowedOrigins,
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
|
||||
allowCredentials: this.dynamicCredentialConfig.corsAllowCredentials,
|
||||
};
|
||||
}
|
||||
|
||||
preflightHandler(req: Request, res: Response, allowedMethods: Method[]): void {
|
||||
if (this.applyCorsHeadersIfEnabled(req, res, allowedMethods)) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
res.status(404).end();
|
||||
}
|
||||
|
||||
applyCorsHeadersIfEnabled(req: Request, res: Response, allowedMethods: Method[]): boolean {
|
||||
if (this.defaultOptions !== null) {
|
||||
return this.corsService.applyCorsHeaders(req, res, {
|
||||
...this.defaultOptions,
|
||||
allowedMethods,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Get, RestController } from '@n8n/decorators';
|
||||
import { Get, Options, RestController } from '@n8n/decorators';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
@@ -7,6 +7,7 @@ import { WorkflowExecutionStatus } from '@n8n/api-types';
|
||||
import { getBearerToken } from './utils';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
|
||||
|
||||
@RestController('/workflows')
|
||||
export class WorkflowStatusController {
|
||||
@@ -14,8 +15,19 @@ export class WorkflowStatusController {
|
||||
private readonly credentialResolverWorkflowService: CredentialResolverWorkflowService,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly dynamicCredentialCorsService: DynamicCredentialCorsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* OPTIONS /workflows/:workflowId/execution-status
|
||||
*
|
||||
* Handles CORS preflight requests
|
||||
*/
|
||||
@Options('/:workflowId/execution-status', { skipAuth: true })
|
||||
handlePreflightExecutionStatus(req: Request, res: Response): void {
|
||||
this.dynamicCredentialCorsService.preflightHandler(req, res, ['get', 'options']);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /workflows/:workflowId/execution-status
|
||||
*
|
||||
@@ -26,7 +38,8 @@ export class WorkflowStatusController {
|
||||
* @throws {BadRequestError} When authorization header is missing or malformed
|
||||
*/
|
||||
@Get('/:workflowId/execution-status', { skipAuth: true })
|
||||
async checkWorkflowForExecution(req: Request, _res: Response): Promise<WorkflowExecutionStatus> {
|
||||
async checkWorkflowForExecution(req: Request, res: Response): Promise<WorkflowExecutionStatus> {
|
||||
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['get', 'options']);
|
||||
const workflowId = req.params['workflowId'];
|
||||
const token = getBearerToken(req);
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { CorsService } from '../cors-service';
|
||||
|
||||
describe('CorsService', () => {
|
||||
let corsService: CorsService;
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let headerSpy: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
corsService = new CorsService();
|
||||
headerSpy = jest.fn();
|
||||
|
||||
mockRequest = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
header: headerSpy,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('applyCorsHeaders()', () => {
|
||||
it('should return false when no origin header present or origin not allowed', () => {
|
||||
// Test 1: req.headers.origin = undefined → returns false
|
||||
const result1 = corsService.applyCorsHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
);
|
||||
|
||||
expect(result1).toBe(false);
|
||||
expect(headerSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Test 2: origin='https://evil.com', allowedOrigins=['https://app.com'] → returns false
|
||||
mockRequest.headers = { origin: 'https://evil.com' };
|
||||
const result2 = corsService.applyCorsHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
{ allowedOrigins: ['https://app.com'] },
|
||||
);
|
||||
|
||||
expect(result2).toBe(false);
|
||||
expect(headerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow origin when in allowed list or wildcard present', () => {
|
||||
// Test 1: origin='https://app.com', allowedOrigins=['https://app.com'] → true
|
||||
mockRequest.headers = { origin: 'https://app.com' };
|
||||
const result1 = corsService.applyCorsHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
{ allowedOrigins: ['https://app.com'] },
|
||||
);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://app.com');
|
||||
|
||||
// Reset for next test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Test 2: origin='https://anything.com', allowedOrigins=['*'] → true
|
||||
mockRequest.headers = { origin: 'https://anything.com' };
|
||||
const result2 = corsService.applyCorsHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
{ allowedOrigins: ['*'] },
|
||||
);
|
||||
|
||||
expect(result2).toBe(true);
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://anything.com');
|
||||
});
|
||||
|
||||
it('should set all CORS headers correctly when origin is allowed', () => {
|
||||
mockRequest.headers = { origin: 'https://app.com' };
|
||||
|
||||
const result = corsService.applyCorsHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
{
|
||||
allowedOrigins: ['https://app.com'],
|
||||
allowedMethods: ['get', 'post'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
maxAge: 86400,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://app.com');
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST');
|
||||
expect(headerSpy).toHaveBeenCalledWith(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type, Authorization',
|
||||
);
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Max-Age', '86400');
|
||||
});
|
||||
|
||||
it('should set Access-Control-Allow-Credentials when explicitly provided', () => {
|
||||
mockRequest.headers = { origin: 'https://app.com' };
|
||||
|
||||
// Test 1: allowCredentials=true → header 'true'
|
||||
corsService.applyCorsHeaders(mockRequest as Request, mockResponse as Response, {
|
||||
allowedOrigins: ['https://app.com'],
|
||||
allowCredentials: true,
|
||||
});
|
||||
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Reset for next test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Test 2: allowCredentials=false → header 'false'
|
||||
corsService.applyCorsHeaders(mockRequest as Request, mockResponse as Response, {
|
||||
allowedOrigins: ['https://app.com'],
|
||||
allowCredentials: false,
|
||||
});
|
||||
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'false');
|
||||
|
||||
// Reset for next test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Test 3: allowCredentials=undefined → header not set
|
||||
corsService.applyCorsHeaders(mockRequest as Request, mockResponse as Response, {
|
||||
allowedOrigins: ['https://app.com'],
|
||||
});
|
||||
|
||||
const hasCredentialsHeader = headerSpy.mock.calls.some(
|
||||
(call: string[]) => call[0] === 'Access-Control-Allow-Credentials',
|
||||
);
|
||||
expect(hasCredentialsHeader).toBe(false);
|
||||
});
|
||||
|
||||
it('should merge custom options with defaults', () => {
|
||||
mockRequest.headers = { origin: 'https://app.com' };
|
||||
|
||||
const result = corsService.applyCorsHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
{ allowedOrigins: ['https://app.com'] },
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify custom origin is used
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://app.com');
|
||||
|
||||
// Verify default methods are used (uppercase)
|
||||
expect(headerSpy).toHaveBeenCalledWith(
|
||||
'Access-Control-Allow-Methods',
|
||||
'GET, POST, OPTIONS, PUT, PATCH, DELETE',
|
||||
);
|
||||
|
||||
// Verify default headers are used
|
||||
expect(headerSpy).toHaveBeenCalledWith(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept',
|
||||
);
|
||||
|
||||
// Verify default maxAge is used
|
||||
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Max-Age', '86400');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CorsOptions } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { Response, Request } from 'express';
|
||||
|
||||
@Service()
|
||||
export class CorsService {
|
||||
private readonly defaultOptions: CorsOptions = {
|
||||
allowedOrigins: ['*'],
|
||||
allowedMethods: ['get', 'post', 'options', 'put', 'patch', 'delete'],
|
||||
allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept'],
|
||||
maxAge: 86400,
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply CORS headers to response based on request origin
|
||||
* @returns true if origin is allowed, false otherwise
|
||||
*/
|
||||
applyCorsHeaders(req: Request, res: Response, options?: Partial<CorsOptions>): boolean {
|
||||
const config = { ...this.defaultOptions, ...options };
|
||||
|
||||
const requestOrigin = req.headers.origin;
|
||||
|
||||
if (!requestOrigin) return false;
|
||||
|
||||
const isAllowed = this.isOriginAllowed(requestOrigin, config.allowedOrigins ?? []);
|
||||
if (!isAllowed) return false;
|
||||
|
||||
res.header('Access-Control-Allow-Origin', requestOrigin);
|
||||
|
||||
if (config.allowCredentials !== undefined) {
|
||||
res.header('Access-Control-Allow-Credentials', `${config.allowCredentials}`);
|
||||
}
|
||||
|
||||
if (config.allowedMethods) {
|
||||
res.header('Access-Control-Allow-Methods', config.allowedMethods.join(', ').toUpperCase());
|
||||
}
|
||||
|
||||
if (config.allowedHeaders) {
|
||||
res.header('Access-Control-Allow-Headers', config.allowedHeaders.join(', '));
|
||||
}
|
||||
|
||||
if (config.maxAge) {
|
||||
res.header('Access-Control-Max-Age', config.maxAge.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
|
||||
return allowedOrigins.includes('*') || allowedOrigins.includes(origin);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user