Compare commits

...
Author SHA1 Message Date
Félix MalfaitandGitHub 7ee8a29cfa Merge branch 'main' into claude/app-key-rotation-iwiq3 2026-04-10 18:03:03 +02:00
Claude 658420d5cc Add integration test, polish signing keys UI, fix formatting
- ES256 lifecycle integration test (sign, rotate, verify old token, retire)
- SettingsSigningKeysCard: loading skeleton, key count, last rotation
  timestamp, confirmation dialog before retire
- Prettier formatting fixes

https://claude.ai/code/session_0188Xia7v1AtRPe7LULjVrYF
2026-04-01 16:59:39 +00:00
Claude 852173b670 Add asymmetric JWT signing with ES256 key rotation (Enterprise)
Implement ES256 asymmetric JWT signing as an enterprise feature, enabling
private key rotation with destruction for enhanced security. Existing HS256
tokens continue to work via kid-based routing in the JWT verification path.

Key changes:
- SigningKey entity, service, module with in-memory cache
- JwtWrapperService sign() now async with ES256 path when enabled
- JWKS endpoint at /.well-known/jwks.json
- Admin panel GraphQL resolver for key management
- Daily key rotation cron job (configurable)
- Frontend SettingsSigningKeysCard with rotate/retire actions
- All sign()/verifyJwtToken() callers updated for async

https://claude.ai/code/session_0188Xia7v1AtRPe7LULjVrYF
2026-04-01 16:16:47 +00:00
Claude 04d12156c4 Add APP_SECRET_PREVIOUS_KEYS config variable for key rotation support
Introduces a new optional config variable that accepts a comma-separated
list of previous APP_SECRET values. This is the first step toward
supporting safe APP_SECRET rotation without invalidating existing tokens.

https://claude.ai/code/session_0188Xia7v1AtRPe7LULjVrYF
2026-04-01 14:56:46 +00:00
56 changed files with 1843 additions and 265 deletions
@@ -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>
);
};
@@ -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
}
}
`;
@@ -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`}
@@ -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"`);
}
}
@@ -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', () => {
@@ -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,
@@ -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,
},
};
}),
);
};
}
@@ -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,
@@ -31,7 +31,7 @@ export class ApplicationOAuthResolver {
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationTokenPairDTO> {
const applicationRefreshTokenPayload =
this.applicationTokenService.validateApplicationRefreshToken(
await this.applicationTokenService.validateApplicationRefreshToken(
applicationRefreshToken,
);
@@ -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 },
@@ -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,
@@ -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'],
});
}
@@ -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,
@@ -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,
};
}
@@ -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,
@@ -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,
};
}
@@ -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,
@@ -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,
};
}
@@ -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);
@@ -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,
};
}
@@ -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,
@@ -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,
};
}
@@ -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);
@@ -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,
};
}
@@ -61,7 +61,7 @@ export class FileAIChatService {
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
url: await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
@@ -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,
});
@@ -62,7 +62,7 @@ export class FileWorkflowService {
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
url: await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.Workflow,
@@ -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 {}
@@ -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);
};
}
}
@@ -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(
@@ -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);
});
});
@@ -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();
});
});
});
@@ -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 },
},
});
}
}
@@ -0,0 +1 @@
export const SIGNING_KEY_ROTATION_CRON_PATTERN = '0 0 * * *';
@@ -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);
}
}
}
@@ -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',
@@ -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,
@@ -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),
),
);
}
}
@@ -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,
@@ -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;
}
@@ -121,7 +121,7 @@ export class NavigationMenuItemRecordIdentifierService {
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
const imageIdentifier = await getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,