feat(engine): Authenticate control plane requests to the engine (#36894)

This commit is contained in:
Tomi Turtiainen
2026-08-24 12:47:49 +00:00
committed by GitHub
parent 4a971787cb
commit 62a0b2af07
30 changed files with 670 additions and 25 deletions
@@ -1,5 +1,13 @@
import { z } from 'zod';
import { Config, Env } from '../decorators';
/**
* Floor for the CP → DP shared secret. Kept in step with the engine's identity
* verifier, which rejects anything shorter.
*/
const AUTH_SECRET_MIN_LENGTH = 32;
@Config
export class EngineConfig {
/** Port the engine HTTP server listens on. */
@@ -33,4 +41,13 @@ export class EngineConfig {
*/
@Env('N8N_ENGINE_DATABASE_URL')
databaseUrl: string = '';
/**
* Shared secret the control plane signs its identity token with and the engine
* verifies against. Both planes must hold the same value.
*
* This is in development and not ready for use.
*/
@Env('N8N_ENGINE_AUTH_SECRET', z.string().min(AUTH_SECRET_MIN_LENGTH))
authSecret: string = '';
}
@@ -0,0 +1,34 @@
import { Container } from '@n8n/di';
import type { MockInstance } from 'vitest';
import { EngineConfig } from '../src/index';
describe('EngineConfig', () => {
const originalEnv = process.env;
let consoleWarnMock: MockInstance;
beforeEach(() => {
Container.reset();
process.env = {};
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
process.env = originalEnv;
consoleWarnMock.mockRestore();
});
it('should accept an auth secret of at least 32 characters', () => {
const secret = 'a'.repeat(32);
process.env.N8N_ENGINE_AUTH_SECRET = secret;
expect(Container.get(EngineConfig).authSecret).toBe(secret);
});
it('should reject a shorter auth secret and fall back to the default', () => {
process.env.N8N_ENGINE_AUTH_SECRET = 'a'.repeat(31);
expect(Container.get(EngineConfig).authSecret).toBe('');
expect(consoleWarnMock).toHaveBeenCalledWith(expect.stringContaining('N8N_ENGINE_AUTH_SECRET'));
});
});
+2
View File
@@ -28,6 +28,7 @@
"@n8n/di": "workspace:*",
"@n8n/typeorm": "workspace:*",
"express": "catalog:",
"jsonwebtoken": "catalog:",
"pg": "catalog:",
"reflect-metadata": "catalog:",
"uuid": "catalog:",
@@ -38,6 +39,7 @@
"@n8n/vitest-config": "workspace:*",
"@testcontainers/postgresql": "catalog:",
"@types/express": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/pg": "^8.15.6",
"@types/supertest": "^6.0.3",
"n8n-containers": "workspace:*",
@@ -0,0 +1,82 @@
import express from 'express';
import request from 'supertest';
import type { MockInstance } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAuthenticationMiddleware } from '../authenticate';
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../identity-token';
const secret = 'a'.repeat(32);
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
const app = () => {
const application = express();
application.use(createAuthenticationMiddleware(new SharedSecretIdentityVerifier(secret)));
application.get('/', (req, res) => {
res.status(200).json({ caller: req.caller });
});
return application;
};
describe('createAuthenticationMiddleware', () => {
let consoleWarnMock: MockInstance;
beforeEach(() => {
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
consoleWarnMock.mockRestore();
});
it('401s when no header is present', async () => {
const response = await request(app()).get('/');
expect(response.status).toBe(401);
});
it('401s on a non-Bearer scheme', async () => {
const response = await request(app()).get('/').set('Authorization', 'Basic garbage');
expect(response.status).toBe(401);
});
it('401s on a garbage Bearer token', async () => {
const response = await request(app()).get('/').set('Authorization', 'Bearer garbage');
expect(response.status).toBe(401);
});
it('the 401 body carries no failure reason', async () => {
const response = await request(app()).get('/').set('Authorization', 'Bearer garbage');
expect(response.body).toEqual({ error: 'unauthenticated' });
});
it('warns on a rejected attempt, naming the route it guarded', async () => {
await request(app()).get('/').set('Authorization', 'Bearer garbage');
expect(consoleWarnMock).toHaveBeenCalledWith('engine: rejected GET / - token rejected');
});
it('warns when no bearer token is present', async () => {
await request(app()).get('/');
expect(consoleWarnMock).toHaveBeenCalledWith('engine: rejected GET / - no bearer token');
});
it('keeps the query string out of the log', async () => {
await request(app()).get('/?token=secret-value');
expect(consoleWarnMock).toHaveBeenCalledWith('engine: rejected GET / - no bearer token');
});
it('200s and populates req.caller for a valid token', async () => {
const token = mintIdentityToken(secret, caller);
const response = await request(app()).get('/').set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ caller });
});
});
@@ -0,0 +1,165 @@
import jwt from 'jsonwebtoken';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
IDENTITY_AUDIENCE,
IDENTITY_ISSUER,
IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS,
IDENTITY_TOKEN_TTL_SECONDS,
InvalidIdentityTokenError,
mintIdentityToken,
SharedSecretIdentityVerifier,
} from '../identity-token';
const secret = 'a'.repeat(32);
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
/** Past every deadline the verifier allows, so one advance covers expiry and max age. */
const PAST_EVERY_DEADLINE_MS =
(IDENTITY_TOKEN_TTL_SECONDS + IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS + 1) * 1000;
/**
* Signs a token from raw claims, bypassing {@link mintIdentityToken}. Only for
* tokens the control plane cannot mint: a missing `exp`, a foreign audience, an
* expiry untied to the TTL. Anything the clock can express moves the clock
* instead, so these tests do not restate how a real token is built.
*/
const signRawToken = (claims: Record<string, unknown>, options: jwt.SignOptions = {}) =>
jwt.sign(claims, secret, {
algorithm: 'HS256',
issuer: IDENTITY_ISSUER,
audience: IDENTITY_AUDIENCE,
...options,
});
describe('SharedSecretIdentityVerifier', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('constructing rejects an under-length secret', () => {
expect(() => new SharedSecretIdentityVerifier('short')).toThrow();
});
it('constructing rejects a missing secret', () => {
expect(() => new SharedSecretIdentityVerifier('')).toThrow();
});
it('round trips: verify returns the caller mint signed', () => {
const token = mintIdentityToken(secret, caller);
const verifier = new SharedSecretIdentityVerifier(secret);
expect(verifier.verify(token)).toEqual(caller);
});
it('rejects a token signed with a different secret', () => {
const token = mintIdentityToken('b'.repeat(32), caller);
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('accepts a token still inside its lifetime', () => {
const token = mintIdentityToken(secret, caller);
const verifier = new SharedSecretIdentityVerifier(secret);
vi.advanceTimersByTime((IDENTITY_TOKEN_TTL_SECONDS - 1) * 1000);
expect(verifier.verify(token)).toEqual(caller);
});
it('rejects a token once its lifetime has passed', () => {
const token = mintIdentityToken(secret, caller);
const verifier = new SharedSecretIdentityVerifier(secret);
vi.advanceTimersByTime(PAST_EVERY_DEADLINE_MS);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a token minted further ahead than the clock-skew tolerance', () => {
const now = Date.now();
const verifier = new SharedSecretIdentityVerifier(secret);
// Minted on a clock that runs ahead of this host by more than it tolerates.
vi.setSystemTime(now + (IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS + 60) * 1000);
const token = mintIdentityToken(secret, caller);
vi.setSystemTime(now);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a token older than the maximum age even when it has not expired', () => {
// `mintIdentityToken` ties `exp` to the TTL, so only a raw token can outlive it.
const token = signRawToken(
{ sub: caller.cpId, tenant_id: caller.tenantId },
{ expiresIn: '1h' },
);
const verifier = new SharedSecretIdentityVerifier(secret);
vi.advanceTimersByTime(PAST_EVERY_DEADLINE_MS);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a token without an expiration', () => {
const token = signRawToken({ sub: caller.cpId, tenant_id: caller.tenantId });
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a token with the wrong audience', () => {
const token = signRawToken(
{ sub: caller.cpId, tenant_id: caller.tenantId },
{ audience: 'someone-else', expiresIn: IDENTITY_TOKEN_TTL_SECONDS },
);
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a token with the wrong issuer', () => {
const token = signRawToken(
{ sub: caller.cpId, tenant_id: caller.tenantId },
{ issuer: 'someone-else', expiresIn: IDENTITY_TOKEN_TTL_SECONDS },
);
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects an alg: none token', () => {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(
JSON.stringify({
sub: caller.cpId,
tenant_id: caller.tenantId,
iss: IDENTITY_ISSUER,
aud: IDENTITY_AUDIENCE,
exp: Math.floor(Date.now() / 1000) + IDENTITY_TOKEN_TTL_SECONDS,
}),
).toString('base64url');
const token = `${header}.${payload}.`;
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a token missing tenant_id', () => {
const token = signRawToken({ sub: caller.cpId }, { expiresIn: IDENTITY_TOKEN_TTL_SECONDS });
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
});
it('rejects a non-JWT string', () => {
const verifier = new SharedSecretIdentityVerifier(secret);
expect(() => verifier.verify('not-a-jwt')).toThrow(InvalidIdentityTokenError);
});
});
@@ -0,0 +1,36 @@
import type { RequestHandler } from 'express';
import type { IdentityVerifier } from './identity.types';
import { fail } from '../server/error-response';
const BEARER_PREFIX = /^bearer /i;
/** Verifies `Authorization: Bearer <token>` on every request it guards, or 401s with no reason. */
export function createAuthenticationMiddleware(verifier: IdentityVerifier): RequestHandler {
return (req, res, next) => {
// The reason is logged, never returned: an operator needs it, a caller must not have it.
const reject = (reason: string): void => {
// Query string dropped: it can carry values that must not reach a log.
const path = req.originalUrl.split('?')[0];
console.warn(`engine: rejected ${req.method} ${path} - ${reason}`);
fail(res, 401, { error: 'unauthenticated' });
};
const header = req.header('authorization');
if (!header || !BEARER_PREFIX.test(header)) {
reject('no bearer token');
return;
}
const token = header.replace(BEARER_PREFIX, '');
try {
req.caller = verifier.verify(token);
} catch {
reject('token rejected');
return;
}
next();
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { AuthenticatedCaller } from './identity.types';
declare global {
namespace Express {
interface Request {
/** Set by the authentication middleware once a request clears it. */
caller?: AuthenticatedCaller;
}
}
}
@@ -0,0 +1,67 @@
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
export const IDENTITY_ISSUER = 'n8n-cp';
export const IDENTITY_AUDIENCE = 'n8n-engine-dp';
export const IDENTITY_TOKEN_TTL_SECONDS = 60;
export const IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS = 30;
export const MIN_SECRET_LENGTH = 32;
const identityClaimsSchema = z.object({
sub: z.string().min(1),
tenant_id: z.string().min(1),
iat: z.number().int(),
exp: z.number().int(),
});
/** Every rejection path throws this one type, so the middleware cannot leak which check failed. */
export class InvalidIdentityTokenError extends Error {}
/** Signs an identity token the engine's {@link SharedSecretIdentityVerifier} accepts. */
export function mintIdentityToken(secret: string, caller: AuthenticatedCaller): string {
return jwt.sign({ sub: caller.cpId, tenant_id: caller.tenantId }, secret, {
algorithm: 'HS256',
issuer: IDENTITY_ISSUER,
audience: IDENTITY_AUDIENCE,
expiresIn: IDENTITY_TOKEN_TTL_SECONDS,
});
}
/** Verifies an identity token against a shared secret. The trust source for CP → DP today. */
export class SharedSecretIdentityVerifier implements IdentityVerifier {
constructor(private readonly secret: string) {
if (!secret || secret.length < MIN_SECRET_LENGTH) {
throw new Error(`Identity verifier secret must be at least ${MIN_SECRET_LENGTH} chars`);
}
}
verify(token: string): AuthenticatedCaller {
const now = Math.floor(Date.now() / 1000);
let claims: unknown;
try {
// `algorithms` is pinned: an unpinned verify accepts whatever `alg` the
// token names, including `none`. `clockTolerance` allows for clock skew
// between the CP and DP hosts.
claims = jwt.verify(token, this.secret, {
algorithms: ['HS256'],
issuer: IDENTITY_ISSUER,
audience: IDENTITY_AUDIENCE,
maxAge: IDENTITY_TOKEN_TTL_SECONDS,
clockTolerance: IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS,
clockTimestamp: now,
});
} catch {
throw new InvalidIdentityTokenError();
}
const parsed = identityClaimsSchema.safeParse(claims);
if (!parsed.success) throw new InvalidIdentityTokenError();
if (parsed.data.iat > now + IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS) {
throw new InvalidIdentityTokenError();
}
return { cpId: parsed.data.sub, tenantId: parsed.data.tenant_id };
}
}
@@ -0,0 +1,20 @@
/** The peer a verified identity token proves. */
export interface AuthenticatedCaller {
/** Identifies the control plane that signed the token. Carried in the `sub` claim. */
cpId: string;
/** The tenant the call acts for. Scopes every resource the request may touch. */
tenantId: string;
}
/**
* Turns an identity token into the caller it proves. The only seam that knows
* the trust source: a shared secret today, an STS JWKS in cloud.
*/
export interface IdentityVerifier {
/**
* Returns the caller the token proves, or throws when the token is not
* trustworthy. Throws one error type only, so no caller can tell which check
* failed.
*/
verify(token: string): AuthenticatedCaller;
}
+12
View File
@@ -0,0 +1,12 @@
export { createAuthenticationMiddleware } from './authenticate';
export {
IDENTITY_AUDIENCE,
IDENTITY_ISSUER,
IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS,
IDENTITY_TOKEN_TTL_SECONDS,
InvalidIdentityTokenError,
MIN_SECRET_LENGTH,
mintIdentityToken,
SharedSecretIdentityVerifier,
} from './identity-token';
export type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
@@ -5,6 +5,7 @@ import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { AllowAllAdmittance } from '../../admittance';
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
import {
createDataSource,
createStores,
@@ -27,6 +28,12 @@ const graph: WorkflowGraph = {
edges: [{ from: 'trigger', to: 'node-a', outputIndex: 0, inputIndex: 0 }],
};
const secret = 'a'.repeat(32);
const authHeader = () => ({
authorization: `Bearer ${mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' })}`,
});
describe('step execution (integration)', () => {
let container: StartedPostgreSqlContainer;
let dataSource: DataSource;
@@ -59,6 +66,7 @@ describe('step execution (integration)', () => {
const runtime = createEngineRuntime({
dataSource,
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(secret),
// also how the test reaches the stores the runtime owns
externalDependencies: ({ executionStore }) => {
const finishExecution = executionStore.finishExecution.bind(executionStore);
@@ -74,6 +82,7 @@ describe('step execution (integration)', () => {
const response = await request(runtime.app)
.post('/api/workflow-executions')
.set(authHeader())
.send({ workflowId, graph: workflowGraph, triggerOutputs })
.expect(201);
const { executionId } = response.body as StartExecutionResult;
+7
View File
@@ -1,6 +1,13 @@
export { createEngineRuntime } from './runtime';
export type { EngineRuntime, EngineRuntimeOptions } from './runtime';
export {
InvalidIdentityTokenError,
mintIdentityToken,
SharedSecretIdentityVerifier,
} from './auth';
export type { AuthenticatedCaller, IdentityVerifier } from './auth';
export type { EngineErrorResponse } from './server';
export type { JsonObject, JsonValue } from './common';
@@ -3,26 +3,48 @@ import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';
import { AllowAllAdmittance } from '../../admittance';
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
import type { EngineStores } from '../../database';
import { createEngineRuntime } from '../create-engine-runtime';
/** Enough of a `DataSource` for the stores: they only hold on to a repository. */
const fakeDataSource = () => ({ getRepository: vi.fn(() => ({})) }) as unknown as DataSource;
const secret = 'a'.repeat(32);
const identityVerifier = new SharedSecretIdentityVerifier(secret);
const token = mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' });
const runtime = () =>
createEngineRuntime({
dataSource: fakeDataSource(),
admittance: new AllowAllAdmittance(),
identityVerifier,
});
describe('createEngineRuntime', () => {
it('mounts the execution API', async () => {
it('mounts the execution API behind authentication', async () => {
const unauthenticated = await request(runtime().app).post('/api/workflow-executions').send({});
expect(unauthenticated.status).toBe(401);
// a rejected body proves the route is mounted without reaching the store
const response = await request(runtime().app).post('/api/workflow-executions').send({});
const response = await request(runtime().app)
.post('/api/workflow-executions')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(response.status).toBe(400);
});
it('does not parse an unauthenticated request body', async () => {
const response = await request(runtime().app)
.post('/api/workflow-executions')
.set('Content-Type', 'application/json')
.send('{');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'unauthenticated' });
});
it('serves the healthcheck', async () => {
const response = await request(runtime().app).get('/healthz');
@@ -36,6 +58,7 @@ describe('createEngineRuntime', () => {
createEngineRuntime({
dataSource: fakeDataSource(),
admittance: new AllowAllAdmittance(),
identityVerifier,
externalDependencies: (given) => {
stores = given;
return {};
@@ -2,6 +2,7 @@ import type { DataSource } from '@n8n/typeorm';
import type { Application } from 'express';
import type { AdmittanceService } from '../admittance';
import type { IdentityVerifier } from '../auth/identity.types';
import { createStores } from '../database';
import type { EngineStores } from '../database';
import type { ExternalDependencies } from '../dependencies';
@@ -21,6 +22,8 @@ export interface EngineRuntimeOptions {
/** The data plane database, already initialized and migrated. */
dataSource: DataSource;
admittance: AdmittanceService;
/** Verifies the identity token on every `/api` request. No default: an unauthenticated engine must never boot by omission. */
identityVerifier: IdentityVerifier;
/**
* Builds the capabilities the engine does not own. It receives the engine's
* stores, because a `v1-node` executor reads step data through them and the
@@ -54,6 +57,7 @@ export interface EngineRuntime {
export function createEngineRuntime({
dataSource,
admittance,
identityVerifier,
externalDependencies,
}: EngineRuntimeOptions): EngineRuntime {
const orchestrationQueue = new InMemoryWorkQueue<OrchestrationMessage>();
@@ -77,6 +81,7 @@ export function createEngineRuntime({
const { app } = createEngineServer(
new StartExecutionService(admittance, executionStore, orchestrationQueue),
identityVerifier,
);
return {
+10 -1
View File
@@ -2,6 +2,7 @@ import { EngineConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { AllowAllAdmittance } from './admittance';
import { SharedSecretIdentityVerifier } from './auth';
import { createDataSource } from './database';
import { createEngineRuntime } from './runtime';
@@ -14,11 +15,19 @@ async function main(): Promise<void> {
throw new Error('engine: N8N_ENGINE_DATABASE_URL is not set');
}
if (!config.authSecret) {
throw new Error('engine: N8N_ENGINE_AUTH_SECRET is not set');
}
const dataSource = createDataSource(config.databaseUrl);
await dataSource.initialize();
await dataSource.runMigrations();
const runtime = createEngineRuntime({ dataSource, admittance: new AllowAllAdmittance() });
const runtime = createEngineRuntime({
dataSource,
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(config.authSecret),
});
runtime.start();
const server = runtime.app.listen(config.port, config.host, () => {
@@ -5,6 +5,7 @@ import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AllowAllAdmittance } from '../../admittance';
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
import { createDataSource, createStores, WorkflowExecution } from '../../database';
import { StartExecutionService } from '../../execution';
import type { WorkflowGraph } from '../../graph';
@@ -16,6 +17,12 @@ const sampleGraph: WorkflowGraph = {
edges: [],
};
const secret = 'a'.repeat(32);
const authHeader = () => ({
authorization: `Bearer ${mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' })}`,
});
describe('POST /api/workflow-executions (integration)', () => {
let container: StartedPostgreSqlContainer;
let dataSource: DataSource;
@@ -35,6 +42,7 @@ describe('POST /api/workflow-executions (integration)', () => {
const { executionStore } = createStores(dataSource);
({ url, stop } = await startEngineServer(
new StartExecutionService(new AllowAllAdmittance(), executionStore, workQueue),
new SharedSecretIdentityVerifier(secret),
));
});
@@ -50,6 +58,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it('creates an execution row, publishes execution:enqueued, returns 201', async () => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({
workflowId: 'wf-1',
graph: sampleGraph,
@@ -76,6 +85,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it('rejects an invalid body with 400', async () => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({ workflowId: 'wf-1' }); // missing graph
expect(response.status).toBe(400);
@@ -85,6 +95,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it('rejects a bare-object triggerOutputs with 400 (the legacy, dropped shape)', async () => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({
workflowId: 'wf-1',
graph: sampleGraph,
@@ -98,6 +109,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it.each([['str'], [42]])('rejects triggerOutputs %p with 400', async (triggerOutputs) => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({ workflowId: 'wf-1', graph: sampleGraph, triggerOutputs });
expect(response.status).toBe(400);
@@ -105,7 +117,7 @@ describe('POST /api/workflow-executions (integration)', () => {
});
it('rejects an empty-array triggerOutputs with 400 (send null or omit for "no payload")', async () => {
const response = await request(url).post('/api/workflow-executions').send({
const response = await request(url).post('/api/workflow-executions').set(authHeader()).send({
workflowId: 'wf-1',
graph: sampleGraph,
triggerOutputs: [],
@@ -118,6 +130,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it('rejects a triggerOutputs with more slots than the cap with 400', async () => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({
workflowId: 'wf-1',
graph: sampleGraph,
@@ -131,6 +144,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it('rejects a graph without a trigger with 400, creating nothing', async () => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({
workflowId: 'wf-1',
graph: { nodes: [{ id: 'a', name: 'A', type: 'v1-node' }], edges: [] },
@@ -144,6 +158,7 @@ describe('POST /api/workflow-executions (integration)', () => {
it('rejects a graph with back-edges with 501, creating nothing', async () => {
const response = await request(url)
.post('/api/workflow-executions')
.set(authHeader())
.send({
workflowId: 'wf-1',
graph: {
@@ -1,17 +1,25 @@
import express, { type Application } from 'express';
import { createAuthenticationMiddleware } from '../auth/authenticate';
import type { IdentityVerifier } from '../auth/identity.types';
import type { StartExecutionService } from '../execution/start-execution.service';
import { createWorkflowExecutionsRouter } from './routes/workflow-executions';
/** Builds the engine HTTP app: `/healthz` plus the execution API. */
export function createEngineServer(startExecution: StartExecutionService): { app: Application } {
/** Builds the engine HTTP app: `/healthz` plus the authenticated execution API. */
export function createEngineServer(
startExecution: StartExecutionService,
identityVerifier: IdentityVerifier,
): { app: Application } {
const app = express();
app.use(express.json());
// Stays open: a liveness probe, and it reveals nothing.
app.get('/healthz', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
// Mounted on the prefix, not on each router, so a future router cannot forget it.
app.use('/api', createAuthenticationMiddleware(identityVerifier));
app.use('/api', express.json());
app.use('/api/workflow-executions', createWorkflowExecutionsRouter(startExecution));
return { app };
@@ -1,3 +1,6 @@
import type { Response } from 'express';
import assert from 'node:assert';
/**
* Body the engine API returns for every non-2xx response.
*
@@ -11,3 +14,9 @@ export interface EngineErrorResponse {
reason?: string;
details?: unknown;
}
/** Sends an error response. Shared, so every route and middleware answers in one shape. */
export function fail(res: Response, status: number, body: EngineErrorResponse): void {
assert(status >= 400, `fail() sends error responses only, but got status ${status}`);
res.status(status).json(body);
}
+1
View File
@@ -1,2 +1,3 @@
export { createEngineServer } from './create-engine-server';
export { fail } from './error-response';
export type { EngineErrorResponse } from './error-response';
@@ -1,12 +1,11 @@
import { Router, type Response, type Router as RouterType } from 'express';
import assert from 'node:assert';
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { AdmittanceRejectedError } from '../../admittance';
import { UnimplementedError, type JsonValue } from '../../common';
import type { StartExecutionService } from '../../execution/start-execution.service';
import { GraphValidationError, MAX_SLOT_INDEX } from '../../graph';
import type { EngineErrorResponse } from '../error-response';
import { fail } from '../error-response';
const MAX_TRIGGER_SLOTS = MAX_SLOT_INDEX + 1;
@@ -56,11 +55,6 @@ const StartExecutionBody = z.object({
export function createWorkflowExecutionsRouter(startExecution: StartExecutionService): RouterType {
const router = Router();
const fail = (res: Response, status: number, body: EngineErrorResponse): void => {
assert(status >= 400, `fail() sends error responses only, but got status ${status}`);
res.status(status).json(body);
};
router.post('/', async (req, res) => {
const parsed = StartExecutionBody.safeParse(req.body);
if (!parsed.success) {
@@ -1,6 +1,7 @@
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { SharedSecretIdentityVerifier } from '../../auth';
import type { StartExecutionService } from '../../execution';
import { startEngineServer } from '../start-engine-server';
@@ -10,7 +11,10 @@ describe('engine HTTP server (e2e)', () => {
beforeAll(async () => {
// only /healthz is under test, and the execution route never calls the service
({ url, stop } = await startEngineServer({} as StartExecutionService));
({ url, stop } = await startEngineServer(
{} as StartExecutionService,
new SharedSecretIdentityVerifier('a'.repeat(32)),
));
});
afterAll(async () => {
@@ -1,13 +1,17 @@
import type { Server } from 'node:http';
import type { IdentityVerifier } from '../auth/identity.types';
import type { StartExecutionService } from '../execution';
import { createEngineServer } from '../server';
export async function startEngineServer(startExecution: StartExecutionService): Promise<{
export async function startEngineServer(
startExecution: StartExecutionService,
identityVerifier: IdentityVerifier,
): Promise<{
url: string;
stop: () => Promise<void>;
}> {
const { app } = createEngineServer(startExecution);
const { app } = createEngineServer(startExecution, identityVerifier);
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
@@ -7,6 +7,8 @@ import type {
import {
AllowAllAdmittance,
createEngineRuntime,
mintIdentityToken,
SharedSecretIdentityVerifier,
WorkflowExecution,
WorkflowStepExecution,
} from '@n8n/engine';
@@ -48,6 +50,9 @@ export const realNodeTypes: INodeTypes = {
export const converter = new V1WorkflowConverter();
const authSecret = 'a'.repeat(32);
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
export type Assignment = { name: string; value: string | number; type: string };
/** A Set node definition applying `assignments`, wired by the caller. */
@@ -97,6 +102,7 @@ export function makeRunWorkflow(getDataSource: () => EngineDataSource) {
const runtime = createEngineRuntime({
dataSource,
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(authSecret),
// also how the test reaches the stores the runtime owns
externalDependencies: ({ executionStore, stepStore }) => {
const finishExecution = executionStore.finishExecution.bind(executionStore);
@@ -120,6 +126,7 @@ export function makeRunWorkflow(getDataSource: () => EngineDataSource) {
// over HTTP, because that is the engine's only boundary
const response = await request(runtime.app)
.post('/api/workflow-executions')
.set('Authorization', `Bearer ${mintIdentityToken(authSecret, caller)}`)
.send({ workflowId: 'wf-m1', graph, triggerOutputs })
.expect(201);
const { executionId } = response.body as StartExecutionResult;
@@ -4,12 +4,16 @@ import type {
OutboundHttp,
} from '@n8n/backend-network';
import type { EngineConfig } from '@n8n/config';
import { SharedSecretIdentityVerifier } from '@n8n/engine';
import type { StartExecutionRequest } from '@n8n/engine';
import type { InstanceSettings } from 'n8n-core';
import { OperationalError, UserError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { EngineDataPlaneClient } from '../engine-data-plane-client';
const authSecret = 'a'.repeat(32);
describe('EngineDataPlaneClient', () => {
const request: StartExecutionRequest = {
workflowId: 'wf-1',
@@ -42,8 +46,9 @@ describe('EngineDataPlaneClient', () => {
});
return new EngineDataPlaneClient(
mock<EngineConfig>({ port: 3000, host: '0.0.0.0', baseUrl: '', ...config }),
mock<EngineConfig>({ port: 3000, host: '0.0.0.0', baseUrl: '', authSecret, ...config }),
outboundHttp,
mock<InstanceSettings>({ instanceId: 'instance-1' }),
);
};
@@ -66,6 +71,16 @@ describe('EngineDataPlaneClient', () => {
);
});
it('does not follow redirects, so the identity token reaches only the configured host', async () => {
respondWith(201, { executionId: 'exec-1' });
await client.startExecution(request);
expect(http.request).toHaveBeenCalledWith(
expect.objectContaining({ disableFollowRedirect: true }),
);
});
it('dials the loopback, not the bind host', () => {
expect(clientOptions?.baseURL).toBe('http://127.0.0.1:3000');
});
@@ -86,6 +101,19 @@ describe('EngineDataPlaneClient', () => {
expect(clientOptions?.ssrf).toBe('disabled');
});
it('sends an identity token the engine accepts, proving the caller is the instance id', () => {
const headers = clientOptions?.headers;
const resolved = typeof headers === 'function' ? headers() : headers;
const authorization = resolved?.authorization ?? '';
expect(authorization).toMatch(/^Bearer .+/);
const token = authorization.replace('Bearer ', '');
const verifier = new SharedSecretIdentityVerifier(authSecret);
expect(verifier.verify(token)).toEqual({ cpId: 'instance-1', tenantId: 'instance-1' });
});
it.each([
{
case: 'a rejected graph',
@@ -122,6 +150,13 @@ describe('EngineDataPlaneClient', () => {
errorClass: OperationalError,
message: 'Engine responded with 500: boom',
},
{
case: 'a redirect the client refused to follow',
statusCode: 302,
body: '',
errorClass: OperationalError,
message: 'Engine responded with 302',
},
{
case: 'a body that is not the engine error shape',
statusCode: 502,
@@ -1,5 +1,5 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { ExecutionsConfig } from '@n8n/config';
import { EngineConfig, ExecutionsConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
@@ -11,6 +11,7 @@ import { EngineV2Runtime } from '../engine-v2.runtime';
describe('EngineV2Module', () => {
let module: EngineV2Module;
let executionsConfig: ExecutionsConfig;
let engineConfig: EngineConfig;
let runtime: EngineV2Runtime;
let client: EngineDataPlaneClient;
@@ -18,6 +19,7 @@ describe('EngineV2Module', () => {
vi.clearAllMocks();
executionsConfig = mockInstance(ExecutionsConfig, { mode: 'regular' });
engineConfig = mockInstance(EngineConfig, { authSecret: '' });
runtime = mockInstance(EngineV2Runtime);
client = mockInstance(EngineDataPlaneClient);
Container.set(EngineDataPlaneProxyService, new EngineDataPlaneProxyService());
@@ -49,6 +51,20 @@ describe('EngineV2Module', () => {
expect(client.startExecution).toHaveBeenCalledWith(request);
});
it('generates a secret when unset', async () => {
await module.init();
expect(engineConfig.authSecret).toMatch(/^[0-9a-f]{64}$/);
});
it('leaves a configured secret untouched', async () => {
engineConfig.authSecret = 'a-configured-secret';
await module.init();
expect(engineConfig.authSecret).toBe('a-configured-secret');
});
});
describe('shutdown', () => {
@@ -66,6 +66,7 @@ const mocks = vi.hoisted(() => {
vi.mock('@n8n/engine', () => ({
AllowAllAdmittance: vi.fn(),
SharedSecretIdentityVerifier: vi.fn(),
createDataSource: mocks.createDataSource,
createEngineRuntime: mocks.createEngineRuntime,
}));
@@ -3,7 +3,14 @@ import type { HttpRequestClient } from '@n8n/backend-network';
import { OutboundHttp } from '@n8n/backend-network';
import { EngineConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { EngineErrorResponse, StartExecutionRequest, StartExecutionResult } from '@n8n/engine';
import type {
AuthenticatedCaller,
EngineErrorResponse,
StartExecutionRequest,
StartExecutionResult,
} from '@n8n/engine';
import { mintIdentityToken } from '@n8n/engine';
import { InstanceSettings } from 'n8n-core';
import { OperationalError, UserError } from 'n8n-workflow';
import type { EngineDataPlaneProvider } from '@/services/engine-data-plane-proxy.service';
@@ -20,7 +27,11 @@ import type { EngineDataPlaneProvider } from '@/services/engine-data-plane-proxy
export class EngineDataPlaneClient implements EngineDataPlaneProvider {
private readonly http: HttpRequestClient;
constructor(engineConfig: EngineConfig, outboundHttp: OutboundHttp) {
constructor(
private readonly engineConfig: EngineConfig,
outboundHttp: OutboundHttp,
private readonly instanceSettings: InstanceSettings,
) {
this.http = outboundHttp.requests({
// Fixed, n8n-controlled host.
ssrf: 'disabled',
@@ -28,9 +39,24 @@ export class EngineDataPlaneClient implements EngineDataPlaneProvider {
// dialable. Default to loopback and let `N8N_ENGINE_BASE_URL` override
// when the engine answers somewhere else.
baseURL: engineConfig.baseUrl || `http://127.0.0.1:${engineConfig.port}`,
// A factory, not a fixed value: every request gets a fresh short-lived
// token, and `engineConfig.authSecret` is read at request time — after
// the module generates it, which is after this constructor runs.
headers: () => ({
authorization: `Bearer ${mintIdentityToken(this.engineConfig.authSecret, this.caller())}`,
}),
});
}
/**
* In a single-tenant deployment the CP is the tenant; cloud replaces
* `tenantId` with a real one.
*/
private caller(): AuthenticatedCaller {
const { instanceId } = this.instanceSettings;
return { cpId: instanceId, tenantId: instanceId };
}
async startExecution(request: StartExecutionRequest): Promise<StartExecutionResult> {
const response = await this.http.request<StartExecutionResult | EngineErrorResponse>({
url: '/api/workflow-executions',
@@ -41,9 +67,16 @@ export class EngineDataPlaneClient implements EngineDataPlaneProvider {
// Inspect the status here so engine failures map onto n8n error types
// instead of surfacing as a generic request error.
ignoreHttpStatusErrors: true,
// The identity token must reach the configured data plane and nowhere
// else. Following a redirect would forward it to whatever host the
// response names.
disableFollowRedirect: true,
});
if (response.statusCode >= 400) throw this.toError(response.statusCode, response.body);
// 3xx included: redirects are not followed, so a redirecting target is a
// misconfiguration, not a hop. Treating it as success would parse the
// redirect body as an execution result.
if (response.statusCode >= 300) throw this.toError(response.statusCode, response.body);
return response.body as StartExecutionResult;
}
@@ -1,8 +1,9 @@
import { ExecutionsConfig } from '@n8n/config';
import { EngineConfig, ExecutionsConfig } from '@n8n/config';
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { randomBytes } from 'node:crypto';
/**
* Runs the engine 2.0 data plane in-process.
@@ -21,6 +22,13 @@ export class EngineV2Module implements ModuleInterface {
throw new UserError('The engine-v2 module does not support queue mode.');
}
const engineConfig = Container.get(EngineConfig);
// Both planes live in this process, so a generated secret is enough and the
// integrated engine is never unauthenticated. A separate CP must set it.
if (!engineConfig.authSecret) {
engineConfig.authSecret = randomBytes(32).toString('hex');
}
const { EngineV2Runtime } = await import('./engine-v2.runtime.js');
await Container.get(EngineV2Runtime).init();
@@ -2,7 +2,12 @@ import { Logger } from '@n8n/backend-common';
import { EngineConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { EngineRuntime } from '@n8n/engine';
import { AllowAllAdmittance, createDataSource, createEngineRuntime } from '@n8n/engine';
import {
AllowAllAdmittance,
createDataSource,
createEngineRuntime,
SharedSecretIdentityVerifier,
} from '@n8n/engine';
import { createEngineStepDataLoader, V1StepExecutor } from '@n8n/node-engine-compatibility';
import { UserError } from 'n8n-workflow';
import assert from 'node:assert';
@@ -76,6 +81,7 @@ export class EngineV2Runtime {
// TODO(CAT-2909): placeholder policy — every execution is admitted and no
// limits are applied.
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(this.engineConfig.authSecret),
externalDependencies: ({ executionStore, stepStore }) => ({
v1StepExecutor: new V1StepExecutor({
nodeTypes: this.nodeTypes,
+6
View File
@@ -2289,6 +2289,9 @@ importers:
express:
specifier: 'catalog:'
version: 5.1.0
jsonwebtoken:
specifier: 'catalog:'
version: 9.0.3
pg:
specifier: 8.21.0
version: 8.21.0(pg-native@3.8.0)
@@ -2314,6 +2317,9 @@ importers:
'@types/express':
specifier: 'catalog:'
version: 5.0.1
'@types/jsonwebtoken':
specifier: 'catalog:'
version: 9.0.10
'@types/pg':
specifier: ^8.15.6
version: 8.20.0