refactor(jwt): gate signing-key auto-rotation cron on SIGNING_KEY_ROTATION_DAYS (#20866)

Only register the JWT signing-key rotation cron when
`SIGNING_KEY_ROTATION_DAYS` is set, and move that variable to Advanced
Settings.
This commit is contained in:
Charles Bochet
2026-05-23 11:51:06 +02:00
committed by GitHub
parent 056e3a4cd8
commit 91ce59d8e2
3 changed files with 25 additions and 7 deletions
@@ -16,10 +16,9 @@ Each key carries a `publicKey` (kept indefinitely so it can verify previously is
### Rotate the current key
- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire.
- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`.
Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation.
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
<Note>Auto-rotation ships in v2.6+.</Note>
### Revoke a key (leak / emergency only)
@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
@@ -10,6 +11,7 @@ import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/c
import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command';
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command';
import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command';
@@ -62,6 +64,7 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
private readonly twentyConfigService: TwentyConfigService,
) {
super();
}
@@ -69,6 +72,10 @@ export class CronRegisterAllCommand extends CommandRunner {
async run(): Promise<void> {
this.logger.log('Registering all background sync cron jobs...');
const isSigningKeyAutoRotationEnabled = isDefined(
this.twentyConfigService.get('SIGNING_KEY_ROTATION_DAYS'),
);
const allCommands = [
{
name: 'MessagingMessagesImport',
@@ -161,6 +168,7 @@ export class CronRegisterAllCommand extends CommandRunner {
{
name: 'RotateSigningKeys',
command: this.rotateSigningKeysCronCommand,
isEnabled: isSigningKeyAutoRotationEnabled,
},
{
name: 'StaleRegistrationCleanup',
@@ -172,8 +180,15 @@ export class CronRegisterAllCommand extends CommandRunner {
let failureCount = 0;
const failures: string[] = [];
const successes: string[] = [];
const skipped: string[] = [];
for (const { name, command, isEnabled = true } of allCommands) {
if (!isEnabled) {
this.logger.log(`Skipping ${name} cron job (disabled by config)`);
skipped.push(name);
continue;
}
for (const { name, command } of allCommands) {
try {
this.logger.log(`Registering ${name} cron job...`);
await command.run();
@@ -188,7 +203,7 @@ export class CronRegisterAllCommand extends CommandRunner {
}
this.logger.log(
`Cron job registration completed: ${successCount} successful, ${failureCount} failed`,
`Cron job registration completed: ${successCount} successful, ${failureCount} failed, ${skipped.length} skipped`,
);
if (failures.length > 0) {
@@ -198,5 +213,9 @@ export class CronRegisterAllCommand extends CommandRunner {
if (successCount > 0) {
this.logger.log(`Successful commands: ${successes.join(', ')}`);
}
if (skipped.length > 0) {
this.logger.log(`Skipped commands: ${skipped.join(', ')}`);
}
}
}
@@ -1185,9 +1185,9 @@ export class ConfigVariables {
FALLBACK_ENCRYPTION_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'Number of days after which the Enterprise auto-rotation cron issues a new current JWT signing key. When unset, the cron is a no-op. Previous keys remain in the database to keep verifying tokens they signed; revocation stays a manual admin action.',
'Days the current JWT signing key stays valid before the rotation cron issues a new one. Leave unset to disable auto-rotation.',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()