diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index b750e9c075..6e2d75221e 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -379,6 +379,7 @@ export type Mutation = { removeAiProvider: Scalars['Boolean']; removeModelFromProvider: Scalars['Boolean']; retryJobs: RetryJobsResponse; + revokeSigningKey: SigningKeyDto; setAdminAiModelEnabled: Scalars['Boolean']; setAdminAiModelRecommended: Scalars['Boolean']; setAdminAiModelsEnabled: Scalars['Boolean']; @@ -436,6 +437,11 @@ export type MutationRetryJobsArgs = { }; +export type MutationRevokeSigningKeyArgs = { + id: Scalars['UUID']; +}; + + export type MutationSetAdminAiModelEnabledArgs = { enabled: Scalars['Boolean']; modelId: Scalars['String']; @@ -505,6 +511,7 @@ export type Query = { getModelsDevSuggestions: Array; getQueueJobs: QueueJobsResponse; getQueueMetrics: QueueMetricsData; + getSigningKeys: SigningKeysAdminPanelDto; getSystemHealthStatus: SystemHealth; getUpgradeStatus: Array; userLookupAdminPanel: UserLookup; @@ -661,6 +668,23 @@ export type RetryJobsResponse = { retriedCount: Scalars['Int']; }; +export type SigningKeyDto = { + __typename?: 'SigningKeyDTO'; + createdAt: Scalars['DateTime']; + id: Scalars['UUID']; + isCurrent: Scalars['Boolean']; + publicKey: Scalars['String']; + revokedAt?: Maybe; + verifyCountInWindow: Scalars['Int']; +}; + +export type SigningKeysAdminPanelDto = { + __typename?: 'SigningKeysAdminPanelDTO'; + legacyVerifyCountInWindow: Scalars['Int']; + signingKeys: Array; + verifyWindowDays: Scalars['Int']; +}; + export enum SubscriptionInterval { Month = 'Month', Year = 'Year' @@ -1074,6 +1098,18 @@ export type GetMaintenanceModeQueryVariables = Exact<{ [key: string]: never; }>; export type GetMaintenanceModeQuery = { __typename?: 'Query', getMaintenanceMode?: { __typename?: 'MaintenanceMode', startAt: string, endAt: string, link?: string | null } | null }; +export type RevokeSigningKeyMutationVariables = Exact<{ + id: Scalars['UUID']; +}>; + + +export type RevokeSigningKeyMutation = { __typename?: 'Mutation', revokeSigningKey: { __typename?: 'SigningKeyDTO', id: string, isCurrent: boolean, createdAt: string, revokedAt?: string | null, verifyCountInWindow: number } }; + +export type GetSigningKeysQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetSigningKeysQuery = { __typename?: 'Query', getSigningKeys: { __typename?: 'SigningKeysAdminPanelDTO', legacyVerifyCountInWindow: number, verifyWindowDays: number, signingKeys: Array<{ __typename?: 'SigningKeyDTO', id: string, publicKey: string, isCurrent: boolean, createdAt: string, revokedAt?: string | null, verifyCountInWindow: number }> } }; + export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array, oAuthScopes: Array, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string }; export const UserInfoFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserInfoFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; @@ -1119,4 +1155,6 @@ export const GetQueueMetricsDocument = {"kind":"Document","definitions":[{"kind" export const GetSystemHealthStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSystemHealthStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getSystemHealthStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode; export const ClearMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ClearMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clearMaintenanceMode"}}]}}]} as unknown as DocumentNode; export const SetMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetMaintenanceMode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"link"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setMaintenanceMode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"startAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startAt"}}},{"kind":"Argument","name":{"kind":"Name","value":"endAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endAt"}}},{"kind":"Argument","name":{"kind":"Name","value":"link"},"value":{"kind":"Variable","name":{"kind":"Name","value":"link"}}}]}]}}]} as unknown as DocumentNode; -export const GetMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startAt"}},{"kind":"Field","name":{"kind":"Name","value":"endAt"}},{"kind":"Field","name":{"kind":"Name","value":"link"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetMaintenanceModeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMaintenanceMode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startAt"}},{"kind":"Field","name":{"kind":"Name","value":"endAt"}},{"kind":"Field","name":{"kind":"Name","value":"link"}}]}}]}}]} as unknown as DocumentNode; +export const RevokeSigningKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeSigningKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"revokeSigningKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isCurrent"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verifyCountInWindow"}}]}}]}}]} as unknown as DocumentNode; +export const GetSigningKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSigningKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getSigningKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signingKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"publicKey"}},{"kind":"Field","name":{"kind":"Name","value":"isCurrent"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verifyCountInWindow"}}]}},{"kind":"Field","name":{"kind":"Name","value":"legacyVerifyCountInWindow"}},{"kind":"Field","name":{"kind":"Name","value":"verifyWindowDays"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminHealthStatus.tsx b/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminHealthStatus.tsx index 5afdc821ad..cba37ea19e 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminHealthStatus.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminHealthStatus.tsx @@ -3,6 +3,7 @@ import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health import { SettingsAdminUpgradeStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminUpgradeStatusListCard'; import { SettingsAdminMaintenanceMode } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode'; import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect'; +import { SettingsAdminSigningKeysTable } from '@/settings/admin-panel/signing-keys/components/SettingsAdminSigningKeysTable'; import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; import { useQuery } from '@apollo/client/react'; import { t } from '@lingui/core/macro'; @@ -60,6 +61,13 @@ export const SettingsAdminHealthStatus = () => { )} +
+ + +
); diff --git a/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/components/SettingsAdminRevokeSigningKeyConfirmationModal.tsx b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/components/SettingsAdminRevokeSigningKeyConfirmationModal.tsx new file mode 100644 index 0000000000..fc6782ed03 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/components/SettingsAdminRevokeSigningKeyConfirmationModal.tsx @@ -0,0 +1,32 @@ +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { t } from '@lingui/core/macro'; + +type SettingsAdminRevokeSigningKeyConfirmationModalProps = { + modalInstanceId: string; + isCurrent: boolean; + onConfirm: () => void; + onClose?: () => void; +}; + +export const SettingsAdminRevokeSigningKeyConfirmationModal = ({ + modalInstanceId, + isCurrent, + onConfirm, + onClose, +}: SettingsAdminRevokeSigningKeyConfirmationModalProps) => { + const subtitle = isCurrent + ? t`This is the current signing key. Revoking it will invalidate every JWT signed with it and users may need to sign in again. A new signing key will be generated automatically on the next request.` + : t`Revoking this key will invalidate every JWT signed with it. Users with active tokens signed by this key will be logged out.`; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/components/SettingsAdminSigningKeysTable.tsx b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/components/SettingsAdminSigningKeysTable.tsx new file mode 100644 index 0000000000..727624fc84 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/components/SettingsAdminSigningKeysTable.tsx @@ -0,0 +1,192 @@ +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { SettingsAdminRevokeSigningKeyConfirmationModal } from '@/settings/admin-panel/signing-keys/components/SettingsAdminRevokeSigningKeyConfirmationModal'; +import { useRevokeSigningKey } from '@/settings/admin-panel/signing-keys/hooks/useRevokeSigningKey'; +import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { useQuery } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { Tag, type TagColor } from 'twenty-ui/components'; +import { IconCopy, OverflowingTextWithTooltip } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { + GetSigningKeysDocument, + type SigningKeyDto, +} from '~/generated-admin/graphql'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; +import { + beautifyExactDateTime, + beautifyPastDateRelativeToNow, +} from '~/utils/date-utils'; + +const REVOKE_MODAL_ID = 'revoke-signing-key-modal'; + +const SIGNING_KEYS_GRID_TEMPLATE_COLUMNS = '2fr 88px 96px 96px 88px'; + +const EM_DASH = '\u2014'; + +type SelectedSigningKey = { + id: string; + isCurrent: boolean; +}; + +const getStatusTag = ( + signingKey: Pick, +): { text: string; color: TagColor } => { + if (isDefined(signingKey.revokedAt)) { + return { text: t`Revoked`, color: 'red' }; + } + + if (signingKey.isCurrent === true) { + return { text: t`Current`, color: 'green' }; + } + + return { text: t`Active`, color: 'gray' }; +}; + +export const SettingsAdminSigningKeysTable = () => { + const apolloAdminClient = useApolloAdminClient(); + const { openModal } = useModal(); + const { copyToClipboard } = useCopyToClipboard(); + const [selectedSigningKey, setSelectedSigningKey] = + useState(null); + + const { data, loading } = useQuery(GetSigningKeysDocument, { + client: apolloAdminClient, + fetchPolicy: 'network-only', + }); + + const { revokeSigningKey, isRevoking } = useRevokeSigningKey(() => { + setSelectedSigningKey(null); + }); + + const handleRevokeClick = (signingKey: SelectedSigningKey) => { + setSelectedSigningKey(signingKey); + openModal(REVOKE_MODAL_ID); + }; + + const handleConfirmRevoke = async () => { + if (!isDefined(selectedSigningKey)) { + return; + } + + await revokeSigningKey(selectedSigningKey.id); + }; + + if (loading && !isDefined(data)) { + return ; + } + + const signingKeys = data?.getSigningKeys.signingKeys ?? []; + const legacyVerifyCountInWindow = + data?.getSigningKeys.legacyVerifyCountInWindow ?? 0; + const verifyWindowDays = data?.getSigningKeys.verifyWindowDays ?? 7; + + return ( + <> + + + + {t`Key ID`} + {t`Revoked`} + {t`Status`} + + {t`Uses (last ${verifyWindowDays}d)`} + + + + {signingKeys.map((signingKey) => { + const status = getStatusTag(signingKey); + const isRevoked = isDefined(signingKey.revokedAt); + + return ( + + + +
+ setSelectedSigningKey(null)} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/graphql/mutations/revokeSigningKey.ts b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/graphql/mutations/revokeSigningKey.ts new file mode 100644 index 0000000000..224180160f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/graphql/mutations/revokeSigningKey.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const REVOKE_SIGNING_KEY = gql` + mutation RevokeSigningKey($id: UUID!) { + revokeSigningKey(id: $id) { + id + isCurrent + createdAt + revokedAt + verifyCountInWindow + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/graphql/queries/getSigningKeys.ts b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/graphql/queries/getSigningKeys.ts new file mode 100644 index 0000000000..635aec3ccb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/graphql/queries/getSigningKeys.ts @@ -0,0 +1,18 @@ +import { gql } from '@apollo/client'; + +export const GET_SIGNING_KEYS = gql` + query GetSigningKeys { + getSigningKeys { + signingKeys { + id + publicKey + isCurrent + createdAt + revokedAt + verifyCountInWindow + } + legacyVerifyCountInWindow + verifyWindowDays + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/hooks/useRevokeSigningKey.ts b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/hooks/useRevokeSigningKey.ts new file mode 100644 index 0000000000..129eb28bb6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/signing-keys/hooks/useRevokeSigningKey.ts @@ -0,0 +1,43 @@ +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { GET_SIGNING_KEYS } from '@/settings/admin-panel/signing-keys/graphql/queries/getSigningKeys'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useMutation } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { RevokeSigningKeyDocument } from '~/generated-admin/graphql'; +import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util'; + +export const useRevokeSigningKey = (onSuccess?: () => void) => { + const apolloAdminClient = useApolloAdminClient(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const [isRevoking, setIsRevoking] = useState(false); + const [revokeSigningKeyMutation] = useMutation(RevokeSigningKeyDocument, { + client: apolloAdminClient, + }); + + const revokeSigningKey = async (id: string) => { + setIsRevoking(true); + + try { + await revokeSigningKeyMutation({ + variables: { id }, + refetchQueries: [{ query: GET_SIGNING_KEYS }], + awaitRefetchQueries: true, + }); + + enqueueSuccessSnackBar({ message: t`Signing key revoked` }); + onSuccess?.(); + } catch (error) { + enqueueErrorSnackBar({ + message: CombinedGraphQLErrors.is(error) + ? getErrorMessageFromApolloError(error) + : t`Failed to revoke signing key. Please try again later.`, + }); + } finally { + setIsRevoking(false); + } + }; + + return { revokeSigningKey, isRevoking }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts index d62e94f64f..d56887d7c9 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -15,6 +15,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service'; import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service'; +import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service'; import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service'; import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service'; import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service'; @@ -29,6 +30,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { ImpersonationModule } from 'src/engine/core-modules/impersonation/impersonation.module'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module'; @@ -76,6 +78,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi UserVarsModule, UpgradeModule, UserModule, + JwtModule, ], providers: [ AdminPanelResolver, @@ -85,6 +88,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi AdminPanelBillingService, AdminPanelChatService, AdminPanelConfigService, + AdminPanelSigningKeyService, AdminPanelVersionService, AdminPanelHealthService, AdminPanelQueueService, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index f7c97b8d7c..6e284604b6 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -25,6 +25,9 @@ import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/con import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto'; import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto'; import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto'; +import { RevokeSigningKeyInput } from 'src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input'; +import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto'; +import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto'; import { SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto'; import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input'; import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto'; @@ -37,6 +40,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service'; import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service'; +import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service'; import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service'; import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service'; import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service'; @@ -98,6 +102,7 @@ export class AdminPanelResolver { private readonly adminConfigService: AdminPanelConfigService, private readonly adminVersionService: AdminPanelVersionService, private readonly adminPanelHealthService: AdminPanelHealthService, + private readonly adminPanelSigningKeyService: AdminPanelSigningKeyService, private readonly applicationRegistrationService: ApplicationRegistrationService, private adminPanelQueueService: AdminPanelQueueService, private featureFlagService: FeatureFlagService, @@ -729,4 +734,18 @@ export class AdminPanelResolver { return this.upgradeStatusService.getWorkspaceStatuses(workspaceIds); } + + @UseGuards(AdminPanelGuard) + @Query(() => SigningKeysAdminPanelDTO) + async getSigningKeys(): Promise { + return this.adminPanelSigningKeyService.getSigningKeys(); + } + + @UseGuards(AdminPanelGuard) + @Mutation(() => SigningKeyDTO) + async revokeSigningKey( + @Args() { id }: RevokeSigningKeyInput, + ): Promise { + return this.adminPanelSigningKeyService.revokeSigningKey(id); + } } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input.ts new file mode 100644 index 0000000000..7997cad214 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input.ts @@ -0,0 +1,12 @@ +import { ArgsType, Field } from '@nestjs/graphql'; +import { IsNotEmpty, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ArgsType() +export class RevokeSigningKeyInput { + @Field(() => UUIDScalarType) + @IsNotEmpty() + @IsUUID() + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/signing-key.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/signing-key.dto.ts new file mode 100644 index 0000000000..2bec304aa4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/signing-key.dto.ts @@ -0,0 +1,24 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType() +export class SigningKeyDTO { + @Field(() => UUIDScalarType) + id: string; + + @Field() + publicKey: string; + + @Field() + isCurrent: boolean; + + @Field(() => Date) + createdAt: Date; + + @Field(() => Date, { nullable: true }) + revokedAt: Date | null; + + @Field(() => Int) + verifyCountInWindow: number; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto.ts new file mode 100644 index 0000000000..8eb6b8c2cf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto.ts @@ -0,0 +1,15 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto'; + +@ObjectType() +export class SigningKeysAdminPanelDTO { + @Field(() => [SigningKeyDTO]) + signingKeys: SigningKeyDTO[]; + + @Field(() => Int) + legacyVerifyCountInWindow: number; + + @Field(() => Int) + verifyWindowDays: number; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service.ts new file mode 100644 index 0000000000..d11fd356ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; + +import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto'; +import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto'; +import { type 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 { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service'; + +@Injectable() +export class AdminPanelSigningKeyService { + constructor( + private readonly jwtKeyManagerService: JwtKeyManagerService, + private readonly signingKeyVerifyCounterService: SigningKeyVerifyCounterService, + ) {} + + async getSigningKeys(): Promise { + const signingKeys = await this.jwtKeyManagerService.listSigningKeys(); + const usage = await this.signingKeyVerifyCounterService.getUsageInWindow( + signingKeys.map((signingKey) => signingKey.id), + ); + + return { + signingKeys: signingKeys.map((signingKey) => + this.toSigningKeyDTO(signingKey, usage.byKid[signingKey.id] ?? 0), + ), + legacyVerifyCountInWindow: usage.legacyCount, + verifyWindowDays: usage.windowDays, + }; + } + + async revokeSigningKey(id: string): Promise { + const revoked = await this.jwtKeyManagerService.revokeSigningKey(id); + const usage = await this.signingKeyVerifyCounterService.getUsageInWindow([ + revoked.id, + ]); + + return this.toSigningKeyDTO(revoked, usage.byKid[revoked.id] ?? 0); + } + + private toSigningKeyDTO( + signingKey: SigningKeyEntity, + verifyCountInWindow: number, + ): SigningKeyDTO { + return { + id: signingKey.id, + publicKey: signingKey.publicKey, + isCurrent: signingKey.isCurrent, + createdAt: signingKey.createdAt, + revokedAt: signingKey.revokedAt, + verifyCountInWindow, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts b/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts index 9552c81e3c..e47120df53 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts @@ -9,6 +9,7 @@ import { export const JwtKeyManagerExceptionCode = appendCommonExceptionCode({ INVALID_PRIVATE_KEY: 'INVALID_PRIVATE_KEY', + SIGNING_KEY_NOT_FOUND: 'SIGNING_KEY_NOT_FOUND', } as const); const getJwtKeyManagerExceptionUserFriendlyMessage = ( @@ -16,6 +17,7 @@ const getJwtKeyManagerExceptionUserFriendlyMessage = ( ): MessageDescriptor => { switch (code) { case JwtKeyManagerExceptionCode.INVALID_PRIVATE_KEY: + case JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND: case JwtKeyManagerExceptionCode.INTERNAL_SERVER_ERROR: return STANDARD_ERROR_MESSAGE; default: 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 eb86025490..ebe6c1cdf3 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 @@ -11,6 +11,7 @@ import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-k 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 { 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'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -44,7 +45,12 @@ const InternalJwtModule = NestJwtModule.registerAsync({ JwtWrapperService, JwtKeyManagerService, SigningKeyEntityCacheProviderService, + SigningKeyVerifyCounterService, + ], + exports: [ + JwtWrapperService, + JwtKeyManagerService, + SigningKeyVerifyCounterService, ], - exports: [JwtWrapperService, JwtKeyManagerService], }) 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 4ded09a65c..1bfe2466b4 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 @@ -21,6 +21,7 @@ export type CurrentSigningKey = { }; const UNIQUE_VIOLATION_PG_CODE = '23505'; +const CURRENT_SIGNING_KEY_LOCAL_TTL_MS = 60 * 1000; @Injectable() export class JwtKeyManagerService { @@ -28,6 +29,7 @@ export class JwtKeyManagerService { private currentSigningKeyPromise: Promise | null = null; + private currentSigningKeyCachedAt = 0; constructor( @InjectRepository(SigningKeyEntity) @@ -37,8 +39,13 @@ export class JwtKeyManagerService { ) {} async getCurrentSigningKey(): Promise { - if (this.currentSigningKeyPromise === null) { + const isLocalCacheExpired = + Date.now() - this.currentSigningKeyCachedAt > + CURRENT_SIGNING_KEY_LOCAL_TTL_MS; + + if (!isDefined(this.currentSigningKeyPromise) || isLocalCacheExpired) { this.currentSigningKeyPromise = this.loadOrCreateCurrentSigningKey(); + this.currentSigningKeyCachedAt = Date.now(); } try { @@ -46,11 +53,13 @@ export class JwtKeyManagerService { if (!isDefined(result)) { this.currentSigningKeyPromise = null; + this.currentSigningKeyCachedAt = 0; } return result; } catch (error) { this.currentSigningKeyPromise = null; + this.currentSigningKeyCachedAt = 0; throw error; } } @@ -63,6 +72,47 @@ export class JwtKeyManagerService { return this.coreEntityCacheService.get('signingKeyPublicKey', id); } + async listSigningKeys(): Promise { + return this.signingKeyRepository.find({ + order: { createdAt: 'DESC' }, + }); + } + + async revokeSigningKey(id: string): Promise { + if (!isNonEmptyString(id) || !isValidUuid(id)) { + throw new JwtKeyManagerException( + `Invalid signing key id: ${id}`, + JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND, + ); + } + + const existing = await this.signingKeyRepository.findOne({ where: { id } }); + + if (!isDefined(existing)) { + throw new JwtKeyManagerException( + `Signing key not found: ${id}`, + JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND, + ); + } + + if (!isDefined(existing.revokedAt)) { + await this.signingKeyRepository.update( + { id }, + { + revokedAt: new Date(), + isCurrent: false, + privateKey: null, + }, + ); + } + + await this.coreEntityCacheService.invalidate('signingKeyPublicKey', id); + this.currentSigningKeyPromise = null; + this.currentSigningKeyCachedAt = 0; + + return this.signingKeyRepository.findOneByOrFail({ id }); + } + private async loadOrCreateCurrentSigningKey(): Promise { try { const existing = await this.findCurrentSigningKeyRow(); diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index f13557e263..d4b271c0d9 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -21,6 +21,7 @@ import { JWT_LEGACY_ALGORITHM, } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant'; import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service'; +import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { decodeJwtHeader } from 'src/engine/core-modules/jwt/utils/decode-jwt-header.util'; import { decodeJwtPayload } from 'src/engine/core-modules/jwt/utils/decode-jwt-payload.util'; @@ -45,6 +46,7 @@ export class JwtWrapperService { private readonly jwtService: JwtService, private readonly twentyConfigService: TwentyConfigService, private readonly jwtKeyManagerService: JwtKeyManagerService, + private readonly signingKeyVerifyCounterService: SigningKeyVerifyCounterService, ) {} async signAsyncOrThrow( @@ -131,6 +133,7 @@ export class JwtWrapperService { options?: JwtVerifyOptions, // oxlint-disable-next-line @typescripttypescript/no-explicit-any ): Promise { + const header = decodeJwtHeader(token); const payload = this.decode(token, { json: true }); if (!isDefined(payload)) { @@ -140,7 +143,14 @@ export class JwtWrapperService { const { key, algorithm } = await this.resolveVerificationKey(token); try { - return jwt.verify(token, key, { ...options, algorithms: [algorithm] }); + const verified = jwt.verify(token, key, { + ...options, + algorithms: [algorithm], + }); + + this.recordVerifyForAlgorithm(algorithm, header); + + return verified; } catch (error) { // API_KEY tokens created before 12/12/2025 were accidentally signed // with ACCESS type instead of API_KEY. Fall back to the legacy ACCESS @@ -154,11 +164,15 @@ export class JwtWrapperService { if (isDefined(appSecretBody)) { try { - return jwt.verify( + const verified = jwt.verify( token, this.generateAppSecret(JwtTokenTypeEnum.ACCESS, appSecretBody), { ...options, algorithms: [JWT_LEGACY_ALGORITHM] }, ); + + this.recordVerifyForAlgorithm(JWT_LEGACY_ALGORITHM, header); + + return verified; } catch { throw this.toAuthException(error); } @@ -185,6 +199,22 @@ export class JwtWrapperService { return ExtractJwt.fromAuthHeaderAsBearerToken(); } + private recordVerifyForAlgorithm( + algorithm: ResolvedVerificationKey['algorithm'], + header: ReturnType, + ): void { + if ( + algorithm === JWT_ASYMMETRIC_ALGORITHM && + isAsymmetricJwtHeader(header) + ) { + this.signingKeyVerifyCounterService.recordKidVerify(header.kid); + + return; + } + + this.signingKeyVerifyCounterService.recordLegacyVerify(); + } + private extractAppSecretBody(payload: JwtPayload): string | undefined { const workspaceParse = APP_SECRET_BODY_WORKSPACE_SCHEMA.safeParse(payload); diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-verify-counter.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-verify-counter.service.ts new file mode 100644 index 0000000000..62bf95fa42 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-verify-counter.service.ts @@ -0,0 +1,183 @@ +import { + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; +import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; +import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; + +const WINDOW_DAYS = 7; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const BUCKET_TTL_MS = (WINDOW_DAYS + 1) * ONE_DAY_MS; +const FLUSH_INTERVAL_MS = 30 * 1000; +const REDIS_KEY_PREFIX = 'signing-key-verifies'; +const LEGACY_BUCKET_ID = 'legacy'; + +export type SigningKeyUsage = { + byKid: Record; + legacyCount: number; + windowDays: number; +}; + +@Injectable() +export class SigningKeyVerifyCounterService + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(SigningKeyVerifyCounterService.name); + + private pendingCounts = new Map(); + private flushIntervalHandle: NodeJS.Timeout | null = null; + + constructor( + @InjectCacheStorage(CacheStorageNamespace.EngineMetrics) + private readonly cacheStorage: CacheStorageService, + ) {} + + onModuleInit() { + this.flushIntervalHandle = setInterval(() => { + void this.flush(); + }, FLUSH_INTERVAL_MS); + + if (isDefined(this.flushIntervalHandle.unref)) { + this.flushIntervalHandle.unref(); + } + } + + async onModuleDestroy() { + if (isDefined(this.flushIntervalHandle)) { + clearInterval(this.flushIntervalHandle); + this.flushIntervalHandle = null; + } + + await this.flush(); + } + + recordKidVerify(kid: string): void { + this.increment(kid); + } + + recordLegacyVerify(): void { + this.increment(LEGACY_BUCKET_ID); + } + + async getUsageInWindow(kids: string[]): Promise { + await this.flush(); + + const bucketIds = [...kids, LEGACY_BUCKET_ID]; + const keysByBucket = bucketIds.map((bucketId) => + this.buildBucketKeysInWindow(bucketId), + ); + + let valuesByBucket: (number | undefined)[][]; + + try { + const flatValues = await this.cacheStorage.mget( + keysByBucket.flat(), + ); + + valuesByBucket = bucketIds.map((_, bucketIndex) => + flatValues.slice( + bucketIndex * WINDOW_DAYS, + (bucketIndex + 1) * WINDOW_DAYS, + ), + ); + } catch (error) { + this.logger.warn( + `Failed to read signing key verify counts: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + valuesByBucket = bucketIds.map(() => []); + } + + const sumWindow = (windowValues: (number | undefined)[]): number => + windowValues.reduce( + (total, value) => + isDefined(value) && Number.isFinite(value) ? total + value : total, + 0, + ); + + return { + byKid: Object.fromEntries( + kids.map((kid, kidIndex) => [kid, sumWindow(valuesByBucket[kidIndex])]), + ), + legacyCount: sumWindow(valuesByBucket[bucketIds.length - 1]), + windowDays: WINDOW_DAYS, + }; + } + + private increment(bucketId: string): void { + const key = this.buildBucketKey(bucketId, Date.now()); + + this.pendingCounts.set(key, (this.pendingCounts.get(key) ?? 0) + 1); + } + + private async flush(): Promise { + if (this.pendingCounts.size === 0) { + return; + } + + const snapshot = this.pendingCounts; + + this.pendingCounts = new Map(); + + const entries = Array.from(snapshot.entries()); + const incrResults = await Promise.allSettled( + entries.map(([key, increment]) => + this.cacheStorage.incrBy(key, increment), + ), + ); + + const incrementedKeys: string[] = []; + let failedCount = 0; + + for (let index = 0; index < entries.length; index++) { + const [key, increment] = entries[index]; + + if (incrResults[index].status === 'rejected') { + this.pendingCounts.set( + key, + (this.pendingCounts.get(key) ?? 0) + increment, + ); + failedCount++; + continue; + } + + incrementedKeys.push(key); + } + + await Promise.allSettled( + incrementedKeys.map((key) => + this.cacheStorage.expire(key, BUCKET_TTL_MS), + ), + ); + + if (failedCount > 0) { + this.logger.warn( + `Failed to flush ${failedCount}/${entries.length} signing key verify bucket(s); re-buffered for next flush`, + ); + } + } + + private buildBucketKey(bucketId: string, timestamp: number): string { + const bucketStart = Math.floor(timestamp / ONE_DAY_MS) * ONE_DAY_MS; + + return `${REDIS_KEY_PREFIX}:${bucketId}:${bucketStart}`; + } + + private buildBucketKeysInWindow(bucketId: string): string[] { + const currentBucketStart = Math.floor(Date.now() / ONE_DAY_MS) * ONE_DAY_MS; + + return Array.from( + { length: WINDOW_DAYS }, + (_, index) => + `${REDIS_KEY_PREFIX}:${bucketId}:${currentBucketStart - index * ONE_DAY_MS}`, + ); + } +} diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts new file mode 100644 index 0000000000..4ef2fdffc0 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts @@ -0,0 +1,268 @@ +import { randomUUID } from 'crypto'; + +import { gql } from 'graphql-tag'; +import * as jwt from 'jsonwebtoken'; +import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util'; +import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { signUp } from 'test/integration/graphql/utils/sign-up.util'; +import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; +import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util'; + +import { + type AccessTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; + +import { + PREVIOUS_PRIVATE_KEY_PEM, + PREVIOUS_PUBLIC_KEY_PEM, +} from './jwt-key-rotation.fixture'; + +const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({ + sub: payload.sub, + userId: payload.userId, + workspaceId: payload.workspaceId, + workspaceMemberId: payload.workspaceMemberId, + userWorkspaceId: payload.userWorkspaceId, + authProvider: payload.authProvider, + isImpersonating: false, + type: JwtTokenTypeEnum.ACCESS, +}); + +const GET_SIGNING_KEYS = gql` + query GetSigningKeys { + getSigningKeys { + signingKeys { + id + publicKey + isCurrent + createdAt + revokedAt + verifyCountInWindow + } + legacyVerifyCountInWindow + verifyWindowDays + } + } +`; + +const REVOKE_SIGNING_KEY = gql` + mutation RevokeSigningKey($id: UUID!) { + revokeSigningKey(id: $id) { + id + isCurrent + revokedAt + } + } +`; + +describe('Admin panel signing keys (integration)', () => { + let sharedAccessToken: string; + let sharedAccessPayload: AccessTokenJwtPayload; + + beforeAll(async () => { + const uniqueEmail = `admin-signing-keys-${randomUUID()}@example.com`; + + const { data: signUpData } = await signUp({ + input: { email: uniqueEmail, password: 'Test123!@#' }, + expectToFail: false, + }); + + const workspaceAgnosticToken = + signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + await global.testDataSource.query( + 'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1', + [uniqueEmail], + ); + + const { data: workspaceData } = await signUpInNewWorkspace({ + accessToken: workspaceAgnosticToken, + expectToFail: false, + }); + + const subdomainUrl = + workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl; + const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token; + + const { data: tokensData } = await getAuthTokensFromLoginToken({ + loginToken, + origin: subdomainUrl, + expectToFail: false, + }); + + sharedAccessToken = + tokensData.getAuthTokensFromLoginToken.tokens + .accessOrWorkspaceAgnosticToken.token; + sharedAccessPayload = jwt.decode( + sharedAccessToken, + ) as AccessTokenJwtPayload; + }); + + afterAll(async () => { + try { + await deleteUser({ + accessToken: sharedAccessToken, + expectToFail: false, + }); + } catch { + /* */ + } + }); + + it('exposes signing keys with current marker and a 7-day window', async () => { + const response = await makeAdminPanelAPIRequest({ + query: GET_SIGNING_KEYS, + }); + + expect(response.body.errors).toBeUndefined(); + + const payload = response.body.data?.getSigningKeys; + + expect(payload).toBeDefined(); + expect(payload.verifyWindowDays).toBe(7); + expect(Array.isArray(payload.signingKeys)).toBe(true); + expect(payload.signingKeys.length).toBeGreaterThan(0); + + const currentKeys = payload.signingKeys.filter( + (signingKey: { isCurrent: boolean }) => signingKey.isCurrent === true, + ); + + expect(currentKeys).toHaveLength(1); + expect(currentKeys[0].revokedAt).toBeNull(); + expect(typeof currentKeys[0].publicKey).toBe('string'); + expect(typeof payload.legacyVerifyCountInWindow).toBe('number'); + }); + + it('revokes a non-current signing key, keeps the current key active, and rejects tokens signed with the revoked kid', async () => { + const seededRow = await global.testDataSource.query( + `SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`, + ); + const seededCurrentKid: string = seededRow[0].id; + + const obsoleteKid = randomUUID(); + + await global.testDataSource.query( + `INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent") + VALUES ($1, $2, NULL, false)`, + [obsoleteKid, PREVIOUS_PUBLIC_KEY_PEM], + ); + + const tokenSignedByObsoleteKey = jwt.sign( + buildAccessTokenPayload(sharedAccessPayload), + PREVIOUS_PRIVATE_KEY_PEM, + { algorithm: 'ES256', keyid: obsoleteKid, expiresIn: '5m' }, + ); + + const { data: userBeforeRevoke, errors: userBeforeRevokeErrors } = + await getCurrentUser({ + accessToken: tokenSignedByObsoleteKey, + expectToFail: false, + }); + + expect(userBeforeRevokeErrors).toBeUndefined(); + expect(userBeforeRevoke?.currentUser?.id).toBe(sharedAccessPayload.userId); + + const response = await makeAdminPanelAPIRequest({ + query: REVOKE_SIGNING_KEY, + variables: { id: obsoleteKid }, + }); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data?.revokeSigningKey?.id).toBe(obsoleteKid); + expect(response.body.data?.revokeSigningKey?.isCurrent).toBe(false); + expect(response.body.data?.revokeSigningKey?.revokedAt).not.toBeNull(); + + const stillCurrentRows = await global.testDataSource.query( + `SELECT "id", "isCurrent", "revokedAt" FROM core."signingKey" + WHERE "id" = $1`, + [seededCurrentKid], + ); + + expect(stillCurrentRows[0].isCurrent).toBe(true); + expect(stillCurrentRows[0].revokedAt).toBeNull(); + + const { data: userAfterRevoke, errors: userAfterRevokeErrors } = + await getCurrentUser({ + accessToken: tokenSignedByObsoleteKey, + expectToFail: true, + }); + + expect(userAfterRevoke?.currentUser).toBeFalsy(); + expect(userAfterRevokeErrors).toBeDefined(); + + await global.testDataSource.query( + `DELETE FROM core."signingKey" WHERE "id" = $1`, + [obsoleteKid], + ); + }); + + it('revokes the current signing key and the next sign mints a new current key', async () => { + const before = await global.testDataSource.query( + `SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`, + ); + const previousCurrentKid: string = before[0].id; + + const response = await makeAdminPanelAPIRequest({ + query: REVOKE_SIGNING_KEY, + variables: { id: previousCurrentKid }, + }); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data?.revokeSigningKey?.isCurrent).toBe(false); + expect(response.body.data?.revokeSigningKey?.revokedAt).not.toBeNull(); + + const uniqueEmail = `signing-key-revoke-${randomUUID()}@example.com`; + + const { data: signUpData } = await signUp({ + input: { email: uniqueEmail, password: 'Test123!@#' }, + expectToFail: false, + }); + + const workspaceAgnosticToken = + signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + await global.testDataSource.query( + 'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1', + [uniqueEmail], + ); + + const { data: workspaceData } = await signUpInNewWorkspace({ + accessToken: workspaceAgnosticToken, + expectToFail: false, + }); + + const subdomainUrl = + workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl; + const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token; + + const { data: tokensData } = await getAuthTokensFromLoginToken({ + loginToken, + origin: subdomainUrl, + expectToFail: false, + }); + + const accessToken = + tokensData.getAuthTokensFromLoginToken.tokens + .accessOrWorkspaceAgnosticToken.token; + const decoded = decodeJwtCompleteOrThrow(accessToken); + + expect(decoded.header.alg).toBe('ES256'); + expect(decoded.header.kid).not.toBe(previousCurrentKid); + + const { errors } = await getCurrentUser({ + accessToken, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + + try { + await deleteUser({ accessToken, expectToFail: false }); + } catch { + /* */ + } + }); +});