From 72ce77864e1a600bf4a5eb71ed7224c7f326678e Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 19 May 2026 12:41:04 +0200 Subject: [PATCH] feat(server): Enterprise cron that rotates the current JWT signing key (#20612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a daily Enterprise-only cron that rotates the current ES256 JWT signing key once it has been current for `SIGNING_KEY_ROTATION_DAYS`. Manual rotation from the admin panel is unaffected. ### Behaviour - `SIGNING_KEY_ROTATION_DAYS` is **opt-in**: when unset, the cron is a no-op. - Rotation flips `isCurrent` and clears the previous key's `privateKey` in the same transaction, then inserts the new `isCurrent=true` row. - The previous key's row is kept (`revokedAt` stays `null`) so its `publicKey` can keep verifying tokens it signed until they expire; only the encrypted `privateKey` is wiped since it can no longer be used to sign. - **No auto-revocation** — revoking a key remains a manual admin action, reserved for leak / emergency response. - The cron is also a no-op when `EnterprisePlanService.isValid()` is `false`. ### Wiring - `JwtKeyManagerService.rotateCurrent()` - `SigningKeyRotationService.rotateIfDue()` (reads `SIGNING_KEY_ROTATION_DAYS`, skips when unset) - `RotateSigningKeysCronJob` (Enterprise-gated, rethrows on failure) registered in `JwtModule` - `RotateSigningKeysCronCommand` registered with `cron:register:all` - `ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *'` (daily, no-op until threshold) Operator documentation lives in #20611 (docs PR). --- .../src/metadata/generated/schema.graphql | 42 ++--- .../src/metadata/generated/schema.ts | 128 +++++++------- .../src/metadata/generated/types.ts | 166 +++++++++--------- .../commands/cron-register-all.command.ts | 6 + .../commands/database-command.module.ts | 2 + ...tate-signing-keys-cron-pattern.constant.ts | 3 + .../rotate-signing-keys.cron.command.ts | 35 ++++ .../jobs/rotate-signing-keys.cron.job.ts | 55 ++++++ .../src/engine/core-modules/jwt/jwt.module.ts | 7 + .../jwt/services/jwt-key-manager.service.ts | 45 ++++- .../services/signing-key-rotation.service.ts | 65 +++++++ .../twenty-config/config-variables.ts | 10 ++ ...-signing-keys-cron-job.integration-spec.ts | 105 +++++++++++ 13 files changed, 495 insertions(+), 174 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-rotation.service.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/rotate-signing-keys-cron-job.integration-spec.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 465050fca3..14e2f72332 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1421,6 +1421,22 @@ type ApplicationConnectionProvider { oauth: ApplicationConnectionProviderOAuthConfig } +type EnterpriseLicenseInfoDTO { + isValid: Boolean! + licensee: String + expiresAt: DateTime + subscriptionId: String +} + +type EnterpriseSubscriptionStatusDTO { + status: String! + licensee: String + expiresAt: DateTime + cancelAt: DateTime + currentPeriodEnd: DateTime + isCancellationScheduled: Boolean! +} + type Analytics { """Boolean that confirms query was dispatched""" success: Boolean! @@ -1441,22 +1457,6 @@ type FileWithSignedUrl { url: String! } -type EnterpriseLicenseInfoDTO { - isValid: Boolean! - licensee: String - expiresAt: DateTime - subscriptionId: String -} - -type EnterpriseSubscriptionStatusDTO { - status: String! - licensee: String - expiresAt: DateTime - cancelAt: DateTime - currentPeriodEnd: DateTime - isCancellationScheduled: Boolean! -} - type BillingSubscriptionSchedulePhaseItem { price: String! quantity: Float @@ -2936,6 +2936,9 @@ type Webhook { type Query { navigationMenuItems: [NavigationMenuItem!]! navigationMenuItem(id: UUID!): NavigationMenuItem + enterprisePortalSession(returnUrlPath: String): String + enterpriseCheckoutSession(billingInterval: String): String + enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO getViewFilterGroups(viewId: String): [ViewFilterGroup!]! getViewFilterGroup(id: String!): ViewFilterGroup getViewFilters(viewId: String): [ViewFilter!]! @@ -2950,9 +2953,6 @@ type Query { getViewFieldGroup(id: String!): ViewFieldGroup apiKeys: [ApiKey!]! apiKey(input: GetApiKeyInput!): ApiKey - enterprisePortalSession(returnUrlPath: String): String - enterpriseCheckoutSession(billingInterval: String): String - enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO billingPortalSession(returnUrlPath: String): BillingSession! listPlans: [BillingPlan!]! getResourceCreditUsage: [BillingResourceCreditUsage!]! @@ -3145,6 +3145,8 @@ type Mutation { deleteManyNavigationMenuItems(ids: [UUID!]!): [NavigationMenuItem!]! deleteNavigationMenuItem(id: UUID!): NavigationMenuItem! uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl! + refreshEnterpriseValidityToken: Boolean! + setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO! uploadAiChatFile(file: Upload!): FileWithSignedUrl! uploadWorkflowFile(file: Upload!): FileWithSignedUrl! uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl! @@ -3185,8 +3187,6 @@ type Mutation { assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean! createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics! trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics! - refreshEnterpriseValidityToken: Boolean! - setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO! skipSyncEmailOnboardingStep: OnboardingStepSuccess! skipBookOnboardingStep: OnboardingStepSuccess! checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 9780dc6adf..8da9e7cbc4 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1080,6 +1080,24 @@ export interface ApplicationConnectionProvider { __typename: 'ApplicationConnectionProvider' } +export interface EnterpriseLicenseInfoDTO { + isValid: Scalars['Boolean'] + licensee?: Scalars['String'] + expiresAt?: Scalars['DateTime'] + subscriptionId?: Scalars['String'] + __typename: 'EnterpriseLicenseInfoDTO' +} + +export interface EnterpriseSubscriptionStatusDTO { + status: Scalars['String'] + licensee?: Scalars['String'] + expiresAt?: Scalars['DateTime'] + cancelAt?: Scalars['DateTime'] + currentPeriodEnd?: Scalars['DateTime'] + isCancellationScheduled: Scalars['Boolean'] + __typename: 'EnterpriseSubscriptionStatusDTO' +} + export interface Analytics { /** Boolean that confirms query was dispatched */ success: Scalars['Boolean'] @@ -1103,24 +1121,6 @@ export interface FileWithSignedUrl { __typename: 'FileWithSignedUrl' } -export interface EnterpriseLicenseInfoDTO { - isValid: Scalars['Boolean'] - licensee?: Scalars['String'] - expiresAt?: Scalars['DateTime'] - subscriptionId?: Scalars['String'] - __typename: 'EnterpriseLicenseInfoDTO' -} - -export interface EnterpriseSubscriptionStatusDTO { - status: Scalars['String'] - licensee?: Scalars['String'] - expiresAt?: Scalars['DateTime'] - cancelAt?: Scalars['DateTime'] - currentPeriodEnd?: Scalars['DateTime'] - isCancellationScheduled: Scalars['Boolean'] - __typename: 'EnterpriseSubscriptionStatusDTO' -} - export interface BillingSubscriptionSchedulePhaseItem { price: Scalars['String'] quantity?: Scalars['Float'] @@ -2564,6 +2564,9 @@ export interface Webhook { export interface Query { navigationMenuItems: NavigationMenuItem[] navigationMenuItem?: NavigationMenuItem + enterprisePortalSession?: Scalars['String'] + enterpriseCheckoutSession?: Scalars['String'] + enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO getViewFilterGroups: ViewFilterGroup[] getViewFilterGroup?: ViewFilterGroup getViewFilters: ViewFilter[] @@ -2578,9 +2581,6 @@ export interface Query { getViewFieldGroup?: ViewFieldGroup apiKeys: ApiKey[] apiKey?: ApiKey - enterprisePortalSession?: Scalars['String'] - enterpriseCheckoutSession?: Scalars['String'] - enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO billingPortalSession: BillingSession listPlans: BillingPlan[] getResourceCreditUsage: BillingResourceCreditUsage[] @@ -2679,6 +2679,8 @@ export interface Mutation { deleteManyNavigationMenuItems: NavigationMenuItem[] deleteNavigationMenuItem: NavigationMenuItem uploadEmailAttachmentFile: FileWithSignedUrl + refreshEnterpriseValidityToken: Scalars['Boolean'] + setEnterpriseKey: EnterpriseLicenseInfoDTO uploadAiChatFile: FileWithSignedUrl uploadWorkflowFile: FileWithSignedUrl uploadWorkspaceLogo: FileWithSignedUrl @@ -2719,8 +2721,6 @@ export interface Mutation { assignRoleToApiKey: Scalars['Boolean'] createObjectEvent: Analytics trackAnalytics: Analytics - refreshEnterpriseValidityToken: Scalars['Boolean'] - setEnterpriseKey: EnterpriseLicenseInfoDTO skipSyncEmailOnboardingStep: OnboardingStepSuccess skipBookOnboardingStep: OnboardingStepSuccess checkoutSession: BillingSession @@ -4029,6 +4029,26 @@ export interface ApplicationConnectionProviderGenqlSelection{ __scalar?: boolean | number } +export interface EnterpriseLicenseInfoDTOGenqlSelection{ + isValid?: boolean | number + licensee?: boolean | number + expiresAt?: boolean | number + subscriptionId?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface EnterpriseSubscriptionStatusDTOGenqlSelection{ + status?: boolean | number + licensee?: boolean | number + expiresAt?: boolean | number + cancelAt?: boolean | number + currentPeriodEnd?: boolean | number + isCancellationScheduled?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface AnalyticsGenqlSelection{ /** Boolean that confirms query was dispatched */ success?: boolean | number @@ -4055,26 +4075,6 @@ export interface FileWithSignedUrlGenqlSelection{ __scalar?: boolean | number } -export interface EnterpriseLicenseInfoDTOGenqlSelection{ - isValid?: boolean | number - licensee?: boolean | number - expiresAt?: boolean | number - subscriptionId?: boolean | number - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface EnterpriseSubscriptionStatusDTOGenqlSelection{ - status?: boolean | number - licensee?: boolean | number - expiresAt?: boolean | number - cancelAt?: boolean | number - currentPeriodEnd?: boolean | number - isCancellationScheduled?: boolean | number - __typename?: boolean | number - __scalar?: boolean | number -} - export interface BillingSubscriptionSchedulePhaseItemGenqlSelection{ price?: boolean | number quantity?: boolean | number @@ -5600,6 +5600,9 @@ export interface WebhookGenqlSelection{ export interface QueryGenqlSelection{ navigationMenuItems?: NavigationMenuItemGenqlSelection navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} }) + enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number + enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number + enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection getViewFilterGroups?: (ViewFilterGroupGenqlSelection & { __args?: {viewId?: (Scalars['String'] | null)} }) getViewFilterGroup?: (ViewFilterGroupGenqlSelection & { __args: {id: Scalars['String']} }) getViewFilters?: (ViewFilterGenqlSelection & { __args?: {viewId?: (Scalars['String'] | null)} }) @@ -5614,9 +5617,6 @@ export interface QueryGenqlSelection{ getViewFieldGroup?: (ViewFieldGroupGenqlSelection & { __args: {id: Scalars['String']} }) apiKeys?: ApiKeyGenqlSelection apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} }) - enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number - enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number - enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} }) listPlans?: BillingPlanGenqlSelection getResourceCreditUsage?: BillingResourceCreditUsageGenqlSelection @@ -5754,6 +5754,8 @@ export interface MutationGenqlSelection{ deleteManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {ids: Scalars['UUID'][]} }) deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} }) uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) + refreshEnterpriseValidityToken?: boolean | number + setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} }) uploadAiChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) @@ -5794,8 +5796,6 @@ export interface MutationGenqlSelection{ assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} } createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} }) trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} }) - refreshEnterpriseValidityToken?: boolean | number - setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} }) skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} }) @@ -6956,6 +6956,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO'] + export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => { + if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"') + return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename) + } + + + + const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO'] + export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => { + if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"') + return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename) + } + + + const Analytics_possibleTypes: string[] = ['Analytics'] export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => { if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"') @@ -6980,22 +6996,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null - const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO'] - export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => { - if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"') - return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename) - } - - - - const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO'] - export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => { - if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"') - return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename) - } - - - const BillingSubscriptionSchedulePhaseItem_possibleTypes: string[] = ['BillingSubscriptionSchedulePhaseItem'] export const isBillingSubscriptionSchedulePhaseItem = (obj?: { __typename?: any } | null): obj is BillingSubscriptionSchedulePhaseItem => { if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscriptionSchedulePhaseItem"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index d0a1ce3738..5cd6d98b4b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -2828,6 +2828,46 @@ export default { 1 ] }, + "EnterpriseLicenseInfoDTO": { + "isValid": [ + 6 + ], + "licensee": [ + 1 + ], + "expiresAt": [ + 4 + ], + "subscriptionId": [ + 1 + ], + "__typename": [ + 1 + ] + }, + "EnterpriseSubscriptionStatusDTO": { + "status": [ + 1 + ], + "licensee": [ + 1 + ], + "expiresAt": [ + 4 + ], + "cancelAt": [ + 4 + ], + "currentPeriodEnd": [ + 4 + ], + "isCancellationScheduled": [ + 6 + ], + "__typename": [ + 1 + ] + }, "Analytics": { "success": [ 6 @@ -2873,46 +2913,6 @@ export default { 1 ] }, - "EnterpriseLicenseInfoDTO": { - "isValid": [ - 6 - ], - "licensee": [ - 1 - ], - "expiresAt": [ - 4 - ], - "subscriptionId": [ - 1 - ], - "__typename": [ - 1 - ] - }, - "EnterpriseSubscriptionStatusDTO": { - "status": [ - 1 - ], - "licensee": [ - 1 - ], - "expiresAt": [ - 4 - ], - "cancelAt": [ - 4 - ], - "currentPeriodEnd": [ - 4 - ], - "isCancellationScheduled": [ - 6 - ], - "__typename": [ - 1 - ] - }, "BillingSubscriptionSchedulePhaseItem": { "price": [ 1 @@ -5819,6 +5819,25 @@ export default { ] } ], + "enterprisePortalSession": [ + 1, + { + "returnUrlPath": [ + 1 + ] + } + ], + "enterpriseCheckoutSession": [ + 1, + { + "billingInterval": [ + 1 + ] + } + ], + "enterpriseSubscriptionStatus": [ + 128 + ], "getViewFilterGroups": [ 61, { @@ -5939,25 +5958,6 @@ export default { ] } ], - "enterprisePortalSession": [ - 1, - { - "returnUrlPath": [ - 1 - ] - } - ], - "enterpriseCheckoutSession": [ - 1, - { - "billingInterval": [ - 1 - ] - } - ], - "enterpriseSubscriptionStatus": [ - 131 - ], "billingPortalSession": [ 151, { @@ -5976,7 +5976,7 @@ export default { 154 ], "getApprovedAccessDomains": [ - 128 + 130 ], "getPageLayoutTabs": [ 122, @@ -6759,7 +6759,7 @@ export default { } ], "uploadEmailAttachmentFile": [ - 129, + 131, { "file": [ 342, @@ -6767,8 +6767,20 @@ export default { ] } ], + "refreshEnterpriseValidityToken": [ + 6 + ], + "setEnterpriseKey": [ + 127, + { + "enterpriseKey": [ + 1, + "String!" + ] + } + ], "uploadAiChatFile": [ - 129, + 131, { "file": [ 342, @@ -6777,7 +6789,7 @@ export default { } ], "uploadWorkflowFile": [ - 129, + 131, { "file": [ 342, @@ -6786,7 +6798,7 @@ export default { } ], "uploadWorkspaceLogo": [ - 129, + 131, { "file": [ 342, @@ -6795,7 +6807,7 @@ export default { } ], "uploadWorkspaceMemberProfilePicture": [ - 129, + 131, { "file": [ 342, @@ -6804,7 +6816,7 @@ export default { } ], "uploadFilesFieldFile": [ - 129, + 131, { "file": [ 342, @@ -6817,7 +6829,7 @@ export default { } ], "uploadFilesFieldFileByUniversalIdentifier": [ - 129, + 131, { "file": [ 342, @@ -7130,7 +7142,7 @@ export default { } ], "createObjectEvent": [ - 127, + 129, { "event": [ 1, @@ -7150,7 +7162,7 @@ export default { } ], "trackAnalytics": [ - 127, + 129, { "type": [ 378, @@ -7167,18 +7179,6 @@ export default { ] } ], - "refreshEnterpriseValidityToken": [ - 6 - ], - "setEnterpriseKey": [ - 130, - { - "enterpriseKey": [ - 1, - "String!" - ] - } - ], "skipSyncEmailOnboardingStep": [ 153 ], @@ -7263,7 +7263,7 @@ export default { } ], "createApprovedAccessDomain": [ - 128, + 130, { "input": [ 379, @@ -7281,7 +7281,7 @@ export default { } ], "validateApprovedAccessDomain": [ - 128, + 130, { "input": [ 381, diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index 1b21346812..65b5824dfa 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -7,6 +7,7 @@ import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/app import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command'; import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command'; import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command'; +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 { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command'; @@ -57,6 +58,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly trashCleanupCronCommand: TrashCleanupCronCommand, private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand, private readonly enterpriseKeyValidationCronCommand: EnterpriseKeyValidationCronCommand, + private readonly rotateSigningKeysCronCommand: RotateSigningKeysCronCommand, private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand, private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand, private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand, @@ -156,6 +158,10 @@ export class CronRegisterAllCommand extends CommandRunner { name: 'EnterpriseKeyValidation', command: this.enterpriseKeyValidationCronCommand, }, + { + name: 'RotateSigningKeys', + command: this.rotateSigningKeysCronCommand, + }, { name: 'StaleRegistrationCleanup', command: this.staleRegistrationCleanupCronCommand, diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index a4c9df86a5..6f2bd3d2bc 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -25,6 +25,7 @@ import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/ente import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module'; import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; @@ -94,6 +95,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au RunInstanceCommandsCommand, ListOrphanedWorkspaceEntitiesCommand, EnterpriseKeyValidationCronCommand, + RotateSigningKeysCronCommand, GenerateApiKeyCommand, UpgradeStatusCommand, RebuildApplicationDefaultDepsCommand, diff --git a/packages/twenty-server/src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant.ts b/packages/twenty-server/src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant.ts new file mode 100644 index 0000000000..8c182f6a7f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant.ts @@ -0,0 +1,3 @@ +/* @license Enterprise */ + +export const ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *'; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command.ts b/packages/twenty-server/src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command.ts new file mode 100644 index 0000000000..989a0d1430 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command.ts @@ -0,0 +1,35 @@ +/* @license Enterprise */ + +import { Command, CommandRunner } from 'nest-commander'; + +import { ROTATE_SIGNING_KEYS_CRON_PATTERN } from 'src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant'; +import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; + +@Command({ + name: 'cron:rotate-signing-keys', + description: + 'Starts a daily cron job that issues a fresh current JWT signing key once SIGNING_KEY_ROTATION_DAYS has elapsed. Enterprise-only.', +}) +export class RotateSigningKeysCronCommand extends CommandRunner { + constructor( + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise { + await this.messageQueueService.addCron({ + jobName: RotateSigningKeysCronJob.name, + data: undefined, + options: { + repeat: { + pattern: ROTATE_SIGNING_KEYS_CRON_PATTERN, + }, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job.ts b/packages/twenty-server/src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job.ts new file mode 100644 index 0000000000..867fd7b982 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job.ts @@ -0,0 +1,55 @@ +/* @license Enterprise */ + +import { Injectable, Logger } from '@nestjs/common'; + +import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service'; +import { ROTATE_SIGNING_KEYS_CRON_PATTERN } from 'src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant'; +import { SigningKeyRotationService } from 'src/engine/core-modules/jwt/services/signing-key-rotation.service'; +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; + +@Injectable() +@Processor(MessageQueue.cronQueue) +export class RotateSigningKeysCronJob { + private readonly logger = new Logger(RotateSigningKeysCronJob.name); + + constructor( + private readonly enterprisePlanService: EnterprisePlanService, + private readonly signingKeyRotationService: SigningKeyRotationService, + ) {} + + @Process(RotateSigningKeysCronJob.name) + @SentryCronMonitor( + RotateSigningKeysCronJob.name, + ROTATE_SIGNING_KEYS_CRON_PATTERN, + ) + async handle(): Promise { + if (!this.enterprisePlanService.isValid()) { + this.logger.log( + 'Enterprise plan not valid, skipping signing key rotation', + ); + + return; + } + + try { + const result = await this.signingKeyRotationService.rotateIfDue(); + + if (result.rotated) { + this.logger.log( + `Rotated current signing key: ${result.previousId} -> ${result.newId}`, + ); + } + } catch (error) { + this.logger.error( + `Signing key rotation failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + throw error; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts index ebe6c1cdf3..96e9037908 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts @@ -3,14 +3,17 @@ import { JwtModule as NestJwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; +import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module'; import { JWT_LEGACY_ALGORITHM, JWT_SUPPORTED_VERIFY_ALGORITHMS, } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant'; +import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job'; import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity'; import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SigningKeyEntityCacheProviderService } from 'src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service'; +import { SigningKeyRotationService } from 'src/engine/core-modules/jwt/services/signing-key-rotation.service'; import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service'; import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; @@ -39,6 +42,7 @@ const InternalJwtModule = NestJwtModule.registerAsync({ TypeOrmModule.forFeature([SigningKeyEntity]), CoreEntityCacheModule, SecretEncryptionModule, + EnterpriseModule, ], controllers: [], providers: [ @@ -46,11 +50,14 @@ const InternalJwtModule = NestJwtModule.registerAsync({ JwtKeyManagerService, SigningKeyEntityCacheProviderService, SigningKeyVerifyCounterService, + SigningKeyRotationService, + RotateSigningKeysCronJob, ], exports: [ JwtWrapperService, JwtKeyManagerService, SigningKeyVerifyCounterService, + SigningKeyRotationService, ], }) export class JwtModule {} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts index 1bfe2466b4..8698061b94 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts @@ -52,14 +52,12 @@ export class JwtKeyManagerService { const result = await this.currentSigningKeyPromise; if (!isDefined(result)) { - this.currentSigningKeyPromise = null; - this.currentSigningKeyCachedAt = 0; + this.invalidateCurrentSigningKeyLocalCache(); } return result; } catch (error) { - this.currentSigningKeyPromise = null; - this.currentSigningKeyCachedAt = 0; + this.invalidateCurrentSigningKeyLocalCache(); throw error; } } @@ -78,6 +76,37 @@ export class JwtKeyManagerService { }); } + async rotateCurrent(): Promise { + const generated = this.generateEcP256KeyPair(); + const newId = randomUUID(); + + await this.signingKeyRepository.manager.transaction( + async (entityManager) => { + const repository = entityManager.getRepository(SigningKeyEntity); + + await repository.update( + { isCurrent: true }, + { isCurrent: false, privateKey: null }, + ); + + await repository.insert({ + id: newId, + publicKey: generated.publicKeyPem, + privateKey: this.secretEncryptionService.encryptVersioned( + generated.privateKeyPem, + ), + isCurrent: true, + revokedAt: null, + }); + }, + ); + + await this.coreEntityCacheService.invalidate('signingKeyPublicKey', newId); + this.invalidateCurrentSigningKeyLocalCache(); + + return { id: newId, privateKeyPem: generated.privateKeyPem }; + } + async revokeSigningKey(id: string): Promise { if (!isNonEmptyString(id) || !isValidUuid(id)) { throw new JwtKeyManagerException( @@ -107,12 +136,16 @@ export class JwtKeyManagerService { } await this.coreEntityCacheService.invalidate('signingKeyPublicKey', id); - this.currentSigningKeyPromise = null; - this.currentSigningKeyCachedAt = 0; + this.invalidateCurrentSigningKeyLocalCache(); return this.signingKeyRepository.findOneByOrFail({ id }); } + private invalidateCurrentSigningKeyLocalCache(): void { + this.currentSigningKeyPromise = null; + this.currentSigningKeyCachedAt = 0; + } + private async loadOrCreateCurrentSigningKey(): Promise { try { const existing = await this.findCurrentSigningKeyRow(); diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-rotation.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-rotation.service.ts new file mode 100644 index 0000000000..11d4f6041f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-rotation.service.ts @@ -0,0 +1,65 @@ +/* @license Enterprise */ + +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export type SigningKeyRotationResult = { + rotated: boolean; + previousId: string | null; + newId: string | null; +}; + +@Injectable() +export class SigningKeyRotationService { + private readonly logger = new Logger(SigningKeyRotationService.name); + + constructor( + private readonly jwtKeyManagerService: JwtKeyManagerService, + private readonly twentyConfigService: TwentyConfigService, + ) {} + + async rotateIfDue(): Promise { + const rotationDays = this.twentyConfigService.get( + 'SIGNING_KEY_ROTATION_DAYS', + ); + + if (!isDefined(rotationDays)) { + this.logger.log( + 'SIGNING_KEY_ROTATION_DAYS is not configured, skipping signing key rotation', + ); + + return { rotated: false, previousId: null, newId: null }; + } + + const signingKeys = await this.jwtKeyManagerService.listSigningKeys(); + const current = signingKeys.find( + (signingKey) => signingKey.isCurrent && !isDefined(signingKey.revokedAt), + ); + + if (!isDefined(current)) { + return { rotated: false, previousId: null, newId: null }; + } + + const ageDays = (Date.now() - current.createdAt.getTime()) / ONE_DAY_MS; + + if (ageDays < rotationDays) { + this.logger.log( + `Current signing key ${current.id} is ${ageDays.toFixed( + 2, + )} days old, rotation threshold is ${rotationDays} days, skipping`, + ); + + return { rotated: false, previousId: current.id, newId: null }; + } + + const next = await this.jwtKeyManagerService.rotateCurrent(); + + return { rotated: true, previousId: current.id, newId: next.id }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index fcad949659..bace86bd9e 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1165,6 +1165,16 @@ export class ConfigVariables { @IsOptional() FALLBACK_ENCRYPTION_KEY: string; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.SERVER_CONFIG, + 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.', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsOptional() + SIGNING_KEY_ROTATION_DAYS?: number; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.RATE_LIMITING, description: 'Maximum number of records affected by mutations', diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/rotate-signing-keys-cron-job.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/rotate-signing-keys-cron-job.integration-spec.ts new file mode 100644 index 0000000000..6fac2d7688 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/rotate-signing-keys-cron-job.integration-spec.ts @@ -0,0 +1,105 @@ +/* @license Enterprise */ + +import { isNonEmptyString } from '@sniptt/guards'; +import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util'; +import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; +import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util'; +import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util'; +import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util'; + +import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; +import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant'; + +const SIGNING_KEY_ROTATION_DAYS_KEY = 'SIGNING_KEY_ROTATION_DAYS'; +const ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *'; + +describe('RotateSigningKeysCronJob (integration)', () => { + const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1; + let cronQueue: MessageQueueService; + + beforeAll(() => { + cronQueue = global.app.get( + getQueueToken(MessageQueue.cronQueue), + ); + }); + + afterAll(async () => { + await deleteConfigVariable({ + input: { key: SIGNING_KEY_ROTATION_DAYS_KEY }, + }).catch(() => {}); + }); + + it('rotates the current JWT signing key when the cron command runs and keeps verifying tokens signed by the previous key', async () => { + const initialTokenResponse = await generateApiKeyToken({ + apiKeyId: seededApiKeyId, + accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + expect(initialTokenResponse.body.errors).toBeUndefined(); + + const initialApiKeyToken: string = + initialTokenResponse.body.data?.generateApiKeyToken.token ?? ''; + + expect(isNonEmptyString(initialApiKeyToken)).toBe(true); + + const initialKid = decodeJwtCompleteOrThrow(initialApiKeyToken).header + .kid as string; + + expect(isNonEmptyString(initialKid)).toBe(true); + + const initialAuthCall = await findManyApplications({ + accessToken: initialApiKeyToken, + expectToFail: false, + }); + + expect(initialAuthCall.errors).toBeUndefined(); + expect(initialAuthCall.data?.findManyApplications).toBeDefined(); + + await updateConfigVariable({ + input: { key: SIGNING_KEY_ROTATION_DAYS_KEY, value: 0 }, + }); + + await cronQueue.addCron({ + jobName: RotateSigningKeysCronJob.name, + data: undefined, + options: { repeat: { pattern: ROTATE_SIGNING_KEYS_CRON_PATTERN } }, + }); + + const rotatedTokenResponse = await generateApiKeyToken({ + apiKeyId: seededApiKeyId, + accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + expect(rotatedTokenResponse.body.errors).toBeUndefined(); + + const rotatedApiKeyToken: string = + rotatedTokenResponse.body.data?.generateApiKeyToken.token ?? ''; + + expect(isNonEmptyString(rotatedApiKeyToken)).toBe(true); + + const rotatedKid = decodeJwtCompleteOrThrow(rotatedApiKeyToken).header + .kid as string; + + expect(isNonEmptyString(rotatedKid)).toBe(true); + expect(rotatedKid).not.toBe(initialKid); + + const callWithPreviousToken = await findManyApplications({ + accessToken: initialApiKeyToken, + expectToFail: false, + }); + + expect(callWithPreviousToken.errors).toBeUndefined(); + expect(callWithPreviousToken.data?.findManyApplications).toBeDefined(); + + const callWithRotatedToken = await findManyApplications({ + accessToken: rotatedApiKeyToken, + expectToFail: false, + }); + + expect(callWithRotatedToken.errors).toBeUndefined(); + expect(callWithRotatedToken.data?.findManyApplications).toBeDefined(); + }); +});