fix: accept production enterprise keys in development environment (#18611)

## Problem
When `NODE_ENV` is development, the server was only using the dev public
key to verify enterprise JWTs. Production keys are signed with the
production private key, so they failed verification with the dev public
key, resulting in "Invalid enterprise key" errors.

## Solution
Try both production and dev public keys when in development, so
production keys work when testing locally. In production, only the
production key is used (unchanged behavior).

## Changes
- `enterprise-plan.service.ts`: Replaced `getPublicKey()` with
`getPublicKeysToTry()` that returns both keys in development; updated
`verifyJwt()` to try each key until one succeeds
- `enterprise-plan.service.spec.ts`: Added test for production key
acceptance when `NODE_ENV` is development

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-03-13 09:20:14 +01:00
committed by GitHub
parent 1cb4c98cb3
commit 5f558e5539
2 changed files with 44 additions and 22 deletions
@@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
@@ -353,6 +354,20 @@ describe('EnterprisePlanService', () => {
expect(service.isValidEnterpriseKeyFormat(invalidKey)).toBe(false);
});
it('should accept production key when NODE_ENV is development', () => {
configGetMock.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return NodeEnvironment.DEVELOPMENT;
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
return undefined;
});
mockCryptoVerify.mockReturnValueOnce(false).mockReturnValueOnce(true);
const productionKey = createFakeJwt(MOCK_KEY_PAYLOAD);
expect(service.isValidEnterpriseKeyFormat(productionKey)).toBe(true);
expect(mockCryptoVerify).toHaveBeenCalledTimes(2);
});
});
describe('getLicenseInfo', () => {
@@ -477,12 +477,15 @@ export class EnterprisePlanService implements OnModuleInit {
}
}
private getPublicKey(): string {
// In development, try both keys so production keys work when testing locally
private getPublicKeysToTry(): string[] {
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
return nodeEnv === NodeEnvironment.DEVELOPMENT
? ENTERPRISE_JWT_DEV_PUBLIC_KEY
: ENTERPRISE_JWT_PUBLIC_KEY;
if (nodeEnv === NodeEnvironment.DEVELOPMENT) {
return [ENTERPRISE_JWT_PUBLIC_KEY, ENTERPRISE_JWT_DEV_PUBLIC_KEY];
}
return [ENTERPRISE_JWT_PUBLIC_KEY];
}
private verifyJwt<T extends Record<string, unknown>>(
@@ -504,27 +507,31 @@ export class EnterprisePlanService implements OnModuleInit {
'base64',
);
const isValid = crypto.verify(
'sha256',
Buffer.from(signingInput),
{
key: this.getPublicKey(),
padding: crypto.constants.RSA_PKCS1_PADDING,
},
signatureBuffer,
);
const publicKeys = this.getPublicKeysToTry();
if (!isValid) {
return null;
for (const publicKey of publicKeys) {
const isValid = crypto.verify(
'sha256',
Buffer.from(signingInput),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
},
signatureBuffer,
);
if (isValid) {
const payloadStr = Buffer.from(
encodedPayload.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - (encodedPayload.length % 4)) % 4),
'base64',
).toString('utf-8');
return JSON.parse(payloadStr) as T;
}
}
const payloadStr = Buffer.from(
encodedPayload.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - (encodedPayload.length % 4)) % 4),
'base64',
).toString('utf-8');
return JSON.parse(payloadStr) as T;
return null;
} catch {
return null;
}