mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Add instance-level JWE key infrastructure (no-changelog) (#29071)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c65fa28e1c
commit
e90397627d
@@ -43,6 +43,7 @@ describe('eligibleModules', () => {
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
'oauth-jwe',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -72,6 +73,7 @@ describe('eligibleModules', () => {
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
'oauth-jwe',
|
||||
'instance-ai',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export class ModuleRegistry {
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
'oauth-jwe',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
||||
@@ -27,6 +27,7 @@ export const MODULE_NAMES = [
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
'oauth-jwe',
|
||||
] as const;
|
||||
|
||||
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||
|
||||
@@ -43,6 +43,7 @@ export const LOG_SCOPES = [
|
||||
'instance-registry',
|
||||
'expression-engine',
|
||||
'encryption-key-manager',
|
||||
'oauth-jwe',
|
||||
] as const;
|
||||
|
||||
export type LogScope = (typeof LOG_SCOPES)[number];
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddJweKeyIndexesToDeploymentKey1777023444000 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createIndex } }: MigrationContext) {
|
||||
// Partial unique index keyed on (type, algorithm) and scoped to active JWE
|
||||
// private-key rows. This guarantees at most one active key per algorithm
|
||||
// (so multi-main boots race-safely on a single algorithm) while leaving
|
||||
// room for additional active keys under different algorithms in future.
|
||||
await createIndex(
|
||||
'deployment_key',
|
||||
['type', 'algorithm'],
|
||||
true,
|
||||
'IDX_deployment_key_jwe_private_key_active',
|
||||
"status = 'active' AND type = 'jwe.private-key'",
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropIndex } }: MigrationContext) {
|
||||
await dropIndex('deployment_key', ['type', 'algorithm'], {
|
||||
customIndexName: 'IDX_deployment_key_jwe_private_key_active',
|
||||
skipIfMissing: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ import { ChangeWorkflowPublishHistoryVersionIdToSetNull1775740765000 } from '../
|
||||
import { CreateTrustedKeyTables1776000000000 } from '../common/1776000000000-CreateTrustedKeyTables';
|
||||
import { CreateFavoritesTable1776150756000 } from '../common/1776150756000-CreateFavoritesTable';
|
||||
import { CreateDeploymentKeyTable1777000000000 } from '../common/1777000000000-CreateDeploymentKeyTable';
|
||||
import { AddJweKeyIndexesToDeploymentKey1777023444000 } from '../common/1777023444000-AddJweKeyIndexesToDeploymentKey';
|
||||
import { AddTracingContextToExecution1777045000000 } from '../common/1777045000000-AddTracingContextToExecution';
|
||||
import { AddLangsmithIdsToInstanceAiRunSnapshots1777100000000 } from '../common/1777100000000-AddLangsmithIdsToInstanceAiRunSnapshots';
|
||||
import { CreateAiBuilderTemporaryWorkflowTable1777281990043 } from '../common/1777281990043-CreateAiBuilderTemporaryWorkflowTable';
|
||||
@@ -333,6 +334,7 @@ export const postgresMigrations: Migration[] = [
|
||||
CreateTrustedKeyTables1776000000000,
|
||||
CreateFavoritesTable1776150756000,
|
||||
CreateDeploymentKeyTable1777000000000,
|
||||
AddJweKeyIndexesToDeploymentKey1777023444000,
|
||||
AddLangsmithIdsToInstanceAiRunSnapshots1777100000000,
|
||||
AddExecutionDeduplicationKey1778000000000,
|
||||
AddTracingContextToExecution1777045000000,
|
||||
|
||||
@@ -156,6 +156,7 @@ import { ChangeWorkflowPublishHistoryVersionIdToSetNull1775740765000 } from '../
|
||||
import { CreateTrustedKeyTables1776000000000 } from '../common/1776000000000-CreateTrustedKeyTables';
|
||||
import { CreateFavoritesTable1776150756000 } from '../common/1776150756000-CreateFavoritesTable';
|
||||
import { CreateDeploymentKeyTable1777000000000 } from '../common/1777000000000-CreateDeploymentKeyTable';
|
||||
import { AddJweKeyIndexesToDeploymentKey1777023444000 } from '../common/1777023444000-AddJweKeyIndexesToDeploymentKey';
|
||||
import { AddTracingContextToExecution1777045000000 } from '../common/1777045000000-AddTracingContextToExecution';
|
||||
import { AddLangsmithIdsToInstanceAiRunSnapshots1777100000000 } from '../common/1777100000000-AddLangsmithIdsToInstanceAiRunSnapshots';
|
||||
import { CreateAiBuilderTemporaryWorkflowTable1777281990043 } from '../common/1777281990043-CreateAiBuilderTemporaryWorkflowTable';
|
||||
@@ -321,6 +322,7 @@ const sqliteMigrations: Migration[] = [
|
||||
CreateTrustedKeyTables1776000000000,
|
||||
CreateFavoritesTable1776150756000,
|
||||
CreateDeploymentKeyTable1777000000000,
|
||||
AddJweKeyIndexesToDeploymentKey1777023444000,
|
||||
AddLangsmithIdsToInstanceAiRunSnapshots1777100000000,
|
||||
AddExecutionDeduplicationKey1778000000000,
|
||||
AddTracingContextToExecution1777045000000,
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
"infisical-node": "1.3.0",
|
||||
"ioredis": "5.3.2",
|
||||
"isbot": "3.6.13",
|
||||
"jose": "^6.2.2",
|
||||
"json-diff": "1.0.6",
|
||||
"jsonschema": "1.4.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { CryptoKey } from 'jose';
|
||||
import { CompactEncrypt, compactDecrypt, importJWK } from 'jose';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import { OAuthJweKeyService } from '../oauth-jwe-key.service';
|
||||
import {
|
||||
JWE_KEY_ALGORITHMS,
|
||||
JWE_KEY_CACHE_KEY,
|
||||
JWE_PRIVATE_KEY_TYPE,
|
||||
} from '../oauth-jwe.constants';
|
||||
|
||||
beforeAll(async () => {
|
||||
mockInstance(InstanceSettings, {
|
||||
encryptionKey: 'oauth-jwe-test-encryption-key',
|
||||
n8nFolder: '/tmp/n8n-test',
|
||||
});
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['DeploymentKey']);
|
||||
await Container.get(CacheService).delete(JWE_KEY_CACHE_KEY);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('OAuthJweKeyService (integration)', () => {
|
||||
it('persists exactly one active private-key row per algorithm on first initialize', async () => {
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
where: { type: JWE_PRIVATE_KEY_TYPE, status: 'active' },
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(JWE_KEY_ALGORITHMS.length);
|
||||
expect(rows.map((r) => r.algorithm).sort()).toEqual([...JWE_KEY_ALGORITHMS].sort());
|
||||
for (const row of rows) {
|
||||
expect(row.value).toEqual(expect.any(String));
|
||||
expect(row.id).toEqual(expect.any(String));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not insert a second row when initialize is called again on a fresh process', async () => {
|
||||
const service = Container.get(OAuthJweKeyService);
|
||||
|
||||
await service.initialize();
|
||||
// Simulate a fresh process / cluster mate that has not warmed the cache yet:
|
||||
// it must read the existing row instead of inserting a duplicate.
|
||||
await Container.get(CacheService).delete(JWE_KEY_CACHE_KEY);
|
||||
await service.initialize();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
where: { type: JWE_PRIVATE_KEY_TYPE, status: 'active' },
|
||||
});
|
||||
expect(rows).toHaveLength(JWE_KEY_ALGORITHMS.length);
|
||||
});
|
||||
|
||||
it('returns a key pair whose public JWK has no private RSA material', async () => {
|
||||
const service = Container.get(OAuthJweKeyService);
|
||||
await service.initialize();
|
||||
|
||||
const { publicJwk, kid, algorithm } = await service.getKeyPair();
|
||||
|
||||
expect(algorithm).toBe(JWE_KEY_ALGORITHMS[0]);
|
||||
expect(typeof kid).toBe('string');
|
||||
expect(publicJwk).toHaveProperty('n');
|
||||
expect(publicJwk).toHaveProperty('e');
|
||||
expect(publicJwk).not.toHaveProperty('d');
|
||||
expect(publicJwk).not.toHaveProperty('p');
|
||||
expect(publicJwk).not.toHaveProperty('q');
|
||||
});
|
||||
|
||||
it('generates a usable JWE key pair (encrypt with public, decrypt with private)', async () => {
|
||||
const service = Container.get(OAuthJweKeyService);
|
||||
await service.initialize();
|
||||
|
||||
const { publicJwk, privateKey, algorithm } = await service.getKeyPair();
|
||||
const publicKey = (await importJWK(publicJwk, algorithm)) as CryptoKey;
|
||||
|
||||
const token = await new CompactEncrypt(new TextEncoder().encode('hello-jwe'))
|
||||
.setProtectedHeader({ alg: algorithm, enc: 'A256GCM' })
|
||||
.encrypt(publicKey);
|
||||
|
||||
const { plaintext } = await compactDecrypt(token, privateKey);
|
||||
expect(new TextDecoder().decode(plaintext)).toBe('hello-jwe');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { DeploymentKey } from '@n8n/db';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { exportJWK, generateKeyPair } from 'jose';
|
||||
import type { JWK } from 'jose';
|
||||
import { Cipher } from 'n8n-core';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import { OAuthJweKeyService } from '../oauth-jwe-key.service';
|
||||
import { JWE_KEY_ALGORITHMS, JWE_PRIVATE_KEY_TYPE } from '../oauth-jwe.constants';
|
||||
|
||||
const ALGORITHM = JWE_KEY_ALGORITHMS[0];
|
||||
|
||||
let privateJwkFixture: JWK;
|
||||
|
||||
const makeRow = (overrides: Partial<DeploymentKey> = {}): DeploymentKey =>
|
||||
({
|
||||
id: 'row-1',
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
value: 'enc-private-jwk',
|
||||
algorithm: ALGORITHM,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) as DeploymentKey;
|
||||
|
||||
const makeUniqueViolation = (code: string): QueryFailedError => {
|
||||
const err = new QueryFailedError('insert', [], new Error('duplicate'));
|
||||
(err as unknown as { driverError: { code: string } }).driverError = { code };
|
||||
return err;
|
||||
};
|
||||
|
||||
describe('OAuthJweKeyService', () => {
|
||||
const repository = mockInstance(DeploymentKeyRepository);
|
||||
const cipher = mockInstance(Cipher);
|
||||
const cacheService = mockInstance(CacheService);
|
||||
|
||||
beforeAll(async () => {
|
||||
const pair = await generateKeyPair(ALGORITHM, { extractable: true });
|
||||
privateJwkFixture = {
|
||||
...(await exportJWK(pair.privateKey)),
|
||||
kid: 'row-1',
|
||||
alg: ALGORITHM,
|
||||
use: 'enc',
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default: cache miss; refreshFn runs and its result is returned.
|
||||
cacheService.get.mockImplementation(async (_key, options) => {
|
||||
return await options?.refreshFn?.('cache-key');
|
||||
});
|
||||
cipher.encryptWithInstanceKey.mockImplementation((value: string) => `enc(${value})`);
|
||||
cipher.decryptWithInstanceKey.mockImplementation((value: string) =>
|
||||
value.startsWith('enc(') ? value.slice(4, -1) : JSON.stringify(privateJwkFixture),
|
||||
);
|
||||
});
|
||||
|
||||
describe('initialize / loadOrGenerate', () => {
|
||||
it('reuses an existing active row without inserting', async () => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: JSON.stringify(privateJwkFixture) }));
|
||||
cipher.decryptWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates and persists a new key pair when none exists', async () => {
|
||||
let row: DeploymentKey | null = null;
|
||||
repository.findOne.mockImplementation(async () => row);
|
||||
repository.insert.mockImplementation(async (entity) => {
|
||||
row = makeRow({
|
||||
value: (entity as DeploymentKey).value,
|
||||
algorithm: (entity as DeploymentKey).algorithm,
|
||||
});
|
||||
cipher.decryptWithInstanceKey.mockImplementation((value: string) =>
|
||||
value === row?.value ? JSON.stringify(privateJwkFixture) : '',
|
||||
);
|
||||
return { identifiers: [{ id: 'row-1' }], generatedMaps: [], raw: [] };
|
||||
});
|
||||
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledTimes(1);
|
||||
const [inserted] = repository.insert.mock.calls[0];
|
||||
expect(inserted).toMatchObject({
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
algorithm: ALGORITHM,
|
||||
status: 'active',
|
||||
});
|
||||
expect((inserted as DeploymentKey).id).toEqual(expect.any(String));
|
||||
expect(cipher.encryptWithInstanceKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when the persisted private JWK has no kid', async () => {
|
||||
const { kid: _kid, ...kidless } = privateJwkFixture;
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: 'enc-row' }));
|
||||
cipher.decryptWithInstanceKey.mockReturnValue(JSON.stringify(kidless));
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toThrow(
|
||||
`OAuth JWE private key for "${ALGORITHM}" is missing a kid`,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the persisted JWK kid does not match the row id', async () => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ id: 'different-id', value: 'enc-row' }));
|
||||
cipher.decryptWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toThrow(
|
||||
`OAuth JWE private key for "${ALGORITHM}" has a kid that does not match its row id`,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the post-generate re-read returns null', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.insert.mockResolvedValue({ identifiers: [], generatedMaps: [], raw: [] });
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toThrow(
|
||||
`OAuth JWE key for algorithm "${ALGORITHM}" not found after generation`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('race handling', () => {
|
||||
it('swallows a postgres unique-constraint violation and re-reads the winner', async () => {
|
||||
let row: DeploymentKey | null = null;
|
||||
repository.findOne.mockImplementation(async () => row);
|
||||
repository.insert.mockImplementation(async () => {
|
||||
row = makeRow({ value: 'winner-row' });
|
||||
cipher.decryptWithInstanceKey.mockImplementation((value: string) =>
|
||||
value === 'winner-row' ? JSON.stringify(privateJwkFixture) : '',
|
||||
);
|
||||
throw makeUniqueViolation('23505');
|
||||
});
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('swallows a sqlite unique-constraint violation', async () => {
|
||||
let row: DeploymentKey | null = null;
|
||||
repository.findOne.mockImplementation(async () => row);
|
||||
repository.insert.mockImplementation(async () => {
|
||||
row = makeRow({ value: 'winner-row' });
|
||||
cipher.decryptWithInstanceKey.mockImplementation((value: string) =>
|
||||
value === 'winner-row' ? JSON.stringify(privateJwkFixture) : '',
|
||||
);
|
||||
throw makeUniqueViolation('SQLITE_CONSTRAINT_UNIQUE');
|
||||
});
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('re-throws non-unique-constraint errors unchanged', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
const connectionError = makeUniqueViolation('ECONNREFUSED');
|
||||
repository.insert.mockRejectedValue(connectionError);
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toBe(connectionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeyPair / getPublicJwk', () => {
|
||||
beforeEach(() => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: 'enc-active' }));
|
||||
cipher.decryptWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
});
|
||||
|
||||
it('uses the first algorithm by default', async () => {
|
||||
const pair = await Container.get(OAuthJweKeyService).getKeyPair();
|
||||
|
||||
expect(pair.algorithm).toBe(ALGORITHM);
|
||||
expect(pair.kid).toBe('row-1');
|
||||
});
|
||||
|
||||
it('throws for an unsupported algorithm', async () => {
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Container.get(OAuthJweKeyService).getKeyPair('NOT-A-REAL-ALG' as any),
|
||||
).rejects.toThrow('No active OAuth JWE key found for algorithm "NOT-A-REAL-ALG"');
|
||||
});
|
||||
|
||||
it('strips private RSA fields from the derived public JWK', async () => {
|
||||
const { publicJwk } = await Container.get(OAuthJweKeyService).getKeyPair();
|
||||
|
||||
expect(publicJwk).not.toHaveProperty('d');
|
||||
expect(publicJwk).not.toHaveProperty('p');
|
||||
expect(publicJwk).not.toHaveProperty('q');
|
||||
expect(publicJwk).not.toHaveProperty('dp');
|
||||
expect(publicJwk).not.toHaveProperty('dq');
|
||||
expect(publicJwk).not.toHaveProperty('qi');
|
||||
expect(publicJwk).toHaveProperty('n');
|
||||
expect(publicJwk).toHaveProperty('e');
|
||||
expect(publicJwk.kid).toBe('row-1');
|
||||
});
|
||||
|
||||
it('getPublicJwk returns the same JWK as getKeyPair().publicJwk', async () => {
|
||||
const service = Container.get(OAuthJweKeyService);
|
||||
|
||||
const publicJwk = await service.getPublicJwk();
|
||||
const fromPair = (await service.getKeyPair()).publicJwk;
|
||||
|
||||
expect(publicJwk).toEqual(fromPair);
|
||||
});
|
||||
|
||||
it('getPublicJwks returns one JWK per supported algorithm', async () => {
|
||||
const jwks = await Container.get(OAuthJweKeyService).getPublicJwks();
|
||||
|
||||
expect(jwks).toHaveLength(JWE_KEY_ALGORITHMS.length);
|
||||
expect(jwks[0]).toHaveProperty('n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache hit', () => {
|
||||
it('does not invoke refreshFn when the cache returns data', async () => {
|
||||
cacheService.get.mockResolvedValue([
|
||||
{
|
||||
algorithm: ALGORITHM,
|
||||
encryptedPrivateJwk: 'cached-row',
|
||||
kid: 'row-1',
|
||||
},
|
||||
]);
|
||||
cipher.decryptWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
|
||||
await Container.get(OAuthJweKeyService).getKeyPair();
|
||||
|
||||
expect(repository.findOne).not.toHaveBeenCalled();
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CompactEncrypt, exportJWK, generateKeyPair, importJWK } from 'jose';
|
||||
import type { CryptoKey } from 'jose';
|
||||
|
||||
import { decryptJweToken, decryptJweTokenData, isJweToken } from '../oauth-jwe.utils';
|
||||
|
||||
const ALG = 'RSA-OAEP-256';
|
||||
const ENC = 'A256GCM';
|
||||
|
||||
async function makeJweToken(plaintext: string, publicKey: CryptoKey): Promise<string> {
|
||||
return await new CompactEncrypt(new TextEncoder().encode(plaintext))
|
||||
.setProtectedHeader({ alg: ALG, enc: ENC })
|
||||
.encrypt(publicKey);
|
||||
}
|
||||
|
||||
let publicKey: CryptoKey;
|
||||
let privateKey: CryptoKey;
|
||||
let otherPrivateKey: CryptoKey;
|
||||
|
||||
beforeAll(async () => {
|
||||
const pair = await generateKeyPair(ALG, { extractable: true });
|
||||
publicKey = pair.publicKey;
|
||||
privateKey = pair.privateKey;
|
||||
|
||||
const other = await generateKeyPair(ALG, { extractable: true });
|
||||
otherPrivateKey = other.privateKey;
|
||||
});
|
||||
|
||||
describe('isJweToken', () => {
|
||||
it('returns true for a real compact-serialisation JWE', async () => {
|
||||
const token = await makeJweToken('hello', publicKey);
|
||||
|
||||
expect(isJweToken(token)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a 5-segment token with an empty encryptedKey segment', () => {
|
||||
// dir-style and other key-management algorithms produce an empty 2nd segment
|
||||
expect(isJweToken('a..b.c.d')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['number', 42],
|
||||
['object', { foo: 'bar' }],
|
||||
['empty string', ''],
|
||||
])('returns false for %s', (_label, value) => {
|
||||
expect(isJweToken(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['3-segment JWS', 'a.b.c'],
|
||||
['4-segment token', 'a.b.c.d'],
|
||||
['6-segment token', 'a.b.c.d.e.f'],
|
||||
])('returns false for %s', (_label, value) => {
|
||||
expect(isJweToken(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptJweToken', () => {
|
||||
it('roundtrips a JWE through encrypt → decrypt', async () => {
|
||||
const token = await makeJweToken('payload-123', publicKey);
|
||||
|
||||
const result = await decryptJweToken(token, privateKey);
|
||||
|
||||
expect(result).toBe('payload-123');
|
||||
});
|
||||
|
||||
it('throws when decrypted with the wrong private key', async () => {
|
||||
const token = await makeJweToken('payload', publicKey);
|
||||
|
||||
await expect(decryptJweToken(token, otherPrivateKey)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on a malformed token', async () => {
|
||||
await expect(decryptJweToken('not.a.real.jwe.token', privateKey)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptJweTokenData', () => {
|
||||
it('decrypts both access_token and id_token when both are JWEs', async () => {
|
||||
const accessToken = await makeJweToken('plain-access', publicKey);
|
||||
const idToken = await makeJweToken('plain-id', publicKey);
|
||||
|
||||
const result = await decryptJweTokenData(
|
||||
{ access_token: accessToken, id_token: idToken, token_type: 'Bearer' },
|
||||
privateKey,
|
||||
);
|
||||
|
||||
expect(result.access_token).toBe('plain-access');
|
||||
expect(result.id_token).toBe('plain-id');
|
||||
expect(result.token_type).toBe('Bearer');
|
||||
});
|
||||
|
||||
it('leaves non-JWE values untouched', async () => {
|
||||
const result = await decryptJweTokenData(
|
||||
{
|
||||
access_token: 'plain-jws.aaa.bbb',
|
||||
id_token: 12345,
|
||||
refresh_token: 'opaque-refresh-token',
|
||||
},
|
||||
privateKey,
|
||||
);
|
||||
|
||||
expect(result.access_token).toBe('plain-jws.aaa.bbb');
|
||||
expect(result.id_token).toBe(12345);
|
||||
expect(result.refresh_token).toBe('opaque-refresh-token');
|
||||
});
|
||||
|
||||
it('passes through when the JWE fields are missing', async () => {
|
||||
const result = await decryptJweTokenData({ token_type: 'Bearer' }, privateKey);
|
||||
|
||||
expect(result).toEqual({ token_type: 'Bearer' });
|
||||
});
|
||||
|
||||
it('does not mutate the input object', async () => {
|
||||
const accessToken = await makeJweToken('plain', publicKey);
|
||||
const input = { access_token: accessToken };
|
||||
|
||||
await decryptJweTokenData(input, privateKey);
|
||||
|
||||
expect(input.access_token).toBe(accessToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public/private JWK roundtrip', () => {
|
||||
it('an importable private JWK round-trips through decryptJweToken', async () => {
|
||||
const exported = await exportJWK(privateKey);
|
||||
const reimported = (await importJWK({ ...exported, alg: ALG }, ALG)) as CryptoKey;
|
||||
const token = await makeJweToken('roundtrip', publicKey);
|
||||
|
||||
const plaintext = await decryptJweToken(token, reimported);
|
||||
|
||||
expect(plaintext).toBe('roundtrip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { generateNanoId } from '@n8n/utils';
|
||||
import type { CryptoKey, JWK } from 'jose';
|
||||
import { exportJWK, generateKeyPair, importJWK } from 'jose';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import {
|
||||
JWE_KEY_ALGORITHMS,
|
||||
JWE_KEY_CACHE_KEY,
|
||||
JWE_KEY_USE,
|
||||
JWE_PRIVATE_KEY_TYPE,
|
||||
type JweKeyAlgorithm,
|
||||
} from './oauth-jwe.constants';
|
||||
|
||||
type OAuthJweKeyEntry = {
|
||||
algorithm: JweKeyAlgorithm;
|
||||
encryptedPrivateJwk: string;
|
||||
kid: string;
|
||||
};
|
||||
|
||||
type OAuthJweKeyPair = {
|
||||
algorithm: JweKeyAlgorithm;
|
||||
privateKey: CryptoKey;
|
||||
publicKey: CryptoKey;
|
||||
publicJwk: JWK;
|
||||
kid: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages the instance-level OAuth JWE key pairs. One active private JWK is
|
||||
* stored in `deployment_key` per algorithm in {@link JWE_KEY_ALGORITHMS},
|
||||
* enforced by a partial unique index on `(type, algorithm)`. Today the list
|
||||
* holds only `RSA-OAEP-256`; adding another algorithm is a constant-only
|
||||
* change and the next boot generates the missing key pair.
|
||||
*
|
||||
* The JWK `kid` and the `deployment_key.id` are the same nanoid, so a future
|
||||
* kid-keyed lookup over decryption-only inactive rows can use the row's
|
||||
* primary key directly.
|
||||
*/
|
||||
@Service()
|
||||
export class OAuthJweKeyService {
|
||||
constructor(
|
||||
private readonly deploymentKeyRepository: DeploymentKeyRepository,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.logger = this.logger.scoped('oauth-jwe');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures every supported algorithm has an active private JWK in the
|
||||
* database and is present in the shared cache. Safe to call concurrently
|
||||
* across mains: the partial unique index serialises insertion attempts
|
||||
* per algorithm and losers re-read the winner's row.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OAuth JWE key pair for the given algorithm, with imported
|
||||
* {@link CryptoKey} handles. Reads from the shared cache (populated from
|
||||
* the database on miss) so every main and worker sees the same pair under
|
||||
* queue mode.
|
||||
*/
|
||||
async getKeyPair(algorithm: JweKeyAlgorithm = JWE_KEY_ALGORITHMS[0]): Promise<OAuthJweKeyPair> {
|
||||
const entry = await this.findEntry(algorithm);
|
||||
return await this.deriveKeyPair(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public JWK for the given algorithm. For credential-setup
|
||||
* UIs or a future JWKS endpoint that advertises the key to an IdP.
|
||||
*/
|
||||
async getPublicJwk(algorithm: JweKeyAlgorithm = JWE_KEY_ALGORITHMS[0]): Promise<JWK> {
|
||||
const { publicJwk } = await this.getKeyPair(algorithm);
|
||||
return publicJwk;
|
||||
}
|
||||
|
||||
/** Returns the public JWK for every supported algorithm, e.g. for a JWKS endpoint. */
|
||||
async getPublicJwks(): Promise<JWK[]> {
|
||||
const data = await this.loadData();
|
||||
return await Promise.all(
|
||||
data.map(async (entry) => (await this.deriveKeyPair(entry)).publicJwk),
|
||||
);
|
||||
}
|
||||
|
||||
private async findEntry(algorithm: JweKeyAlgorithm): Promise<OAuthJweKeyEntry> {
|
||||
const data = await this.loadData();
|
||||
const entry = data.find((e) => e.algorithm === algorithm);
|
||||
if (!entry) {
|
||||
throw new UnexpectedError(`No active OAuth JWE key found for algorithm "${algorithm}"`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async loadData(): Promise<OAuthJweKeyEntry[]> {
|
||||
const data = await this.cacheService.get<OAuthJweKeyEntry[]>(JWE_KEY_CACHE_KEY, {
|
||||
refreshFn: async () => await this.loadOrGenerate(),
|
||||
});
|
||||
if (!data || data.length === 0) {
|
||||
throw new UnexpectedError('OAuth JWE key pair unavailable');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private async deriveKeyPair(entry: OAuthJweKeyEntry): Promise<OAuthJweKeyPair> {
|
||||
const decryptedPrivate = this.cipher.decryptWithInstanceKey(entry.encryptedPrivateJwk);
|
||||
const privateJwk = jsonParse<JWK>(decryptedPrivate, {
|
||||
errorMessage: 'Failed to parse OAuth JWE private key',
|
||||
});
|
||||
const publicJwk = toPublicJwk(privateJwk, entry.algorithm);
|
||||
|
||||
const [publicKey, privateKey] = await Promise.all([
|
||||
importJWK(publicJwk, entry.algorithm),
|
||||
importJWK(privateJwk, entry.algorithm),
|
||||
]);
|
||||
|
||||
return {
|
||||
algorithm: entry.algorithm,
|
||||
publicKey: publicKey as CryptoKey,
|
||||
privateKey: privateKey as CryptoKey,
|
||||
publicJwk,
|
||||
kid: entry.kid,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadOrGenerate(): Promise<OAuthJweKeyEntry[]> {
|
||||
const entries: OAuthJweKeyEntry[] = [];
|
||||
|
||||
for (const algorithm of JWE_KEY_ALGORITHMS) {
|
||||
let entry = await this.readActiveEntry(algorithm);
|
||||
|
||||
if (!entry) {
|
||||
await this.generateAndPersist(algorithm);
|
||||
entry = await this.readActiveEntry(algorithm);
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
throw new UnexpectedError(
|
||||
`OAuth JWE key for algorithm "${algorithm}" not found after generation`,
|
||||
);
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async readActiveEntry(algorithm: JweKeyAlgorithm): Promise<OAuthJweKeyEntry | null> {
|
||||
const privateRow = await this.deploymentKeyRepository.findOne({
|
||||
where: {
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
if (!privateRow) return null;
|
||||
|
||||
const decryptedPrivate = this.cipher.decryptWithInstanceKey(privateRow.value);
|
||||
const privateJwk = jsonParse<JWK>(decryptedPrivate, {
|
||||
errorMessage: 'Failed to parse OAuth JWE private key',
|
||||
});
|
||||
|
||||
if (!privateJwk.kid) {
|
||||
throw new UnexpectedError(`OAuth JWE private key for "${algorithm}" is missing a kid`);
|
||||
}
|
||||
|
||||
if (privateJwk.kid !== privateRow.id) {
|
||||
throw new UnexpectedError(
|
||||
`OAuth JWE private key for "${algorithm}" has a kid that does not match its row id`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
encryptedPrivateJwk: privateRow.value,
|
||||
kid: privateRow.id,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateAndPersist(algorithm: JweKeyAlgorithm): Promise<void> {
|
||||
const { privateKey } = await generateKeyPair(algorithm, { extractable: true });
|
||||
// The JWK kid is the deployment_key row's primary key.
|
||||
const id = generateNanoId();
|
||||
|
||||
const privateJwk: JWK = {
|
||||
...(await exportJWK(privateKey)),
|
||||
kid: id,
|
||||
alg: algorithm,
|
||||
use: JWE_KEY_USE,
|
||||
};
|
||||
|
||||
const encryptedPrivate = this.cipher.encryptWithInstanceKey(JSON.stringify(privateJwk));
|
||||
|
||||
try {
|
||||
await this.deploymentKeyRepository.insert({
|
||||
id,
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
value: encryptedPrivate,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
this.logger.info('Generated new instance OAuth JWE key pair', { algorithm, kid: id });
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintViolation(error)) throw error;
|
||||
|
||||
this.logger.debug(
|
||||
'OAuth JWE key insert raced with another main; re-reading winner',
|
||||
error instanceof Error ? { algorithm, message: error.message } : { algorithm },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-algorithm allow-list of JWK fields safe to expose publicly. Adding a
|
||||
* new algorithm to {@link JWE_KEY_ALGORITHMS} forces a corresponding entry
|
||||
* here at compile time, so the failure mode for an unrecognised algorithm
|
||||
* is "we forgot to expose a public field" (visible) rather than "we leaked
|
||||
* a private one" (silent).
|
||||
*/
|
||||
const PUBLIC_JWK_FIELDS: Record<JweKeyAlgorithm, ReadonlyArray<keyof JWK>> = {
|
||||
'RSA-OAEP-256': ['kty', 'kid', 'alg', 'use', 'n', 'e'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks only the public fields of a private JWK based on the algorithm's
|
||||
* allow-list. Any field not explicitly listed is dropped.
|
||||
*/
|
||||
function toPublicJwk(privateJwk: JWK, algorithm: JweKeyAlgorithm): JWK {
|
||||
const allowed = PUBLIC_JWK_FIELDS[algorithm];
|
||||
const entries = allowed
|
||||
.filter((field) => privateJwk[field] !== undefined)
|
||||
.map((field) => [field, privateJwk[field]] as const);
|
||||
return Object.fromEntries(entries) as JWK;
|
||||
}
|
||||
|
||||
function isUniqueConstraintViolation(error: unknown): boolean {
|
||||
if (!(error instanceof QueryFailedError)) return false;
|
||||
const driverError = error.driverError as { code?: string };
|
||||
const code = driverError?.code;
|
||||
return code === '23505' /* postgres */ || code === 'SQLITE_CONSTRAINT_UNIQUE';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const JWE_PRIVATE_KEY_TYPE = 'jwe.private-key';
|
||||
|
||||
/**
|
||||
* Supported algorithms for the instance OAuth JWE key pair. Each algorithm
|
||||
* has at most one active private-key row in `deployment_key`, enforced by a
|
||||
* partial unique index on `(type, algorithm)`.
|
||||
*/
|
||||
export const JWE_KEY_ALGORITHMS = ['RSA-OAEP-256'] as const;
|
||||
export type JweKeyAlgorithm = (typeof JWE_KEY_ALGORITHMS)[number];
|
||||
|
||||
export const JWE_KEY_USE = 'enc';
|
||||
export const JWE_KEY_CACHE_KEY = 'jwe:key-pair';
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ModuleInterface } from '@n8n/decorators';
|
||||
import { BackendModule } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
@BackendModule({ name: 'oauth-jwe', instanceTypes: ['main'] })
|
||||
export class OAuthJweModule implements ModuleInterface {
|
||||
async init() {
|
||||
const { OAuthJweKeyService } = await import('./oauth-jwe-key.service');
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { CryptoKey } from 'jose';
|
||||
import { compactDecrypt } from 'jose';
|
||||
|
||||
const JWE_SEGMENT_COUNT = 5;
|
||||
|
||||
/**
|
||||
* Returns true if the value is a compact-serialisation JWE token:
|
||||
* five dot-separated segments (header.encryptedKey.iv.ciphertext.tag).
|
||||
* The `encryptedKey` segment may be empty for key-management algorithms
|
||||
* such as `dir`. Cheap prefilter; does not parse or validate the token.
|
||||
*/
|
||||
export function isJweToken(token: unknown): token is string {
|
||||
if (typeof token !== 'string' || token.length === 0) return false;
|
||||
return token.split('.').length === JWE_SEGMENT_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a compact-serialisation JWE token and returns the plaintext payload
|
||||
* as a UTF-8 string. The caller is responsible for any further handling
|
||||
* (e.g. verifying an inner JWT).
|
||||
*/
|
||||
export async function decryptJweToken(token: string, privateKey: CryptoKey): Promise<string> {
|
||||
const { plaintext } = await compactDecrypt(token, privateKey);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of the token response data with `access_token` and
|
||||
* `id_token` decrypted if they are JWE tokens. Non-JWE values are passed
|
||||
* through unchanged.
|
||||
*/
|
||||
export async function decryptJweTokenData(
|
||||
data: Record<string, unknown>,
|
||||
privateKey: CryptoKey,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const result: Record<string, unknown> = { ...data };
|
||||
|
||||
for (const field of ['access_token', 'id_token'] as const) {
|
||||
const value = result[field];
|
||||
if (isJweToken(value)) {
|
||||
result[field] = await decryptJweToken(value, privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Generated
+10
-7
@@ -2635,6 +2635,9 @@ importers:
|
||||
isbot:
|
||||
specifier: 3.6.13
|
||||
version: 3.6.13
|
||||
jose:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
json-diff:
|
||||
specifier: 1.0.6
|
||||
version: 1.0.6
|
||||
@@ -16564,12 +16567,12 @@ packages:
|
||||
jose@4.15.9:
|
||||
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||
|
||||
jose@6.0.11:
|
||||
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
jose@6.2.2:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
|
||||
js-base64@3.7.2:
|
||||
resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==}
|
||||
|
||||
@@ -28097,7 +28100,7 @@ snapshots:
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.2.2(express@5.2.1)
|
||||
hono: 4.12.14
|
||||
jose: 6.1.3
|
||||
jose: 6.2.2
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.0(patch_hash=651e785d0b7bbf5be9210e1e895c39a16dc3ce8a5a3843b4819565fb6e175b90)
|
||||
raw-body: 3.0.0
|
||||
@@ -38595,10 +38598,10 @@ snapshots:
|
||||
|
||||
jose@4.15.9: {}
|
||||
|
||||
jose@6.0.11: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
jose@6.2.2: {}
|
||||
|
||||
js-base64@3.7.2: {}
|
||||
|
||||
js-base64@3.7.8: {}
|
||||
@@ -41022,7 +41025,7 @@ snapshots:
|
||||
|
||||
openid-client@6.5.0:
|
||||
dependencies:
|
||||
jose: 6.0.11
|
||||
jose: 6.2.2
|
||||
oauth4webapi: 3.5.1
|
||||
|
||||
option@0.2.4: {}
|
||||
|
||||
Reference in New Issue
Block a user