Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ee8a29cfa | ||
|
|
658420d5cc | ||
|
|
852173b670 | ||
|
|
04d12156c4 |
+314
@@ -0,0 +1,314 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext, useState } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
|
||||
import { GET_SIGNING_KEYS } from '@/settings/security/graphql/queries/getSigningKeys';
|
||||
import { ROTATE_SIGNING_KEY } from '@/settings/security/graphql/mutations/rotateSigningKey';
|
||||
import { RETIRE_SIGNING_KEY } from '@/settings/security/graphql/mutations/retireSigningKey';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { Tag, type TagColor } from 'twenty-ui/components';
|
||||
import { H2Title, IconKey, IconLock } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type SigningKey = {
|
||||
id: string;
|
||||
kid: string;
|
||||
algorithm: string;
|
||||
isActive: boolean;
|
||||
hasPrivateKey: boolean;
|
||||
createdAt: string;
|
||||
rotatedAt: string | null;
|
||||
retiredAt: string | null;
|
||||
};
|
||||
|
||||
const StyledTable = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
justify-content: space-between;
|
||||
padding: ${themeCssVariables.spacing[2]} 0;
|
||||
`;
|
||||
|
||||
const StyledKeyInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledKid = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledDate = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledActions = styled.div`
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledRotateContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledSkeletonContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const RETIRE_SIGNING_KEY_MODAL_ID = 'retire-signing-key-modal';
|
||||
|
||||
const getKeyStatus = (key: SigningKey): { label: string; color: TagColor } => {
|
||||
if (key.retiredAt) {
|
||||
return { label: 'Retired', color: 'red' };
|
||||
}
|
||||
|
||||
if (key.hasPrivateKey) {
|
||||
return { label: 'Active', color: 'green' };
|
||||
}
|
||||
|
||||
return { label: 'Rotated', color: 'orange' };
|
||||
};
|
||||
|
||||
export const SettingsSigningKeysCard = () => {
|
||||
const { t } = useLingui();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { openModal } = useModal();
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const hasEnterpriseAccess = currentWorkspace?.hasValidEnterpriseKey === true;
|
||||
|
||||
const [keyToRetire, setKeyToRetire] = useState<SigningKey | null>(null);
|
||||
|
||||
const { data, loading, refetch } = useQuery<{
|
||||
signingKeys: SigningKey[];
|
||||
}>(GET_SIGNING_KEYS, {
|
||||
fetchPolicy: 'network-only',
|
||||
skip: !hasEnterpriseAccess,
|
||||
});
|
||||
|
||||
const [rotateSigningKey, { loading: rotating }] = useMutation(
|
||||
ROTATE_SIGNING_KEY,
|
||||
{
|
||||
onCompleted: () => {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Signing key rotated successfully`,
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ message: error.message });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [retireSigningKey, { loading: retiring }] = useMutation(
|
||||
RETIRE_SIGNING_KEY,
|
||||
{
|
||||
onCompleted: () => {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Signing key retired successfully`,
|
||||
});
|
||||
setKeyToRetire(null);
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ message: error.message });
|
||||
setKeyToRetire(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const signingKeys: SigningKey[] = data?.signingKeys ?? [];
|
||||
|
||||
const activeKeyCount = signingKeys.filter(
|
||||
(key) => !key.retiredAt && key.hasPrivateKey,
|
||||
).length;
|
||||
|
||||
const lastRotationTimestamp = signingKeys.reduce<string | null>(
|
||||
(latest, key) => {
|
||||
if (!key.rotatedAt) return latest;
|
||||
if (!latest) return key.rotatedAt;
|
||||
|
||||
return new Date(key.rotatedAt) > new Date(latest)
|
||||
? key.rotatedAt
|
||||
: latest;
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const buildDescription = () => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (!loading && signingKeys.length > 0) {
|
||||
parts.push(
|
||||
t`${activeKeyCount} active ${activeKeyCount === 1 ? 'key' : 'keys'}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (lastRotationTimestamp) {
|
||||
parts.push(
|
||||
t`Last rotated ${new Date(lastRotationTimestamp).toLocaleDateString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
return t`Manage asymmetric JWT signing keys for enhanced security`;
|
||||
};
|
||||
|
||||
const handleRetireClick = (key: SigningKey) => {
|
||||
setKeyToRetire(key);
|
||||
openModal(RETIRE_SIGNING_KEY_MODAL_ID);
|
||||
};
|
||||
|
||||
const handleConfirmRetire = () => {
|
||||
if (keyToRetire) {
|
||||
retireSigningKey({ variables: { kid: keyToRetire.kid } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Signing Keys`}
|
||||
description={buildDescription()}
|
||||
adornment={
|
||||
<Tag
|
||||
text={t`Enterprise`}
|
||||
color="transparent"
|
||||
Icon={IconLock}
|
||||
variant="border"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{hasEnterpriseAccess ? (
|
||||
<>
|
||||
<Card rounded>
|
||||
{loading && (
|
||||
<StyledSkeletonContainer>
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Skeleton height={20} width="60%" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Skeleton height={20} width="55%" />
|
||||
<Skeleton height={16} width="35%" />
|
||||
</SkeletonTheme>
|
||||
</StyledSkeletonContainer>
|
||||
)}
|
||||
{!loading && signingKeys.length > 0 && (
|
||||
<StyledTable>
|
||||
{signingKeys.map((key) => {
|
||||
const status = getKeyStatus(key);
|
||||
|
||||
return (
|
||||
<StyledRow key={key.id}>
|
||||
<StyledKeyInfo>
|
||||
<StyledKid>
|
||||
{key.kid} ({key.algorithm})
|
||||
</StyledKid>
|
||||
<StyledDate>
|
||||
<Trans>Created</Trans>{' '}
|
||||
{new Date(key.createdAt).toLocaleDateString()}
|
||||
{key.rotatedAt && (
|
||||
<>
|
||||
{' · '}
|
||||
<Trans>Rotated</Trans>{' '}
|
||||
{new Date(key.rotatedAt).toLocaleDateString()}
|
||||
</>
|
||||
)}
|
||||
</StyledDate>
|
||||
</StyledKeyInfo>
|
||||
<StyledActions>
|
||||
<Tag
|
||||
text={status.label}
|
||||
color={status.color}
|
||||
variant="outline"
|
||||
/>
|
||||
{!key.retiredAt && !key.hasPrivateKey && (
|
||||
<Button
|
||||
title={t`Retire`}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={retiring}
|
||||
onClick={() => handleRetireClick(key)}
|
||||
/>
|
||||
)}
|
||||
</StyledActions>
|
||||
</StyledRow>
|
||||
);
|
||||
})}
|
||||
</StyledTable>
|
||||
)}
|
||||
{!loading && signingKeys.length === 0 && (
|
||||
<StyledTable>
|
||||
<StyledRow>
|
||||
<StyledKeyInfo>
|
||||
<StyledDate>
|
||||
<Trans>
|
||||
No signing keys found. Asymmetric signing may not be
|
||||
enabled.
|
||||
</Trans>
|
||||
</StyledDate>
|
||||
</StyledKeyInfo>
|
||||
</StyledRow>
|
||||
</StyledTable>
|
||||
)}
|
||||
<StyledRotateContainer>
|
||||
<Button
|
||||
title={t`Rotate Now`}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
Icon={IconKey}
|
||||
disabled={rotating || loading}
|
||||
onClick={() => rotateSigningKey()}
|
||||
/>
|
||||
</StyledRotateContainer>
|
||||
</Card>
|
||||
<ConfirmationModal
|
||||
modalInstanceId={RETIRE_SIGNING_KEY_MODAL_ID}
|
||||
title={t`Retire Signing Key`}
|
||||
subtitle={t`This action cannot be undone. Tokens signed with this key will no longer be verified. Are you sure you want to retire this key?`}
|
||||
onConfirmClick={handleConfirmRetire}
|
||||
confirmButtonText={t`Retire key`}
|
||||
loading={retiring}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SettingsEnterpriseFeatureGateCard
|
||||
description={t`Upgrade to Enterprise to manage signing keys.`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RETIRE_SIGNING_KEY = gql`
|
||||
mutation RetireSigningKey($kid: String!) {
|
||||
retireSigningKey(kid: $kid) {
|
||||
id
|
||||
kid
|
||||
algorithm
|
||||
isActive
|
||||
hasPrivateKey
|
||||
createdAt
|
||||
rotatedAt
|
||||
retiredAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ROTATE_SIGNING_KEY = gql`
|
||||
mutation RotateSigningKey {
|
||||
rotateSigningKey {
|
||||
id
|
||||
kid
|
||||
algorithm
|
||||
isActive
|
||||
hasPrivateKey
|
||||
createdAt
|
||||
rotatedAt
|
||||
retiredAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_SIGNING_KEYS = gql`
|
||||
query GetSigningKeys {
|
||||
signingKeys {
|
||||
id
|
||||
kid
|
||||
algorithm
|
||||
isActive
|
||||
hasPrivateKey
|
||||
createdAt
|
||||
rotatedAt
|
||||
retiredAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -19,6 +19,7 @@ import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/comp
|
||||
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
|
||||
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
|
||||
import { SettingsSecurityEditableProfileFields } from '@/settings/security/components/SettingsSecurityEditableProfileFields';
|
||||
import { SettingsSigningKeysCard } from '@/settings/security/components/SettingsSigningKeysCard';
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
|
||||
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
@@ -299,6 +300,7 @@ export const SettingsSecurity = () => {
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<SettingsSigningKeysCard />
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Other`}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSigningKeyEntity1770400000000 implements MigrationInterface {
|
||||
name = 'AddSigningKeyEntity1770400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."signingKey" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"kid" character varying NOT NULL,
|
||||
"publicKey" text NOT NULL,
|
||||
"privateKey" text,
|
||||
"algorithm" character varying NOT NULL DEFAULT 'ES256',
|
||||
"isActive" boolean NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"rotatedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"retiredAt" TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT "PK_signingKey_id" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "UQ_signingKey_kid" UNIQUE ("kid")
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "core"."signingKey"`);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ const richTextFieldMetadata = [
|
||||
] as FlatFieldMetadata[];
|
||||
|
||||
const mockFileUrlService = {
|
||||
signFileByIdUrl: jest.fn().mockReturnValue('signed-path'),
|
||||
signFileByIdUrl: jest.fn().mockResolvedValue('signed-path'),
|
||||
} as unknown as FileUrlService;
|
||||
|
||||
describe('RichTextFieldQueryResultGetterHandler', () => {
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ export class FilesFieldQueryResultGetterHandler
|
||||
const signedFilesFieldValue: SignedFileOutput[] = [];
|
||||
|
||||
for (const file of filesFieldValue) {
|
||||
const url = this.fileUrlService.signFileByIdUrl({
|
||||
const url = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: file.fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
|
||||
+29
-27
@@ -62,7 +62,7 @@ export class RichTextFieldQueryResultGetterHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
const signedBlocks = this.signBlocknoteImageUrls(
|
||||
const signedBlocks = await this.signBlocknoteImageUrls(
|
||||
blocknoteBlocks,
|
||||
workspaceId,
|
||||
);
|
||||
@@ -76,37 +76,39 @@ export class RichTextFieldQueryResultGetterHandler
|
||||
return record;
|
||||
}
|
||||
|
||||
signBlocknoteImageUrls = (
|
||||
signBlocknoteImageUrls = async (
|
||||
blocknoteBlocks: RichTextBlock[],
|
||||
workspaceId: string,
|
||||
): RichTextBlock[] => {
|
||||
return blocknoteBlocks.map((block: RichTextBlock) => {
|
||||
if (!isDefined(block.props?.url)) {
|
||||
return block;
|
||||
}
|
||||
): Promise<RichTextBlock[]> => {
|
||||
return Promise.all(
|
||||
blocknoteBlocks.map(async (block: RichTextBlock) => {
|
||||
if (!isDefined(block.props?.url)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const fileIdFromUrl = extractFileIdFromUrl(
|
||||
block.props.url,
|
||||
FileFolder.FilesField,
|
||||
);
|
||||
const fileIdFromUrl = extractFileIdFromUrl(
|
||||
block.props.url,
|
||||
FileFolder.FilesField,
|
||||
);
|
||||
|
||||
if (!isDefined(fileIdFromUrl)) {
|
||||
return block;
|
||||
}
|
||||
if (!isDefined(fileIdFromUrl)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const url = this.fileUrlService.signFileByIdUrl({
|
||||
fileId: fileIdFromUrl,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
});
|
||||
const url = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: fileIdFromUrl,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
});
|
||||
|
||||
return {
|
||||
...block,
|
||||
props: {
|
||||
...block.props,
|
||||
url,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
...block,
|
||||
props: {
|
||||
...block.props,
|
||||
url,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export class WorkspaceMemberQueryResultGetterHandler
|
||||
return workspaceMember;
|
||||
}
|
||||
|
||||
const signedUrl = this.fileUrlService.signFileByIdUrl({
|
||||
const signedUrl = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
|
||||
@@ -77,39 +77,42 @@ export class AdminPanelService {
|
||||
firstName: targetUser.firstName,
|
||||
lastName: targetUser.lastName,
|
||||
},
|
||||
workspaces: targetUser.userWorkspaces.map((userWorkspace) => ({
|
||||
id: userWorkspace.workspace.id,
|
||||
name: userWorkspace.workspace.displayName ?? '',
|
||||
totalUsers: userWorkspace.workspace.workspaceUsers.length,
|
||||
logo: isDefined(userWorkspace.workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
fileId: userWorkspace.workspace.logoFileId,
|
||||
workspaceId: userWorkspace.workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
})
|
||||
: undefined,
|
||||
allowImpersonation: userWorkspace.workspace.allowImpersonation,
|
||||
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
|
||||
subdomain: userWorkspace.workspace.subdomain,
|
||||
customDomain: userWorkspace.workspace.customDomain,
|
||||
isCustomDomainEnabled: userWorkspace.workspace.isCustomDomainEnabled,
|
||||
}),
|
||||
users: userWorkspace.workspace.workspaceUsers
|
||||
.filter((workspaceUser) => isDefined(workspaceUser.user))
|
||||
.map((workspaceUser) => ({
|
||||
id: workspaceUser.user.id,
|
||||
email: workspaceUser.user.email,
|
||||
firstName: workspaceUser.user.firstName,
|
||||
lastName: workspaceUser.user.lastName,
|
||||
})),
|
||||
featureFlags: allFeatureFlagKeys.map((key) => ({
|
||||
key,
|
||||
value:
|
||||
userWorkspace.workspace.featureFlags?.find(
|
||||
(flag) => flag.key === key,
|
||||
)?.value ?? false,
|
||||
})) as FeatureFlagEntity[],
|
||||
})),
|
||||
workspaces: await Promise.all(
|
||||
targetUser.userWorkspaces.map(async (userWorkspace) => ({
|
||||
id: userWorkspace.workspace.id,
|
||||
name: userWorkspace.workspace.displayName ?? '',
|
||||
totalUsers: userWorkspace.workspace.workspaceUsers.length,
|
||||
logo: isDefined(userWorkspace.workspace.logoFileId)
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: userWorkspace.workspace.logoFileId,
|
||||
workspaceId: userWorkspace.workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
})
|
||||
: undefined,
|
||||
allowImpersonation: userWorkspace.workspace.allowImpersonation,
|
||||
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
|
||||
subdomain: userWorkspace.workspace.subdomain,
|
||||
customDomain: userWorkspace.workspace.customDomain,
|
||||
isCustomDomainEnabled:
|
||||
userWorkspace.workspace.isCustomDomainEnabled,
|
||||
}),
|
||||
users: userWorkspace.workspace.workspaceUsers
|
||||
.filter((workspaceUser) => isDefined(workspaceUser.user))
|
||||
.map((workspaceUser) => ({
|
||||
id: workspaceUser.user.id,
|
||||
email: workspaceUser.user.email,
|
||||
firstName: workspaceUser.user.firstName,
|
||||
lastName: workspaceUser.user.lastName,
|
||||
})),
|
||||
featureFlags: allFeatureFlagKeys.map((key) => ({
|
||||
key,
|
||||
value:
|
||||
userWorkspace.workspace.featureFlags?.find(
|
||||
(flag) => flag.key === key,
|
||||
)?.value ?? false,
|
||||
})) as FeatureFlagEntity[],
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ export class ApiKeyService {
|
||||
expiresIn = '100y';
|
||||
}
|
||||
|
||||
const token = this.jwtWrapperService.sign(
|
||||
const token = await this.jwtWrapperService.sign(
|
||||
{
|
||||
sub: workspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class ApplicationOAuthResolver {
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationTokenPairDTO> {
|
||||
const applicationRefreshTokenPayload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
await this.applicationTokenService.validateApplicationRefreshToken(
|
||||
applicationRefreshToken,
|
||||
);
|
||||
|
||||
|
||||
+8
-4
@@ -334,7 +334,7 @@ export class OAuthService {
|
||||
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
await this.applicationTokenService.validateApplicationRefreshToken(
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
@@ -409,7 +409,9 @@ export class OAuthService {
|
||||
// We validate the token to log that revocation was requested.
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
await this.applicationTokenService.validateApplicationRefreshToken(
|
||||
token,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Token revocation requested for application ${payload.applicationId}`,
|
||||
@@ -448,7 +450,7 @@ export class OAuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
await this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
|
||||
const decoded = this.applicationTokenService.decodeToken(token);
|
||||
|
||||
@@ -483,7 +485,9 @@ export class OAuthService {
|
||||
// Try as access token (with signature verification)
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationAccessToken(token);
|
||||
await this.applicationTokenService.validateApplicationAccessToken(
|
||||
token,
|
||||
);
|
||||
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: payload.applicationId },
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ export class ApprovedAccessDomainService {
|
||||
workspace: {
|
||||
name: workspace.displayName,
|
||||
logo: isDefined(workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
|
||||
+6
@@ -22,6 +22,7 @@ describe('JwtAuthStrategy', () => {
|
||||
let permissionsService: any;
|
||||
let workspaceCacheService: any;
|
||||
let coreEntityCacheService: any;
|
||||
let signingKeyService: any;
|
||||
|
||||
const jwt = {
|
||||
sub: 'sub-default',
|
||||
@@ -85,6 +86,10 @@ describe('JwtAuthStrategy', () => {
|
||||
),
|
||||
};
|
||||
|
||||
signingKeyService = {
|
||||
getSigningKey: jest.fn(),
|
||||
};
|
||||
|
||||
coreEntityCacheService = {
|
||||
get: jest.fn(async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
@@ -117,6 +122,7 @@ describe('JwtAuthStrategy', () => {
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
coreEntityCacheService,
|
||||
signingKeyService,
|
||||
);
|
||||
|
||||
describe('API_KEY validation', () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
AuthException,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
@@ -43,11 +46,26 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
private readonly signingKeyService: SigningKeyService,
|
||||
) {
|
||||
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => {
|
||||
try {
|
||||
// Check for kid header — asymmetric token verification
|
||||
const decoded = jwt.decode(rawJwtToken, { complete: true });
|
||||
|
||||
if (decoded?.header?.kid) {
|
||||
const publicKey = await signingKeyService.getPublicKeyByKid(
|
||||
decoded.header.kid,
|
||||
);
|
||||
|
||||
if (publicKey) {
|
||||
return done(null, publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: existing HS256 path
|
||||
const decodedToken = jwtWrapperService.decode<
|
||||
| FileTokenJwtPayloadLegacy
|
||||
| AccessTokenJwtPayload
|
||||
@@ -74,6 +92,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
jwtFromRequest: jwtFromRequestFunction,
|
||||
ignoreExpiration: false,
|
||||
secretOrKeyProvider: secretOrKeyProviderFunction,
|
||||
algorithms: ['HS256', 'ES256'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -137,7 +137,7 @@ describe('AccessTokenService', () => {
|
||||
jest.spyOn(globalWorkspaceOrmManager, 'getRepository').mockResolvedValue({
|
||||
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
|
||||
} as any);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateAccessToken({
|
||||
userId,
|
||||
@@ -198,7 +198,7 @@ describe('AccessTokenService', () => {
|
||||
} as any);
|
||||
const signSpy = jest
|
||||
.spyOn(jwtWrapperService, 'sign')
|
||||
.mockReturnValue(mockToken);
|
||||
.mockResolvedValue(mockToken);
|
||||
|
||||
await service.generateAccessToken({
|
||||
userId,
|
||||
|
||||
+9
-7
@@ -141,14 +141,16 @@ export class AccessTokenService {
|
||||
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
|
||||
};
|
||||
|
||||
const token = await this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
}),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
+32
-30
@@ -81,7 +81,7 @@ describe('ApplicationTokenService', () => {
|
||||
jest
|
||||
.spyOn(applicationRepository, 'findOne')
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
@@ -116,7 +116,7 @@ describe('ApplicationTokenService', () => {
|
||||
jest
|
||||
.spyOn(applicationRepository, 'findOne')
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
@@ -172,7 +172,7 @@ describe('ApplicationTokenService', () => {
|
||||
});
|
||||
|
||||
describe('validateApplicationRefreshToken', () => {
|
||||
it('should validate and return payload for a valid refresh token', () => {
|
||||
it('should validate and return payload for a valid refresh token', async () => {
|
||||
const mockToken = 'valid-refresh-token';
|
||||
const mockPayload = {
|
||||
sub: 'application-id',
|
||||
@@ -183,10 +183,10 @@ describe('ApplicationTokenService', () => {
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
|
||||
const result = service.validateApplicationRefreshToken(mockToken);
|
||||
const result = await service.validateApplicationRefreshToken(mockToken);
|
||||
|
||||
expect(result).toEqual(mockPayload);
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
|
||||
@@ -195,12 +195,12 @@ describe('ApplicationTokenService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when token type is not APPLICATION_REFRESH', () => {
|
||||
it('should throw when token type is not APPLICATION_REFRESH', async () => {
|
||||
const mockToken = 'access-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({
|
||||
sub: 'application-id',
|
||||
applicationId: 'application-id',
|
||||
@@ -208,12 +208,12 @@ describe('ApplicationTokenService', () => {
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
AuthException,
|
||||
);
|
||||
await expect(
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
|
||||
try {
|
||||
service.validateApplicationRefreshToken(mockToken);
|
||||
await service.validateApplicationRefreshToken(mockToken);
|
||||
} catch (error) {
|
||||
expect((error as AuthException).code).toBe(
|
||||
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
|
||||
@@ -221,22 +221,24 @@ describe('ApplicationTokenService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw dedicated code when token verification fails', () => {
|
||||
it('should throw dedicated code when token verification fails', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
|
||||
throw new AuthException(
|
||||
'Token has expired.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockRejectedValue(
|
||||
new AuthException(
|
||||
'Token has expired.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
AuthException,
|
||||
);
|
||||
await expect(
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
|
||||
try {
|
||||
service.validateApplicationRefreshToken(mockToken);
|
||||
await service.validateApplicationRefreshToken(mockToken);
|
||||
} catch (error) {
|
||||
expect((error as AuthException).code).toBe(
|
||||
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
|
||||
@@ -244,16 +246,16 @@ describe('ApplicationTokenService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should rethrow unexpected token verification errors', () => {
|
||||
it('should rethrow unexpected token verification errors', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
|
||||
throw new Error('Unexpected verification error');
|
||||
});
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockRejectedValue(new Error('Unexpected verification error'));
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
'Unexpected verification error',
|
||||
);
|
||||
await expect(
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).rejects.toThrow('Unexpected verification error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,7 +273,7 @@ describe('ApplicationTokenService', () => {
|
||||
jest
|
||||
.spyOn(applicationRepository, 'findOne')
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationTokenPair({
|
||||
workspaceId,
|
||||
@@ -304,7 +306,7 @@ describe('ApplicationTokenService', () => {
|
||||
jest
|
||||
.spyOn(applicationRepository, 'findOne')
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.renewApplicationTokens({
|
||||
workspaceId,
|
||||
|
||||
+17
-18
@@ -58,7 +58,7 @@ export class ApplicationTokenService {
|
||||
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
return this.signApplicationToken({
|
||||
return await this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
@@ -91,7 +91,7 @@ export class ApplicationTokenService {
|
||||
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
const applicationAccessToken = this.signApplicationToken({
|
||||
const applicationAccessToken = await this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
@@ -100,7 +100,7 @@ export class ApplicationTokenService {
|
||||
expiresIn: accessTokenExpiresIn,
|
||||
});
|
||||
|
||||
const applicationRefreshToken = this.signApplicationToken({
|
||||
const applicationRefreshToken = await this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
@@ -112,11 +112,11 @@ export class ApplicationTokenService {
|
||||
return { applicationAccessToken, applicationRefreshToken };
|
||||
}
|
||||
|
||||
validateApplicationRefreshToken(
|
||||
async validateApplicationRefreshToken(
|
||||
refreshToken: string,
|
||||
): ApplicationRefreshTokenJwtPayload {
|
||||
): Promise<ApplicationRefreshTokenJwtPayload> {
|
||||
try {
|
||||
this.jwtWrapperService.verifyJwtToken(refreshToken);
|
||||
await this.jwtWrapperService.verifyJwtToken(refreshToken);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
|
||||
@@ -148,11 +148,11 @@ export class ApplicationTokenService {
|
||||
}
|
||||
}
|
||||
|
||||
validateApplicationAccessToken(
|
||||
async validateApplicationAccessToken(
|
||||
token: string,
|
||||
): ApplicationAccessTokenJwtPayload {
|
||||
): Promise<ApplicationAccessTokenJwtPayload> {
|
||||
try {
|
||||
this.jwtWrapperService.verifyJwtToken(token);
|
||||
await this.jwtWrapperService.verifyJwtToken(token);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationAccessTokenJwtPayload>(token, {
|
||||
@@ -229,7 +229,7 @@ export class ApplicationTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
private signApplicationToken({
|
||||
private async signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
@@ -245,7 +245,7 @@ export class ApplicationTokenService {
|
||||
| JwtTokenTypeEnum.APPLICATION_ACCESS
|
||||
| JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
expiresIn: string;
|
||||
}): AuthToken {
|
||||
}): Promise<AuthToken> {
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const jwtPayload:
|
||||
@@ -259,14 +259,13 @@ export class ApplicationTokenService {
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
|
||||
const token = await this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(tokenType, workspaceId),
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
tokenType,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
}),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@ describe('LoginTokenService', () => {
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue(mockSecret);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateLoginToken(
|
||||
email,
|
||||
@@ -99,7 +99,7 @@ describe('LoginTokenService', () => {
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue(mockSecret);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateLoginToken(
|
||||
email,
|
||||
|
||||
+6
-4
@@ -46,11 +46,13 @@ export class LoginTokenService {
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const token = await this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
}),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ describe('RefreshTokenService', () => {
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue('mock-secret');
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'create')
|
||||
.mockReturnValue({ id: 'new-token-id' } as AppTokenEntity);
|
||||
|
||||
+14
-12
@@ -137,19 +137,21 @@ export class RefreshTokenService {
|
||||
|
||||
await this.appTokenRepository.save(refreshToken);
|
||||
|
||||
const token = await this.jwtWrapperService.sign(
|
||||
{
|
||||
...payload,
|
||||
sub: payload.userId,
|
||||
type: JwtTokenTypeEnum.REFRESH,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: refreshToken.id,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(
|
||||
{
|
||||
...payload,
|
||||
sub: payload.userId,
|
||||
type: JwtTokenTypeEnum.REFRESH,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: refreshToken.id,
|
||||
},
|
||||
),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ describe('TransientTokenService', () => {
|
||||
|
||||
return undefined;
|
||||
});
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateTransientToken({
|
||||
workspaceMemberId,
|
||||
|
||||
+6
-4
@@ -45,11 +45,13 @@ export class TransientTokenService {
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const token = await this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
}),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ describe('WorkspaceAgnosticToken', () => {
|
||||
|
||||
return undefined;
|
||||
});
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockResolvedValue(mockToken);
|
||||
jest
|
||||
.spyOn(userRepository, 'findOne')
|
||||
.mockResolvedValue(mockUser as UserEntity);
|
||||
|
||||
+9
-7
@@ -59,14 +59,16 @@ export class WorkspaceAgnosticTokenService {
|
||||
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
};
|
||||
|
||||
const token = await this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
user.id,
|
||||
),
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
user.id,
|
||||
),
|
||||
expiresIn,
|
||||
}),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export class FileAIChatService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
|
||||
+2
-2
@@ -126,7 +126,7 @@ export class FileCorePictureService {
|
||||
});
|
||||
}
|
||||
|
||||
const url = this.fileUrlService.signFileByIdUrl({
|
||||
const url = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: savedFile.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
workspaceId: workspace.id,
|
||||
@@ -159,7 +159,7 @@ export class FileCorePictureService {
|
||||
queryRunner,
|
||||
});
|
||||
|
||||
const url = this.fileUrlService.signFileByIdUrl({
|
||||
const url = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: savedFile.id,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
|
||||
@@ -16,7 +16,7 @@ export class FileUrlService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
signFileByIdUrl({
|
||||
async signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder,
|
||||
@@ -24,7 +24,7 @@ export class FileUrlService {
|
||||
fileId: string;
|
||||
workspaceId: string;
|
||||
fileFolder: FileFolder;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
const fileTokenExpiresIn = this.twentyConfigService.get(
|
||||
'FILE_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
@@ -41,7 +41,7 @@ export class FileUrlService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const token = this.jwtWrapperService.sign(payload, {
|
||||
const token = await this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
expiresIn: fileTokenExpiresIn,
|
||||
});
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export class FileWorkflowService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.Workflow,
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ export class FilesFieldService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { JwtModule as NestJwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { SigningKeyModule } from 'src/engine/core-modules/signing-key/signing-key.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';
|
||||
|
||||
@@ -14,7 +15,7 @@ const InternalJwtModule = NestJwtModule.registerAsync({
|
||||
expiresIn: twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN'),
|
||||
},
|
||||
verifyOptions: {
|
||||
algorithms: ['HS256'],
|
||||
algorithms: ['HS256', 'ES256'],
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -22,9 +23,9 @@ const InternalJwtModule = NestJwtModule.registerAsync({
|
||||
});
|
||||
|
||||
@Module({
|
||||
imports: [InternalJwtModule, TwentyConfigModule],
|
||||
imports: [InternalJwtModule, TwentyConfigModule, SigningKeyModule],
|
||||
controllers: [],
|
||||
providers: [JwtWrapperService],
|
||||
exports: [JwtWrapperService],
|
||||
exports: [JwtWrapperService, SigningKeyModule],
|
||||
})
|
||||
export class JwtModule {}
|
||||
|
||||
+98
-31
@@ -20,6 +20,7 @@ import {
|
||||
type JwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -27,10 +28,24 @@ export class JwtWrapperService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly signingKeyService: SigningKeyService,
|
||||
) {}
|
||||
|
||||
sign(payload: JwtPayload, options?: JwtSignOptions): string {
|
||||
// Typescript does not handle well the overloads of the sign method, helping it a little bit
|
||||
async sign(payload: JwtPayload, options?: JwtSignOptions): Promise<string> {
|
||||
if (this.signingKeyService.isAsymmetricSigningEnabled()) {
|
||||
const signingKey = await this.signingKeyService.getCurrentSigningKey();
|
||||
|
||||
if (signingKey) {
|
||||
return jwt.sign(payload as object, signingKey.privateKey, {
|
||||
algorithm: 'ES256',
|
||||
keyid: signingKey.kid,
|
||||
expiresIn: options?.expiresIn,
|
||||
jwtid: options?.jwtid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: existing HS256 path
|
||||
return this.jwtService.sign(payload, options);
|
||||
}
|
||||
|
||||
@@ -47,7 +62,87 @@ export class JwtWrapperService {
|
||||
return this.jwtService.decode(payload, options);
|
||||
}
|
||||
|
||||
verifyJwtToken(token: string, options?: JwtVerifyOptions) {
|
||||
async verifyJwtToken(token: string, options?: JwtVerifyOptions) {
|
||||
// Check for kid header — if present, use asymmetric verification
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
|
||||
if (decoded?.header?.kid) {
|
||||
return this.verifyAsymmetricToken(token, decoded.header.kid);
|
||||
}
|
||||
|
||||
// Fallback: existing HS256 verification path
|
||||
return this.verifySymmetricToken(token, options);
|
||||
}
|
||||
|
||||
generateAppSecret(type: JwtTokenTypeEnum, appSecretBody: string): string {
|
||||
const appSecret = this.twentyConfigService.get('APP_SECRET');
|
||||
|
||||
if (!appSecret) {
|
||||
throw new Error('APP_SECRET is not set');
|
||||
}
|
||||
|
||||
return createHash('sha256')
|
||||
.update(`${appSecret}${appSecretBody}${type}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async getVerificationKeyForToken(
|
||||
rawJwtToken: string,
|
||||
): Promise<string | null> {
|
||||
const decoded = jwt.decode(rawJwtToken, { complete: true });
|
||||
|
||||
if (decoded?.header?.kid) {
|
||||
return this.signingKeyService.getPublicKeyByKid(decoded.header.kid);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
extractJwtFromRequest(): JwtFromRequestFunction {
|
||||
return (request: ExpressRequest) => {
|
||||
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (tokenFromHeader) {
|
||||
return tokenFromHeader;
|
||||
}
|
||||
|
||||
return ExtractJwt.fromUrlQueryParameter('token')(request);
|
||||
};
|
||||
}
|
||||
|
||||
private async verifyAsymmetricToken(token: string, kid: string) {
|
||||
const publicKey = await this.signingKeyService.getPublicKeyByKid(kid);
|
||||
|
||||
if (!publicKey) {
|
||||
throw new AuthException(
|
||||
'Unknown signing key.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return jwt.verify(token, publicKey, { algorithms: ['ES256'] });
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
throw new AuthException(
|
||||
'Token has expired.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new AuthException(
|
||||
'Token invalid.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
throw new AuthException(
|
||||
'Unknown token error.',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private verifySymmetricToken(token: string, options?: JwtVerifyOptions) {
|
||||
const payload = this.decode<JwtPayload>(token, {
|
||||
json: true,
|
||||
});
|
||||
@@ -117,32 +212,4 @@ export class JwtWrapperService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
generateAppSecret(type: JwtTokenTypeEnum, appSecretBody: string): string {
|
||||
const appSecret = this.twentyConfigService.get('APP_SECRET');
|
||||
|
||||
if (!appSecret) {
|
||||
throw new Error('APP_SECRET is not set');
|
||||
}
|
||||
|
||||
return createHash('sha256')
|
||||
.update(`${appSecret}${appSecretBody}${type}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
extractJwtFromRequest(): JwtFromRequestFunction {
|
||||
return (request: ExpressRequest) => {
|
||||
// First try to extract token from Authorization header
|
||||
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (tokenFromHeader) {
|
||||
return tokenFromHeader;
|
||||
}
|
||||
|
||||
// If not found in header, try to extract from URL query parameter
|
||||
// This is for edge cases where we don't control the origin request
|
||||
// (e.g. the REST API playground)
|
||||
return ExtractJwt.fromUrlQueryParameter('token')(request);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -13,15 +13,18 @@ type GetRecordImageIdentifierOptions = {
|
||||
record: Record<string, unknown>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
signUrl?: (fileId: string, fileFolder: FileFolder) => string | null;
|
||||
signUrl?: (
|
||||
fileId: string,
|
||||
fileFolder: FileFolder,
|
||||
) => Promise<string | null> | string | null;
|
||||
};
|
||||
|
||||
export const getRecordImageIdentifier = ({
|
||||
export const getRecordImageIdentifier = async ({
|
||||
record,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
signUrl,
|
||||
}: GetRecordImageIdentifierOptions): string | null => {
|
||||
}: GetRecordImageIdentifierOptions): Promise<string | null> => {
|
||||
if (flatObjectMetadata.nameSingular === 'company') {
|
||||
const domainNameObj = record.domainName as
|
||||
| { primaryLinkUrl?: string }
|
||||
|
||||
@@ -543,11 +543,11 @@ export class SearchService {
|
||||
return imageIdentifierField.name;
|
||||
}
|
||||
|
||||
private getImageUrlWithToken(
|
||||
private async getImageUrlWithToken(
|
||||
avatarFileId: string,
|
||||
fileFolder: FileFolder,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
return this.fileUrlService.signFileByIdUrl({
|
||||
fileId: avatarFileId,
|
||||
workspaceId,
|
||||
@@ -555,12 +555,12 @@ export class SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
getImageIdentifierValue(
|
||||
async getImageIdentifierValue(
|
||||
record: ObjectRecord,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
const imageIdentifierField = this.getImageIdentifierColumn(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
@@ -603,7 +603,7 @@ export class SearchService {
|
||||
|
||||
return imageIdentifierField &&
|
||||
isNonEmptyString(record[imageIdentifierField])
|
||||
? this.getImageUrlWithToken(
|
||||
? await this.getImageUrlWithToken(
|
||||
record[imageIdentifierField],
|
||||
FileFolder.FilesField,
|
||||
workspaceId,
|
||||
@@ -648,7 +648,7 @@ export class SearchService {
|
||||
return recordEdges;
|
||||
}
|
||||
|
||||
computeSearchObjectResults({
|
||||
async computeSearchObjectResults({
|
||||
recordsWithObjectMetadataItems,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId,
|
||||
@@ -660,32 +660,34 @@ export class SearchService {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
after?: string;
|
||||
}): SearchResultConnectionDTO {
|
||||
const searchRecords = recordsWithObjectMetadataItems.flatMap(
|
||||
({ objectMetadataItem, records }) => {
|
||||
return records.map((record) => {
|
||||
return {
|
||||
recordId: record.id,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
objectLabelSingular:
|
||||
objectMetadataItem.standardOverrides?.labelSingular ??
|
||||
objectMetadataItem.labelSingular,
|
||||
label: this.getLabelIdentifierValue(
|
||||
record,
|
||||
objectMetadataItem,
|
||||
flatFieldMetadataMaps,
|
||||
),
|
||||
imageUrl: this.getImageIdentifierValue(
|
||||
record,
|
||||
objectMetadataItem,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId,
|
||||
),
|
||||
tsRankCD: record.tsRankCD,
|
||||
tsRank: record.tsRank,
|
||||
};
|
||||
});
|
||||
},
|
||||
}): Promise<SearchResultConnectionDTO> {
|
||||
const searchRecords = await Promise.all(
|
||||
recordsWithObjectMetadataItems.flatMap(
|
||||
({ objectMetadataItem, records }) => {
|
||||
return records.map(async (record) => {
|
||||
return {
|
||||
recordId: record.id,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
objectLabelSingular:
|
||||
objectMetadataItem.standardOverrides?.labelSingular ??
|
||||
objectMetadataItem.labelSingular,
|
||||
label: this.getLabelIdentifierValue(
|
||||
record,
|
||||
objectMetadataItem,
|
||||
flatFieldMetadataMaps,
|
||||
),
|
||||
imageUrl: await this.getImageIdentifierValue(
|
||||
record,
|
||||
objectMetadataItem,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId,
|
||||
),
|
||||
tsRankCD: record.tsRankCD,
|
||||
tsRank: record.tsRank,
|
||||
};
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const sortedRecords = this.sortSearchObjectResults(searchRecords).slice(
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { SigningKeyEntity } from 'src/engine/core-modules/signing-key/signing-key.entity';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
|
||||
// In-memory store that simulates the DB repository
|
||||
class InMemorySigningKeyRepository {
|
||||
private store: SigningKeyEntity[] = [];
|
||||
private idCounter = 0;
|
||||
|
||||
create(data: Partial<SigningKeyEntity>): SigningKeyEntity {
|
||||
return { ...data } as SigningKeyEntity;
|
||||
}
|
||||
|
||||
async save(entity: SigningKeyEntity): Promise<SigningKeyEntity> {
|
||||
const existing = this.store.find((e) => e.id === entity.id);
|
||||
|
||||
if (existing) {
|
||||
Object.assign(existing, entity);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
entity.id = `uuid-${++this.idCounter}`;
|
||||
entity.createdAt = entity.createdAt ?? new Date();
|
||||
this.store.push(entity);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async findOne(options: {
|
||||
where: Record<string, unknown>;
|
||||
}): Promise<SigningKeyEntity | null> {
|
||||
const where = options.where;
|
||||
|
||||
return (
|
||||
this.store.find((entity) => {
|
||||
for (const [key, condition] of Object.entries(where)) {
|
||||
const value = (entity as Record<string, unknown>)[key];
|
||||
|
||||
// Handle TypeORM Not(IsNull()) — represented as FindOperator
|
||||
if (
|
||||
condition &&
|
||||
typeof condition === 'object' &&
|
||||
'_type' in condition
|
||||
) {
|
||||
const op = condition as { _type: string; _value?: unknown };
|
||||
|
||||
if (op._type === 'not') {
|
||||
const inner = op._value as {
|
||||
_type: string;
|
||||
} | null;
|
||||
|
||||
if (inner && inner._type === 'isNull') {
|
||||
if (value === null || value === undefined) return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (op._type === 'isNull') {
|
||||
if (value !== null && value !== undefined) return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== condition) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async find(
|
||||
options?: { where?: Record<string, unknown>; order?: unknown },
|
||||
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
|
||||
): Promise<any[]> {
|
||||
if (!options?.where) return [...this.store];
|
||||
|
||||
const results: SigningKeyEntity[] = [];
|
||||
|
||||
for (const entity of this.store) {
|
||||
let match = true;
|
||||
|
||||
for (const [key, condition] of Object.entries(options.where)) {
|
||||
const value = (entity as Record<string, unknown>)[key];
|
||||
|
||||
if (
|
||||
condition &&
|
||||
typeof condition === 'object' &&
|
||||
'_type' in condition
|
||||
) {
|
||||
const op = condition as { _type: string };
|
||||
|
||||
if (op._type === 'isNull') {
|
||||
if (value !== null && value !== undefined) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== condition) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) results.push(entity);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.store = [];
|
||||
this.idCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ES256 Asymmetric JWT Signing Integration', () => {
|
||||
let signingKeyService: SigningKeyService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let repository: InMemorySigningKeyRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = new InMemorySigningKeyRepository();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SigningKeyService,
|
||||
JwtWrapperService,
|
||||
{
|
||||
provide: getRepositoryToken(SigningKeyEntity),
|
||||
useValue: repository,
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'IS_ASYMMETRIC_SIGNING_ENABLED') return true;
|
||||
if (key === 'APP_SECRET') return 'test-secret';
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnterprisePlanService,
|
||||
useValue: {
|
||||
hasValidEnterpriseKey: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
signingKeyService = module.get<SigningKeyService>(SigningKeyService);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
});
|
||||
|
||||
it('should complete the full key lifecycle: generate, sign, verify, rotate, retire', async () => {
|
||||
// Step 1: Generate an ES256 key pair via onModuleInit
|
||||
await signingKeyService.onModuleInit();
|
||||
|
||||
const signingKey = await signingKeyService.getCurrentSigningKey();
|
||||
|
||||
expect(signingKey).not.toBeNull();
|
||||
expect(signingKey!.kid).toHaveLength(12);
|
||||
expect(signingKey!.publicKey).toContain('-----BEGIN PUBLIC KEY-----');
|
||||
expect(signingKey!.privateKey).toContain('-----BEGIN PRIVATE KEY-----');
|
||||
|
||||
const originalKid = signingKey!.kid;
|
||||
|
||||
// Step 2: Sign a JWT with the active private key
|
||||
const payload = {
|
||||
sub: 'user-123',
|
||||
type: 'ACCESS',
|
||||
workspaceId: 'ws-456',
|
||||
};
|
||||
|
||||
const token = await jwtWrapperService.sign(payload, {
|
||||
expiresIn: '1h',
|
||||
jwtid: 'test-jti-1',
|
||||
});
|
||||
|
||||
expect(token).toBeDefined();
|
||||
|
||||
// Verify the token has the correct header
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
|
||||
expect(decoded?.header.alg).toBe('ES256');
|
||||
expect(decoded?.header.kid).toBe(originalKid);
|
||||
|
||||
// Step 3: Verify the JWT succeeds with kid-based routing
|
||||
const verified = await jwtWrapperService.verifyJwtToken(token);
|
||||
|
||||
expect(verified).toBeDefined();
|
||||
expect((verified as jwt.JwtPayload).sub).toBe('user-123');
|
||||
|
||||
// Step 4: Rotate the key (new key pair generated, old private key destroyed)
|
||||
const newKeyEntity = await signingKeyService.rotateKey();
|
||||
|
||||
expect(newKeyEntity.kid).not.toBe(originalKid);
|
||||
expect(newKeyEntity.algorithm).toBe('ES256');
|
||||
|
||||
const newSigningKey = await signingKeyService.getCurrentSigningKey();
|
||||
|
||||
expect(newSigningKey).not.toBeNull();
|
||||
expect(newSigningKey!.kid).toBe(newKeyEntity.kid);
|
||||
|
||||
// Step 5: Verify the OLD token still works (public key retained)
|
||||
const verifiedOld = await jwtWrapperService.verifyJwtToken(token);
|
||||
|
||||
expect(verifiedOld).toBeDefined();
|
||||
expect((verifiedOld as jwt.JwtPayload).sub).toBe('user-123');
|
||||
|
||||
// Step 6: Sign a NEW token with the new active key
|
||||
const newPayload = {
|
||||
sub: 'user-789',
|
||||
type: 'ACCESS',
|
||||
workspaceId: 'ws-999',
|
||||
};
|
||||
|
||||
const newToken = await jwtWrapperService.sign(newPayload, {
|
||||
expiresIn: '1h',
|
||||
jwtid: 'test-jti-2',
|
||||
});
|
||||
|
||||
const newDecoded = jwt.decode(newToken, { complete: true });
|
||||
|
||||
expect(newDecoded?.header.kid).toBe(newKeyEntity.kid);
|
||||
|
||||
// Step 7: Verify the new token works
|
||||
const verifiedNew = await jwtWrapperService.verifyJwtToken(newToken);
|
||||
|
||||
expect(verifiedNew).toBeDefined();
|
||||
expect((verifiedNew as jwt.JwtPayload).sub).toBe('user-789');
|
||||
|
||||
// Step 8: Retire the old key
|
||||
const retired = await signingKeyService.retireKey(originalKid);
|
||||
|
||||
expect(retired).not.toBeNull();
|
||||
expect(retired!.isActive).toBe(false);
|
||||
expect(retired!.retiredAt).toBeDefined();
|
||||
|
||||
// Step 9: Verify the old token fails after retirement
|
||||
await expect(jwtWrapperService.verifyJwtToken(token)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
|
||||
// The new token should still work
|
||||
const stillValid = await jwtWrapperService.verifyJwtToken(newToken);
|
||||
|
||||
expect(stillValid).toBeDefined();
|
||||
expect((stillValid as jwt.JwtPayload).sub).toBe('user-789');
|
||||
});
|
||||
|
||||
it('should handle multiple rotations correctly', async () => {
|
||||
await signingKeyService.onModuleInit();
|
||||
|
||||
// Sign with key 1
|
||||
const token1 = await jwtWrapperService.sign(
|
||||
{ sub: 'user-1', type: 'ACCESS', workspaceId: 'ws-1' },
|
||||
{ expiresIn: '1h', jwtid: 'jti-rot-1' },
|
||||
);
|
||||
|
||||
// Rotate to key 2
|
||||
await signingKeyService.rotateKey();
|
||||
|
||||
const token2 = await jwtWrapperService.sign(
|
||||
{ sub: 'user-2', type: 'ACCESS', workspaceId: 'ws-2' },
|
||||
{ expiresIn: '1h', jwtid: 'jti-rot-2' },
|
||||
);
|
||||
|
||||
// Rotate to key 3
|
||||
await signingKeyService.rotateKey();
|
||||
|
||||
const token3 = await jwtWrapperService.sign(
|
||||
{ sub: 'user-3', type: 'ACCESS', workspaceId: 'ws-3' },
|
||||
{ expiresIn: '1h', jwtid: 'jti-rot-3' },
|
||||
);
|
||||
|
||||
// All three tokens should verify (public keys retained)
|
||||
const v1 = await jwtWrapperService.verifyJwtToken(token1);
|
||||
const v2 = await jwtWrapperService.verifyJwtToken(token2);
|
||||
const v3 = await jwtWrapperService.verifyJwtToken(token3);
|
||||
|
||||
expect((v1 as jwt.JwtPayload).sub).toBe('user-1');
|
||||
expect((v2 as jwt.JwtPayload).sub).toBe('user-2');
|
||||
expect((v3 as jwt.JwtPayload).sub).toBe('user-3');
|
||||
|
||||
// All three tokens should have different kids
|
||||
const kid1 = jwt.decode(token1, { complete: true })?.header.kid;
|
||||
const kid2 = jwt.decode(token2, { complete: true })?.header.kid;
|
||||
const kid3 = jwt.decode(token3, { complete: true })?.header.kid;
|
||||
|
||||
expect(kid1).not.toBe(kid2);
|
||||
expect(kid2).not.toBe(kid3);
|
||||
expect(kid1).not.toBe(kid3);
|
||||
});
|
||||
});
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { SigningKeyEntity } from 'src/engine/core-modules/signing-key/signing-key.entity';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
|
||||
type MockRepository = Record<string, jest.Mock<any>>;
|
||||
|
||||
describe('SigningKeyService', () => {
|
||||
let service: SigningKeyService;
|
||||
let mockSigningKeyRepository: MockRepository;
|
||||
let mockTwentyConfigService: Record<string, jest.Mock>;
|
||||
let mockEnterprisePlanService: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockSigningKeyRepository = {
|
||||
create: jest.fn((data) => ({ ...data })),
|
||||
save: jest.fn((entity) => ({ ...entity, id: 'generated-uuid' })),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
mockTwentyConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'IS_ASYMMETRIC_SIGNING_ENABLED') return true;
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
mockEnterprisePlanService = {
|
||||
hasValidEnterpriseKey: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SigningKeyService,
|
||||
{
|
||||
provide: getRepositoryToken(SigningKeyEntity),
|
||||
useValue: mockSigningKeyRepository,
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: mockTwentyConfigService,
|
||||
},
|
||||
{
|
||||
provide: EnterprisePlanService,
|
||||
useValue: mockEnterprisePlanService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SigningKeyService>(SigningKeyService);
|
||||
});
|
||||
|
||||
describe('isAsymmetricSigningEnabled', () => {
|
||||
it('should return true when config enabled and enterprise key valid', () => {
|
||||
expect(service.isAsymmetricSigningEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when config disabled', () => {
|
||||
mockTwentyConfigService.get.mockReturnValue(false);
|
||||
expect(service.isAsymmetricSigningEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when enterprise key invalid', () => {
|
||||
mockEnterprisePlanService.hasValidEnterpriseKey.mockReturnValue(false);
|
||||
expect(service.isAsymmetricSigningEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateKeyPair and getCurrentSigningKey', () => {
|
||||
it('should generate an ES256 key pair and cache it', async () => {
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue(null);
|
||||
mockSigningKeyRepository.find.mockResolvedValue([]);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(mockSigningKeyRepository.create).toHaveBeenCalled();
|
||||
expect(mockSigningKeyRepository.save).toHaveBeenCalled();
|
||||
|
||||
const createCall = mockSigningKeyRepository.create.mock.calls[0][0];
|
||||
|
||||
expect(createCall.algorithm).toBe('ES256');
|
||||
expect(createCall.kid).toHaveLength(12);
|
||||
expect(createCall.publicKey).toContain('-----BEGIN PUBLIC KEY-----');
|
||||
expect(createCall.privateKey).toContain('-----BEGIN PRIVATE KEY-----');
|
||||
expect(createCall.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should not generate a new key if one exists', async () => {
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue({
|
||||
kid: 'existing-kid',
|
||||
publicKey: 'pub',
|
||||
privateKey: 'priv',
|
||||
isActive: true,
|
||||
});
|
||||
mockSigningKeyRepository.find.mockResolvedValue([]);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(mockSigningKeyRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateKey', () => {
|
||||
it('should null out old private key and generate new key pair', async () => {
|
||||
const oldKey = {
|
||||
id: 'old-id',
|
||||
kid: 'old-kid',
|
||||
publicKey: 'old-pub',
|
||||
privateKey: 'old-priv',
|
||||
isActive: true,
|
||||
rotatedAt: null,
|
||||
};
|
||||
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue(oldKey);
|
||||
mockSigningKeyRepository.save.mockImplementation((entity) => ({
|
||||
...entity,
|
||||
id: entity.id ?? 'new-id',
|
||||
}));
|
||||
|
||||
const result = await service.rotateKey();
|
||||
|
||||
// First save: nulling old key's private key
|
||||
expect(mockSigningKeyRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'old-id',
|
||||
privateKey: null,
|
||||
}),
|
||||
);
|
||||
|
||||
// Second save: new key
|
||||
expect(result.kid).not.toBe('old-kid');
|
||||
expect(result.algorithm).toBe('ES256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retireKey', () => {
|
||||
it('should mark key as retired and remove from cache', async () => {
|
||||
const key = {
|
||||
kid: 'retire-me',
|
||||
publicKey: 'pub',
|
||||
privateKey: null,
|
||||
isActive: true,
|
||||
retiredAt: null,
|
||||
rotatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue(key);
|
||||
mockSigningKeyRepository.save.mockImplementation((entity) => entity);
|
||||
|
||||
const result = await service.retireKey('retire-me');
|
||||
|
||||
expect(result?.isActive).toBe(false);
|
||||
expect(result?.retiredAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null for unknown kid', async () => {
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.retireKey('unknown');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicKeyByKid', () => {
|
||||
it('should return public key from DB on cache miss', async () => {
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue({
|
||||
kid: 'test-kid',
|
||||
publicKey: 'test-pub-key',
|
||||
retiredAt: null,
|
||||
});
|
||||
|
||||
const result = await service.getPublicKeyByKid('test-kid');
|
||||
|
||||
expect(result).toBe('test-pub-key');
|
||||
});
|
||||
|
||||
it('should return null for retired key', async () => {
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getPublicKeyByKid('retired-kid');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return cached value on second call', async () => {
|
||||
mockSigningKeyRepository.findOne.mockResolvedValue({
|
||||
kid: 'cached-kid',
|
||||
publicKey: 'cached-pub',
|
||||
retiredAt: null,
|
||||
});
|
||||
|
||||
await service.getPublicKeyByKid('cached-kid');
|
||||
const result = await service.getPublicKeyByKid('cached-kid');
|
||||
|
||||
expect(result).toBe('cached-pub');
|
||||
expect(mockSigningKeyRepository.findOne).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicKeyAsJwk', () => {
|
||||
it('should convert PEM to JWK format', async () => {
|
||||
// Generate a real key pair for this test
|
||||
const { generateKeyPairSync } = await import('crypto');
|
||||
const { publicKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const jwk = service.getPublicKeyAsJwk(publicKey, 'test-kid');
|
||||
|
||||
expect(jwk.kty).toBe('EC');
|
||||
expect(jwk.crv).toBe('P-256');
|
||||
expect(jwk.kid).toBe('test-kid');
|
||||
expect(jwk.use).toBe('sig');
|
||||
expect(jwk.alg).toBe('ES256');
|
||||
expect(jwk.x).toBeDefined();
|
||||
expect(jwk.y).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-to-end signing and verification', () => {
|
||||
it('should sign with ES256 and verify with public key', async () => {
|
||||
const { generateKeyPairSync, createHash } = await import('crypto');
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const kid = createHash('sha256')
|
||||
.update(publicKey)
|
||||
.digest('hex')
|
||||
.substring(0, 12);
|
||||
|
||||
const token = jwt.sign({ sub: 'test-user' }, privateKey, {
|
||||
algorithm: 'ES256',
|
||||
keyid: kid,
|
||||
});
|
||||
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
|
||||
expect(decoded?.header.kid).toBe(kid);
|
||||
expect(decoded?.header.alg).toBe('ES256');
|
||||
|
||||
const verified = jwt.verify(token, publicKey, {
|
||||
algorithms: ['ES256'],
|
||||
});
|
||||
|
||||
expect((verified as jwt.JwtPayload).sub).toBe('test-user');
|
||||
});
|
||||
|
||||
it('should fail verification with wrong public key', async () => {
|
||||
const { generateKeyPairSync } = await import('crypto');
|
||||
const keyPair1 = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
const keyPair2 = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const token = jwt.sign({ sub: 'test' }, keyPair1.privateKey, {
|
||||
algorithm: 'ES256',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
jwt.verify(token, keyPair2.publicKey, { algorithms: ['ES256'] }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { SigningKeyRotationCronJob } from 'src/engine/core-modules/signing-key/crons/signing-key-rotation.cron.job';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:signing-key:rotate',
|
||||
description:
|
||||
'Starts a cron job to rotate JWT signing keys on a configurable schedule',
|
||||
})
|
||||
export class SigningKeyRotationCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const cronPattern = this.twentyConfigService.get(
|
||||
'SIGNING_KEY_ROTATION_CRON_PATTERN',
|
||||
);
|
||||
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: SigningKeyRotationCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: { pattern: cronPattern },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SIGNING_KEY_ROTATION_CRON_PATTERN = '0 0 * * *';
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { SIGNING_KEY_ROTATION_CRON_PATTERN } from 'src/engine/core-modules/signing-key/crons/constants/signing-key-rotation-cron-pattern.constant';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class SigningKeyRotationCronJob {
|
||||
private readonly logger = new Logger(SigningKeyRotationCronJob.name);
|
||||
|
||||
constructor(private readonly signingKeyService: SigningKeyService) {}
|
||||
|
||||
@Process(SigningKeyRotationCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
SigningKeyRotationCronJob.name,
|
||||
SIGNING_KEY_ROTATION_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
if (!this.signingKeyService.isAsymmetricSigningEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('Starting signing key rotation');
|
||||
|
||||
await this.signingKeyService.rotateKey();
|
||||
|
||||
this.logger.log('Signing key rotation completed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
|
||||
@Controller('.well-known')
|
||||
export class SigningKeyDiscoveryController {
|
||||
constructor(private readonly signingKeyService: SigningKeyService) {}
|
||||
|
||||
@Get('jwks.json')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getJwks() {
|
||||
if (!this.signingKeyService.isAsymmetricSigningEnabled()) {
|
||||
return { keys: [] };
|
||||
}
|
||||
|
||||
const activeKeys = await this.signingKeyService.getAllActivePublicKeys();
|
||||
|
||||
const jwks = activeKeys.map((key) =>
|
||||
this.signingKeyService.getPublicKeyAsJwk(key.publicKey, key.kid),
|
||||
);
|
||||
|
||||
return { keys: jwks };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@Entity({ name: 'signingKey', schema: 'core' })
|
||||
@ObjectType('SigningKey')
|
||||
export class SigningKeyEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column({ unique: true })
|
||||
kid: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
publicKey: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
privateKey: string | null;
|
||||
|
||||
@Field()
|
||||
@Column({ type: 'varchar', default: 'ES256' })
|
||||
algorithm: string;
|
||||
|
||||
@Field()
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
get hasPrivateKey(): boolean {
|
||||
return this.privateKey !== null;
|
||||
}
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
rotatedAt: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
retiredAt: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { SigningKeyDiscoveryController } from 'src/engine/core-modules/signing-key/signing-key.controller';
|
||||
import { SigningKeyEntity } from 'src/engine/core-modules/signing-key/signing-key.entity';
|
||||
import { SigningKeyResolver } from 'src/engine/core-modules/signing-key/signing-key.resolver';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
import { SigningKeyRotationCronCommand } from 'src/engine/core-modules/signing-key/crons/commands/signing-key-rotation.cron.command';
|
||||
import { SigningKeyRotationCronJob } from 'src/engine/core-modules/signing-key/crons/signing-key-rotation.cron.job';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([SigningKeyEntity]),
|
||||
TwentyConfigModule,
|
||||
EnterpriseModule,
|
||||
],
|
||||
providers: [
|
||||
SigningKeyService,
|
||||
SigningKeyResolver,
|
||||
SigningKeyRotationCronJob,
|
||||
SigningKeyRotationCronCommand,
|
||||
],
|
||||
controllers: [SigningKeyDiscoveryController],
|
||||
exports: [SigningKeyService],
|
||||
})
|
||||
export class SigningKeyModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { SigningKeyEntity } from 'src/engine/core-modules/signing-key/signing-key.entity';
|
||||
import { SigningKeyService } from 'src/engine/core-modules/signing-key/signing-key.service';
|
||||
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
UserAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.SECURITY),
|
||||
)
|
||||
@Resolver(() => SigningKeyEntity)
|
||||
export class SigningKeyResolver {
|
||||
constructor(private readonly signingKeyService: SigningKeyService) {}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => [SigningKeyEntity])
|
||||
async signingKeys(): Promise<SigningKeyEntity[]> {
|
||||
return this.signingKeyService.getAllKeys();
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => SigningKeyEntity)
|
||||
async rotateSigningKey(): Promise<SigningKeyEntity> {
|
||||
return this.signingKeyService.rotateKey();
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => SigningKeyEntity)
|
||||
async retireSigningKey(@Args('kid') kid: string): Promise<SigningKeyEntity> {
|
||||
const result = await this.signingKeyService.retireKey(kid);
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Signing key with kid "${kid}" not found`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { createHash, createPublicKey, generateKeyPairSync } from 'crypto';
|
||||
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { SigningKeyEntity } from 'src/engine/core-modules/signing-key/signing-key.entity';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export type CachedSigningKey = {
|
||||
kid: string;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SigningKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SigningKeyService.name);
|
||||
private publicKeyCache = new Map<string, string>();
|
||||
private currentSigningKey: CachedSigningKey | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SigningKeyEntity)
|
||||
private readonly signingKeyRepository: Repository<SigningKeyEntity>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (!this.isAsymmetricSigningEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingKey = await this.signingKeyRepository.findOne({
|
||||
where: { privateKey: Not(IsNull()), isActive: true },
|
||||
});
|
||||
|
||||
if (!existingKey) {
|
||||
this.logger.log(
|
||||
'Asymmetric signing enabled but no active key found — generating first key pair',
|
||||
);
|
||||
await this.generateAndStoreKeyPair();
|
||||
} else {
|
||||
this.warmCache(existingKey);
|
||||
}
|
||||
|
||||
await this.warmPublicKeyCache();
|
||||
}
|
||||
|
||||
isAsymmetricSigningEnabled(): boolean {
|
||||
return (
|
||||
this.twentyConfigService.get('IS_ASYMMETRIC_SIGNING_ENABLED') === true &&
|
||||
this.enterprisePlanService.hasValidEnterpriseKey()
|
||||
);
|
||||
}
|
||||
|
||||
async getCurrentSigningKey(): Promise<CachedSigningKey | null> {
|
||||
if (this.currentSigningKey) {
|
||||
return this.currentSigningKey;
|
||||
}
|
||||
|
||||
const key = await this.signingKeyRepository.findOne({
|
||||
where: { privateKey: Not(IsNull()), isActive: true },
|
||||
});
|
||||
|
||||
if (!key || !key.privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.warmCache(key);
|
||||
|
||||
return this.currentSigningKey;
|
||||
}
|
||||
|
||||
async getPublicKeyByKid(kid: string): Promise<string | null> {
|
||||
const cached = this.publicKeyCache.get(kid);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss — query DB
|
||||
const key = await this.signingKeyRepository.findOne({
|
||||
where: { kid, retiredAt: IsNull() },
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.publicKeyCache.set(kid, key.publicKey);
|
||||
|
||||
return key.publicKey;
|
||||
}
|
||||
|
||||
async rotateKey(): Promise<SigningKeyEntity> {
|
||||
const currentKey = await this.signingKeyRepository.findOne({
|
||||
where: { privateKey: Not(IsNull()), isActive: true },
|
||||
});
|
||||
|
||||
if (currentKey) {
|
||||
currentKey.privateKey = null;
|
||||
currentKey.rotatedAt = new Date();
|
||||
await this.signingKeyRepository.save(currentKey);
|
||||
}
|
||||
|
||||
const newKey = await this.generateAndStoreKeyPair();
|
||||
|
||||
this.logger.log(`Signing key rotated — new kid: ${newKey.kid}`);
|
||||
|
||||
return newKey;
|
||||
}
|
||||
|
||||
async retireKey(kid: string): Promise<SigningKeyEntity | null> {
|
||||
const key = await this.signingKeyRepository.findOne({
|
||||
where: { kid },
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
key.privateKey = null;
|
||||
key.isActive = false;
|
||||
key.retiredAt = new Date();
|
||||
key.rotatedAt = key.rotatedAt ?? new Date();
|
||||
await this.signingKeyRepository.save(key);
|
||||
|
||||
this.publicKeyCache.delete(kid);
|
||||
|
||||
if (this.currentSigningKey?.kid === kid) {
|
||||
this.currentSigningKey = null;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
async getAllActivePublicKeys(): Promise<SigningKeyEntity[]> {
|
||||
return this.signingKeyRepository.find({
|
||||
where: { retiredAt: IsNull() },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getAllKeys(): Promise<SigningKeyEntity[]> {
|
||||
return this.signingKeyRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
getPublicKeyAsJwk(publicKeyPem: string, kid: string): Record<string, string> {
|
||||
const keyObject = createPublicKey(publicKeyPem);
|
||||
const jwk = keyObject.export({ format: 'jwk' });
|
||||
|
||||
return {
|
||||
...jwk,
|
||||
kid,
|
||||
use: 'sig',
|
||||
alg: 'ES256',
|
||||
};
|
||||
}
|
||||
|
||||
private async generateAndStoreKeyPair(): Promise<SigningKeyEntity> {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const kid = createHash('sha256')
|
||||
.update(publicKey)
|
||||
.digest('hex')
|
||||
.substring(0, 12);
|
||||
|
||||
const entity = this.signingKeyRepository.create({
|
||||
kid,
|
||||
publicKey,
|
||||
privateKey,
|
||||
algorithm: 'ES256',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const saved = await this.signingKeyRepository.save(entity);
|
||||
|
||||
this.warmCache(saved);
|
||||
this.publicKeyCache.set(kid, publicKey);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private warmCache(key: SigningKeyEntity): void {
|
||||
if (key.privateKey) {
|
||||
this.currentSigningKey = {
|
||||
kid: key.kid,
|
||||
privateKey: key.privateKey,
|
||||
publicKey: key.publicKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async warmPublicKeyCache(): Promise<void> {
|
||||
const keys = await this.signingKeyRepository.find({
|
||||
where: { retiredAt: IsNull() },
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
this.publicKeyCache.set(key.kid, key.publicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -110,7 +110,7 @@ export class CodeInterpreterTool implements Tool {
|
||||
);
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
const sessionToken = this.generateSessionToken(
|
||||
const sessionToken = await this.generateSessionToken(
|
||||
workspaceId,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
@@ -293,11 +293,11 @@ export class CodeInterpreterTool implements Tool {
|
||||
return inputFiles;
|
||||
}
|
||||
|
||||
private generateSessionToken(
|
||||
private async generateSessionToken(
|
||||
workspaceId: string,
|
||||
userId?: string,
|
||||
userWorkspaceId?: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
@@ -312,7 +312,7 @@ export class CodeInterpreterTool implements Tool {
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
};
|
||||
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
return await this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
expiresIn: '5m', // Short-lived token for code execution session
|
||||
});
|
||||
@@ -349,7 +349,7 @@ export class CodeInterpreterTool implements Tool {
|
||||
},
|
||||
});
|
||||
|
||||
const signedUrl = this.fileUrlService.signFileByIdUrl({
|
||||
const signedUrl = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: savedFile.id,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
|
||||
@@ -1144,6 +1144,24 @@ export class ConfigVariables {
|
||||
})
|
||||
APP_SECRET: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
description:
|
||||
'Enable asymmetric JWT signing with ES256. Requires enterprise license. When enabled, tokens are signed with rotating EC key pairs instead of APP_SECRET.',
|
||||
type: ConfigVariableType.BOOLEAN,
|
||||
})
|
||||
@IsOptional()
|
||||
IS_ASYMMETRIC_SIGNING_ENABLED = false;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
description:
|
||||
'Cron pattern for automatic signing key rotation (default: daily at midnight UTC)',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsOptional()
|
||||
SIGNING_KEY_ROTATION_CRON_PATTERN = '0 0 * * *';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.RATE_LIMITING,
|
||||
description: 'Maximum number of records affected by mutations',
|
||||
|
||||
+7
-6
@@ -498,13 +498,13 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
return savedFile?.url;
|
||||
}
|
||||
|
||||
castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
|
||||
async castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
displayName: workspace.displayName,
|
||||
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls(workspace),
|
||||
logo: isDefined(workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
@@ -546,20 +546,21 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
authProvider: AuthProviderEnum,
|
||||
) {
|
||||
return {
|
||||
availableWorkspacesForSignUp:
|
||||
availableWorkspacesForSignUp: await Promise.all(
|
||||
availableWorkspaces.availableWorkspacesForSignUp.map(
|
||||
({ workspace, appToken }) => {
|
||||
async ({ workspace, appToken }) => {
|
||||
return {
|
||||
...this.castWorkspaceToAvailableWorkspace(workspace),
|
||||
...(await this.castWorkspaceToAvailableWorkspace(workspace)),
|
||||
...(appToken ? { personalInviteToken: appToken.value } : {}),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
availableWorkspacesForSignIn: await Promise.all(
|
||||
availableWorkspaces.availableWorkspacesForSignIn.map(
|
||||
async ({ workspace }) => {
|
||||
return {
|
||||
...this.castWorkspaceToAvailableWorkspace(workspace),
|
||||
...(await this.castWorkspaceToAvailableWorkspace(workspace)),
|
||||
loginToken: workspaceValidator.isAuthEnabled(
|
||||
authProvider,
|
||||
workspace,
|
||||
|
||||
+17
-13
@@ -28,13 +28,13 @@ export type ToWorkspaceMemberDtoArgs = {
|
||||
export class WorkspaceMemberTranspiler {
|
||||
constructor(private readonly fileUrlService: FileUrlService) {}
|
||||
|
||||
generateSignedAvatarUrl({
|
||||
async generateSignedAvatarUrl({
|
||||
workspaceId,
|
||||
workspaceMember,
|
||||
}: {
|
||||
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
|
||||
workspaceId: string;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
if (
|
||||
!isDefined(workspaceMember.avatarUrl) ||
|
||||
!isNonEmptyString(workspaceMember.avatarUrl)
|
||||
@@ -58,11 +58,11 @@ export class WorkspaceMemberTranspiler {
|
||||
});
|
||||
}
|
||||
|
||||
toWorkspaceMemberDto({
|
||||
async toWorkspaceMemberDto({
|
||||
userWorkspace,
|
||||
workspaceMemberEntity,
|
||||
userWorkspaceRoles,
|
||||
}: ToWorkspaceMemberDtoArgs): WorkspaceMemberDTO {
|
||||
}: ToWorkspaceMemberDtoArgs): Promise<WorkspaceMemberDTO> {
|
||||
const {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
id,
|
||||
@@ -77,7 +77,7 @@ export class WorkspaceMemberTranspiler {
|
||||
numberFormat,
|
||||
} = workspaceMemberEntity;
|
||||
|
||||
const avatarUrl = this.generateSignedAvatarUrl({
|
||||
const avatarUrl = await this.generateSignedAvatarUrl({
|
||||
workspaceId: userWorkspace.workspaceId,
|
||||
workspaceMember: {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
@@ -111,15 +111,17 @@ export class WorkspaceMemberTranspiler {
|
||||
toWorkspaceMemberDtos(
|
||||
allWorkspaceEntitiesBundles: ToWorkspaceMemberDtoArgs[],
|
||||
) {
|
||||
return allWorkspaceEntitiesBundles.map((bundle) =>
|
||||
this.toWorkspaceMemberDto(bundle),
|
||||
return Promise.all(
|
||||
allWorkspaceEntitiesBundles.map((bundle) =>
|
||||
this.toWorkspaceMemberDto(bundle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
toDeletedWorkspaceMemberDto(
|
||||
async toDeletedWorkspaceMemberDto(
|
||||
workspaceMember: WorkspaceMemberWorkspaceEntity,
|
||||
userWorkspaceId?: string,
|
||||
): DeletedWorkspaceMemberDTO {
|
||||
): Promise<DeletedWorkspaceMemberDTO> {
|
||||
const {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
id,
|
||||
@@ -132,7 +134,7 @@ export class WorkspaceMemberTranspiler {
|
||||
}
|
||||
|
||||
const avatarUrl = userWorkspaceId
|
||||
? this.generateSignedAvatarUrl({
|
||||
? await this.generateSignedAvatarUrl({
|
||||
workspaceId: userWorkspaceId,
|
||||
workspaceMember: {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
@@ -153,9 +155,11 @@ export class WorkspaceMemberTranspiler {
|
||||
toDeletedWorkspaceMemberDtos(
|
||||
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
|
||||
userWorkspaceId?: string,
|
||||
): DeletedWorkspaceMemberDTO[] {
|
||||
return workspaceMembers.map((workspaceMember) =>
|
||||
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
|
||||
): Promise<DeletedWorkspaceMemberDTO[]> {
|
||||
return Promise.all(
|
||||
workspaceMembers.map((workspaceMember) =>
|
||||
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -319,7 +319,7 @@ export class WorkspaceInvitationService {
|
||||
workspace: {
|
||||
name: workspace.displayName,
|
||||
logo: isDefined(workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
|
||||
@@ -392,7 +392,7 @@ export class WorkspaceResolver {
|
||||
let workspaceLogoWithToken = '';
|
||||
|
||||
if (isDefined(workspace.logoFileId)) {
|
||||
workspaceLogoWithToken = this.fileUrlService.signFileByIdUrl({
|
||||
workspaceLogoWithToken = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ export class AgentMessagePartResolver {
|
||||
constructor(private readonly fileUrlService: FileUrlService) {}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
fileUrl(
|
||||
async fileUrl(
|
||||
@Parent() part: AgentMessagePartEntity,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
if (!part.fileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ export class NavigationMenuItemRecordIdentifierService {
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const imageIdentifier = getRecordImageIdentifier({
|
||||
const imageIdentifier = await getRecordImageIdentifier({
|
||||
record,
|
||||
flatObjectMetadata: objectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
|
||||
Reference in New Issue
Block a user