From 463ce434428e9f98184df54e82d79cfa39352661 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:48:09 +0530 Subject: [PATCH] [FRONT COMPONENT] Add Front component token generation (#17855) closes https://github.com/twentyhq/core-team-issues/issues/2180 https://github.com/user-attachments/assets/a898455d-eb1c-4d22-b585-785e98fc38a7 --- packages/twenty-front/codegen-metadata.cjs | 1 + .../src/generated-metadata/graphql.ts | 108 ++++++++++++ .../components/FrontComponentRenderer.tsx | 39 +++-- .../graphql/queries/findOneFrontComponent.ts | 21 +++ .../FrontComponentRenderer.stories.tsx | 2 +- .../components/FrontComponentRenderer.tsx | 12 +- .../components/FrontComponentWorkerEffect.tsx | 21 ++- .../remote/worker/remote-worker.ts | 16 +- .../utils/__tests__/setWorkerEnv.test.ts | 46 ++++++ .../remote/worker/utils/setWorkerEnv.ts | 14 ++ .../types/HostToWorkerRenderContext.ts | 3 +- .../api/graphql/workspace-schema.factory.ts | 35 ++++ .../application/application-sync.module.ts | 2 + .../dtos/application-token-pair.dto.ts | 12 ++ .../application-development.resolver.ts | 140 ++++++++++++++++ .../resolvers/application.resolver.ts | 131 +++------------ .../auth/strategies/jwt.auth.strategy.spec.ts | 6 +- .../auth/strategies/jwt.auth.strategy.ts | 8 +- .../application-token.service.spec.ts | 150 +++++++++++++++-- .../services/application-token.service.ts | 156 ++++++++++++++++-- .../auth/types/auth-context.type.ts | 20 ++- .../logic-function-executor.service.ts | 2 +- .../src/engine/guards/development.guard.ts | 25 +++ .../dtos/front-component.dto.ts | 4 + .../front-component/front-component.module.ts | 2 + .../front-component.resolver.ts | 43 ++++- 26 files changed, 845 insertions(+), 174 deletions(-) create mode 100644 packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts create mode 100644 packages/twenty-sdk/src/front-component/remote/worker/utils/__tests__/setWorkerEnv.test.ts create mode 100644 packages/twenty-sdk/src/front-component/remote/worker/utils/setWorkerEnv.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/dtos/application-token-pair.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/resolvers/application-development.resolver.ts create mode 100644 packages/twenty-server/src/engine/guards/development.guard.ts diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 74d33bc7eb..9aeee20808 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -28,6 +28,7 @@ module.exports = { './src/modules/attachments/graphql/**/*.{ts,tsx}', './src/modules/file/graphql/**/*.{ts,tsx}', './src/modules/onboarding/graphql/**/*.{ts,tsx}', + './src/modules/front-components/graphql/**/*.{ts,tsx}', './src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index fc23a27265..20e5518bc3 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -292,6 +292,12 @@ export type Application = { yarnLockFileId?: Maybe; }; +export type ApplicationTokenPair = { + __typename?: 'ApplicationTokenPair'; + applicationAccessToken: AuthToken; + applicationRefreshToken: AuthToken; +}; + export type ApplicationVariable = { __typename?: 'ApplicationVariable'; description: Scalars['String']; @@ -1659,6 +1665,7 @@ export type FindAvailableSsoidpOutput = { export type FrontComponent = { __typename?: 'FrontComponent'; applicationId: Scalars['UUID']; + applicationTokenPair?: Maybe; builtComponentChecksum: Scalars['String']; builtComponentPath: Scalars['String']; componentName: Scalars['String']; @@ -2242,6 +2249,7 @@ export type Mutation = { installMarketplaceApp: Scalars['Boolean']; removeQueryFromEventStream: Scalars['Boolean']; removeRoleFromAgent: Scalars['Boolean']; + renewApplicationToken: ApplicationTokenPair; renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; @@ -2819,6 +2827,11 @@ export type MutationRemoveRoleFromAgentArgs = { }; +export type MutationRenewApplicationTokenArgs = { + applicationRefreshToken: Scalars['String']; +}; + + export type MutationRenewTokenArgs = { appToken: Scalars['String']; }; @@ -5712,6 +5725,18 @@ export type UploadFilesFieldFileMutationVariables = Exact<{ export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'FilesFieldFile', id: string, path: string, size: number, createdAt: string, url: string } }; +export type FindManyFrontComponentsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FindManyFrontComponentsQuery = { __typename?: 'Query', frontComponents: Array<{ __typename?: 'FrontComponent', id: string, name: string, applicationId: string }> }; + +export type FindOneFrontComponentQueryVariables = Exact<{ + id: Scalars['UUID']; +}>; + + +export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null }; + export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }; export type CreateOneLogicFunctionMutationVariables = Exact<{ @@ -10399,6 +10424,89 @@ export function useUploadFilesFieldFileMutation(baseOptions?: Apollo.MutationHoo export type UploadFilesFieldFileMutationHookResult = ReturnType; export type UploadFilesFieldFileMutationResult = Apollo.MutationResult; export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions; +export const FindManyFrontComponentsDocument = gql` + query FindManyFrontComponents { + frontComponents { + id + name + applicationId + } +} + `; + +/** + * __useFindManyFrontComponentsQuery__ + * + * To run a query within a React component, call `useFindManyFrontComponentsQuery` and pass it any options that fit your needs. + * When your component renders, `useFindManyFrontComponentsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindManyFrontComponentsQuery({ + * variables: { + * }, + * }); + */ +export function useFindManyFrontComponentsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindManyFrontComponentsDocument, options); + } +export function useFindManyFrontComponentsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindManyFrontComponentsDocument, options); + } +export type FindManyFrontComponentsQueryHookResult = ReturnType; +export type FindManyFrontComponentsLazyQueryHookResult = ReturnType; +export type FindManyFrontComponentsQueryResult = Apollo.QueryResult; +export const FindOneFrontComponentDocument = gql` + query FindOneFrontComponent($id: UUID!) { + frontComponent(id: $id) { + id + name + applicationId + applicationTokenPair { + applicationAccessToken { + token + expiresAt + } + applicationRefreshToken { + token + expiresAt + } + } + } +} + `; + +/** + * __useFindOneFrontComponentQuery__ + * + * To run a query within a React component, call `useFindOneFrontComponentQuery` and pass it any options that fit your needs. + * When your component renders, `useFindOneFrontComponentQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindOneFrontComponentQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useFindOneFrontComponentQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindOneFrontComponentDocument, options); + } +export function useFindOneFrontComponentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindOneFrontComponentDocument, options); + } +export type FindOneFrontComponentQueryHookResult = ReturnType; +export type FindOneFrontComponentLazyQueryHookResult = ReturnType; +export type FindOneFrontComponentQueryResult = Apollo.QueryResult; export const CreateOneLogicFunctionDocument = gql` mutation CreateOneLogicFunction($input: CreateLogicFunctionFromSourceInput!) { createOneLogicFunction(input: $input) { diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx index 682fb51ad5..f4327333a1 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -1,12 +1,13 @@ import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; -import { getTokenPair } from '@/apollo/utils/getTokenPair'; import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; -import { useState } from 'react'; +import { useCallback } from 'react'; import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-sdk/front-component'; import { isDefined } from 'twenty-shared/utils'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { useFindOneFrontComponentQuery } from '~/generated-metadata/graphql'; type FrontComponentRendererProps = { frontComponentId: string; @@ -16,28 +17,37 @@ export const FrontComponentRenderer = ({ frontComponentId, }: FrontComponentRendererProps) => { const theme = useTheme(); - const [hasError, setHasError] = useState(false); - const { enqueueErrorSnackBar } = useSnackBar(); const { executionContext, frontComponentHostCommunicationApi } = useFrontComponentExecutionContext(); const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`; - const authToken = getTokenPair()?.accessOrWorkspaceAgnosticToken?.token; - const handleError = (error?: Error) => { - if (isDefined(error)) { + const handleError = useCallback( + (error?: Error) => { + if (!isDefined(error)) { + return; + } + const errorMessage = error.message; enqueueErrorSnackBar({ message: t`Failed to load front component: ${errorMessage}`, }); - } - setHasError(true); - }; + }, + [enqueueErrorSnackBar], + ); - if (hasError || !isDefined(authToken)) { - // TODO: Add an error display component here + const { data, loading } = useFindOneFrontComponentQuery({ + variables: { id: frontComponentId }, + onError: handleError, + }); + + if ( + loading || + !isDefined(data?.frontComponent) || + !isDefined(data.frontComponent.applicationTokenPair) + ) { return null; } @@ -45,7 +55,10 @@ export const FrontComponentRenderer = ({ = { }, args: { onError: errorHandler, - authToken: 'fake-token', + applicationAccessToken: 'fake-token', }, beforeEach: () => { errorHandler.mockClear(); diff --git a/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx b/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx index b384a8eec2..326699656d 100644 --- a/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx @@ -19,7 +19,8 @@ import { componentRegistry } from '../generated/host-component-registry'; type FrontComponentContentProps = { componentUrl: string; - authToken: string; + applicationAccessToken?: string; + apiUrl?: string; executionContext: FrontComponentExecutionContext; frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi; onError: (error?: Error) => void; @@ -28,7 +29,8 @@ type FrontComponentContentProps = { export const FrontComponentRenderer = ({ componentUrl, - authToken, + applicationAccessToken, + apiUrl, executionContext, frontComponentHostCommunicationApi, onError, @@ -45,7 +47,8 @@ export const FrontComponentRenderer = ({ return ( >; setThread: React.Dispatch< @@ -21,7 +22,8 @@ type FrontComponentWorkerEffectProps = { export const FrontComponentWorkerEffect = ({ componentUrl, - authToken, + applicationAccessToken, + apiUrl, frontComponentHostCommunicationApi, setReceiver, setThread, @@ -58,7 +60,11 @@ export const FrontComponentWorkerEffect = ({ setThread(thread); thread.imports - .render(newReceiver.connection, { componentUrl, authToken }) + .render(newReceiver.connection, { + componentUrl, + applicationAccessToken, + apiUrl, + }) .catch((error: Error) => { setError(error); }); @@ -69,7 +75,14 @@ export const FrontComponentWorkerEffect = ({ setThread(null); worker.terminate(); }; - }, [componentUrl, authToken, setError, setReceiver, setThread]); + }, [ + componentUrl, + applicationAccessToken, + apiUrl, + setError, + setReceiver, + setThread, + ]); return null; }; diff --git a/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts b/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts index 0a301a275d..d6138c7527 100644 --- a/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts +++ b/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts @@ -14,6 +14,7 @@ import { createRoot } from 'react-dom/client'; import { jsx, jsxs } from 'react/jsx-runtime'; import * as TwentySharedTypes from 'twenty-shared/types'; import * as TwentySharedUtils from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; import * as TwentySdk from '@/sdk'; import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext'; @@ -25,6 +26,7 @@ import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderCo import { type WorkerExports } from '../../types/WorkerExports'; import * as RemoteComponents from '../generated/remote-components'; import { exposeGlobals } from '../utils/exposeGlobals'; +import { setWorkerEnv } from './utils/setWorkerEnv'; exposeGlobals({ React, @@ -47,8 +49,20 @@ const render: WorkerExports['render'] = async ( root.connect(batchedConnection); document.body.append(root); + if ( + isDefined(renderContext.applicationAccessToken) && + isDefined(renderContext.apiUrl) + ) { + setWorkerEnv({ + TWENTY_APP_ACCESS_TOKEN: renderContext.applicationAccessToken, + TWENTY_API_URL: renderContext.apiUrl, + }); + } + const response = await fetch(renderContext.componentUrl, { - headers: { Authorization: `Bearer ${renderContext.authToken}` }, + headers: isDefined(renderContext.applicationAccessToken) + ? { Authorization: `Bearer ${renderContext.applicationAccessToken}` } + : undefined, }); if (!response.ok) { diff --git a/packages/twenty-sdk/src/front-component/remote/worker/utils/__tests__/setWorkerEnv.test.ts b/packages/twenty-sdk/src/front-component/remote/worker/utils/__tests__/setWorkerEnv.test.ts new file mode 100644 index 0000000000..e0d4828ba2 --- /dev/null +++ b/packages/twenty-sdk/src/front-component/remote/worker/utils/__tests__/setWorkerEnv.test.ts @@ -0,0 +1,46 @@ +import { setWorkerEnv } from '../setWorkerEnv'; + +describe('setWorkerEnv', () => { + beforeEach(() => { + delete (globalThis as Record)['process']; + }); + + it('should set process.env on globalThis', () => { + setWorkerEnv({ + TWENTY_APP_ACCESS_TOKEN: 'test-key', + TWENTY_API_URL: 'https://api.example.com', + }); + + const processObject = (globalThis as Record)[ + 'process' + ] as Record; + const processEnvironment = processObject['env'] as Record; + + expect(processEnvironment['TWENTY_APP_ACCESS_TOKEN']).toBe('test-key'); + expect(processEnvironment['TWENTY_API_URL']).toBe( + 'https://api.example.com', + ); + }); + + it('should preserve existing process properties and environment values', () => { + (globalThis as Record)['process'] = { + env: { + EXISTING_VALUE: 'existing', + }, + version: 'test-version', + }; + + setWorkerEnv({ + TWENTY_APP_ACCESS_TOKEN: 'test-key', + }); + + const processObject = (globalThis as Record)[ + 'process' + ] as Record; + const processEnvironment = processObject['env'] as Record; + + expect(processObject['version']).toBe('test-version'); + expect(processEnvironment['EXISTING_VALUE']).toBe('existing'); + expect(processEnvironment['TWENTY_APP_ACCESS_TOKEN']).toBe('test-key'); + }); +}); diff --git a/packages/twenty-sdk/src/front-component/remote/worker/utils/setWorkerEnv.ts b/packages/twenty-sdk/src/front-component/remote/worker/utils/setWorkerEnv.ts new file mode 100644 index 0000000000..5e470887b5 --- /dev/null +++ b/packages/twenty-sdk/src/front-component/remote/worker/utils/setWorkerEnv.ts @@ -0,0 +1,14 @@ +export const setWorkerEnv = (environmentVariables: Record) => { + const globalObject = globalThis as Record; + const processObject = + (globalObject['process'] as Record | undefined) ?? {}; + const processEnvironment = + (processObject['env'] as Record | undefined) ?? {}; + + processObject['env'] = { + ...processEnvironment, + ...environmentVariables, + }; + + globalObject['process'] = processObject; +}; diff --git a/packages/twenty-sdk/src/front-component/types/HostToWorkerRenderContext.ts b/packages/twenty-sdk/src/front-component/types/HostToWorkerRenderContext.ts index f181a34565..a56aeb5305 100644 --- a/packages/twenty-sdk/src/front-component/types/HostToWorkerRenderContext.ts +++ b/packages/twenty-sdk/src/front-component/types/HostToWorkerRenderContext.ts @@ -1,4 +1,5 @@ export type HostToWorkerRenderContext = { componentUrl: string; - authToken: string; + applicationAccessToken?: string; + apiUrl?: string; }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 864d4267db..6f8c8258ac 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -103,6 +103,12 @@ export class WorkspaceSchemaFactory { applicationIds, ); + flatObjectMetadataMaps = + this.reconcileObjectFieldIdsWithFilteredFieldMaps( + flatObjectMetadataMaps, + flatFieldMetadataMaps, + ); + if (isDefined(allFlatIndexMaps)) { flatIndexMaps = this.filterFlatEntityMapsByApplicationIds( allFlatIndexMaps, @@ -188,6 +194,35 @@ export class WorkspaceSchemaFactory { return executableSchema; } + private reconcileObjectFieldIdsWithFilteredFieldMaps( + flatObjectMetadataMaps: FlatEntityMaps, + flatFieldMetadataMaps: FlatEntityMaps, + ): FlatEntityMaps { + const filteredFieldIds = new Set( + Object.keys(flatFieldMetadataMaps.universalIdentifierById), + ); + + const reconciledByUniversalIdentifier: Partial< + Record + > = {}; + + for (const [universalId, object] of Object.entries( + flatObjectMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(object)) continue; + + reconciledByUniversalIdentifier[universalId] = { + ...object, + fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)), + }; + } + + return { + ...flatObjectMetadataMaps, + byUniversalIdentifier: reconciledByUniversalIdentifier, + }; + } + private filterFlatEntityMapsByApplicationIds< T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata, >( diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts index 8caa1c5747..4ef2755977 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver'; import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver'; import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver'; import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service'; @@ -38,6 +39,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf ], providers: [ ApplicationResolver, + ApplicationDevelopmentResolver, MarketplaceResolver, ApplicationManifestMigrationService, ApplicationSyncService, diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application-token-pair.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application-token-pair.dto.ts new file mode 100644 index 0000000000..be444a8277 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application-token-pair.dto.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; + +@ObjectType('ApplicationTokenPair') +export class ApplicationTokenPairDTO { + @Field(() => AuthToken) + applicationAccessToken: AuthToken; + + @Field(() => AuthToken) + applicationRefreshToken: AuthToken; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/resolvers/application-development.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/resolvers/application-development.resolver.ts new file mode 100644 index 0000000000..623e069850 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/resolvers/application-development.resolver.ts @@ -0,0 +1,140 @@ +import { + UseFilters, + UseGuards, + UseInterceptors, + UsePipes, +} from '@nestjs/common'; +import { Args, Mutation } from '@nestjs/graphql'; + +import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { PermissionFlagType } from 'twenty-shared/constants'; +import { FileFolder } from 'twenty-shared/types'; + +import type { FileUpload } from 'graphql-upload/processRequest.mjs'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter'; +import { + ApplicationException, + ApplicationExceptionCode, +} from 'src/engine/core-modules/application/application.exception'; +import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; +import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input'; +import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input'; +import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input'; +import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput'; +import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { DevelopmentGuard } from 'src/engine/guards/development.guard'; +import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; + +@UsePipes(ResolverValidationPipe) +@MetadataResolver() +@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor) +@UseFilters(ApplicationExceptionFilter) +@UseGuards( + WorkspaceAuthGuard, + DevelopmentGuard, + SettingsPermissionGuard(PermissionFlagType.APPLICATIONS), +) +export class ApplicationDevelopmentResolver { + constructor( + private readonly applicationTokenService: ApplicationTokenService, + private readonly applicationSyncService: ApplicationSyncService, + private readonly applicationService: ApplicationService, + private readonly fileStorageService: FileStorageService, + ) {} + + @Mutation(() => AuthToken) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async generateApplicationToken( + @Args() { applicationId }: GenerateApplicationTokenInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ): Promise { + return this.applicationTokenService.generateApplicationAccessToken({ + workspaceId, + applicationId, + }); + } + + @Mutation(() => Boolean) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async syncApplication( + @Args() { manifest }: ApplicationInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + await this.applicationSyncService.synchronizeFromManifest({ + workspaceId, + manifest, + }); + + return true; + } + + @Mutation(() => ApplicationDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async createOneApplication( + @Args('input') input: CreateApplicationInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + return await this.applicationService.create({ + ...input, + sourceType: 'local', + workspaceId, + }); + } + + @Mutation(() => FileDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async uploadApplicationFile( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, mimetype }: FileUpload, + @Args() + { + applicationUniversalIdentifier, + fileFolder, + filePath, + }: UploadApplicationFileInput, + ): Promise { + const allowedApplicationFileFolders: FileFolder[] = [ + FileFolder.BuiltLogicFunction, + FileFolder.BuiltFrontComponent, + FileFolder.PublicAsset, + FileFolder.Source, + FileFolder.Dependencies, + ]; + + if (!allowedApplicationFileFolders.includes(fileFolder)) { + throw new ApplicationException( + `Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`, + ApplicationExceptionCode.INVALID_INPUT, + ); + } + + const buffer = await streamToBuffer(createReadStream()); + + return await this.fileStorageService.writeFile({ + sourceFile: buffer, + mimeType: mimetype, + fileFolder, + applicationUniversalIdentifier, + workspaceId, + resourcePath: filePath, + settings: { isTemporaryFile: false, toDelete: false }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts index 02eafd2070..fe2b83d9e2 100644 --- a/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts @@ -6,13 +6,7 @@ import { } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; -import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { PermissionFlagType } from 'twenty-shared/constants'; -import { FileFolder } from 'twenty-shared/types'; - -import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; - -import type { FileUpload } from 'graphql-upload/processRequest.mjs'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -21,52 +15,41 @@ import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; +import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto'; import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; -import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input'; -import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input'; -import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input'; import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input'; import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput'; -import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput'; import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; -import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; -import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service'; -import { streamToBuffer } from 'src/utils/stream-to-buffer'; -@UseGuards( - WorkspaceAuthGuard, - SettingsPermissionGuard(PermissionFlagType.APPLICATIONS), -) @UsePipes(ResolverValidationPipe) @MetadataResolver() @UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor) @UseFilters(ApplicationExceptionFilter) +@UseGuards(WorkspaceAuthGuard) export class ApplicationResolver { constructor( private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, private readonly applicationSyncService: ApplicationSyncService, private readonly applicationService: ApplicationService, private readonly applicationTokenService: ApplicationTokenService, - private readonly twentyConfigService: TwentyConfigService, - private readonly fileStorageService: FileStorageService, private readonly workspaceCacheService: WorkspaceCacheService, ) {} @Query(() => [ApplicationDTO]) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async findManyApplications( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -75,6 +58,7 @@ export class ApplicationResolver { } @Query(() => Boolean) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async checkApplicationExist( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -90,6 +74,7 @@ export class ApplicationResolver { } @Query(() => ApplicationDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async findOneApplication( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -104,61 +89,32 @@ export class ApplicationResolver { }); } - @Mutation(() => AuthToken) + @Mutation(() => ApplicationTokenPairDTO) + @UseGuards(NoPermissionGuard) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) - async generateApplicationToken( - @Args() { applicationId }: GenerateApplicationTokenInput, + async renewApplicationToken( + @Args('applicationRefreshToken') applicationRefreshToken: string, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, - ): Promise { - const nodeEnv = this.twentyConfigService.get('NODE_ENV'); + ): Promise { + const applicationRefreshTokenPayload = + this.applicationTokenService.validateApplicationRefreshToken( + applicationRefreshToken, + ); - if ( - nodeEnv !== NodeEnvironment.DEVELOPMENT && - nodeEnv !== NodeEnvironment.TEST - ) { + if (applicationRefreshTokenPayload.workspaceId !== workspaceId) { throw new ApplicationException( - 'This endpoint is only available in development mode', + 'Refresh token workspace does not match authenticated workspace', ApplicationExceptionCode.FORBIDDEN, ); } - const APPLICATION_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; - - return this.applicationTokenService.generateApplicationToken({ - workspaceId, - applicationId, - expiresInSeconds: APPLICATION_TOKEN_EXPIRY_SECONDS, - }); - } - - @Mutation(() => ApplicationDTO) - @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) - async createOneApplication( - @Args('input') input: CreateApplicationInput, - @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, - ) { - return await this.applicationService.create({ - ...input, - sourceType: 'local', - workspaceId, - }); - } - - @Mutation(() => Boolean) - @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) - async syncApplication( - @Args() { manifest }: ApplicationInput, - @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, - ) { - await this.applicationSyncService.synchronizeFromManifest({ - workspaceId, - manifest, - }); - - return true; + return this.applicationTokenService.renewApplicationTokens( + applicationRefreshTokenPayload, + ); } @Mutation(() => Boolean) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async installApplication( @Args() { workspaceMigration: { actions } }: InstallApplicationInput, @@ -198,6 +154,7 @@ export class ApplicationResolver { } @Mutation(() => Boolean) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async uninstallApplication( @Args() { universalIdentifier }: UninstallApplicationInput, @@ -210,46 +167,4 @@ export class ApplicationResolver { return true; } - - @Mutation(() => FileDTO) - @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) - @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) - async uploadApplicationFile( - @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, - @Args({ name: 'file', type: () => GraphQLUpload }) - { createReadStream, mimetype }: FileUpload, - @Args() - { - applicationUniversalIdentifier, - fileFolder, - filePath, - }: UploadApplicationFileInput, - ): Promise { - const allowedApplicationFileFolders: FileFolder[] = [ - FileFolder.BuiltLogicFunction, - FileFolder.BuiltFrontComponent, - FileFolder.PublicAsset, - FileFolder.Source, - FileFolder.Dependencies, - ]; - - if (!allowedApplicationFileFolders.includes(fileFolder)) { - throw new ApplicationException( - `Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`, - ApplicationExceptionCode.INVALID_INPUT, - ); - } - - const buffer = await streamToBuffer(createReadStream()); - - return await this.fileStorageService.writeFile({ - sourceFile: buffer, - mimeType: mimetype, - fileFolder, - applicationUniversalIdentifier, - workspaceId, - resourcePath: filePath, - settings: { isTemporaryFile: false, toDelete: false }, - }); - } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index 59ec7a97aa..38de866c04 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -356,14 +356,14 @@ describe('JwtAuthStrategy', () => { }); }); - describe('APPLICATION token validation', () => { - it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => { + describe('APPLICATION_ACCESS token validation', () => { + it('should throw AuthExceptionCode if type is APPLICATION_ACCESS, and application not found', async () => { const validApplicationId = randomUUID(); const validWorkspaceId = randomUUID(); const payload = { sub: validApplicationId, - type: JwtTokenTypeEnum.APPLICATION, + type: JwtTokenTypeEnum.APPLICATION_ACCESS, applicationId: validApplicationId, workspaceId: validWorkspaceId, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index e0bfe78a1d..e91bf1defb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -18,7 +18,7 @@ import { import { type AccessTokenJwtPayload, type ApiKeyTokenJwtPayload, - ApplicationTokenJwtPayload, + ApplicationAccessTokenJwtPayload, type AuthContext, type FileTokenJwtPayload, type JwtPayload, @@ -333,7 +333,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { } private async validateApplicationToken( - payload: ApplicationTokenJwtPayload, + payload: ApplicationAccessTokenJwtPayload, ): Promise { const workspace = await this.workspaceRepository.findOneBy({ id: payload.workspaceId, @@ -359,6 +359,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } + // TODO: Token carries userId/userWorkspaceId but they are unused. + // Compute the intersection of user and application permissions instead. return { application, workspace, @@ -388,7 +390,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { return await this.validateAccessToken(payload); } - if (payload.type === JwtTokenTypeEnum.APPLICATION) { + if (payload.type === JwtTokenTypeEnum.APPLICATION_ACCESS) { return await this.validateApplicationToken(payload); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts index 99ec4a4865..86682c4303 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts @@ -3,11 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationException } from 'src/engine/core-modules/application/application.exception'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception'; describe('ApplicationTokenService', () => { @@ -55,8 +57,8 @@ describe('ApplicationTokenService', () => { expect(service).toBeDefined(); }); - describe('generateApplicationToken', () => { - it('should generate an application token successfully', async () => { + describe('generateApplicationAccessToken', () => { + it('should generate an application access token successfully', async () => { const workspaceId = 'workspace-id'; const applicationId = 'application-id'; const mockWorkspace = { id: workspaceId }; @@ -71,7 +73,7 @@ describe('ApplicationTokenService', () => { .mockResolvedValue(mockApplication as ApplicationEntity); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); - const result = await service.generateApplicationToken({ + const result = await service.generateApplicationAccessToken({ workspaceId, applicationId, expiresInSeconds: 10, @@ -90,9 +92,11 @@ describe('ApplicationTokenService', () => { ); }); - it('should handle missing userId successfully', async () => { + it('should include optional userWorkspaceId and userId in payload', async () => { const workspaceId = 'workspace-id'; const applicationId = 'application-id'; + const userWorkspaceId = 'user-workspace-id'; + const userId = 'user-id'; const mockWorkspace = { id: workspaceId }; const mockApplication = { id: applicationId }; const mockToken = 'mock-token'; @@ -105,9 +109,11 @@ describe('ApplicationTokenService', () => { .mockResolvedValue(mockApplication as ApplicationEntity); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); - const result = await service.generateApplicationToken({ + const result = await service.generateApplicationAccessToken({ workspaceId, applicationId, + userWorkspaceId, + userId, expiresInSeconds: 10, }); @@ -119,7 +125,9 @@ describe('ApplicationTokenService', () => { expect.objectContaining({ sub: applicationId, applicationId, - workspaceId: workspaceId, + workspaceId, + userWorkspaceId, + userId, }), expect.any(Object), ); @@ -137,7 +145,7 @@ describe('ApplicationTokenService', () => { .mockResolvedValue(mockWorkspace as WorkspaceEntity); await expect( - service.generateApplicationToken({ + service.generateApplicationAccessToken({ applicationId: 'non-existent-application', workspaceId: 'workspace-id', expiresInSeconds: 10, @@ -149,11 +157,131 @@ describe('ApplicationTokenService', () => { jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); await expect( - service.generateApplicationToken({ + service.generateApplicationAccessToken({ applicationId: 'application-id', workspaceId: 'non-existent-workspace', expiresInSeconds: 10, }), ).rejects.toThrow(WorkspaceException); }); + + describe('validateApplicationRefreshToken', () => { + it('should validate and return payload for a valid refresh token', () => { + const mockToken = 'valid-refresh-token'; + const mockPayload = { + sub: 'application-id', + applicationId: 'application-id', + workspaceId: 'workspace-id', + type: JwtTokenTypeEnum.APPLICATION_REFRESH, + }; + + jest + .spyOn(jwtWrapperService, 'verifyJwtToken') + .mockReturnValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); + + const result = service.validateApplicationRefreshToken(mockToken); + + expect(result).toEqual(mockPayload); + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, { + json: true, + }); + }); + + it('should throw when token type is not APPLICATION_REFRESH', () => { + const mockToken = 'access-token'; + + jest + .spyOn(jwtWrapperService, 'verifyJwtToken') + .mockReturnValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({ + sub: 'application-id', + applicationId: 'application-id', + workspaceId: 'workspace-id', + type: JwtTokenTypeEnum.APPLICATION_ACCESS, + }); + + expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow( + AuthException, + ); + }); + + it('should throw when token verification fails', () => { + const mockToken = 'invalid-token'; + + jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => { + throw new Error('Invalid token'); + }); + + expect(() => + service.validateApplicationRefreshToken(mockToken), + ).toThrow(); + }); + }); + + describe('generateApplicationTokenPair', () => { + it('should generate both access and refresh tokens', async () => { + const workspaceId = 'workspace-id'; + const applicationId = 'application-id'; + const mockWorkspace = { id: workspaceId }; + const mockApplication = { id: applicationId }; + const mockToken = 'mock-token'; + + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as WorkspaceEntity); + jest + .spyOn(applicationRepository, 'findOne') + .mockResolvedValue(mockApplication as ApplicationEntity); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateApplicationTokenPair({ + workspaceId, + applicationId, + }); + + expect(result.applicationAccessToken).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(result.applicationRefreshToken).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.sign).toHaveBeenCalledTimes(2); + }); + }); + + describe('renewApplicationTokens', () => { + it('should generate a new token pair from validated payload', async () => { + const workspaceId = 'workspace-id'; + const applicationId = 'application-id'; + const mockWorkspace = { id: workspaceId }; + const mockApplication = { id: applicationId }; + const mockToken = 'mock-token'; + + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as WorkspaceEntity); + jest + .spyOn(applicationRepository, 'findOne') + .mockResolvedValue(mockApplication as ApplicationEntity); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.renewApplicationTokens({ + workspaceId, + applicationId, + }); + + expect(result.applicationAccessToken).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(result.applicationRefreshToken).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts index 58dc8096ef..5a0e8bf161 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts @@ -1,7 +1,7 @@ import { InjectRepository } from '@nestjs/typeorm'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { type Repository } from 'typeorm'; import { addMilliseconds } from 'date-fns'; import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; import ms from 'ms'; @@ -9,20 +9,29 @@ import ms from 'ms'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { - ApplicationTokenJwtPayload, + type ApplicationAccessTokenJwtPayload, + type ApplicationRefreshTokenJwtPayload, JwtTokenTypeEnum, } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; +import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; + +const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800; +const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days @Injectable() export class ApplicationTokenService { constructor( + @Inject(JwtWrapperService) private readonly jwtWrapperService: JwtWrapperService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, @@ -30,17 +39,113 @@ export class ApplicationTokenService { private readonly applicationRepository: Repository, ) {} - async generateApplicationToken({ + async generateApplicationAccessToken({ workspaceId, applicationId, - expiresInSeconds, - }: Omit & { - expiresInSeconds: number; + userWorkspaceId, + userId, + expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS, + }: { + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; + expiresInSeconds?: number; }): Promise { - const expiresIn = `${expiresInSeconds}s`; + await this.validateWorkspaceAndApplication(workspaceId, applicationId); - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + return this.signApplicationToken({ + workspaceId, + applicationId, + userWorkspaceId, + userId, + tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS, + expiresInSeconds, + }); + } + async generateApplicationTokenPair({ + workspaceId, + applicationId, + userWorkspaceId, + userId, + }: { + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; + }): Promise<{ + applicationAccessToken: AuthToken; + applicationRefreshToken: AuthToken; + }> { + await this.validateWorkspaceAndApplication(workspaceId, applicationId); + + const [applicationAccessToken, applicationRefreshToken] = await Promise.all( + [ + this.signApplicationToken({ + workspaceId, + applicationId, + userWorkspaceId, + userId, + tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS, + expiresInSeconds: APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS, + }), + this.signApplicationToken({ + workspaceId, + applicationId, + userWorkspaceId, + userId, + tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH, + expiresInSeconds: APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS, + }), + ], + ); + + return { applicationAccessToken, applicationRefreshToken }; + } + + validateApplicationRefreshToken( + refreshToken: string, + ): ApplicationRefreshTokenJwtPayload { + this.jwtWrapperService.verifyJwtToken(refreshToken); + + const payload = + this.jwtWrapperService.decode( + refreshToken, + { json: true }, + ); + + if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) { + throw new AuthException( + 'Expected an application refresh token', + AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, + ); + } + + return payload; + } + + async renewApplicationTokens(payload: { + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; + }): Promise<{ + applicationAccessToken: AuthToken; + applicationRefreshToken: AuthToken; + }> { + return this.generateApplicationTokenPair({ + workspaceId: payload.workspaceId, + applicationId: payload.applicationId, + userWorkspaceId: payload.userWorkspaceId, + userId: payload.userId, + }); + } + + private async validateWorkspaceAndApplication( + workspaceId: string, + applicationId: string, + ): Promise { const workspace = await this.workspaceRepository.findOne({ where: { id: workspaceId }, }); @@ -58,18 +163,43 @@ export class ApplicationTokenService { ApplicationExceptionCode.APPLICATION_NOT_FOUND, ), ); + } - const jwtPayload: ApplicationTokenJwtPayload = { + private signApplicationToken({ + workspaceId, + applicationId, + userWorkspaceId, + userId, + tokenType, + expiresInSeconds, + }: { + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; + tokenType: + | JwtTokenTypeEnum.APPLICATION_ACCESS + | JwtTokenTypeEnum.APPLICATION_REFRESH; + expiresInSeconds: number; + }): AuthToken { + const expiresIn = `${expiresInSeconds}s`; + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const jwtPayload: + | ApplicationAccessTokenJwtPayload + | ApplicationRefreshTokenJwtPayload = { sub: applicationId, applicationId, workspaceId, - type: JwtTokenTypeEnum.APPLICATION, + type: tokenType, + ...(userWorkspaceId ? { userWorkspaceId } : {}), + ...(userId ? { userId } : {}), }; return { token: this.jwtWrapperService.sign(jwtPayload, { secret: this.jwtWrapperService.generateAppSecret( - JwtTokenTypeEnum.APPLICATION, + tokenType, workspaceId, ), expiresIn, diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 225971d8db..a8a04a7922 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -40,7 +40,8 @@ export enum JwtTokenTypeEnum { POSTGRES_PROXY = 'POSTGRES_PROXY', REMOTE_SERVER = 'REMOTE_SERVER', KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY', - APPLICATION = 'APPLICATION', + APPLICATION_ACCESS = 'APPLICATION_ACCESS', + APPLICATION_REFRESH = 'APPLICATION_REFRESH', } type CommonPropertiesJwtPayload = { @@ -102,10 +103,20 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & { jti?: string; }; -export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.APPLICATION; +export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APPLICATION_ACCESS; workspaceId: string; applicationId: string; + userWorkspaceId?: string; + userId?: string; +}; + +export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APPLICATION_REFRESH; + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; }; export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & { @@ -127,7 +138,8 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & { export type JwtPayload = | AccessTokenJwtPayload | ApiKeyTokenJwtPayload - | ApplicationTokenJwtPayload + | ApplicationAccessTokenJwtPayload + | ApplicationRefreshTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload | LoginTokenJwtPayload | TransientTokenJwtPayload diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts index 7716af1dbf..1e86053411 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts @@ -179,7 +179,7 @@ export class LogicFunctionExecutorService { flatApplicationVariables: FlatApplicationVariable[]; }) { const applicationAccessToken = - await this.applicationTokenService.generateApplicationToken({ + await this.applicationTokenService.generateApplicationAccessToken({ workspaceId, applicationId: flatApplication.id, expiresInSeconds: Math.max( diff --git a/packages/twenty-server/src/engine/guards/development.guard.ts b/packages/twenty-server/src/engine/guards/development.guard.ts new file mode 100644 index 0000000000..f2561d217e --- /dev/null +++ b/packages/twenty-server/src/engine/guards/development.guard.ts @@ -0,0 +1,25 @@ +import { type CanActivate, Injectable } from '@nestjs/common'; + +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; + +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +@Injectable() +export class DevelopmentGuard implements CanActivate { + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + canActivate(): boolean { + const nodeEnv = this.twentyConfigService.get('NODE_ENV'); + + if ( + nodeEnv !== NodeEnvironment.DEVELOPMENT && + nodeEnv !== NodeEnvironment.TEST + ) { + throw new Error( + 'This endpoint is only available in development or test environments', + ); + } + + return true; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts index 9b6a9adf73..b73cbdc7f2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts @@ -9,6 +9,7 @@ import { } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto'; @ObjectType('FrontComponent') export class FrontComponentDTO { @@ -61,4 +62,7 @@ export class FrontComponentDTO { @IsDateString() @Field() updatedAt: Date; + + @Field(() => ApplicationTokenPairDTO, { nullable: true }) + applicationTokenPair?: ApplicationTokenPairDTO; } diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts index 4e22dad4fb..161c6b9109 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module'; import { FrontComponentController } from 'src/engine/metadata-modules/front-component/controllers/front-component.controller'; @@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceMigrationModule, ApplicationModule, + TokenModule, PermissionsModule, FlatFrontComponentModule, ], diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts index fb16c1bd02..f91f8731a3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts @@ -1,14 +1,19 @@ -import { UseGuards, UseInterceptors } from '@nestjs/common'; +import { Inject, UseGuards, UseInterceptors } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; -import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.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'; import { fromFlatFrontComponentToFrontComponentDto } from 'src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util'; import { CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; @@ -25,7 +30,12 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor ) @MetadataResolver(() => FrontComponentDTO) export class FrontComponentResolver { - constructor(private readonly frontComponentService: FrontComponentService) {} + constructor( + @Inject(FrontComponentService) + private readonly frontComponentService: FrontComponentService, + @Inject(ApplicationTokenService) + private readonly applicationTokenService: ApplicationTokenService, + ) {} @Query(() => [FrontComponentDTO]) @UseGuards(NoPermissionGuard) @@ -36,12 +46,31 @@ export class FrontComponentResolver { } @Query(() => FrontComponentDTO, { nullable: true }) - @UseGuards(NoPermissionGuard) + @UseGuards(UserAuthGuard, NoPermissionGuard) async frontComponent( @Args('id', { type: () => UUIDScalarType }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, + @AuthUser() user: UserEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, ): Promise { - return await this.frontComponentService.findById(id, workspace.id); + const dto = await this.frontComponentService.findById(id, workspace.id); + + if (!dto) { + return null; + } + + const tokenPair = + await this.applicationTokenService.generateApplicationTokenPair({ + applicationId: dto.applicationId, + workspaceId: workspace.id, + userWorkspaceId, + userId: user.id, + }); + + return { + ...dto, + applicationTokenPair: tokenPair, + }; } @Mutation(() => FrontComponentDTO)