feat(admin-panel): signing keys management tab with usage tracking (#20586)
## Summary - Adds a new admin-only **Security** tab to the Admin Panel (alongside General/Apps/AI/Config/Health) containing a **Signing Keys** section. The tab is intentionally introduced now so the upcoming **Encryption rotation** work can land as a sibling section. - Lists every JWT signing key with key id, `createdAt`, `revokedAt`, current/active/revoked status, and a **7-day verification count** read from Redis. A trailing row aggregates **legacy HS256** verifications so it is clear when the deprecated path is still in use. - Lets an admin **revoke** a public key. Revoking the current key drops `isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and clears the in-process cached current key; the existing lazy path in `JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current key on the next sign. ## Backend - `SigningKeyVerifyCounterService` — bucketed Redis counter under the existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL refreshed on every increment, batched read via `mget`. Failures are swallowed and logged at `warn` so a Redis hiccup cannot break auth. - `JwtWrapperService.verifyJwtToken` records verifies **after success** for both ES256 (`kid` as identifier) and HS256 (the literal `legacy` identifier). - `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`: list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent, validates the UUID, invalidates the public-key cache, and resets the cached current-key promise. - `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey` (mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they are admin-only, like the 35 existing admin-only methods on this resolver. `privateKey` is never returned over GraphQL. ## Frontend - New `SECURITY` tab id wired into `SettingsAdminContent` and `SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`). - `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly reuse existing admin-panel components: `Section`, `H2Title`, `Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`, `Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the queue retry/delete modals. Only one minimal styled helper for the monospaced UUID rendering. - `useRevokeSigningKey` uses `useApolloAdminClient`, refetches `GetSigningKeys`, shows success/error snackbars (same pattern as `useRetryJobs`/`useDeleteJobs`). <img width="1293" height="881" alt="image" src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c" />
This commit is contained in:
@@ -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<ModelsDevModelSuggestion>;
|
||||
getQueueJobs: QueueJobsResponse;
|
||||
getQueueMetrics: QueueMetricsData;
|
||||
getSigningKeys: SigningKeysAdminPanelDto;
|
||||
getSystemHealthStatus: SystemHealth;
|
||||
getUpgradeStatus: Array<WorkspaceUpgradeStatus>;
|
||||
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<Scalars['DateTime']>;
|
||||
verifyCountInWindow: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type SigningKeysAdminPanelDto = {
|
||||
__typename?: 'SigningKeysAdminPanelDTO';
|
||||
legacyVerifyCountInWindow: Scalars['Int'];
|
||||
signingKeys: Array<SigningKeyDto>;
|
||||
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<string>, oAuthScopes: Array<string>, 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<UserInfoFragmentFragment, unknown>;
|
||||
@@ -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<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>;
|
||||
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<ClearMaintenanceModeMutation, ClearMaintenanceModeMutationVariables>;
|
||||
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<SetMaintenanceModeMutation, SetMaintenanceModeMutationVariables>;
|
||||
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<GetMaintenanceModeQuery, GetMaintenanceModeQueryVariables>;
|
||||
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<GetMaintenanceModeQuery, GetMaintenanceModeQueryVariables>;
|
||||
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<RevokeSigningKeyMutation, RevokeSigningKeyMutationVariables>;
|
||||
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<GetSigningKeysQuery, GetSigningKeysQueryVariables>;
|
||||
+8
@@ -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 = () => {
|
||||
<SettingsAdminUpgradeStatusListCard upgradeStatus={upgradeStatus} />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Signing Keys`}
|
||||
description={t`Asymmetric public keys used to sign and verify access tokens. Revoking a key immediately invalidates every JWT signed with it.`}
|
||||
/>
|
||||
<SettingsAdminSigningKeysTable />
|
||||
</Section>
|
||||
<SettingsAdminMaintenanceMode />
|
||||
</>
|
||||
);
|
||||
|
||||
+32
@@ -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 (
|
||||
<ConfirmationModal
|
||||
modalInstanceId={modalInstanceId}
|
||||
title={t`Revoke signing key`}
|
||||
subtitle={subtitle}
|
||||
onConfirmClick={onConfirm}
|
||||
onClose={onClose}
|
||||
confirmButtonText={t`Revoke`}
|
||||
confirmButtonAccent="danger"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+192
@@ -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<SigningKeyDto, 'isCurrent' | 'revokedAt'>,
|
||||
): { 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<SelectedSigningKey | null>(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 <SettingsSectionSkeletonLoader />;
|
||||
}
|
||||
|
||||
const signingKeys = data?.getSigningKeys.signingKeys ?? [];
|
||||
const legacyVerifyCountInWindow =
|
||||
data?.getSigningKeys.legacyVerifyCountInWindow ?? 0;
|
||||
const verifyWindowDays = data?.getSigningKeys.verifyWindowDays ?? 7;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow gridTemplateColumns={SIGNING_KEYS_GRID_TEMPLATE_COLUMNS}>
|
||||
<TableHeader>{t`Key ID`}</TableHeader>
|
||||
<TableHeader>{t`Revoked`}</TableHeader>
|
||||
<TableHeader>{t`Status`}</TableHeader>
|
||||
<TableHeader align="right">
|
||||
{t`Uses (last ${verifyWindowDays}d)`}
|
||||
</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
{signingKeys.map((signingKey) => {
|
||||
const status = getStatusTag(signingKey);
|
||||
const isRevoked = isDefined(signingKey.revokedAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={signingKey.id}
|
||||
gridTemplateColumns={SIGNING_KEYS_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
<TableCell overflow="hidden" gap={themeCssVariables.spacing[1]}>
|
||||
<OverflowingTextWithTooltip
|
||||
text={signingKey.id}
|
||||
tooltipContent={t`Created on ${beautifyExactDateTime(signingKey.createdAt)}`}
|
||||
alwaysShowTooltip
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
ariaLabel={t`Copy key ID`}
|
||||
onClick={() =>
|
||||
copyToClipboard(signingKey.id, t`Key ID copied`)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isDefined(signingKey.revokedAt) ? (
|
||||
<OverflowingTextWithTooltip
|
||||
text={beautifyPastDateRelativeToNow(signingKey.revokedAt)}
|
||||
tooltipContent={beautifyExactDateTime(
|
||||
signingKey.revokedAt,
|
||||
)}
|
||||
alwaysShowTooltip
|
||||
/>
|
||||
) : (
|
||||
EM_DASH
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tag text={status.text} color={status.color} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{signingKey.verifyCountInWindow}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
title={t`Revoke`}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
disabled={isRevoked || isRevoking}
|
||||
onClick={() =>
|
||||
handleRevokeClick({
|
||||
id: signingKey.id,
|
||||
isCurrent: signingKey.isCurrent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
<TableRow gridTemplateColumns={SIGNING_KEYS_GRID_TEMPLATE_COLUMNS}>
|
||||
<TableCell overflow="hidden">
|
||||
<OverflowingTextWithTooltip
|
||||
text={t`Legacy (HS256)`}
|
||||
tooltipContent={t`Legacy HS256 verifications across all tokens`}
|
||||
alwaysShowTooltip
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{EM_DASH}</TableCell>
|
||||
<TableCell>
|
||||
<Tag text={t`Legacy`} color="gray" />
|
||||
</TableCell>
|
||||
<TableCell align="right">{legacyVerifyCountInWindow}</TableCell>
|
||||
<TableCell align="right" />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<SettingsAdminRevokeSigningKeyConfirmationModal
|
||||
modalInstanceId={REVOKE_MODAL_ID}
|
||||
isCurrent={selectedSigningKey?.isCurrent === true}
|
||||
onConfirm={handleConfirmRevoke}
|
||||
onClose={() => setSelectedSigningKey(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+43
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SigningKeysAdminPanelDTO> {
|
||||
return this.adminPanelSigningKeyService.getSigningKeys();
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => SigningKeyDTO)
|
||||
async revokeSigningKey(
|
||||
@Args() { id }: RevokeSigningKeyInput,
|
||||
): Promise<SigningKeyDTO> {
|
||||
return this.adminPanelSigningKeyService.revokeSigningKey(id);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+53
@@ -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<SigningKeysAdminPanelDTO> {
|
||||
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<SigningKeyDTO> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+51
-1
@@ -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<CurrentSigningKey | null> | null =
|
||||
null;
|
||||
private currentSigningKeyCachedAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SigningKeyEntity)
|
||||
@@ -37,8 +39,13 @@ export class JwtKeyManagerService {
|
||||
) {}
|
||||
|
||||
async getCurrentSigningKey(): Promise<CurrentSigningKey | null> {
|
||||
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<SigningKeyEntity[]> {
|
||||
return this.signingKeyRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSigningKey(id: string): Promise<SigningKeyEntity> {
|
||||
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<CurrentSigningKey | null> {
|
||||
try {
|
||||
const existing = await this.findCurrentSigningKeyRow();
|
||||
|
||||
@@ -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<any> {
|
||||
const header = decodeJwtHeader(token);
|
||||
const payload = this.decode<JwtPayload>(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<typeof decodeJwtHeader>,
|
||||
): 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);
|
||||
|
||||
|
||||
+183
@@ -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<string, number>;
|
||||
legacyCount: number;
|
||||
windowDays: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SigningKeyVerifyCounterService
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(SigningKeyVerifyCounterService.name);
|
||||
|
||||
private pendingCounts = new Map<string, number>();
|
||||
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<SigningKeyUsage> {
|
||||
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<number>(
|
||||
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<number>(
|
||||
(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<void> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+268
@@ -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 {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user