diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index aeac57477b2..e8250d598d3 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -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', ]); }); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index f311f8b3961..2241dc56e49 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -53,6 +53,7 @@ export class ModuleRegistry { 'token-exchange', 'instance-version-history', 'encryption-key-manager', + 'oauth-jwe', ]; private readonly activeModules: string[] = []; diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 09172ae615d..d927d32697a 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -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]; diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index 7b906f0bd18..6e39c2a6307 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -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]; diff --git a/packages/@n8n/db/src/migrations/common/1777023444000-AddJweKeyIndexesToDeploymentKey.ts b/packages/@n8n/db/src/migrations/common/1777023444000-AddJweKeyIndexesToDeploymentKey.ts new file mode 100644 index 00000000000..6c0dd0277e3 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1777023444000-AddJweKeyIndexesToDeploymentKey.ts @@ -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, + }); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 628c7c33328..8ae705f24ad 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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, diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index fe04f1965bf..bd54b154f13 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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, diff --git a/packages/cli/package.json b/packages/cli/package.json index 79b882daa5b..42d6651e3cb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts new file mode 100644 index 00000000000..8ee01575787 --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts new file mode 100644 index 00000000000..11ec48bc983 --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts @@ -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 => + ({ + 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(); + }); + }); +}); diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts new file mode 100644 index 00000000000..b29c2ad3464 --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts @@ -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 { + 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'); + }); +}); diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts new file mode 100644 index 00000000000..f142eca6902 --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts @@ -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 { + 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 { + 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 { + 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 { + const data = await this.loadData(); + return await Promise.all( + data.map(async (entry) => (await this.deriveKeyPair(entry)).publicJwk), + ); + } + + private async findEntry(algorithm: JweKeyAlgorithm): Promise { + 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 { + const data = await this.cacheService.get(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 { + const decryptedPrivate = this.cipher.decryptWithInstanceKey(entry.encryptedPrivateJwk); + const privateJwk = jsonParse(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 { + 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 { + 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(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 { + 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> = { + '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'; +} diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe.constants.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe.constants.ts new file mode 100644 index 00000000000..d0798866ee1 --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe.constants.ts @@ -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'; diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe.module.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe.module.ts new file mode 100644 index 00000000000..a3f9177463d --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe.module.ts @@ -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(); + } +} diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts new file mode 100644 index 00000000000..16b47f2a247 --- /dev/null +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts @@ -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 { + 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, + privateKey: CryptoKey, +): Promise> { + const result: Record = { ...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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33fdefe2f38..3dd61af7865 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}