feat(core): Enable the UI to call the public API (no-changelog) (#35692)

This commit is contained in:
Konstantin Tieber 2026-08-13 13:36:06 +00:00 committed by GitHub
parent 2d37afc13d
commit 885083f257
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 426 additions and 52 deletions

View File

@ -1 +1,18 @@
/** `localStorage` key the per-browser identifier is persisted under. */
export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
/**
* Returns a stable per-browser identifier, generating and persisting one to
* `localStorage` on first use. Sent as the `browser-id` header on authenticated
* requests so the backend can bind session cookies to the browser that
* requested them (see `AuthService.validateBrowserId`), preventing a stolen
* cookie from being replayed from a different client.
*/
export function getBrowserId(): string {
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
if (!browserId) {
browserId = crypto.randomUUID();
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
}
return browserId;
}

View File

@ -19,7 +19,6 @@ if (inE2ETests) {
process.env.N8N_AI_ENABLED = 'true';
} else if (inTest) {
globalConfig.logging.level = 'silent';
globalConfig.publicApi.disabled = true;
process.env.SKIP_STATISTICS_EVENTS = 'true';
globalConfig.auth.cookie.secure = false;
process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'false';

View File

@ -20,11 +20,11 @@ import type { RequestHandler } from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { isApiEnabled } from '@/public-api';
import { isApiKeyAuthEnabled } from '@/public-api';
import { PublicApiKeyService } from '@/services/public-api-key.service';
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
if (isApiEnabled()) {
export const isApiKeyAuthEnabledMiddleware: RequestHandler = (_, res, next) => {
if (isApiKeyAuthEnabled()) {
next();
} else {
res.status(404).end();
@ -39,7 +39,7 @@ export class ApiKeysController {
) {}
@GlobalScope('apiKey:create')
@Post('/', { middlewares: [isApiEnabledMiddleware] })
@Post('/', { middlewares: [isApiKeyAuthEnabledMiddleware] })
async createApiKey(
req: AuthenticatedRequest,
_res: Response,
@ -64,7 +64,7 @@ export class ApiKeysController {
// Every authenticated user may list their own keys. The service only
// includes other users' keys for `apiKey:manage` callers; `ownership=mine`
// narrows back to own.
@Get('/', { middlewares: [isApiEnabledMiddleware] })
@Get('/', { middlewares: [isApiKeyAuthEnabledMiddleware] })
async getApiKeys(req: AuthenticatedRequest, _res: Response, @Query query: ListApiKeysQueryDto) {
return await this.publicApiKeyService.getRedactedApiKeys(req.user, {
take: query.take,
@ -78,7 +78,7 @@ export class ApiKeysController {
// No role scope required: own keys are always revocable. The service
// restricts deleting other users' keys to `apiKey:manage` holders.
@Delete('/:id', { middlewares: [isApiEnabledMiddleware] })
@Delete('/:id', { middlewares: [isApiKeyAuthEnabledMiddleware] })
async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
const { isOwn } = await this.publicApiKeyService.deleteApiKey(req.user, apiKeyId);
@ -93,7 +93,7 @@ export class ApiKeysController {
// Owner-only — `apiKey:manage` doesn't extend to editing someone else's key.
@GlobalScope('apiKey:update')
@Patch('/:id', { middlewares: [isApiEnabledMiddleware] })
@Patch('/:id', { middlewares: [isApiKeyAuthEnabledMiddleware] })
async updateApiKey(
req: AuthenticatedRequest,
_res: Response,
@ -111,7 +111,7 @@ export class ApiKeysController {
// Owner-only — re-issues the secret in place, keeping label, scopes and expiry.
@GlobalScope('apiKey:update')
@Post('/:id/rotate', { middlewares: [isApiEnabledMiddleware] })
@Post('/:id/rotate', { middlewares: [isApiKeyAuthEnabledMiddleware] })
async rotateApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
const rotatedApiKey = await this.publicApiKeyService.rotateApiKey(req.user, apiKeyId);
@ -127,7 +127,7 @@ export class ApiKeysController {
// No role scope required: returns the scopes the caller's role can assign
// to a key — empty-ish for roles without apiKey grants.
@Get('/scopes', { middlewares: [isApiEnabledMiddleware] })
@Get('/scopes', { middlewares: [isApiKeyAuthEnabledMiddleware] })
async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) {
const scopes = getApiKeyScopesForRole(req.user);
return scopes;

View File

@ -1,4 +1,4 @@
import { BadRequest } from 'express-openapi-validator/dist/framework/types';
import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types';
import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -52,6 +52,28 @@ describe('classifyHttpError', () => {
});
});
describe('Unauthorized', () => {
it('keeps the express-openapi-validator message when no session cookie was sent', () => {
const err = new Unauthorized({ path: '/x', message: "'X-N8N-API-KEY' header required" });
const d = classifyHttpError(err);
expect(d).toEqual({
kind: HttpErrorKind.httpError,
status: 401,
message: "'X-N8N-API-KEY' header required",
});
});
it('replaces the api key hint with a generic message when a session cookie was sent', () => {
const err = new Unauthorized({ path: '/x', message: "'X-N8N-API-KEY' header required" });
const d = classifyHttpError(err, { hasSessionCookie: true });
expect(d).toEqual({
kind: HttpErrorKind.httpError,
status: 401,
message: 'Unauthorized',
});
});
});
it('tags plain Error as serverError', () => {
const d = classifyHttpError(new Error('plain'));
expect(d).toEqual({

View File

@ -1,4 +1,4 @@
import { HttpError } from 'express-openapi-validator/dist/framework/types';
import { HttpError, Unauthorized } from 'express-openapi-validator/dist/framework/types';
import { UnexpectedError, UserError } from 'n8n-workflow';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
@ -55,7 +55,15 @@ export function isResponseError(error: Error): error is ResponseError {
return false;
}
export function classifyHttpError(error: Error): HttpErrorDescriptor {
export type HttpErrorClassifierContext = {
/** Whether the request carried a session cookie, regardless of whether it was valid. */
hasSessionCookie?: boolean;
};
export function classifyHttpError(
error: Error,
context?: HttpErrorClassifierContext,
): HttpErrorDescriptor {
if (isResponseError(error)) {
const descriptor: HttpErrorDescriptor & { kind: HttpErrorKind.responseError } = {
kind: HttpErrorKind.responseError,
@ -80,6 +88,12 @@ export function classifyHttpError(error: Error): HttpErrorDescriptor {
return { kind: HttpErrorKind.unexpectedError, message: error.message };
}
// Replacing express-openapi-validator's 'api key header required' error message
// for requests that contained a session cookie.
if (error instanceof Unauthorized && context?.hasSessionCookie) {
return { kind: HttpErrorKind.httpError, status: 401, message: 'Unauthorized' };
}
if (error instanceof HttpError) {
return {
kind: HttpErrorKind.httpError,

View File

@ -4,11 +4,13 @@ import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { randomString } from 'n8n-workflow';
import { AuthService } from '@/auth/auth.service';
import { ScopedJwtStrategy } from '@/modules/token-exchange/services/scoped-jwt.strategy';
import { TOKEN_EXCHANGE_ISSUER } from '@/modules/token-exchange/token-exchange.types';
import { ApiKeyAuthStrategy } from '@/services/api-key-auth.strategy';
import { AuthStrategyRegistry } from '@/services/auth-strategy.registry';
import { JwtService } from '@/services/jwt.service';
import { SessionCookieAuthStrategy } from '@/services/session-cookie-auth.strategy';
import { createMember, createOwner, createOwnerWithApiKey } from '@test-integration/db/users';
import { McpServerApiKeyService } from '../mcp-api-key.service';
@ -40,10 +42,11 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
beforeAll(async () => {
await testDb.init();
// Mirror the registration order set up by public API + token-exchange module init.
// Mirror the registration order set up by server.ts + token-exchange module init.
const registry = Container.get(AuthStrategyRegistry);
jwtService = Container.get(JwtService);
registry.register(Container.get(ApiKeyAuthStrategy));
registry.register(Container.get(SessionCookieAuthStrategy));
registry.register(Container.get(ScopedJwtStrategy));
service = Container.get(McpServerApiKeyService);
@ -140,6 +143,16 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
name: 'scoped JWT whose subject is not in the database',
token: () => makeScopedJwt(jwtService, '00000000-0000-0000-0000-000000000000'),
},
{
// SessionCookieAuthStrategy is registered into the same registry above,
// so this proves its buildTokenGrant genuinely abstains rather than the
// audience check merely never encountering the strategy.
name: 'a valid browser session cookie (must not satisfy the mcp-server-api audience)',
token: async () => {
const owner = await createOwner();
return Container.get(AuthService).issueJWT(owner, false);
},
},
])('rejects $name', async ({ token }) => {
const result = await service.verifyApiKey(await token());

View File

@ -8,7 +8,7 @@ import { McpServerApiKeyService } from '../mcp-api-key.service';
// Regression guard for IAM-574: ApiKeyAuthStrategy must be registered regardless
// of whether the public API is enabled, otherwise MCP API keys stop working on
// instances that disable the public REST API. Omitting 'publicApi' from
// endpointGroups mirrors a production instance where isApiEnabled() returns
// endpointGroups mirrors a production instance where isApiKeyAuthEnabled() returns
// false — the MCP endpoint group is still set up so we can exercise the real
// strategy-registration wiring.
// Loading the MCP module plus DB init can exceed the default 30s hook timeout

View File

@ -13,6 +13,7 @@ import validator from 'validator';
import { PublicApiControllerRegistry } from './public-api-controller.registry';
import { sendPublicApiErrorResponse } from './v1/public-api-error-response';
import { AUTH_COOKIE_NAME } from '@/constants';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { createN8nPackageMulterOptions } from '@/modules/n8n-packages/utils/import-package-upload';
@ -321,6 +322,7 @@ function createLazyValidatorMiddleware(
handlers: {
ApiKeyAuth: authenticate,
BearerAuth: authenticate,
CookieAuth: authenticate,
},
},
}),
@ -379,11 +381,13 @@ function createApiRouter(
const publicApiErrorHandler: ErrorRequestHandler = (
error: Error,
_req: express.Request,
req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
sendPublicApiErrorResponse(res, error);
sendPublicApiErrorResponse(res, error, {
hasSessionCookie: Boolean(req.cookies?.[AUTH_COOKIE_NAME]),
});
};
apiController.use(publicApiErrorHandler);
@ -410,6 +414,12 @@ export const loadPublicApiVersions = async (
};
};
export function isApiEnabled(): boolean {
/**
* Whether API-key (token) based authentication is accepted on the public API.
* Public API routes are always registered regardless of this flag — the UI
* authenticates against them with the user's session cookie instead, which
* must keep working even when token-based access is disabled.
*/
export function isApiKeyAuthEnabled(): boolean {
return !Container.get(GlobalConfig).publicApi.disabled && !Container.get(License).isAPIDisabled();
}

View File

@ -1,5 +1,5 @@
import type { Response } from 'express';
import { BadRequest } from 'express-openapi-validator/dist/framework/types';
import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types';
import { UnexpectedError, UserError, OperationalError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -96,6 +96,17 @@ describe('sendPublicApiErrorResponse', () => {
expect(res._payload.body).toEqual({ message: 'schema failed' });
});
it('forwards classifier context so session-cookie Unauthorized is serialized as a generic 401', () => {
const res = createMockRes();
const err = new Unauthorized({
path: '/api/v1/insights/summary',
message: "'X-N8N-API-KEY' header required",
});
sendPublicApiErrorResponse(res, err, { hasSessionCookie: true });
expect(res._payload.statusCode).toBe(401);
expect(res._payload.body).toEqual({ message: 'Unauthorized' });
});
it('maps unknown errors to 500 with a generic message', () => {
const res = createMockRes();
sendPublicApiErrorResponse(res, new Error('plain'));

View File

@ -221,7 +221,13 @@ components:
type: http
scheme: bearer
bearerFormat: JWT
CookieAuth:
type: apiKey
in: cookie
# Must match AUTH_COOKIE_NAME in packages/cli/src/constants.ts
name: n8n-auth
security:
- ApiKeyAuth: []
- BearerAuth: []
- CookieAuth: []

View File

@ -1,5 +1,6 @@
import type { Response } from 'express';
import type { HttpErrorClassifierContext } from '@/errors/http-error-classifier';
import { classifyHttpError } from '@/errors/http-error-classifier';
import { serializePublicApiError } from '@/errors/http-error-serializers';
@ -7,8 +8,12 @@ import { serializePublicApiError } from '@/errors/http-error-serializers';
* Maps errors from the public API stack to HTTP responses. Used by the
* Express error middleware in `index.ts`.
*/
export function sendPublicApiErrorResponse(res: Response, error: Error): void {
const descriptor = classifyHttpError(error);
export function sendPublicApiErrorResponse(
res: Response,
error: Error,
context?: HttpErrorClassifierContext,
): void {
const descriptor = classifyHttpError(error, context);
const { status, body } = serializePublicApiError(descriptor);
res.status(status).json(body);
}

View File

@ -9,7 +9,7 @@ import type { IWorkflowBase } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
import { isApiEnabled } from '@/public-api';
import { isApiKeyAuthEnabled } from '@/public-api';
import {
ENV_VARS_DOCS_URL,
INSTANCE_REPORT,
@ -103,7 +103,7 @@ export class InstanceRiskReporter implements RiskReporter {
communityPackagesEnabled: Container.get(CommunityPackagesConfig).enabled,
versionNotificationsEnabled: this.globalConfig.versionNotifications.enabled,
templatesEnabled: this.globalConfig.templates.enabled,
publicApiEnabled: isApiEnabled(),
publicApiEnabled: isApiKeyAuthEnabled(),
};
const { exclude, include } = this.globalConfig.nodes;

View File

@ -25,7 +25,7 @@ import type { ICredentialsOverwrite } from '@/interfaces';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { handleMfaDisable, isMfaFeatureEnabled } from '@/mfa/helpers';
import { PostHogClient } from '@/posthog';
import { isApiEnabled, loadPublicApiVersions } from '@/public-api';
import { loadPublicApiVersions } from '@/public-api';
import { Push } from '@/push';
import * as ResponseHelper from '@/response-helper';
import type { FrontendService } from '@/services/frontend.service';
@ -77,6 +77,7 @@ import { BrowserUseServer } from './modules/instance-ai/browser/browser-use-serv
import { PubSubRegistry } from './scaling/pubsub/pubsub.registry';
import { ApiKeyAuthStrategy } from './services/api-key-auth.strategy';
import { AuthStrategyRegistry } from './services/auth-strategy.registry';
import { SessionCookieAuthStrategy } from './services/session-cookie-auth.strategy';
@Service()
export class Server extends AbstractServer {
@ -184,22 +185,17 @@ export class Server extends AbstractServer {
// Register auth strategies in priority order. The registry evaluates them
// sequentially — the first strategy that returns a non-null result wins.
// API key auth is registered first so existing behavior is preserved.
// Session cookie auth is registered last: an explicit but wrong API
// key/bearer token fails fast rather than silently falling back to an
// ambient browser session cookie.
// Additional strategies (e.g. scoped JWT from the token-exchange module)
// can be appended later during their own module initialization.
const registry = Container.get(AuthStrategyRegistry);
registry.register(Container.get(ApiKeyAuthStrategy));
registry.register(Container.get(SessionCookieAuthStrategy));
// ----------------------------------------
// Public API
// ----------------------------------------
if (isApiEnabled()) {
const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint);
this.app.use(...apiRouters);
if (frontendService) {
(await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion;
}
}
// Parse cookies for easier access
this.app.use(cookieParser());
// Extract BrowserId from headers
this.app.use((req: APIRequest, _, next) => {
@ -207,8 +203,15 @@ export class Server extends AbstractServer {
next();
});
// Parse cookies for easier access
this.app.use(cookieParser());
// ----------------------------------------
// Public API
// ----------------------------------------
const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint);
this.app.use(...apiRouters);
if (frontendService) {
(await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion;
}
const { restEndpoint, app } = this;
@ -437,7 +440,6 @@ export class Server extends AbstractServer {
'e2e',
this.restEndpoint,
this.endpointPresetCredentials,
isApiEnabled() ? '' : publicApiEndpoint,
...this.globalConfig.endpoints.additionalNonUIRoutes.split(':'),
].filter((u) => !!u);
const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`);

View File

@ -1,5 +1,6 @@
import { Logger } from '@n8n/backend-common';
import { testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { AuthenticatedRequest, User } from '@n8n/db';
import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
@ -215,5 +216,20 @@ describe('ApiKeyAuthStrategy', () => {
expect(req.user.id).toBe(owner.id);
expect(req.tokenGrant?.subject.id).toBe(owner.id);
});
it('abstains (returns null) when N8N_PUBLIC_API_DISABLED is set, even with a valid API key', async () => {
const globalConfig = Container.get(GlobalConfig);
globalConfig.publicApi.disabled = true;
try {
const owner = await createOwnerWithApiKey();
const [{ apiKey }] = owner.apiKeys;
const req = mockReqWith(apiKey);
expect(await strategy.authenticate(req)).toBeNull();
} finally {
globalConfig.publicApi.disabled = false;
}
});
});
});

View File

@ -9,7 +9,7 @@ import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import type { CredentialTypes } from '@/credential-types';
import type { CredentialsOverwrites } from '@/credentials-overwrites';
import type { License } from '@/license';
import { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { MfaService } from '@/mfa/mfa.service';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
@ -200,6 +200,9 @@ describe('FrontendService', () => {
enabled: false,
}),
);
// isApiKeyAuthEnabled() reads License via the container directly, so the
// constructor-injected mock above must also be registered here.
Container.set(License, license);
return {
service: new FrontendService(

View File

@ -0,0 +1,103 @@
import { LicenseState } from '@n8n/backend-common';
import { testDb } from '@n8n/backend-test-utils';
import type { AuthenticatedRequest } from '@n8n/db';
import { InvalidAuthTokenRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import { AuthService } from '@/auth/auth.service';
import { AUTH_COOKIE_NAME } from '@/constants';
import { createOwner } from '@test-integration/db/users';
import { SessionCookieAuthStrategy } from '../session-cookie-auth.strategy';
// MFA enforcement gates inside AuthService.authenticateUserBasedOnToken call into the
// license state; stub it so any feature check returns "not licensed".
const licenseMock = mock<LicenseState>();
licenseMock.isLicensed.mockReturnValue(false);
Container.set(LicenseState, licenseMock);
const mockReqWith = (token: string): AuthenticatedRequest => {
const req = mock<AuthenticatedRequest>({ path: '/test', method: 'GET' });
// Override the deep-mock proxy so the cookie lookup returns the exact value.
req.cookies = { [AUTH_COOKIE_NAME]: token };
return req;
};
const mockReqWithoutCookie = (): AuthenticatedRequest => {
const req = mock<AuthenticatedRequest>({ path: '/test', method: 'GET' });
// Override the deep-mock proxy so the cookie lookup returns undefined.
req.cookies = {};
return req;
};
let authService: AuthService;
let authenticator: SessionCookieAuthStrategy;
describe('SessionCookieAuthStrategy', () => {
beforeAll(async () => {
await testDb.init();
authService = Container.get(AuthService);
authenticator = new SessionCookieAuthStrategy(authService);
});
beforeEach(async () => {
await testDb.truncate(['User']);
});
afterAll(async () => {
await testDb.terminate();
});
it('returns null when no session cookie is present', async () => {
expect(await authenticator.authenticate(mockReqWithoutCookie())).toBeNull();
});
it('returns true and populates req.user + req.tokenGrant on success', async () => {
const owner = await createOwner();
const token = authService.issueJWT(owner, false);
const req = mockReqWith(token);
expect(await authenticator.authenticate(req)).toBe(true);
expect(req.user.id).toBe(owner.id);
expect(req.tokenGrant?.subject.id).toBe(owner.id);
expect(req.tokenGrant?.scopes.length).toBeGreaterThan(0);
expect(req.tokenGrant?.apiKeyScopes?.length).toBeGreaterThan(0);
});
it('returns false for an invalid session cookie', async () => {
expect(await authenticator.authenticate(mockReqWith('not-a-jwt'))).toBe(false);
});
it('returns false for a revoked token', async () => {
const owner = await createOwner();
const token = authService.issueJWT(owner, false);
await Container.get(InvalidAuthTokenRepository).insert({
token,
expiresAt: new Date(Date.now() + 60_000),
});
expect(await authenticator.authenticate(mockReqWith(token))).toBe(false);
});
it('returns false for a disabled user', async () => {
const owner = await createOwner();
const token = authService.issueJWT(owner, false);
await Container.get(UserRepository).update({ id: owner.id }, { disabled: true });
expect(await authenticator.authenticate(mockReqWith(token))).toBe(false);
});
it('buildTokenGrant always abstains — session cookies must never satisfy audience-scoped bearer-token checks', async () => {
expect(await authenticator.buildTokenGrant('anything')).toBeNull();
expect(await authenticator.buildTokenGrant('')).toBeNull();
// Even a genuinely valid session JWT, passed as a raw bearer token with an
// audience mirroring a real scoped-token check, must still be refused.
const owner = await createOwner();
const validSessionJwt = authService.issueJWT(owner, false);
expect(
await authenticator.buildTokenGrant(validSessionJwt, { audience: 'mcp-server-api' }),
).toBeNull();
});
});

View File

@ -5,6 +5,8 @@ import { ApiKeyRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { TokenExpiredError } from 'jsonwebtoken';
import { isApiKeyAuthEnabled } from '@/public-api';
import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types';
import { JwtService } from './jwt.service';
import { API_KEY_AUDIENCE, API_KEY_ISSUER, PREFIX_LEGACY_API_KEY } from './public-api-key.service';
@ -82,6 +84,10 @@ export class ApiKeyAuthStrategy implements AuthStrategy {
}
async authenticate(req: AuthenticatedRequest): Promise<boolean | null> {
// Abstain when API-key auth is disabled so other strategies (e.g. session
// cookie) can still authenticate the request.
if (!isApiKeyAuthEnabled()) return null;
const providedApiKey = req.headers[API_KEY_HEADER];
if (typeof providedApiKey !== 'string' || !providedApiKey) return null;

View File

@ -22,7 +22,7 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { MfaService } from '@/mfa/mfa.service';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
import type { CommunityPackagesService } from '@/modules/community-packages/community-packages.service';
import { isApiEnabled } from '@/public-api';
import { isApiKeyAuthEnabled } from '@/public-api';
import { PushConfig } from '@/push/push.config';
import { OwnershipService } from '@/services/ownership.service';
import { getSamlLoginLabel, getCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
@ -300,7 +300,7 @@ export class FrontendService {
maxSize: this.globalConfig.dataTable.maxSize,
},
publicApi: {
enabled: isApiEnabled(),
enabled: isApiKeyAuthEnabled(),
latestVersion: 1,
path: this.globalConfig.publicApi.path,
swaggerUi: {

View File

@ -0,0 +1,68 @@
import type { AuthenticatedRequest, TokenGrant, User } from '@n8n/db';
import { Service } from '@n8n/di';
import { getApiKeyScopesForRole } from '@n8n/permissions';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { AuthService } from '@/auth/auth.service';
import { AUTH_COOKIE_NAME } from '@/constants';
import { AuthError } from '@/errors/response-errors/auth.error';
import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types';
/**
* Lets the public API accept the browser's `n8n-auth` session cookie as an
* alternative to an API key.
*/
@Service()
export class SessionCookieAuthStrategy implements AuthStrategy {
constructor(private readonly authService: AuthService) {}
/**
* Session cookies authenticate a request via req.cookies, never via an arbitrary
* bearer token string, so this strategy has no legitimate way to answer "is this
* token valid for audience X". It must always abstain here, ignoring `token`/`options`
* entirely — otherwise a browser session JWT could satisfy an audience-scoped
* bearer-token check it was never meant to (e.g. McpServerApiKeyService.verifyApiKey).
*/
async buildTokenGrant(_token: string, _options?: AuthStrategyOptions): Promise<null> {
return null;
}
async authenticate(req: AuthenticatedRequest): Promise<boolean | null> {
const token = req.cookies[AUTH_COOKIE_NAME];
if (!token) return null;
try {
const user = await this.authService.authenticateUserBasedOnToken(
token,
req.method,
this.authService.getEndpoint(req),
this.authService.getBrowserId(req),
);
const tokenGrant = this.toGrant(user);
req.user = user;
req.tokenGrant = tokenGrant;
return true;
} catch (error) {
if (this.isAuthFailure(error)) return false;
throw error;
}
}
private toGrant(user: User): TokenGrant {
return {
scopes: user.role.scopes.map((s) => s.slug),
subject: user,
apiKeyScopes: getApiKeyScopesForRole(user),
};
}
private isAuthFailure(error: unknown): boolean {
return (
error instanceof AuthError ||
error instanceof TokenExpiredError ||
error instanceof JsonWebTokenError
);
}
}

View File

@ -3,6 +3,7 @@ import { createTeamProject, createWorkflow, testDb } from '@n8n/backend-test-uti
import { type User } from '@n8n/db';
import { DateTime } from 'luxon';
import { AUTH_COOKIE_NAME } from '@/constants';
import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils';
import { createOwnerWithApiKey } from '../shared/db/users';
@ -81,6 +82,47 @@ describe('GET /insights/summary', () => {
await testServer.publicApiAgentWithoutApiKey().get('/insights/summary').expect(401);
});
test('returns data via session cookie, without an API key', async () => {
const project = await createTeamProject();
const workflow = await createWorkflow({}, project);
await createSummaryMetrics(workflow, {
success: 3,
failure: 1,
runtimeMs: 400,
timeSavedMin: 20,
});
const response = await testServer
.publicApiAgentWithCookie(scopedOwner)
.get('/insights/summary')
.query({
startDate: DateTime.utc().minus({ days: 2 }).toISO(),
endDate: DateTime.utc().plus({ days: 1 }).toISO(),
})
.expect(200);
expect(response.body.total.value).toBe(4);
expect(response.body.failed.value).toBe(1);
});
test('returns 401 with an invalid session cookie', async () => {
const agent = testServer.publicApiAgentWithoutApiKey();
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`);
const response = await agent.get('/insights/summary').expect(401);
expect(response.body).toEqual({ message: 'Unauthorized' });
});
test('returns 401 with a hint to use an API key when no credentials are sent at all', async () => {
const agent = testServer.publicApiAgentWithoutApiKey();
const response = await agent.get('/insights/summary').expect(401);
expect(response.body).toEqual({ message: "'X-N8N-API-KEY' header required" });
});
test('returns 403 without insights:read scope', async () => {
await authUnscopedAgent.get('/insights/summary').expect(403);
});

View File

@ -255,7 +255,7 @@ test('should report security settings', async () => {
communityPackagesEnabled: true,
versionNotificationsEnabled: true,
templatesEnabled: true,
publicApiEnabled: false,
publicApiEnabled: true,
},
nodes: {
nodesExclude: 'n8n-nodes-base.executeCommand, n8n-nodes-base.localFileTrigger',

View File

@ -91,6 +91,7 @@ export interface TestServer {
publicApiAgentFor: (user: User) => TestAgent;
publicApiAgentWithApiKey: (apiKey: string) => TestAgent;
publicApiAgentWithoutApiKey: () => TestAgent;
publicApiAgentWithCookie: (user: User) => TestAgent;
authlessAgent: TestAgent;
restlessAgent: TestAgent;
license: LicenseMocker;

View File

@ -19,6 +19,7 @@ import { PostHogClient } from '@/posthog';
import { Push } from '@/push';
import { ApiKeyAuthStrategy } from '@/services/api-key-auth.strategy';
import { AuthStrategyRegistry } from '@/services/auth-strategy.registry';
import { SessionCookieAuthStrategy } from '@/services/session-cookie-auth.strategy';
import { Telemetry } from '@/telemetry';
import { resolveBackendHealthEndpointPath } from '@/utils/health-endpoint.util';
import { LicenseMocker } from '@test-integration/license';
@ -93,6 +94,19 @@ const publicApiAgent = (
return agent;
};
const publicApiAgentWithCookie = (app: express.Application, user: User, version = 1) => {
const agent = request.agent(app);
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
// browser-id is needed for session cookie auth
void agent.use(async (req: superagent.SuperAgentRequest) => {
req.set('browser-id', browserId);
return await req;
});
const token = Container.get(AuthService).issueJWT(user, user.mfaEnabled, browserId);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
return agent;
};
export const setupTestServer = ({
endpointGroups,
enabledFeatures,
@ -124,6 +138,7 @@ export const setupTestServer = ({
publicApiAgentFor: (user) => publicApiAgent(app, { user }),
publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }),
publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}),
publicApiAgentWithCookie: (user) => publicApiAgentWithCookie(app, user),
license: new LicenseMocker(),
};
@ -153,10 +168,14 @@ export const setupTestServer = ({
// Register auth strategies in priority order. The registry evaluates them
// sequentially — the first strategy that returns a non-null result wins.
// API key auth is registered first so existing behavior is preserved.
// Session cookie auth is registered last: an explicit but wrong API
// key/bearer token fails fast rather than silently falling back to an
// ambient browser session cookie.
// Additional strategies (e.g. scoped JWT from the token-exchange module)
// can be appended later during their own module initialization.
const registry = Container.get(AuthStrategyRegistry);
registry.register(Container.get(ApiKeyAuthStrategy));
registry.register(Container.get(SessionCookieAuthStrategy));
const enablePublicAPI = endpointGroups?.includes('publicApi');
if (enablePublicAPI) {

View File

@ -1,4 +1,4 @@
import { BROWSER_ID_STORAGE_KEY } from '@n8n/constants';
import { getBrowserId } from '@n8n/constants';
import { assert } from '@n8n/utils/assert';
import type { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
@ -7,15 +7,6 @@ import type { GenericValue, IDataObject } from 'n8n-workflow';
import type { IRestApiContext } from './types';
const getBrowserId = () => {
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
if (!browserId) {
browserId = crypto.randomUUID();
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
}
return browserId;
};
export const NO_NETWORK_ERROR_CODE = 999;
export const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n';

View File

@ -23,6 +23,7 @@ const mockRootStore = {
setExecutionTimeout: vi.fn(),
setMaxExecutionTimeout: vi.fn(),
setInstanceId: vi.fn(),
setPublicApiPath: vi.fn(),
setOauthCallbackUrls: vi.fn(),
setJwksUri: vi.fn(),
setN8nMetadata: vi.fn(),
@ -73,6 +74,12 @@ const mockSettings = mock<FrontendSettings>({
telemetry: {
enabled: false,
},
publicApi: {
enabled: true,
latestVersion: 1,
path: 'api',
swaggerUi: { enabled: true },
},
});
const publicSettingsResponse = {
@ -337,6 +344,7 @@ describe('settings.store', () => {
expect(mockRootStore.setMaxExecutionTimeout).not.toHaveBeenCalled();
expect(mockRootStore.setN8nMetadata).not.toHaveBeenCalled();
expect(mockRootStore.setBinaryDataMode).not.toHaveBeenCalled();
expect(mockRootStore.setPublicApiPath).not.toHaveBeenCalled();
// side effects
expect(sessionStarted).not.toHaveBeenCalled();
@ -391,6 +399,9 @@ describe('settings.store', () => {
expect(mockRootStore.setN8nMetadata).toHaveBeenCalled();
expect(mockRootStore.setDefaultLocale).toHaveBeenCalled();
expect(mockRootStore.setBinaryDataMode).toHaveBeenCalled();
expect(mockRootStore.setPublicApiPath).toHaveBeenCalledWith(
`${mockSettings.publicApi.path}/v${mockSettings.publicApi.latestVersion}`,
);
// side effects
expect(sessionStarted).toHaveBeenCalled();

View File

@ -367,6 +367,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
rootStore.setTimezone(fetchedSettings.timezone);
rootStore.setExecutionTimeout(fetchedSettings.executionTimeout);
rootStore.setMaxExecutionTimeout(fetchedSettings.maxExecutionTimeout);
rootStore.setPublicApiPath(
`${fetchedSettings.publicApi.path}/v${fetchedSettings.publicApi.latestVersion}`,
);
rootStore.setInstanceId(fetchedSettings.instanceId);
rootStore.setOauthCallbackUrls(fetchedSettings.oauthCallbackUrls);
rootStore.setN8nMetadata(fetchedSettings.n8nMetadata ?? {});

View File

@ -29,6 +29,7 @@ export type RootStoreState = {
[key: string]: string | number | undefined;
};
pushRef: string;
publicApiPath: string;
urlBaseWebhook: string;
urlBaseEditor: string;
urlBaseWebhookTest: string;
@ -57,6 +58,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
jwksUri: '',
n8nMetadata: {},
pushRef: randomString(10).toLowerCase(),
publicApiPath: 'api/v1',
urlBaseWebhook: 'http://localhost:5678/',
urlBaseEditor: 'http://localhost:5678',
urlBaseWebhookTest: 'http://localhost:5678/',
@ -127,6 +129,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
pushRef: state.value.pushRef,
}));
const publicApiContext = computed(() => ({
baseUrl: `${state.value.baseUrl}${state.value.publicApiPath}`,
}));
// #endregion
// ---------------------------------------------------------------------------
@ -225,6 +231,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
state.value.pushRef = value;
};
const setPublicApiPath = (value: string) => {
state.value.publicApiPath = value;
};
// #endregion
return {
@ -239,6 +249,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
webhookWaitingUrl,
restUrl,
restApiContext,
publicApiContext,
urlBaseEditor,
urlBaseWebhook,
versionCli,
@ -273,5 +284,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
setDefaultLocale,
setBinaryDataMode,
setPushRef,
setPublicApiPath,
};
});