mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
chore(core): Begins minting asymmetric signed access tokens (#40286)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
66b382de52
commit
e882e8a483
@@ -21,6 +21,7 @@ export type PubSubEventName =
|
||||
| 'reload-license'
|
||||
| 'reload-oidc-config'
|
||||
| 'reload-saml-config'
|
||||
| 'reload-oauth-signing-keys'
|
||||
| 'reload-overwrite-credentials'
|
||||
| 'response-to-get-worker-status'
|
||||
| 'restart-event-bus'
|
||||
|
||||
@@ -111,7 +111,8 @@ describe('Webhook', () => {
|
||||
baseInitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call executionContextHookRegistry.init before LoadNodesAndCredentials.postProcessLoaders', async () => {
|
||||
/** A webhook command whose init steps are all stubbed. */
|
||||
const createWebhook = () => {
|
||||
const webhook = new Webhook();
|
||||
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
@@ -138,6 +139,11 @@ describe('Webhook', () => {
|
||||
webhook.executionContextHookRegistry = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return webhook;
|
||||
};
|
||||
|
||||
it('should call executionContextHookRegistry.init before LoadNodesAndCredentials.postProcessLoaders', async () => {
|
||||
const webhook = createWebhook();
|
||||
|
||||
await webhook.init();
|
||||
|
||||
@@ -151,6 +157,21 @@ describe('Webhook', () => {
|
||||
postProcessMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should register pubsub event handlers again after the modules initialize', async () => {
|
||||
const webhook = createWebhook();
|
||||
|
||||
await webhook.init();
|
||||
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
const initModulesMock = webhook.moduleRegistry.initModules as Mock;
|
||||
const registryInitMock = Container.get(PubSubRegistry).init as Mock;
|
||||
|
||||
expect(registryInitMock).toHaveBeenCalledTimes(1);
|
||||
expect(initModulesMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
registryInitMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,10 @@ export class Webhook extends BaseCommand {
|
||||
|
||||
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
|
||||
|
||||
// Re-register pubsub event handlers after modules have been initialized
|
||||
// As modules can add new event handlers we need to make sure they are registered
|
||||
Container.get(PubSubRegistry).init();
|
||||
|
||||
await this.executionContextHookRegistry.init();
|
||||
await Container.get(LoadNodesAndCredentials).postProcessLoaders();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Mocked } from 'vitest';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
|
||||
@@ -27,6 +29,15 @@ const mockReqWith = (authHeader: string | undefined, body?: any) => {
|
||||
|
||||
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
|
||||
const jwtService = new JwtService(instanceSettings, mock(), mock());
|
||||
const signingKey = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey;
|
||||
|
||||
/** An access token in the shape n8n mints. The verifier is mocked, so only its claims matter. */
|
||||
const signOAuthToken = () =>
|
||||
jwt.sign({ sub: 'user-123', meta: { isOAuth: true } }, signingKey, {
|
||||
algorithm: 'ES256',
|
||||
audience: 'https://n8n.example.com/mcp-server/http',
|
||||
header: { alg: 'ES256', typ: 'at+jwt', kid: 'signing-kid' },
|
||||
});
|
||||
|
||||
let mcpServerApiKeyService: Mocked<McpServerApiKeyService>;
|
||||
let oauthTokenVerifier: Mocked<OAuthTokenVerifierProxy>;
|
||||
@@ -61,10 +72,7 @@ describe('McpServerMiddlewareService', () => {
|
||||
describe('getUserForToken', () => {
|
||||
it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = jwtService.signForResource(
|
||||
{ sub: 'user-123', meta: { isOAuth: true } },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
|
||||
user,
|
||||
@@ -127,10 +135,7 @@ describe('McpServerMiddlewareService', () => {
|
||||
});
|
||||
|
||||
it('should return null when OAuth token verification fails', async () => {
|
||||
const oauthToken = jwtService.signForResource(
|
||||
{ sub: 'user-123', meta: { isOAuth: true } },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });
|
||||
|
||||
@@ -301,10 +306,7 @@ describe('McpServerMiddlewareService', () => {
|
||||
|
||||
it('should authenticate with valid OAuth token and call next', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = jwtService.signForResource(
|
||||
{ sub: 'user-123', meta: { isOAuth: true } },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${oauthToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
@@ -328,10 +330,7 @@ describe('McpServerMiddlewareService', () => {
|
||||
|
||||
it('should attach the OAuth token scopes to the request', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = jwtService.signForResource(
|
||||
{ sub: 'user-123', meta: { isOAuth: true } },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${oauthToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
@@ -352,10 +351,7 @@ describe('McpServerMiddlewareService', () => {
|
||||
|
||||
it('should attach the OAuth client the token was issued to', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = jwtService.signForResource(
|
||||
{ sub: 'user-123', meta: { isOAuth: true } },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${oauthToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
@@ -435,10 +431,7 @@ describe('McpServerMiddlewareService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 with WWW-Authenticate header when token validation fails', async () => {
|
||||
const invalidToken = jwtService.signForResource(
|
||||
{ sub: 'user-123', meta: { isOAuth: true } },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const invalidToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${invalidToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import type { User } from '@n8n/db';
|
||||
import { DeploymentKey, OAUTH_JWE_PRIVATE_KEY_TYPE } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import type { JSONWebKeySet } from 'jose';
|
||||
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import type { JsonWebKey } from 'node:crypto';
|
||||
import { createPrivateKey } from 'node:crypto';
|
||||
|
||||
import { createOwner } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
import { OAUTH_ACCESS_TOKEN_TTL_SECONDS } from '../oauth-signing-key.constants';
|
||||
import { OAuthSigningKeyService } from '../oauth-signing-key.service';
|
||||
import { OAuthTokenService } from '../oauth-token.service';
|
||||
import { AccessTokenNotFoundError, JWTVerificationError } from '../oauth.errors';
|
||||
|
||||
const testServer = setupTestServer({
|
||||
modules: ['oauth-jwe', 'oauth-server', 'mcp'],
|
||||
endpointGroups: ['mcp', 'jwks'],
|
||||
});
|
||||
|
||||
let owner: User;
|
||||
let issuer: string;
|
||||
let jwks: JSONWebKeySet;
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
|
||||
const discovery = await testServer.restlessAgent
|
||||
.get('/.well-known/oauth-authorization-server')
|
||||
.expect(200);
|
||||
issuer = discovery.body.issuer as string;
|
||||
const jwksPath = new URL(discovery.body.jwks_uri as string).pathname;
|
||||
jwks = (await testServer.restlessAgent.get(jwksPath).expect(200)).body as JSONWebKeySet;
|
||||
});
|
||||
|
||||
describe('OAuth access-token signing', () => {
|
||||
const claims = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
iss: issuer,
|
||||
sub: owner.id,
|
||||
aud: `${issuer}/mcp-server/http`,
|
||||
client_id: 'client-1',
|
||||
iat: now,
|
||||
exp: now + 60,
|
||||
scope: '',
|
||||
meta: { isOAuth: true },
|
||||
};
|
||||
};
|
||||
|
||||
it('mints tokens that verify with only the published JWKS, next to the JWE key', async () => {
|
||||
expect(jwks.keys.map((key) => key.use).sort()).toEqual(['enc', 'sig']);
|
||||
const resource = `${issuer}/mcp-server/http`;
|
||||
|
||||
const { accessToken } = Container.get(OAuthTokenService).generateTokenPair(
|
||||
owner.id,
|
||||
'client-1',
|
||||
resource,
|
||||
['tool:read'],
|
||||
);
|
||||
|
||||
const { payload, protectedHeader } = await jwtVerify(accessToken, createLocalJWKSet(jwks), {
|
||||
issuer,
|
||||
audience: resource,
|
||||
typ: 'at+jwt',
|
||||
algorithms: ['ES256'],
|
||||
});
|
||||
const signingKey = jwks.keys.find((key) => key.use === 'sig');
|
||||
expect(protectedHeader).toEqual({ alg: 'ES256', typ: 'at+jwt', kid: signingKey?.kid });
|
||||
expect(payload).toEqual({
|
||||
iss: issuer,
|
||||
sub: owner.id,
|
||||
aud: resource,
|
||||
client_id: 'client-1',
|
||||
jti: expect.any(String),
|
||||
iat: expect.any(Number),
|
||||
exp: payload.iat! + OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
scope: 'tool:read',
|
||||
meta: { isOAuth: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a token signed with the JWE key under its own kid', async () => {
|
||||
const jweRow = await Container.get(DataSource)
|
||||
.getRepository(DeploymentKey)
|
||||
.findOneByOrFail({ type: OAUTH_JWE_PRIVATE_KEY_TYPE, status: 'active' });
|
||||
const jweJwk = JSON.parse(
|
||||
Container.get(Cipher).decryptDEKWithInstanceKey(jweRow.value),
|
||||
) as JsonWebKey;
|
||||
const jweKid = jwks.keys.find((key) => key.use === 'enc')?.kid;
|
||||
expect(jweKid).toBe(jweRow.id);
|
||||
|
||||
const token = jwt.sign(claims(), createPrivateKey({ key: jweJwk, format: 'jwk' }), {
|
||||
algorithm: 'RS256',
|
||||
header: { alg: 'RS256', typ: 'at+jwt', kid: jweRow.id },
|
||||
});
|
||||
expect(decodeProtectedHeader(token).kid).toBe(jweKid);
|
||||
|
||||
await expect(
|
||||
Container.get(OAuthTokenService).verifyAccessToken(token, `${issuer}/mcp-server/http`),
|
||||
).rejects.toThrow(JWTVerificationError);
|
||||
});
|
||||
|
||||
it('accepts the same claims signed with the signing key', async () => {
|
||||
const { aud, ...rest } = claims();
|
||||
const token = Container.get(OAuthSigningKeyService).signAccessToken(rest, aud);
|
||||
|
||||
// The token is not in the database, so it passes JWT verification and
|
||||
// fails only on the lookup.
|
||||
await expect(
|
||||
Container.get(OAuthTokenService).verifyAccessToken(token, `${issuer}/mcp-server/http`),
|
||||
).rejects.toThrow(AccessTokenNotFoundError);
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -1,7 +1,5 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { CacheService } from '@n8n/backend-services';
|
||||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { DeploymentKey, DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type Repository } from '@n8n/typeorm';
|
||||
@@ -14,7 +12,6 @@ import { JwtService } from '@/services/jwt.service';
|
||||
import {
|
||||
OAUTH_SIGNING_ALGORITHM,
|
||||
OAUTH_SIGNING_KEY_TYPE,
|
||||
OAUTH_SIGNING_KEYS_CACHE_KEY,
|
||||
RETIRED_SIGNING_KEY_GRACE_MS,
|
||||
} from '../oauth-signing-key.constants';
|
||||
import { OAuthSigningKeyService } from '../oauth-signing-key.service';
|
||||
@@ -24,12 +21,11 @@ const AUDIENCE = `${ISSUER}/mcp-server/http`;
|
||||
|
||||
let keyStore: Repository<DeploymentKey>;
|
||||
|
||||
/** A process of its own: its own memory cache and its own in-memory private key. */
|
||||
/** A process of its own: its own in-memory public keys and private key. */
|
||||
const createProcess = () =>
|
||||
new OAuthSigningKeyService(
|
||||
Container.get(DeploymentKeyRepository),
|
||||
Container.get(Cipher),
|
||||
new CacheService(Container.get(GlobalConfig)),
|
||||
Container.get(Logger),
|
||||
Container.get(JwtService),
|
||||
);
|
||||
@@ -54,7 +50,6 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.resetDeploymentKeys();
|
||||
await Container.get(CacheService).delete(OAUTH_SIGNING_KEYS_CACHE_KEY);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -75,15 +70,20 @@ describe('OAuthSigningKeyService (integration)', () => {
|
||||
expect(jwk).toMatchObject({ kid: rows[0].id, use: 'sig' });
|
||||
});
|
||||
|
||||
it('keeps the key across a restart', async () => {
|
||||
it('keeps the key across a restart, and the restarted process verifies older tokens', async () => {
|
||||
const before = createProcess();
|
||||
await before.initialize();
|
||||
const token = before.signAccessToken(claims(), AUDIENCE);
|
||||
|
||||
const after = createProcess();
|
||||
await after.initialize();
|
||||
|
||||
expect(await activeRows()).toHaveLength(1);
|
||||
expect(await after.getPublicJwks()).toEqual(await before.getPublicJwks());
|
||||
const [{ kid }] = await after.getPublicJwks();
|
||||
await expect(
|
||||
after.verifyAccessToken(token, { kid, audiences: [AUDIENCE], issuer: ISSUER }),
|
||||
).resolves.toMatchObject({ sub: 'user-1' });
|
||||
});
|
||||
|
||||
it('keeps one active key when two processes initialize at the same time', async () => {
|
||||
|
||||
+242
-14
@@ -1,18 +1,24 @@
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { DeploymentKey } from '@n8n/db';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { createLocalJWKSet, jwtVerify } from 'jose';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import {
|
||||
OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
OAUTH_SIGNING_ALGORITHM,
|
||||
OAUTH_SIGNING_KEY_TYPE,
|
||||
OAUTH_SIGNING_KEYS_CACHE_KEY,
|
||||
RETIRED_SIGNING_KEY_GRACE_MS,
|
||||
SIGNING_KEYS_REFRESH_MS,
|
||||
UNKNOWN_KID_REFRESH_MS,
|
||||
} from '../oauth-signing-key.constants';
|
||||
import type { OAuthSigningKeyService } from '../oauth-signing-key.service';
|
||||
import { createSigningKeyService } from './signing-key-fixtures';
|
||||
import { createSigningKeyService, readStoredPrivateKey } from './signing-key-fixtures';
|
||||
|
||||
const ISSUER = 'https://n8n.example.com';
|
||||
const AUDIENCE = `${ISSUER}/mcp-server/http`;
|
||||
@@ -20,7 +26,7 @@ const AUDIENCE = `${ISSUER}/mcp-server/http`;
|
||||
/** The audience is passed to `signAccessToken` on its own. */
|
||||
const claims = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return { iss: ISSUER, sub: 'user-1', iat: now, exp: now + 3600 };
|
||||
return { iss: ISSUER, sub: 'user-1', iat: now, exp: now + OAUTH_ACCESS_TOKEN_TTL_SECONDS };
|
||||
};
|
||||
|
||||
const makeUniqueViolation = (code: string): QueryFailedError => {
|
||||
@@ -51,7 +57,24 @@ const makeKeyRow = (id: string, overrides: Partial<DeploymentKey> = {}): Deploym
|
||||
});
|
||||
};
|
||||
|
||||
/** Signs with a stored key that the service under test did not create. */
|
||||
const signWithRow = (row: DeploymentKey) =>
|
||||
jwt.sign(claims(), readStoredPrivateKey(row), {
|
||||
algorithm: 'ES256',
|
||||
audience: AUDIENCE,
|
||||
header: { alg: 'ES256', typ: 'at+jwt', kid: row.id },
|
||||
});
|
||||
|
||||
const verifyToken = async (service: OAuthSigningKeyService, token: string, kid: string) =>
|
||||
await service.verifyAccessToken(token, { kid, audiences: [AUDIENCE], issuer: ISSUER });
|
||||
|
||||
describe('OAuthSigningKeyService', () => {
|
||||
let publisher: Publisher;
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = mockInstance(Publisher, { publishCommand: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -64,13 +87,14 @@ describe('OAuthSigningKeyService', () => {
|
||||
await service.initialize();
|
||||
|
||||
expect(keyStore.repository.insertActiveOAuthSigningKey).not.toHaveBeenCalled();
|
||||
expect(publisher.publishCommand).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jwt.decode(service.signAccessToken(claims(), AUDIENCE), { complete: true })?.header.kid,
|
||||
).toBe('existing-key');
|
||||
});
|
||||
|
||||
it('generates a key whose kid is the row id, wraps it, and drops the cached key list', async () => {
|
||||
const { service, keyStore, cipher, cache } = createSigningKeyService();
|
||||
it('generates a key whose kid is the row id, and wraps it', async () => {
|
||||
const { service, keyStore, cipher } = createSigningKeyService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
@@ -86,7 +110,6 @@ describe('OAuthSigningKeyService', () => {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
});
|
||||
expect(cache.delete).toHaveBeenCalledWith(OAUTH_SIGNING_KEYS_CACHE_KEY);
|
||||
expect(
|
||||
jwt.decode(service.signAccessToken(claims(), AUDIENCE), { complete: true })?.header.kid,
|
||||
).toBe(id);
|
||||
@@ -141,33 +164,238 @@ describe('OAuthSigningKeyService', () => {
|
||||
expect(jwks[0]).toMatchObject({ kty: 'EC', crv: 'P-256', alg: 'ES256', use: 'sig' });
|
||||
});
|
||||
|
||||
it('neither publishes nor verifies with a signing-key row that is not ES256', async () => {
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const privateJwk = {
|
||||
...privateKey.export({ format: 'jwk' }),
|
||||
kid: 'rs256-key',
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
};
|
||||
keyStore.rows.push(
|
||||
makeKeyRow('rs256-key', {
|
||||
algorithm: 'RS256',
|
||||
value: `wrapped:${JSON.stringify(privateJwk)}`,
|
||||
}),
|
||||
);
|
||||
await service.initialize();
|
||||
const token = jwt.sign(claims(), privateKey, {
|
||||
algorithm: 'RS256',
|
||||
audience: AUDIENCE,
|
||||
header: { alg: 'RS256', typ: 'at+jwt', kid: 'rs256-key' },
|
||||
});
|
||||
|
||||
expect((await service.getPublicJwks()).map((k) => k.kid)).not.toContain('rs256-key');
|
||||
await expect(
|
||||
service.verifyAccessToken(token, { kid: 'rs256-key', audiences: [AUDIENCE], issuer: ISSUER }),
|
||||
).rejects.toThrow('kid is unknown');
|
||||
});
|
||||
|
||||
describe('retired keys', () => {
|
||||
const setUp = async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
keyStore.rows.push(makeKeyRow('retired', { status: 'inactive', updatedAt: new Date() }));
|
||||
const retired = makeKeyRow('retired', { status: 'inactive', updatedAt: new Date() });
|
||||
keyStore.rows.push(retired);
|
||||
await service.initialize();
|
||||
return { service };
|
||||
const token = jwt.sign(claims(), readStoredPrivateKey(retired), {
|
||||
algorithm: 'ES256',
|
||||
audience: AUDIENCE,
|
||||
header: { alg: 'ES256', typ: 'at+jwt', kid: 'retired' },
|
||||
});
|
||||
return { service, token };
|
||||
};
|
||||
|
||||
const publishedKids = async (service: OAuthSigningKeyService) =>
|
||||
(await service.getPublicJwks()).map((k) => k.kid);
|
||||
|
||||
it('stay published within the grace window', async () => {
|
||||
const { service } = await setUp();
|
||||
const verify = async (service: OAuthSigningKeyService, token: string) =>
|
||||
await service.verifyAccessToken(token, {
|
||||
kid: 'retired',
|
||||
audiences: [AUDIENCE],
|
||||
issuer: ISSUER,
|
||||
});
|
||||
|
||||
it('stay published and verifiable within the grace window', async () => {
|
||||
const { service, token } = await setUp();
|
||||
|
||||
// A token signed at retirement verifies until it expires.
|
||||
vi.advanceTimersByTime(OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000 - 1000);
|
||||
await expect(verify(service, token)).resolves.toMatchObject({ sub: 'user-1' });
|
||||
|
||||
// One second before the window closes.
|
||||
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS - 1000);
|
||||
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS - OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000);
|
||||
expect(await publishedKids(service)).toContain('retired');
|
||||
});
|
||||
|
||||
it('drop out after the grace window, even from a cached key list', async () => {
|
||||
const { service } = await setUp();
|
||||
// Fill the cache while the retired key is still in its window.
|
||||
it('drop out after the grace window', async () => {
|
||||
const { service, token } = await setUp();
|
||||
// Read the list while the retired key is still in its window.
|
||||
await service.getPublicJwks();
|
||||
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS + 1000);
|
||||
|
||||
expect(await publishedKids(service)).not.toContain('retired');
|
||||
await expect(verify(service, token)).rejects.toThrow('kid is unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-memory key list', () => {
|
||||
const setUp = async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
await service.initialize();
|
||||
return {
|
||||
service,
|
||||
keyStore,
|
||||
kid: keyStore.rows[0].id,
|
||||
token: service.signAccessToken(claims(), AUDIENCE),
|
||||
reads: keyStore.repository.findOAuthSigningKeys,
|
||||
};
|
||||
};
|
||||
|
||||
it('reads the rows once for all verifications within the refresh window', async () => {
|
||||
const { service, kid, token, reads } = await setUp();
|
||||
|
||||
await verifyToken(service, token, kid);
|
||||
vi.advanceTimersByTime(SIGNING_KEYS_REFRESH_MS - 1);
|
||||
await verifyToken(service, token, kid);
|
||||
await service.getPublicJwks();
|
||||
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the rows again after the refresh window', async () => {
|
||||
const { service, kid, token, reads } = await setUp();
|
||||
|
||||
await verifyToken(service, token, kid);
|
||||
vi.advanceTimersByTime(SIGNING_KEYS_REFRESH_MS);
|
||||
await verifyToken(service, token, kid);
|
||||
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the rows again for an unknown kid at most once per cooldown', async () => {
|
||||
const { service, kid, token, reads } = await setUp();
|
||||
await verifyToken(service, token, kid);
|
||||
vi.advanceTimersByTime(UNKNOWN_KID_REFRESH_MS);
|
||||
|
||||
await expect(verifyToken(service, token, 'unknown-1')).rejects.toThrow('kid is unknown');
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(UNKNOWN_KID_REFRESH_MS - 1);
|
||||
await expect(verifyToken(service, token, 'unknown-2')).rejects.toThrow('kid is unknown');
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares one read between concurrent verifications', async () => {
|
||||
const { service, kid, token, reads } = await setUp();
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 5 }, async () => await verifyToken(service, token, kid)),
|
||||
);
|
||||
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload across processes', () => {
|
||||
it('tells the other processes when it generates a key', async () => {
|
||||
const { service } = createSigningKeyService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(publisher.publishCommand).toHaveBeenCalledTimes(1);
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'reload-oauth-signing-keys',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not tell the other processes when it loses the insert race', async () => {
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
keyStore.repository.insertActiveOAuthSigningKey.mockImplementation(async () => {
|
||||
keyStore.rows.push(makeKeyRow('winner'));
|
||||
throw makeUniqueViolation('23505');
|
||||
});
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(publisher.publishCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a failed publish and still initializes', async () => {
|
||||
const { service, logger } = createSigningKeyService();
|
||||
const error = new Error('Connection is closed.');
|
||||
vi.mocked(publisher.publishCommand).mockRejectedValue(error);
|
||||
|
||||
await expect(service.initialize()).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), { error });
|
||||
expect(() => service.signAccessToken(claims(), AUDIENCE)).not.toThrow();
|
||||
});
|
||||
|
||||
it('reads the rows again after a reload, within both refresh windows', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
await service.initialize();
|
||||
await service.getPublicJwks();
|
||||
// Another process creates a key after this process read the list.
|
||||
const newKey = makeKeyRow('new-key');
|
||||
keyStore.rows.push(newKey);
|
||||
const token = signWithRow(newKey);
|
||||
|
||||
await expect(verifyToken(service, token, newKey.id)).rejects.toThrow('kid is unknown');
|
||||
|
||||
service.reloadPublishedKeys();
|
||||
|
||||
await expect(verifyToken(service, token, newKey.id)).resolves.toMatchObject({
|
||||
sub: 'user-1',
|
||||
});
|
||||
expect(keyStore.repository.findOAuthSigningKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not keep a read that started before a reload', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
await service.initialize();
|
||||
const rowsBeforeInsert = [...keyStore.rows];
|
||||
const staleRead = createDeferredPromise<DeploymentKey[]>();
|
||||
keyStore.repository.findOAuthSigningKeys.mockReturnValueOnce(staleRead.promise);
|
||||
const inFlight = service.getPublicJwks();
|
||||
|
||||
// Another process creates a key and broadcasts while the read is in flight.
|
||||
const newKey = makeKeyRow('new-key');
|
||||
keyStore.rows.push(newKey);
|
||||
service.reloadPublishedKeys();
|
||||
staleRead.resolve(rowsBeforeInsert);
|
||||
await inFlight;
|
||||
|
||||
await expect(verifyToken(service, signWithRow(newKey), newKey.id)).resolves.toMatchObject({
|
||||
sub: 'user-1',
|
||||
});
|
||||
expect(keyStore.repository.findOAuthSigningKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps sharing the read that started after a reload', async () => {
|
||||
const { service, keyStore } = createSigningKeyService();
|
||||
await service.initialize();
|
||||
const reads = keyStore.repository.findOAuthSigningKeys;
|
||||
const readBeforeReload = createDeferredPromise<DeploymentKey[]>();
|
||||
const readAfterReload = createDeferredPromise<DeploymentKey[]>();
|
||||
reads
|
||||
.mockReturnValueOnce(readBeforeReload.promise)
|
||||
.mockReturnValueOnce(readAfterReload.promise);
|
||||
|
||||
const first = service.getPublicJwks();
|
||||
service.reloadPublishedKeys();
|
||||
const second = service.getPublicJwks();
|
||||
readBeforeReload.resolve([...keyStore.rows]);
|
||||
await first;
|
||||
const third = service.getPublicJwks();
|
||||
readAfterReload.resolve([...keyStore.rows]);
|
||||
await Promise.all([second, third]);
|
||||
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InvalidTargetError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import type { Mocked } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import jwt, { type SignOptions } from 'jsonwebtoken';
|
||||
import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
@@ -8,6 +8,8 @@ import type { OperationContext, TransactionRunner, User } from '@n8n/db';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { mock, type MockProxy } from 'vitest-mock-extended';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import type { KeyObject } from 'node:crypto';
|
||||
import { createHmac, createPublicKey, generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
|
||||
@@ -15,7 +17,16 @@ import type { AccessToken } from '../database/entities/oauth-access-token.entity
|
||||
import type { RefreshToken } from '../database/entities/oauth-refresh-token.entity';
|
||||
import { AccessTokenRepository } from '../database/repositories/oauth-access-token.repository';
|
||||
import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository';
|
||||
import { OAUTH_ACCESS_TOKEN_TTL_SECONDS } from '../oauth-signing-key.constants';
|
||||
import { OAuthTokenService } from '../oauth-token.service';
|
||||
import { JWTVerificationError } from '../oauth.errors';
|
||||
import {
|
||||
createSigningKeyService,
|
||||
es256Signer,
|
||||
forgeJwt,
|
||||
readStoredPrivateKey,
|
||||
rs256Signer,
|
||||
} from './signing-key-fixtures';
|
||||
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
|
||||
import type { McpConfig } from '@/modules/mcp/mcp.config';
|
||||
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
|
||||
@@ -23,8 +34,17 @@ import { ProtectedResourceRegistry } from '@/services/protected-resource.registr
|
||||
import type { UrlService } from '@n8n/backend-services';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
const LEGACY_HMAC_SECRET = 'legacy-hmac-secret';
|
||||
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
|
||||
const jwtService = new JwtService(instanceSettings, mock(), mock());
|
||||
const jwtService = new JwtService(
|
||||
instanceSettings,
|
||||
mock<GlobalConfig>({ userManagement: { jwtSecret: LEGACY_HMAC_SECRET } }),
|
||||
mock(),
|
||||
);
|
||||
|
||||
/** Signs an access token the way n8n did before ES256: HS256 with the HMAC secret. */
|
||||
const signLegacyHmac = (payload: object, audience: string, options: SignOptions = {}) =>
|
||||
jwt.sign(payload, LEGACY_HMAC_SECRET, { algorithm: 'HS256', ...options, audience });
|
||||
|
||||
let logger: Mocked<Logger>;
|
||||
let userRepository: Mocked<UserRepository>;
|
||||
@@ -34,6 +54,8 @@ let urlService: MockProxy<UrlService>;
|
||||
let service: OAuthTokenService;
|
||||
let txRunner: MockProxy<TransactionRunner>;
|
||||
const workflowFinderService = mock<WorkflowFinderService>();
|
||||
const signingKeys = createSigningKeyService();
|
||||
const signingKeyService = signingKeys.service;
|
||||
|
||||
const TEST_BASE_URL = 'https://n8n.example.com';
|
||||
const TEST_RESOURCE_URL = `${TEST_BASE_URL}/mcp-server/http`;
|
||||
@@ -52,7 +74,9 @@ registry.register({
|
||||
});
|
||||
|
||||
describe('OAuthTokenService', () => {
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
await signingKeyService.initialize();
|
||||
|
||||
logger = mockInstance(Logger);
|
||||
userRepository = mockInstance(UserRepository);
|
||||
accessTokenRepository = mockInstance(AccessTokenRepository) as Mocked<AccessTokenRepository>;
|
||||
@@ -77,6 +101,7 @@ describe('OAuthTokenService', () => {
|
||||
txRunner,
|
||||
workflowFinderService,
|
||||
urlService,
|
||||
signingKeyService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,8 +134,11 @@ describe('OAuthTokenService', () => {
|
||||
expect(decoded.exp).toBeDefined();
|
||||
|
||||
const fullToken = jwt.decode(accessToken, { complete: true });
|
||||
expect(fullToken?.header.typ).toBe('at+jwt');
|
||||
expect(fullToken?.header.alg).toBe('HS256');
|
||||
expect(fullToken?.header).toEqual({
|
||||
alg: 'ES256',
|
||||
typ: 'at+jwt',
|
||||
kid: signingKeys.keyStore.rows[0].id,
|
||||
});
|
||||
|
||||
expect(refreshToken).toHaveLength(64); // 32 bytes hex = 64 characters
|
||||
expect(refreshToken).toMatch(/^[a-f0-9]{64}$/);
|
||||
@@ -371,6 +399,7 @@ describe('OAuthTokenService', () => {
|
||||
txRunner,
|
||||
workflowFinderService,
|
||||
urlService,
|
||||
signingKeyService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -475,7 +504,7 @@ describe('OAuthTokenService', () => {
|
||||
});
|
||||
|
||||
it('should throw error for wrong audience', async () => {
|
||||
const wrongAudienceToken = jwtService.signForResource(
|
||||
const wrongAudienceToken = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
'wrong-audience',
|
||||
); // Matches neither legacy literal nor resource URL;
|
||||
@@ -487,7 +516,7 @@ describe('OAuthTokenService', () => {
|
||||
|
||||
it('should accept tokens with canonical audience when expected audience is provided', async () => {
|
||||
const audience = 'https://n8n.example.com/mcp-server/http';
|
||||
const canonicalAudienceToken = jwtService.signForResource(
|
||||
const canonicalAudienceToken = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
audience,
|
||||
);
|
||||
@@ -512,7 +541,7 @@ describe('OAuthTokenService', () => {
|
||||
|
||||
it('should accept legacy audience when expected audience is provided', async () => {
|
||||
const audience = 'https://n8n.example.com/mcp-server/http';
|
||||
const legacyAudienceToken = jwtService.signForResource(
|
||||
const legacyAudienceToken = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
'mcp-server-api',
|
||||
);
|
||||
@@ -536,10 +565,7 @@ describe('OAuthTokenService', () => {
|
||||
it('should accept token whose aud is the legacy literal (backward compat)', async () => {
|
||||
const userId = 'user-123';
|
||||
const clientId = 'client-456';
|
||||
const legacyToken = jwtService.signForResource(
|
||||
{ sub: userId, client_id: clientId },
|
||||
'mcp-server-api',
|
||||
);
|
||||
const legacyToken = signLegacyHmac({ sub: userId, client_id: clientId }, 'mcp-server-api');
|
||||
accessTokenRepository.findOne.mockResolvedValue(
|
||||
mock<AccessToken>({ token: legacyToken, clientId, userId }),
|
||||
);
|
||||
@@ -553,10 +579,7 @@ describe('OAuthTokenService', () => {
|
||||
it('should accept token whose aud is the resource URL', async () => {
|
||||
const userId = 'user-123';
|
||||
const clientId = 'client-456';
|
||||
const urlToken = jwtService.signForResource(
|
||||
{ sub: userId, client_id: clientId },
|
||||
TEST_RESOURCE_URL,
|
||||
);
|
||||
const urlToken = signLegacyHmac({ sub: userId, client_id: clientId }, TEST_RESOURCE_URL);
|
||||
accessTokenRepository.findOne.mockResolvedValue(
|
||||
mock<AccessToken>({ token: urlToken, clientId, userId }),
|
||||
);
|
||||
@@ -792,11 +815,12 @@ describe('OAuthTokenService', () => {
|
||||
txRunner,
|
||||
workflowFinderService,
|
||||
urlService,
|
||||
signingKeyService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a token whose aud belongs to another resource', async () => {
|
||||
const tokenForResourceA = jwtService.signForResource(
|
||||
const tokenForResourceA = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
RESOURCE_A_URL,
|
||||
);
|
||||
@@ -807,7 +831,7 @@ describe('OAuthTokenService', () => {
|
||||
});
|
||||
|
||||
it('should not accept the legacy audience at a non-default resource', async () => {
|
||||
const legacyToken = jwtService.signForResource(
|
||||
const legacyToken = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
LEGACY_AUDIENCE,
|
||||
);
|
||||
@@ -818,7 +842,7 @@ describe('OAuthTokenService', () => {
|
||||
});
|
||||
|
||||
it('should accept a token at its own resource', async () => {
|
||||
const tokenForResourceB = jwtService.signForResource(
|
||||
const tokenForResourceB = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
RESOURCE_B_URL,
|
||||
);
|
||||
@@ -832,7 +856,7 @@ describe('OAuthTokenService', () => {
|
||||
});
|
||||
|
||||
it('should still accept the legacy audience at the default (instance MCP) resource', async () => {
|
||||
const legacyToken = jwtService.signForResource(
|
||||
const legacyToken = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
LEGACY_AUDIENCE,
|
||||
);
|
||||
@@ -873,6 +897,7 @@ describe('OAuthTokenService', () => {
|
||||
txRunner,
|
||||
workflowFinderService,
|
||||
urlService,
|
||||
signingKeyService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -900,7 +925,7 @@ describe('OAuthTokenService', () => {
|
||||
it('treats a token without a scope claim as having no scopes', async () => {
|
||||
// cannot occur legitimately: migration 1784000000047 deleted every
|
||||
// access token minted before scoping shipped
|
||||
const legacyToken = jwtService.signForResource(
|
||||
const legacyToken = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
TEST_RESOURCE_URL,
|
||||
);
|
||||
@@ -993,6 +1018,7 @@ describe('OAuthTokenService', () => {
|
||||
txRunner,
|
||||
workflowFinderService,
|
||||
urlService,
|
||||
signingKeyService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1001,10 +1027,7 @@ describe('OAuthTokenService', () => {
|
||||
['the instance-base-URL-derived resource URL', TEST_RESOURCE_URL],
|
||||
['the legacy audience', LEGACY_AUDIENCE],
|
||||
])('should accept a token whose aud is %s', async (_, audience) => {
|
||||
const token = jwtService.signForResource(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
audience,
|
||||
);
|
||||
const token = signLegacyHmac({ sub: 'user-123', client_id: 'client-456' }, audience);
|
||||
accessTokenRepository.findOne.mockResolvedValue(
|
||||
mock<AccessToken>({ token, clientId: 'client-456', userId: 'user-123' }),
|
||||
);
|
||||
@@ -1015,7 +1038,7 @@ describe('OAuthTokenService', () => {
|
||||
});
|
||||
|
||||
it('should reject a token whose aud is an unconfigured host', async () => {
|
||||
const token = jwtService.signForResource(
|
||||
const token = signLegacyHmac(
|
||||
{ sub: 'user-123', client_id: 'client-456' },
|
||||
'https://other.example.com/mcp-server/http',
|
||||
);
|
||||
@@ -1025,6 +1048,237 @@ describe('OAuthTokenService', () => {
|
||||
).rejects.toThrow('JWT Verification Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ES256 access tokens', () => {
|
||||
const USER_ID = 'user-123';
|
||||
const CLIENT_ID = 'client-456';
|
||||
const ATTACKER_KEY = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
let kid: string;
|
||||
let privateKey: KeyObject;
|
||||
|
||||
const validClaims = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
iss: TEST_BASE_URL,
|
||||
sub: USER_ID,
|
||||
aud: TEST_RESOURCE_URL,
|
||||
client_id: CLIENT_ID,
|
||||
jti: 'jti-1',
|
||||
iat: now,
|
||||
exp: now + OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
scope: '',
|
||||
meta: { isOAuth: true },
|
||||
};
|
||||
};
|
||||
|
||||
const signEs256 = (claims: Record<string, unknown>, header: Record<string, unknown> = {}) =>
|
||||
forgeJwt({ alg: 'ES256', typ: 'at+jwt', kid, ...header }, claims, es256Signer(privateKey));
|
||||
|
||||
const hmacSigner = (secret: string | Buffer) => (signingInput: string) =>
|
||||
createHmac('sha256', secret).update(signingInput).digest();
|
||||
|
||||
/** Signs with the HMAC secret, the way n8n minted access tokens before ES256. */
|
||||
const signHmac = (options: SignOptions) => {
|
||||
const { aud, ...claims } = validClaims();
|
||||
return signLegacyHmac(claims, aud, options);
|
||||
};
|
||||
|
||||
const storeToken = (token: string) => {
|
||||
accessTokenRepository.findOne.mockResolvedValue(
|
||||
mock<AccessToken>({ token, clientId: CLIENT_ID, userId: USER_ID }),
|
||||
);
|
||||
};
|
||||
|
||||
/** The token is in the database, so only its signature and claims can fail it. */
|
||||
const expectRejected = async (token: string) => {
|
||||
storeToken(token);
|
||||
await expect(service.verifyAccessToken(token, TEST_RESOURCE_URL)).rejects.toThrow(
|
||||
JWTVerificationError,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
const [row] = signingKeys.keyStore.rows;
|
||||
kid = row.id;
|
||||
privateKey = readStoredPrivateKey(row);
|
||||
});
|
||||
|
||||
describe('with a kid', () => {
|
||||
it('verifies against the key the kid names and never against the HMAC secret', async () => {
|
||||
const { accessToken } = service.generateTokenPair(USER_ID, CLIENT_ID, undefined, []);
|
||||
storeToken(accessToken);
|
||||
const signingVerify = vi.spyOn(signingKeyService, 'verifyAccessToken');
|
||||
const hmacVerify = vi.spyOn(jwtService, 'verifyLegacyHmacAccessToken');
|
||||
|
||||
try {
|
||||
await expect(
|
||||
service.verifyAccessToken(accessToken, TEST_RESOURCE_URL),
|
||||
).resolves.toMatchObject({ clientId: CLIENT_ID, extra: { userId: USER_ID } });
|
||||
expect(signingVerify).toHaveBeenCalledWith(
|
||||
accessToken,
|
||||
expect.objectContaining({ kid, issuer: TEST_BASE_URL }),
|
||||
);
|
||||
expect(hmacVerify).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
signingVerify.mockRestore();
|
||||
hmacVerify.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each<[string, Record<string, unknown>, Record<string, unknown>]>([
|
||||
['a wrong aud', { aud: 'https://other.example.com/mcp' }, {}],
|
||||
['a wrong iss', { iss: 'https://attacker.example.com' }, {}],
|
||||
['a wrong typ', {}, { typ: 'JWT' }],
|
||||
['an expired exp', { exp: 1 }, {}],
|
||||
])('rejects a token with %s', async (_, claimOverrides, headerOverrides) => {
|
||||
await expectRejected(signEs256({ ...validClaims(), ...claimOverrides }, headerOverrides));
|
||||
});
|
||||
});
|
||||
|
||||
describe('algorithm confusion', () => {
|
||||
it('rejects HS256 signed with the public key PEM as the secret', async () => {
|
||||
const pem = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
|
||||
|
||||
await expectRejected(
|
||||
forgeJwt({ alg: 'HS256', typ: 'at+jwt', kid }, validClaims(), hmacSigner(pem)),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects HS256 signed with the public JWK JSON as the secret', async () => {
|
||||
const [publicJwk] = await signingKeyService.getPublicJwks();
|
||||
|
||||
await expectRejected(
|
||||
forgeJwt(
|
||||
{ alg: 'HS256', typ: 'at+jwt', kid },
|
||||
validClaims(),
|
||||
hmacSigner(JSON.stringify(publicJwk)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a valid HMAC token with a kid added', async () => {
|
||||
const token = signHmac({
|
||||
keyid: kid,
|
||||
header: { alg: 'HS256', typ: 'at+jwt' },
|
||||
});
|
||||
|
||||
await expectRejected(token);
|
||||
});
|
||||
|
||||
it('rejects alg none', async () => {
|
||||
await expectRejected(forgeJwt({ alg: 'none', typ: 'at+jwt', kid }, validClaims(), null));
|
||||
});
|
||||
|
||||
it('rejects ES384 in the header over a signature from the real key', async () => {
|
||||
await expectRejected(
|
||||
forgeJwt({ alg: 'ES384', typ: 'at+jwt', kid }, validClaims(), es256Signer(privateKey)),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects RS256 signed with an RSA key under the real kid', async () => {
|
||||
const { privateKey: rsaKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
|
||||
await expectRejected(
|
||||
forgeJwt({ alg: 'RS256', typ: 'at+jwt', kid }, validClaims(), rs256Signer(rsaKey)),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects another P-256 key under the real kid', async () => {
|
||||
await expectRejected(
|
||||
forgeJwt(
|
||||
{ alg: 'ES256', typ: 'at+jwt', kid },
|
||||
validClaims(),
|
||||
es256Signer(ATTACKER_KEY.privateKey),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a P-384 key under the real kid', async () => {
|
||||
const { privateKey: p384Key } = generateKeyPairSync('ec', { namedCurve: 'P-384' });
|
||||
|
||||
await expectRejected(
|
||||
forgeJwt({ alg: 'ES256', typ: 'at+jwt', kid }, validClaims(), es256Signer(p384Key)),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an all-zero signature', async () => {
|
||||
await expectRejected(
|
||||
forgeJwt({ alg: 'ES256', typ: 'at+jwt', kid }, validClaims(), () => Buffer.alloc(64)),
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, unknown]>([
|
||||
['an unknown kid', 'unknown-kid'],
|
||||
['an empty kid', ''],
|
||||
['a numeric kid', 123],
|
||||
['an object kid', { id: 'x' }],
|
||||
])('rejects %s', async (_, badKid) => {
|
||||
await expectRejected(signEs256(validClaims(), { kid: badKid }));
|
||||
});
|
||||
|
||||
it('ignores an embedded jwk header', async () => {
|
||||
const jwk = ATTACKER_KEY.publicKey.export({ format: 'jwk' });
|
||||
|
||||
await expectRejected(
|
||||
forgeJwt(
|
||||
{ alg: 'ES256', typ: 'at+jwt', kid, jwk },
|
||||
validClaims(),
|
||||
es256Signer(ATTACKER_KEY.privateKey),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a jku header', async () => {
|
||||
await expectRejected(
|
||||
forgeJwt(
|
||||
{ alg: 'ES256', typ: 'at+jwt', kid, jku: 'https://attacker.example.com/jwks.json' },
|
||||
validClaims(),
|
||||
es256Signer(ATTACKER_KEY.privateKey),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a kid', () => {
|
||||
it('rejects an ES256 token whose kid was removed', async () => {
|
||||
await expectRejected(
|
||||
forgeJwt({ alg: 'ES256', typ: 'at+jwt' }, validClaims(), es256Signer(privateKey)),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a legacy HS256 token until it expires', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
// Minted the way n8n did before ES256.
|
||||
const accessToken = signHmac({
|
||||
header: { typ: 'at+jwt', alg: 'HS256' },
|
||||
});
|
||||
storeToken(accessToken);
|
||||
|
||||
await expect(
|
||||
service.verifyAccessToken(accessToken, TEST_RESOURCE_URL),
|
||||
).resolves.toMatchObject({ clientId: CLIENT_ID });
|
||||
|
||||
vi.advanceTimersByTime((OAUTH_ACCESS_TOKEN_TTL_SECONDS + 1) * 1000);
|
||||
|
||||
await expect(service.verifyAccessToken(accessToken, TEST_RESOURCE_URL)).rejects.toThrow(
|
||||
JWTVerificationError,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects HS512', async () => {
|
||||
const token = signHmac({
|
||||
algorithm: 'HS512',
|
||||
header: { alg: 'HS512', typ: 'at+jwt' },
|
||||
});
|
||||
|
||||
await expectRejected(token);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
import type { PostHogClient } from '@/posthog';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { CacheService } from '@n8n/backend-services';
|
||||
import type { DeploymentKey, DeploymentKeyRepository } from '@n8n/db';
|
||||
import type { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import type { JsonWebKey, KeyObject } from 'node:crypto';
|
||||
import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
@@ -21,22 +22,6 @@ export const createTaggingCipher = () => {
|
||||
return cipher;
|
||||
};
|
||||
|
||||
/** A cache backed by a map, with the `refreshFn` semantics of `CacheService.get`. */
|
||||
export const createMapCache = () => {
|
||||
const store = new Map<string, unknown>();
|
||||
const cache = mock<CacheService>();
|
||||
cache.get.mockImplementation(async (key, options) => {
|
||||
if (store.has(key)) return store.get(key);
|
||||
const value = await options?.refreshFn?.(key);
|
||||
if (value !== undefined) store.set(key, value);
|
||||
return value;
|
||||
});
|
||||
cache.delete.mockImplementation(async (key) => {
|
||||
store.delete(key);
|
||||
});
|
||||
return { cache, store };
|
||||
};
|
||||
|
||||
/** `deployment_key` rows in memory, behind the repository methods the service calls. */
|
||||
export const createDeploymentKeyStore = () => {
|
||||
const rows: DeploymentKey[] = [];
|
||||
@@ -72,18 +57,41 @@ export const createDeploymentKeyStore = () => {
|
||||
export const createSigningKeyService = () => {
|
||||
const keyStore = createDeploymentKeyStore();
|
||||
const cipher = createTaggingCipher();
|
||||
const { cache, store: cacheStore } = createMapCache();
|
||||
const jwtService = new JwtService(
|
||||
mock<InstanceSettings>({ encryptionKey: 'test-key' }),
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
const service = new OAuthSigningKeyService(
|
||||
keyStore.repository,
|
||||
cipher,
|
||||
cache,
|
||||
mock<Logger>(),
|
||||
jwtService,
|
||||
);
|
||||
return { service, keyStore, cipher, cache, cacheStore };
|
||||
const logger = mock<Logger>();
|
||||
const service = new OAuthSigningKeyService(keyStore.repository, cipher, logger, jwtService);
|
||||
return { service, keyStore, cipher, logger };
|
||||
};
|
||||
|
||||
/** Reads a stored private key back, e.g. to sign tokens the service would not sign. */
|
||||
export const readStoredPrivateKey = (row: DeploymentKey): KeyObject => {
|
||||
const jwk = JSON.parse(row.value.slice(WRAP_PREFIX.length)) as JsonWebKey;
|
||||
return createPrivateKey({ key: jwk, format: 'jwk' });
|
||||
};
|
||||
|
||||
const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
|
||||
/**
|
||||
* Builds a compact JWS by hand, so a test can set any header and signature
|
||||
* that a JWT library would refuse to produce.
|
||||
*/
|
||||
export const forgeJwt = (
|
||||
header: Record<string, unknown>,
|
||||
payload: Record<string, unknown>,
|
||||
signWith: ((signingInput: string) => Buffer) | null,
|
||||
): string => {
|
||||
const signingInput = `${base64url(header)}.${base64url(payload)}`;
|
||||
const signature = signWith ? signWith(signingInput).toString('base64url') : '';
|
||||
return `${signingInput}.${signature}`;
|
||||
};
|
||||
|
||||
export const rs256Signer = (privateKey: KeyObject) => (signingInput: string) =>
|
||||
cryptoSign('sha256', Buffer.from(signingInput), privateKey);
|
||||
|
||||
/** JWS carries an ECDSA signature as raw `r || s`, not DER (RFC 7518 §3.4). */
|
||||
export const es256Signer = (privateKey: KeyObject) => (signingInput: string) =>
|
||||
cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
|
||||
|
||||
@@ -28,8 +28,8 @@ export class OAuthServerModule implements ModuleInterface {
|
||||
}
|
||||
|
||||
// Main and webhook processes mint access tokens (Form and Chat flows mint
|
||||
// on webhook processes), so they load the private signing key. Workers never
|
||||
// mint.
|
||||
// on webhook processes), so they load the private signing key. Workers only
|
||||
// verify, and load the public keys on first use.
|
||||
if (instanceType === 'main' || instanceType === 'webhook') {
|
||||
const { OAuthSigningKeyService } = await import('./oauth-signing-key.service.js');
|
||||
await Container.get(OAuthSigningKeyService).initialize();
|
||||
|
||||
@@ -7,11 +7,17 @@ export const OAUTH_SIGNING_ALGORITHM = 'ES256';
|
||||
export const OAUTH_SIGNING_KEY_USE = 'sig';
|
||||
export const OAUTH_SIGNING_CURVE = 'P-256';
|
||||
|
||||
/** How long a process uses its in-memory list of public keys before it reads the list again. */
|
||||
export const SIGNING_KEYS_REFRESH_MS = 5 * Time.minutes.toMilliseconds;
|
||||
|
||||
/**
|
||||
* Public signing keys only. The private key stays in process memory.
|
||||
* Delete this entry whenever a signing key is added or changes status.
|
||||
* An unknown `kid` reads the list again only when the list is older than
|
||||
* this, so tokens with made-up kids cannot load the database.
|
||||
*/
|
||||
export const OAUTH_SIGNING_KEYS_CACHE_KEY = 'oauth-server:signing-keys';
|
||||
export const UNKNOWN_KID_REFRESH_MS = 30 * Time.seconds.toMilliseconds;
|
||||
|
||||
/** RFC 9068 §2.1 `typ` values. */
|
||||
export const ACCESS_TOKEN_TYPES = ['at+jwt', 'application/at+jwt'] as const;
|
||||
|
||||
export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 1 * Time.hours.toSeconds;
|
||||
|
||||
|
||||
@@ -1,42 +1,59 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { CacheService } from '@n8n/backend-services';
|
||||
import { DeploymentKeyRepository, isUniqueConstraintError } from '@n8n/db';
|
||||
import type { DeploymentKey } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { generateNanoId } from '@n8n/utils/generate-nano-id';
|
||||
import type { JWK } from 'jose';
|
||||
import { JsonWebTokenError } from 'jsonwebtoken';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
||||
import type { JsonWebKey, KeyObject } from 'node:crypto';
|
||||
import { createPrivateKey, generateKeyPair } from 'node:crypto';
|
||||
import { createPrivateKey, createPublicKey, generateKeyPair } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { JwtService, type JwtPayload } from '@/services/jwt.service';
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN_TYPES,
|
||||
OAUTH_SIGNING_ALGORITHM,
|
||||
OAUTH_SIGNING_CURVE,
|
||||
OAUTH_SIGNING_KEY_USE,
|
||||
OAUTH_SIGNING_KEYS_CACHE_KEY,
|
||||
RETIRED_SIGNING_KEY_GRACE_MS,
|
||||
SIGNING_KEYS_REFRESH_MS,
|
||||
UNKNOWN_KID_REFRESH_MS,
|
||||
} from './oauth-signing-key.constants';
|
||||
|
||||
const generateKeyPairAsync = promisify(generateKeyPair);
|
||||
|
||||
/** A signing key as the shared cache holds it. It never holds the private key. */
|
||||
/** A signing key as process memory holds it. It never holds the private key. */
|
||||
type PublishedSigningKey = {
|
||||
kid: string;
|
||||
publicJwk: JWK;
|
||||
publicKey: KeyObject;
|
||||
active: boolean;
|
||||
/** Epoch ms of the last row update. For an inactive key, this is when it was retired. */
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type PublishedKeyList = {
|
||||
keys: PublishedSigningKey[];
|
||||
/** Epoch ms of the database read. */
|
||||
readAt: number;
|
||||
};
|
||||
|
||||
type ActiveSigningKey = {
|
||||
kid: string;
|
||||
privateKey: KeyObject;
|
||||
};
|
||||
|
||||
type VerifyAccessTokenOptions = {
|
||||
/** The `kid` header value. Its type is not checked yet. */
|
||||
kid: unknown;
|
||||
audiences: string[];
|
||||
issuer: string;
|
||||
};
|
||||
|
||||
/** The only JWK members safe to publish for an EC signing key. */
|
||||
const PUBLIC_JWK_FIELDS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const;
|
||||
|
||||
@@ -47,17 +64,25 @@ const PUBLIC_JWK_FIELDS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const
|
||||
* `deployment_key.id` are the same nanoid, as for the JWE keys.
|
||||
*
|
||||
* Signing is synchronous: {@link initialize} loads the private key into
|
||||
* memory once. The public keys are read through the shared cache, so any
|
||||
* process can publish them without the private key.
|
||||
* memory once. Each process also keeps the public keys in memory, so
|
||||
* verification does not call Redis or the database for each token. Workers
|
||||
* read the public keys on first use and never keep a private key. A process
|
||||
* that creates a key tells the others to drop their list.
|
||||
*/
|
||||
@Service()
|
||||
export class OAuthSigningKeyService {
|
||||
private activeKey: ActiveSigningKey | null = null;
|
||||
|
||||
private publishedKeys: PublishedKeyList | null = null;
|
||||
|
||||
private pendingRead: Promise<PublishedKeyList> | null = null;
|
||||
|
||||
/** Goes up on each reload, so a read that started before it is not kept. */
|
||||
private keysGeneration = 0;
|
||||
|
||||
constructor(
|
||||
private readonly deploymentKeyRepository: DeploymentKeyRepository,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly logger: Logger,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
@@ -93,7 +118,7 @@ export class OAuthSigningKeyService {
|
||||
}
|
||||
|
||||
const { kid, privateKey } = this.activeKey;
|
||||
return this.jwtService.signForResourceWithKey(payload, audience, privateKey, {
|
||||
return this.jwtService.signForResource(payload, audience, privateKey, {
|
||||
algorithm: OAUTH_SIGNING_ALGORITHM,
|
||||
header: { alg: OAUTH_SIGNING_ALGORITHM, typ: 'at+jwt', kid },
|
||||
});
|
||||
@@ -102,37 +127,112 @@ export class OAuthSigningKeyService {
|
||||
/**
|
||||
* Public JWKs of the active key and of the keys retired within
|
||||
* {@link RETIRED_SIGNING_KEY_GRACE_MS}. The window is checked on every
|
||||
* call, so a cached list does not keep a retired key alive.
|
||||
* call, so the in-memory list does not keep a retired key alive.
|
||||
*/
|
||||
async getPublicJwks(): Promise<JWK[]> {
|
||||
const keys = await this.loadPublishedKeys();
|
||||
return keys.map((key) => key.publicJwk);
|
||||
}
|
||||
|
||||
private async loadPublishedKeys(): Promise<PublishedSigningKey[]> {
|
||||
const keys = await this.cacheService.get<PublishedSigningKey[]>(OAUTH_SIGNING_KEYS_CACHE_KEY, {
|
||||
refreshFn: async () => await this.readPublishedKeys(),
|
||||
});
|
||||
/**
|
||||
* Verifies a token that carries a `kid` header. The key comes from this
|
||||
* service's own list, and the algorithm comes from the key, never from
|
||||
* the token header. There is no fallback to the HMAC secret.
|
||||
*/
|
||||
async verifyAccessToken(
|
||||
token: string,
|
||||
{ kid, audiences, issuer }: VerifyAccessTokenOptions,
|
||||
): Promise<JwtPayload> {
|
||||
if (typeof kid !== 'string' || kid.length === 0) {
|
||||
throw new JsonWebTokenError('kid is invalid');
|
||||
}
|
||||
|
||||
const key = await this.findPublishedKey(kid);
|
||||
if (!key) {
|
||||
throw new JsonWebTokenError('kid is unknown');
|
||||
}
|
||||
|
||||
const verified = this.jwtService.verifyForResource(
|
||||
token,
|
||||
audiences as [string, ...string[]],
|
||||
key.publicKey,
|
||||
{ algorithms: [OAUTH_SIGNING_ALGORITHM], issuer },
|
||||
);
|
||||
|
||||
const { typ } = verified.header;
|
||||
if (typeof typ !== 'string' || !ACCESS_TOKEN_TYPES.some((t) => t === typ.toLowerCase())) {
|
||||
throw new JsonWebTokenError('typ is invalid');
|
||||
}
|
||||
|
||||
const { payload } = verified;
|
||||
if (typeof payload !== 'object' || typeof payload.exp !== 'number') {
|
||||
throw new JsonWebTokenError('exp is missing');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kid that is not in the list reads the list again, because another
|
||||
* process may have created the key since this process read it.
|
||||
*/
|
||||
private async findPublishedKey(kid: string): Promise<PublishedSigningKey | undefined> {
|
||||
const keys = await this.loadPublishedKeys(SIGNING_KEYS_REFRESH_MS);
|
||||
const key = keys.find((k) => k.kid === kid);
|
||||
if (key) return key;
|
||||
|
||||
const refreshed = await this.loadPublishedKeys(UNKNOWN_KID_REFRESH_MS);
|
||||
return refreshed.find((k) => k.kid === kid);
|
||||
}
|
||||
|
||||
/** Reads the list again only when it is older than `maxAgeMs`. */
|
||||
private async loadPublishedKeys(
|
||||
maxAgeMs = SIGNING_KEYS_REFRESH_MS,
|
||||
): Promise<PublishedSigningKey[]> {
|
||||
let list = this.publishedKeys;
|
||||
if (!list || Date.now() - list.readAt >= maxAgeMs) {
|
||||
list = await this.readPublishedKeys();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return (keys ?? []).filter(
|
||||
return list.keys.filter(
|
||||
(key) => key.active || now - key.updatedAt <= RETIRED_SIGNING_KEY_GRACE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns `undefined` when there are no keys, so an empty list is never cached. */
|
||||
private async readPublishedKeys(): Promise<PublishedSigningKey[] | undefined> {
|
||||
/** Concurrent callers share one database read. */
|
||||
private async readPublishedKeys(): Promise<PublishedKeyList> {
|
||||
if (!this.pendingRead) {
|
||||
const generation = this.keysGeneration;
|
||||
this.pendingRead = this.readPublishedKeyRows()
|
||||
.then((list) => {
|
||||
if (generation === this.keysGeneration) this.publishedKeys = list;
|
||||
return list;
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === this.keysGeneration) this.pendingRead = null;
|
||||
});
|
||||
}
|
||||
return await this.pendingRead;
|
||||
}
|
||||
|
||||
private async readPublishedKeyRows(): Promise<PublishedKeyList> {
|
||||
const rows = await this.deploymentKeyRepository.findOAuthSigningKeys();
|
||||
const keys = rows
|
||||
.filter((row) => row.algorithm === OAUTH_SIGNING_ALGORITHM)
|
||||
.map((row) => ({
|
||||
kid: row.id,
|
||||
publicJwk: toPublicJwk(this.readPrivateJwk(row)),
|
||||
active: row.status === 'active',
|
||||
updatedAt: new Date(row.updatedAt).getTime(),
|
||||
}));
|
||||
.map((row) => {
|
||||
const publicJwk = toPublicJwk(this.readPrivateJwk(row));
|
||||
const { kty, crv, x, y } = publicJwk;
|
||||
return {
|
||||
kid: row.id,
|
||||
publicJwk,
|
||||
publicKey: createPublicKey({ key: { kty, crv, x, y }, format: 'jwk' }),
|
||||
active: row.status === 'active',
|
||||
updatedAt: new Date(row.updatedAt).getTime(),
|
||||
};
|
||||
});
|
||||
|
||||
return keys.length > 0 ? keys : undefined;
|
||||
return { keys, readAt: Date.now() };
|
||||
}
|
||||
|
||||
private readPrivateJwk(row: DeploymentKey): JsonWebKey {
|
||||
@@ -170,15 +270,50 @@ export class OAuthSigningKeyService {
|
||||
encryptedPrivate,
|
||||
OAUTH_SIGNING_ALGORITHM,
|
||||
);
|
||||
// Another process may hold a list read before this key existed.
|
||||
await this.cacheService.delete(OAUTH_SIGNING_KEYS_CACHE_KEY);
|
||||
|
||||
this.logger.info('Generated new OAuth access-token signing key', { kid: id });
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
|
||||
// The winner already told the other processes.
|
||||
this.logger.debug('OAuth signing key insert raced with another process; re-reading winner');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Generated new OAuth access-token signing key', { kid: id });
|
||||
|
||||
this.reloadPublishedKeys();
|
||||
await this.broadcastReloadSigningKeysCommand();
|
||||
}
|
||||
|
||||
/**
|
||||
* Not limited to multi-main: workers and webhook processes verify tokens
|
||||
* too. Outside queue mode the publisher does nothing.
|
||||
*/
|
||||
private async broadcastReloadSigningKeysCommand(): Promise<void> {
|
||||
try {
|
||||
const { Publisher } = await import('@/scaling/pubsub/publisher.service.js');
|
||||
await Container.get(Publisher).publishCommand({ command: 'reload-oauth-signing-keys' });
|
||||
} catch (error) {
|
||||
// The key is already stored. The other processes read it when their list expires.
|
||||
this.logger.warn('Failed to tell other processes about the new OAuth signing key', {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the list. The next call reads it from the database.
|
||||
*
|
||||
* This does not reload the private key, which {@link initialize} loads
|
||||
* once. That is safe only while a key is created only when no key is
|
||||
* active. Key rotation must also reload the private key here. If it does
|
||||
* not, a process signs with the retired key, and its tokens fail after
|
||||
* {@link RETIRED_SIGNING_KEY_GRACE_MS}.
|
||||
*/
|
||||
@OnPubSubEvent('reload-oauth-signing-keys')
|
||||
reloadPublishedKeys(): void {
|
||||
this.keysGeneration++;
|
||||
this.publishedKeys = null;
|
||||
this.pendingRead = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Time } from '@n8n/constants';
|
||||
import { TransactionRunner, User, UserRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import { JsonWebTokenError } from 'jsonwebtoken';
|
||||
import type { OAuthResourceGrant } from 'n8n-workflow';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
@@ -26,6 +27,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
|
||||
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
|
||||
import { OAUTH_ACCESS_TOKEN_TTL_SECONDS } from './oauth-signing-key.constants';
|
||||
import { OAuthSigningKeyService } from './oauth-signing-key.service';
|
||||
import { AccessTokenNotFoundError, JWTVerificationError } from './oauth.errors';
|
||||
import { authorizeAgainstGrant } from './resource-gate';
|
||||
import { isSameProtectedResource } from './resource-identity';
|
||||
@@ -53,6 +55,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
||||
private readonly txRunner: TransactionRunner,
|
||||
private readonly workflowFinderService: WorkflowFinderService,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly signingKeyService: OAuthSigningKeyService,
|
||||
) {}
|
||||
|
||||
getAccessTokenExpirySeconds(): number {
|
||||
@@ -79,7 +82,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = this.jwtService.signForResource(
|
||||
const accessToken = this.signingKeyService.signAccessToken(
|
||||
{
|
||||
iss: this.urlService.getInstanceBaseUrl(),
|
||||
sub: userId,
|
||||
@@ -96,12 +99,6 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
||||
},
|
||||
},
|
||||
audience,
|
||||
{
|
||||
header: {
|
||||
typ: 'at+jwt',
|
||||
alg: 'HS256',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const refreshToken = randomBytes(32).toString('hex');
|
||||
@@ -242,8 +239,11 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
||||
let decoded: unknown;
|
||||
|
||||
try {
|
||||
decoded = this.verifyJwtWithAllowedAudiences(token, allowedAudiences);
|
||||
decoded = await this.verifyJwt(token, allowedAudiences);
|
||||
} catch (error) {
|
||||
this.logger.debug('OAuth access token failed verification', {
|
||||
error: ensureError(error).message,
|
||||
});
|
||||
throw new JWTVerificationError();
|
||||
}
|
||||
|
||||
@@ -475,16 +475,39 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
||||
return await this.resourceRegistry.getByResourceUrl(expectedAudience);
|
||||
}
|
||||
|
||||
// TODO: drop legacy audiences and the per-audience fallback once all legacy
|
||||
// tokens minted before n8n v2.19 have aged out (refresh-token lifespan).
|
||||
private verifyJwtWithAllowedAudiences(token: string, audiences: string[]): unknown {
|
||||
/**
|
||||
* A token with a `kid` header is ES256 and verifies against the signing
|
||||
* keys only. A token without one is a legacy HMAC token. The `alg` header
|
||||
* never picks the path or the key.
|
||||
*/
|
||||
private async verifyJwt(token: string, audiences: string[]): Promise<unknown> {
|
||||
const decoded = this.jwtService.decodeUnverifiedWithHeader(token);
|
||||
if (!decoded) {
|
||||
throw new JsonWebTokenError('jwt malformed');
|
||||
}
|
||||
|
||||
if (Object.hasOwn(decoded.header, 'kid')) {
|
||||
return await this.signingKeyService.verifyAccessToken(token, {
|
||||
kid: decoded.header.kid,
|
||||
audiences,
|
||||
issuer: this.urlService.getInstanceBaseUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
return this.verifyLegacyHmacJwt(token, audiences);
|
||||
}
|
||||
|
||||
// TODO: remove once every process mints ES256 and the last HS256 tokens
|
||||
// have expired. Drops the legacy audiences and the per-audience fallback
|
||||
// for tokens minted before n8n v2.19 at the same time.
|
||||
private verifyLegacyHmacJwt(token: string, audiences: string[]): unknown {
|
||||
try {
|
||||
return this.jwtService.verifyForResource(token, audiences as [string, ...string[]]);
|
||||
return this.jwtService.verifyLegacyHmacAccessToken(token, audiences as [string, ...string[]]);
|
||||
} catch (error) {
|
||||
// Some jsonwebtoken builds reject the array form for tokens signed with a single-string aud.
|
||||
for (const audience of audiences) {
|
||||
try {
|
||||
return this.jwtService.verifyForResource(token, audience);
|
||||
return this.jwtService.verifyLegacyHmacAccessToken(token, audience);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ export type PubSubCommandMap = {
|
||||
|
||||
// #endregion
|
||||
|
||||
// # region OAuth server
|
||||
'reload-oauth-signing-keys': never;
|
||||
// #endregion
|
||||
|
||||
'reload-source-control-config': never;
|
||||
|
||||
'reload-mcp-registry': never;
|
||||
|
||||
@@ -42,6 +42,7 @@ export namespace PubSub {
|
||||
export type ReloadLicense = ToCommand<'reload-license'>;
|
||||
export type ReloadOIDCConfiguration = ToCommand<'reload-oidc-config'>;
|
||||
export type ReloadSamlConfiguration = ToCommand<'reload-saml-config'>;
|
||||
export type ReloadOAuthSigningKeys = ToCommand<'reload-oauth-signing-keys'>;
|
||||
export type ReloadCredentialsOverwrites = ToCommand<'reload-overwrite-credentials'>;
|
||||
export type RestartEventBus = ToCommand<'restart-event-bus'>;
|
||||
export type ReloadExternalSecretsProviders = ToCommand<'reload-external-secrets-providers'>;
|
||||
@@ -128,6 +129,7 @@ export namespace PubSub {
|
||||
| Commands.ClearTestWebhooks
|
||||
| Commands.ReloadOIDCConfiguration
|
||||
| Commands.ReloadSamlConfiguration
|
||||
| Commands.ReloadOAuthSigningKeys
|
||||
| Commands.ReloadCredentialsOverwrites
|
||||
| Commands.ReloadSsoProvisioningConfiguration
|
||||
| Commands.ReloadSourceControlConfiguration
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Logger } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
@@ -86,12 +87,17 @@ describe('JwtService', () => {
|
||||
|
||||
it('should bind a resource token to the audience it is given', () => {
|
||||
const resource = 'https://n8n.example.com/mcp-server/http';
|
||||
const token = jwtService.signForResource(payload, resource);
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const token = jwtService.signForResource(payload, resource, privateKey, {
|
||||
algorithm: 'ES256',
|
||||
});
|
||||
|
||||
expect(jwtService.verifyForResource(token, resource)).toMatchObject({ sub: 1 });
|
||||
expect(() => jwtService.verifyForResource(token, 'https://other.example.com')).toThrow(
|
||||
jwt.JsonWebTokenError,
|
||||
);
|
||||
expect(jwtService.verifyForResource(token, resource, publicKey).payload).toMatchObject({
|
||||
sub: 1,
|
||||
});
|
||||
expect(() =>
|
||||
jwtService.verifyForResource(token, 'https://other.example.com', publicKey),
|
||||
).toThrow(jwt.JsonWebTokenError);
|
||||
});
|
||||
|
||||
describe('tokens minted before audience binding', () => {
|
||||
|
||||
@@ -78,17 +78,10 @@ export class JwtService {
|
||||
/**
|
||||
* Signs a token bound to a protected resource rather than to a fixed
|
||||
* purpose. Only OAuth access tokens need this: their audience is the
|
||||
* requested resource indicator (RFC 8707), known at runtime.
|
||||
* requested resource indicator (RFC 8707), known at runtime. The caller
|
||||
* owns the asymmetric key, such as the OAuth access-token signing key.
|
||||
*/
|
||||
signForResource(payload: object, audience: string, options: PurposedSignOptions = {}): string {
|
||||
return jwt.sign(payload, this.jwtSecret, { ...options, audience });
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link signForResource}, but signs with an asymmetric key that the
|
||||
* caller owns, such as the OAuth access-token signing key.
|
||||
*/
|
||||
signForResourceWithKey(
|
||||
signForResource(
|
||||
payload: object,
|
||||
audience: string,
|
||||
privateKey: KeyObject,
|
||||
@@ -105,6 +98,14 @@ export class JwtService {
|
||||
return jwt.decode(token) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link decodeUnverified}, but also returns the header, e.g. to route
|
||||
* a token by its `kid`. The same rule applies: never make a decision on it.
|
||||
*/
|
||||
decodeUnverifiedWithHeader(token: string) {
|
||||
return jwt.decode(token, { complete: true });
|
||||
}
|
||||
|
||||
/** Verifies a token and requires it to carry the audience of `purpose`. */
|
||||
verify<T = JwtPayload>(
|
||||
purpose: TokenPurpose,
|
||||
@@ -121,13 +122,32 @@ export class JwtService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Verifies an OAuth access token against the resource(s) it may be used for. */
|
||||
verifyForResource<T = JwtPayload>(
|
||||
/**
|
||||
* Verifies an OAuth access token against the resource(s) it may be used
|
||||
* for, with the public key of the key that signed it. Returns the header
|
||||
* and the payload.
|
||||
*/
|
||||
verifyForResource(
|
||||
token: string,
|
||||
audience: string | [string, ...string[]],
|
||||
publicKey: KeyObject,
|
||||
options: PurposedVerifyOptions = {},
|
||||
) {
|
||||
return jwt.verify(token, this.jwtSecret, { ...options, audience }) as T;
|
||||
return jwt.verify(token, publicKey, { ...options, audience, complete: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an OAuth access token that n8n signed with the HMAC secret,
|
||||
* before it signed access tokens with ES256. Accepts HS256 only.
|
||||
*
|
||||
* DEPRECATED: remove with `OAuthTokenService.verifyLegacyHmacJwt`, after the
|
||||
* last HS256 access tokens have expired.
|
||||
*/
|
||||
verifyLegacyHmacAccessToken<T = JwtPayload>(
|
||||
token: string,
|
||||
audience: string | [string, ...string[]],
|
||||
) {
|
||||
return jwt.verify(token, this.jwtSecret, { audience, algorithms: ['HS256'] }) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user