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:
Guillaume Jacquart
2026-04-28 14:25:05 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent c65fa28e1c
commit e90397627d
16 changed files with 832 additions and 7 deletions
@@ -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];
@@ -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,
+1
View File
@@ -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:",
@@ -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;
}
+10 -7
View File
@@ -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: {}