diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 65cb61ccc9..346575148a 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5884,6 +5884,13 @@ export type UploadWorkflowFileMutationVariables = Exact<{ export type UploadWorkflowFileMutation = { __typename?: 'Mutation', uploadWorkflowFile: { __typename?: 'FileWithSignedUrl', id: string, path: string, size: number, createdAt: string, url: string } }; +export type RenewApplicationTokenMutationVariables = Exact<{ + applicationRefreshToken: Scalars['String']; +}>; + + +export type RenewApplicationTokenMutation = { __typename?: 'Mutation', renewApplicationToken: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; + export type FindManyFrontComponentsQueryVariables = Exact<{ [key: string]: never; }>; @@ -10730,6 +10737,46 @@ export function useUploadWorkflowFileMutation(baseOptions?: Apollo.MutationHookO export type UploadWorkflowFileMutationHookResult = ReturnType; export type UploadWorkflowFileMutationResult = Apollo.MutationResult; export type UploadWorkflowFileMutationOptions = Apollo.BaseMutationOptions; +export const RenewApplicationTokenDocument = gql` + mutation RenewApplicationToken($applicationRefreshToken: String!) { + renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) { + applicationAccessToken { + token + expiresAt + } + applicationRefreshToken { + token + expiresAt + } + } +} + `; +export type RenewApplicationTokenMutationFn = Apollo.MutationFunction; + +/** + * __useRenewApplicationTokenMutation__ + * + * To run a mutation, you first call `useRenewApplicationTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRenewApplicationTokenMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [renewApplicationTokenMutation, { data, loading, error }] = useRenewApplicationTokenMutation({ + * variables: { + * applicationRefreshToken: // value for 'applicationRefreshToken' + * }, + * }); + */ +export function useRenewApplicationTokenMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RenewApplicationTokenDocument, options); + } +export type RenewApplicationTokenMutationHookResult = ReturnType; +export type RenewApplicationTokenMutationResult = Apollo.MutationResult; +export type RenewApplicationTokenMutationOptions = Apollo.BaseMutationOptions; export const FindManyFrontComponentsDocument = gql` query FindManyFrontComponents { frontComponents { 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 8b5fd2eb92..1d0ea7c004 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -1,7 +1,10 @@ +import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider'; import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext'; import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated'; +import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState'; import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; import { useCallback } from 'react'; @@ -19,6 +22,12 @@ export const FrontComponentRenderer = ({ }: FrontComponentRendererProps) => { const theme = useTheme(); const { enqueueErrorSnackBar } = useSnackBar(); + + const setApplicationTokenPair = useSetRecoilComponentState( + frontComponentApplicationTokenPairComponentState, + frontComponentId, + ); + const { executionContext, frontComponentHostCommunicationApi } = useFrontComponentExecutionContext({ frontComponentId }); @@ -40,6 +49,13 @@ export const FrontComponentRenderer = ({ const { data, loading } = useFindOneFrontComponentQuery({ variables: { id: frontComponentId }, onError: handleError, + onCompleted: (completedData) => { + const tokenPair = completedData.frontComponent?.applicationTokenPair; + + if (isDefined(tokenPair)) { + setApplicationTokenPair(tokenPair); + } + }, }); useOnFrontComponentUpdated({ @@ -51,25 +67,30 @@ export const FrontComponentRenderer = ({ checksum: data?.frontComponent?.builtComponentChecksum, }); + const applicationTokenPair = + data?.frontComponent?.applicationTokenPair ?? null; + if ( loading || !isDefined(data?.frontComponent) || - !isDefined(data.frontComponent.applicationTokenPair) + !isDefined(applicationTokenPair) ) { return null; } return ( - + + + ); }; diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx new file mode 100644 index 0000000000..f6a92bc7de --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx @@ -0,0 +1,19 @@ +import { FrontComponentInstanceContext } from '@/front-components/states/contexts/FrontComponentInstanceContext'; + +type FrontComponentRendererProviderProps = { + frontComponentId: string; + children: React.ReactNode; +}; + +export const FrontComponentRendererProvider = ({ + frontComponentId, + children, +}: FrontComponentRendererProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/front-components/graphql/mutations/renewApplicationToken.ts b/packages/twenty-front/src/modules/front-components/graphql/mutations/renewApplicationToken.ts new file mode 100644 index 0000000000..00c9b26609 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/graphql/mutations/renewApplicationToken.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const RENEW_APPLICATION_TOKEN = gql` + mutation RenewApplicationToken($applicationRefreshToken: String!) { + renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) { + applicationAccessToken { + token + expiresAt + } + applicationRefreshToken { + token + expiresAt + } + } + } +`; diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts index 9ead49d165..3a23d1fdb9 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -9,6 +9,7 @@ import { currentUserState } from '@/auth/states/currentUserState'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; +import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh'; import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2'; @@ -26,6 +27,9 @@ export const useFrontComponentExecutionContext = ({ } => { const currentUser = useRecoilValueV2(currentUserState); const navigateApp = useNavigateApp(); + const { requestAccessTokenRefresh } = useRequestApplicationTokenRefresh({ + frontComponentId, + }); const { navigateCommandMenu } = useNavigateCommandMenu(); const setCommandMenuSearchState = useSetRecoilStateV2(commandMenuSearchState); const { getIcon } = useIcons(); @@ -115,6 +119,7 @@ export const useFrontComponentExecutionContext = ({ const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi = { navigate, + requestAccessTokenRefresh, openSidePanelPage, enqueueSnackbar, unmountFrontComponent, diff --git a/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts b/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts new file mode 100644 index 0000000000..0811b5891d --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts @@ -0,0 +1,138 @@ +import { ApolloError, useApolloClient } from '@apollo/client'; +import { type GraphQLFormattedError } from 'graphql'; +import { useRecoilCallback } from 'recoil'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { + FindOneFrontComponentDocument, + type FindOneFrontComponentQuery, + type FindOneFrontComponentQueryVariables, + RenewApplicationTokenDocument, + type RenewApplicationTokenMutation, + type RenewApplicationTokenMutationVariables, +} from '~/generated-metadata/graphql'; + +const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE = + 'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED'; + +const hasApplicationRefreshTokenInvalidOrExpiredSubCode = ( + errors: ReadonlyArray, +): boolean => + errors.some( + (error) => + error.extensions?.subCode === + APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE, + ); + +type UseRequestApplicationTokenRefreshArgs = { + frontComponentId: string; +}; + +export const useRequestApplicationTokenRefresh = ({ + frontComponentId, +}: UseRequestApplicationTokenRefreshArgs) => { + const apolloClient = useApolloClient(); + + const applicationTokenPairCallbackState = useRecoilComponentCallbackState( + frontComponentApplicationTokenPairComponentState, + frontComponentId, + ); + + const requestAccessTokenRefresh = useRecoilCallback( + ({ set, snapshot }) => + async (): Promise => { + const refetchFrontComponentForNewTokenPair = + async (): Promise => { + const result = await apolloClient.query< + FindOneFrontComponentQuery, + FindOneFrontComponentQueryVariables + >({ + query: FindOneFrontComponentDocument, + variables: { id: frontComponentId }, + fetchPolicy: 'network-only', + }); + + const newTokenPair = + result.data?.frontComponent?.applicationTokenPair; + + if (!isDefined(newTokenPair)) { + throw new Error('Failed to refetch application token pair'); + } + + set(applicationTokenPairCallbackState, newTokenPair); + + return newTokenPair.applicationAccessToken.token; + }; + + const applicationTokenPair = getSnapshotValue( + snapshot, + applicationTokenPairCallbackState, + ); + + if (!isDefined(applicationTokenPair)) { + throw new Error( + 'Application token pair must be initialized before requesting a refresh. Ensure the front component has loaded its token pair before invoking refresh.', + ); + } + + // First try renewing via the refresh token (fast path). + // If the refresh token itself is expired, fall back to refetching + // the front component which issues a fresh token pair server-side. + try { + const renewResult = await apolloClient.mutate< + RenewApplicationTokenMutation, + RenewApplicationTokenMutationVariables + >({ + mutation: RenewApplicationTokenDocument, + variables: { + applicationRefreshToken: + applicationTokenPair.applicationRefreshToken.token, + }, + }); + + if (isNonEmptyArray(renewResult.errors)) { + if ( + hasApplicationRefreshTokenInvalidOrExpiredSubCode( + renewResult.errors, + ) + ) { + return await refetchFrontComponentForNewTokenPair(); + } + + const errorMessage = renewResult.errors + .map((error) => error.message) + .join(', '); + + throw new Error(`Token renewal failed: ${errorMessage}`); + } + + const renewedTokenPair = renewResult.data?.renewApplicationToken; + + if (!isDefined(renewedTokenPair)) { + throw new Error('Failed to renew application token'); + } + + set(applicationTokenPairCallbackState, renewedTokenPair); + + return renewedTokenPair.applicationAccessToken.token; + } catch (error) { + if ( + error instanceof ApolloError && + hasApplicationRefreshTokenInvalidOrExpiredSubCode( + error.graphQLErrors, + ) + ) { + return await refetchFrontComponentForNewTokenPair(); + } + + throw error; + } + }, + [apolloClient, applicationTokenPairCallbackState, frontComponentId], + ); + + return { requestAccessTokenRefresh }; +}; diff --git a/packages/twenty-front/src/modules/front-components/states/contexts/FrontComponentInstanceContext.ts b/packages/twenty-front/src/modules/front-components/states/contexts/FrontComponentInstanceContext.ts new file mode 100644 index 0000000000..ea361ffd94 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/states/contexts/FrontComponentInstanceContext.ts @@ -0,0 +1,3 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const FrontComponentInstanceContext = createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/front-components/states/frontComponentApplicationTokenPairComponentState.ts b/packages/twenty-front/src/modules/front-components/states/frontComponentApplicationTokenPairComponentState.ts new file mode 100644 index 0000000000..4ffb3c7231 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/states/frontComponentApplicationTokenPairComponentState.ts @@ -0,0 +1,10 @@ +import { FrontComponentInstanceContext } from '@/front-components/states/contexts/FrontComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { type ApplicationTokenPair } from '~/generated-metadata/graphql'; + +export const frontComponentApplicationTokenPairComponentState = + createComponentState({ + key: 'frontComponentApplicationTokenPairComponentState', + defaultValue: null, + componentInstanceContext: FrontComponentInstanceContext, + }); diff --git a/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts b/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts new file mode 100644 index 0000000000..78bdde4000 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts @@ -0,0 +1,529 @@ +import { transform } from 'esbuild'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +vi.mock('twenty-shared/application', () => ({ + DEFAULT_APP_ACCESS_TOKEN_NAME: 'TWENTY_APP_ACCESS_TOKEN', + DEFAULT_API_KEY_NAME: 'TWENTY_API_KEY', + DEFAULT_API_URL_NAME: 'TWENTY_API_URL', + GENERATED_DIR: 'generated', +})); + +import { ClientService } from '@/cli/utilities/client/client-service'; + +type TwentyClassType = new (options?: { + url?: string; + metadataUrl?: string; + fetch?: typeof globalThis.fetch; +}) => { + query: (request: Record) => Promise; + uploadFile: ( + fileBuffer: Buffer, + filename: string, + contentType: string, + fieldMetadataUniversalIdentifier: string, + ) => Promise<{ + id: string; + path: string; + size: number; + createdAt: string; + url: string; + }>; +}; + +const stubGeneratedIndexSource = ` +export type QueryGenqlSelection = Record +export type MutationGenqlSelection = Record +export type GraphqlOperation = Record + +export type ClientOptions = { + url?: string + metadataUrl?: string + headers?: HeadersInit | (() => HeadersInit | Promise) + fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise + fetch?: typeof globalThis.fetch + batch?: unknown +} + +export type Client = { + query: (request: QueryGenqlSelection & { __name?: string }) => Promise + mutation: ( + request: MutationGenqlSelection & { __name?: string }, + ) => Promise +} + +export class GenqlError extends Error { + constructor( + public readonly errors: unknown, + public readonly data: unknown, + ) { + super('GenqlError') + } +} + +export const createClient = (options: ClientOptions): Client => { + return { + query: (request) => { + return options.fetcher?.({ + query: 'query', + variables: request, + }) + }, + mutation: (request) => { + return options.fetcher?.({ + query: 'mutation', + variables: request, + }) + }, + } +} +`; + +const createJsonResponse = ({ + body, + status = 200, + statusText = 'OK', +}: { + body: unknown; + status?: number; + statusText?: string; +}) => + new Response(JSON.stringify(body), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }); + +const getAuthorizationHeaderValue = (requestInit: RequestInit | undefined) => { + return new Headers(requestInit?.headers).get('Authorization'); +}; + +describe('ClientService generated Twenty auth behavior', () => { + let temporaryGeneratedClientDirectory: string; + let TwentyClass: TwentyClassType; + + beforeAll(async () => { + temporaryGeneratedClientDirectory = await mkdtemp( + join(tmpdir(), 'twenty-generated-client-'), + ); + + const temporaryGeneratedIndexTsPath = join( + temporaryGeneratedClientDirectory, + 'index.ts', + ); + + await writeFile(temporaryGeneratedIndexTsPath, stubGeneratedIndexSource); + + const clientService = new ClientService(); + await ( + clientService as unknown as { + injectTwentyClient: (output: string) => Promise; + } + ).injectTwentyClient(temporaryGeneratedClientDirectory); + + const generatedIndexContent = await readFile( + temporaryGeneratedIndexTsPath, + 'utf-8', + ); + + const transpiledModule = await transform(generatedIndexContent, { + loader: 'ts', + format: 'esm', + target: 'es2022', + }); + + const temporaryGeneratedIndexMjsPath = join( + temporaryGeneratedClientDirectory, + 'index.mjs', + ); + await writeFile(temporaryGeneratedIndexMjsPath, transpiledModule.code); + + const generatedModule = await import( + `${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}` + ); + + TwentyClass = generatedModule.default as TwentyClassType; + }); + + beforeEach(() => { + delete process.env.TWENTY_APP_ACCESS_TOKEN; + delete process.env.TWENTY_API_KEY; + delete (globalThis as Record) + .frontComponentHostCommunicationApi; + }); + + afterAll(async () => { + if (temporaryGeneratedClientDirectory) { + await rm(temporaryGeneratedClientDirectory, { + recursive: true, + force: true, + }); + } + }); + + it('uses TWENTY_APP_ACCESS_TOKEN before TWENTY_API_KEY', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'application-token'; + process.env.TWENTY_API_KEY = 'api-key-token'; + + const capturedAuthorizationHeaders: string[] = []; + const fetchMock = vi.fn( + async (_url: string | URL | Request, requestInit?: RequestInit) => { + const authorizationHeaderValue = + getAuthorizationHeaderValue(requestInit); + + if (authorizationHeaderValue) { + capturedAuthorizationHeaders.push(authorizationHeaderValue); + } + + return createJsonResponse({ + body: { data: { record: { id: 'record-id' } } }, + }); + }, + ); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + await twentyClient.query({ record: { id: true } }); + + expect(capturedAuthorizationHeaders).toEqual(['Bearer application-token']); + }); + + it('falls back to TWENTY_API_KEY when TWENTY_APP_ACCESS_TOKEN is absent', async () => { + process.env.TWENTY_API_KEY = 'legacy-api-key-token'; + + const capturedAuthorizationHeaders: string[] = []; + const fetchMock = vi.fn( + async (_url: string | URL | Request, requestInit?: RequestInit) => { + const authorizationHeaderValue = + getAuthorizationHeaderValue(requestInit); + + if (authorizationHeaderValue) { + capturedAuthorizationHeaders.push(authorizationHeaderValue); + } + + return createJsonResponse({ + body: { data: { record: { id: 'record-id' } } }, + }); + }, + ); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + await twentyClient.query({ record: { id: true } }); + + expect(capturedAuthorizationHeaders).toEqual([ + 'Bearer legacy-api-key-token', + ]); + }); + + it('refreshes and retries once after auth error when refresh callback is available', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token'; + + const requestAccessTokenRefresh = vi + .fn<() => Promise>() + .mockResolvedValue('fresh-token'); + + (globalThis as Record).frontComponentHostCommunicationApi = + { + requestAccessTokenRefresh, + }; + + const fetchMock = vi.fn( + async (_url: string | URL | Request, requestInit?: RequestInit) => { + const authorizationHeaderValue = + getAuthorizationHeaderValue(requestInit); + + if (authorizationHeaderValue === 'Bearer stale-token') { + return createJsonResponse({ + body: { + errors: [ + { + extensions: { code: 'UNAUTHENTICATED' }, + message: 'Unauthorized', + }, + ], + }, + }); + } + + return createJsonResponse({ + body: { data: { record: { id: 'record-id' } } }, + }); + }, + ); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + const queryResult = await twentyClient.query({ record: { id: true } }); + + expect(queryResult).toEqual({ data: { record: { id: 'record-id' } } }); + expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(process.env.TWENTY_APP_ACCESS_TOKEN).toBe('fresh-token'); + expect(process.env.TWENTY_API_KEY).toBe('fresh-token'); + }); + + it('deduplicates concurrent token refresh requests', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token'; + + const requestAccessTokenRefresh = vi + .fn<() => Promise>() + .mockImplementation(async () => { + await Promise.resolve(); + return 'fresh-token'; + }); + + (globalThis as Record).frontComponentHostCommunicationApi = + { + requestAccessTokenRefresh, + }; + + const fetchMock = vi.fn( + async (_url: string | URL | Request, requestInit?: RequestInit) => { + const authorizationHeaderValue = + getAuthorizationHeaderValue(requestInit); + + if (authorizationHeaderValue === 'Bearer stale-token') { + return createJsonResponse({ + body: { + errors: [ + { + extensions: { code: 'UNAUTHENTICATED' }, + message: 'Unauthorized', + }, + ], + }, + }); + } + + return createJsonResponse({ + body: { data: { record: { id: 'record-id' } } }, + }); + }, + ); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + await Promise.all([ + twentyClient.query({ record: { id: true } }), + twentyClient.query({ record: { id: true } }), + ]); + + expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('retries uploadFile once after 401 when refresh callback is available', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token'; + + const requestAccessTokenRefresh = vi + .fn<() => Promise>() + .mockResolvedValue('fresh-token'); + + (globalThis as Record).frontComponentHostCommunicationApi = + { + requestAccessTokenRefresh, + }; + + const fetchMock = vi.fn( + async (_url: string | URL | Request, requestInit?: RequestInit) => { + const authorizationHeaderValue = + getAuthorizationHeaderValue(requestInit); + + if (authorizationHeaderValue === 'Bearer stale-token') { + return createJsonResponse({ + body: { message: 'Unauthorized' }, + status: 401, + statusText: 'Unauthorized', + }); + } + + return createJsonResponse({ + body: { + data: { + uploadFilesFieldFileByUniversalIdentifier: { + id: 'uploaded-file-id', + path: 'test/path.txt', + size: 10, + createdAt: '2026-02-24T00:00:00.000Z', + url: 'https://example.com/test/path.txt', + }, + }, + }, + }); + }, + ); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + metadataUrl: 'https://example.com/metadata', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + const uploadResult = await twentyClient.uploadFile( + Buffer.from('content'), + 'test.txt', + 'text/plain', + 'field-uuid', + ); + + expect(uploadResult.id).toBe('uploaded-file-id'); + expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('bubbles auth error when refresh callback throws', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token'; + + const requestAccessTokenRefresh = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('refresh failed')); + + (globalThis as Record).frontComponentHostCommunicationApi = + { + requestAccessTokenRefresh, + }; + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const fetchMock = vi.fn(async () => { + return createJsonResponse({ + body: { message: 'Unauthorized' }, + status: 401, + statusText: 'Unauthorized', + }); + }); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow( + 'Unauthorized', + ); + expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Twenty client: token refresh failed', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('bubbles auth error when refresh callback returns an empty token', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token'; + + const requestAccessTokenRefresh = vi + .fn<() => Promise>() + .mockResolvedValue(''); + + (globalThis as Record).frontComponentHostCommunicationApi = + { + requestAccessTokenRefresh, + }; + + const fetchMock = vi.fn(async () => { + return createJsonResponse({ + body: { message: 'Unauthorized' }, + status: 401, + statusText: 'Unauthorized', + }); + }); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow( + 'Unauthorized', + ); + expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('bubbles auth error without retry when refresh callback is unavailable', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token'; + + const fetchMock = vi.fn(async () => { + return createJsonResponse({ + body: { message: 'Unauthorized' }, + status: 401, + statusText: 'Unauthorized', + }); + }); + + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + fetch: fetchMock as unknown as typeof globalThis.fetch, + }); + + await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow( + 'Unauthorized', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('calls global fetch with globalThis binding when no custom fetch is provided', async () => { + process.env.TWENTY_APP_ACCESS_TOKEN = 'application-token'; + + const originalFetch = globalThis.fetch; + const capturedFetchThisValues: unknown[] = []; + + const fetchMock = vi.fn(function ( + this: unknown, + _url: string | URL | Request, + _requestInit?: RequestInit, + ) { + capturedFetchThisValues.push(this); + + return Promise.resolve( + createJsonResponse({ + body: { data: { record: { id: 'record-id' } } }, + }), + ); + }) as unknown as typeof globalThis.fetch; + + globalThis.fetch = fetchMock; + + try { + const twentyClient = new TwentyClass({ + url: 'https://example.com/graphql', + }); + + await twentyClient.query({ record: { id: true } }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(capturedFetchThisValues).toEqual([globalThis]); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts index b0620318fc..af5827cb07 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts @@ -3,6 +3,7 @@ import { generate } from '@genql/cli'; import * as fs from 'fs-extra'; import { join } from 'path'; import { + DEFAULT_APP_ACCESS_TOKEN_NAME, DEFAULT_API_KEY_NAME, DEFAULT_API_URL_NAME, GENERATED_DIR, @@ -65,12 +66,108 @@ export class ClientService { // Custom Twenty client (auto-injected) // ---------------------------------------------------- -const defaultOptions: ClientOptions = { +const APP_ACCESS_TOKEN_ENV_KEY = '${DEFAULT_APP_ACCESS_TOKEN_NAME}'; +const API_KEY_ENV_KEY = '${DEFAULT_API_KEY_NAME}'; + +type TwentyClientOptions = ClientOptions & { + metadataUrl?: string; +} + +type ProcessEnvironment = Record + +type GraphqlError = { + message?: string; + extensions?: { code?: string }; +} + +type GraphqlResponsePayload = { + data?: Record + errors?: GraphqlError[]; +} + +type GraphqlResponse = { + status: number; + statusText: string; + payload: GraphqlResponsePayload | null; + rawBody: string; +} + +const getProcessEnvironment = (): ProcessEnvironment => { + const processObject = (globalThis as { process?: { env?: ProcessEnvironment } }) + .process; + + return processObject?.env ?? {}; +} + +const getTokenFromAuthorizationHeader = ( + authorizationHeader: string | undefined, +): string | null => { + if (typeof authorizationHeader !== 'string') { + return null; + } + + const trimmedAuthorizationHeader = authorizationHeader.trim(); + + if (trimmedAuthorizationHeader.length === 0) { + return null; + } + + if (trimmedAuthorizationHeader === 'Bearer') { + return null; + } + + if (trimmedAuthorizationHeader.startsWith('Bearer ')) { + return trimmedAuthorizationHeader.slice('Bearer '.length).trim(); + } + + return trimmedAuthorizationHeader; +} + +const getTokenFromHeaders = (headers: HeadersInit | undefined): string | null => { + if (!headers) { + return null; + } + + if (headers instanceof Headers) { + return getTokenFromAuthorizationHeader(headers.get('Authorization') ?? undefined); + } + + if (Array.isArray(headers)) { + const matchedAuthorizationHeader = headers.find( + ([headerName]) => headerName.toLowerCase() === 'authorization', + ); + + return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]); + } + + const headersRecord = headers as Record; + + return getTokenFromAuthorizationHeader( + headersRecord.Authorization ?? headersRecord.authorization, + ); +} + +const hasAuthenticationErrorInGraphqlPayload = ( + payload: GraphqlResponsePayload | null, +): boolean => { + if (!payload?.errors) { + return false; + } + + return payload.errors.some((error) => { + return ( + error.extensions?.code === 'UNAUTHENTICATED' || + // Fallback for payloads that don't provide structured error codes. + error.message?.toLowerCase() === 'unauthorized' + ); + }); +} + +const defaultOptions: TwentyClientOptions = { url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`, metadataUrl: \`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\`, headers: { 'Content-Type': 'application/json', - Authorization: \`Bearer \${process.env.${DEFAULT_API_KEY_NAME}}\`, }, } @@ -78,21 +175,54 @@ export default class Twenty { private client: Client; private url: string; private metadataUrl: string; - private authorizationToken: string; + private requestOptions: RequestInit; + private headers: HeadersInit | (() => HeadersInit | Promise); + private fetchImplementation: typeof globalThis.fetch | null; + private authorizationToken: string | null; + private refreshAccessTokenPromise: Promise | null = null; - constructor(options?: ClientOptions) { - const merged: ClientOptions = { + constructor(options?: TwentyClientOptions) { + const merged: TwentyClientOptions = { ...defaultOptions, ...options, - headers: { - ...defaultOptions.headers, - ...(options?.headers ?? {}), - }, - }; - this.client = createClient(merged); - this.url = merged.url; - this.metadataUrl = merged.metadataUrl; - this.authorizationToken = merged.headers.Authorization; + } + + const { + url, + metadataUrl, + headers, + fetch: customFetchImplementation, + fetcher: _fetcher, + batch: _batch, + ...requestOptions + } = merged; + + this.url = url ?? ''; + this.metadataUrl = metadataUrl ?? this.url.replace(/\\/graphql$/, '/metadata'); + this.requestOptions = requestOptions; + this.headers = headers ?? {}; + this.fetchImplementation = customFetchImplementation ?? globalThis.fetch ?? null; + + const processEnvironment = getProcessEnvironment(); + const tokenFromHeaders = getTokenFromHeaders( + typeof headers === 'function' ? undefined : headers, + ); + + // Priority: explicit header > TWENTY_APP_ACCESS_TOKEN > TWENTY_API_KEY (legacy fallback). + this.authorizationToken = + tokenFromHeaders ?? + processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ?? + processEnvironment[API_KEY_ENV_KEY] ?? + null; + + this.client = createClient({ + ...merged, + headers: undefined, + fetcher: async (operation) => + this.executeGraphqlRequestWithOptionalRefresh({ + operation, + }), + }); } query(request: R & { __name?: string }) { @@ -129,21 +259,208 @@ export default class Twenty { form.append('map', JSON.stringify({ '0': ['variables.file'] })); form.append('0', new Blob([fileBuffer], { type: contentType }), filename); - const response = await fetch(this.metadataUrl, { - method: 'POST', - headers: { - Authorization: this.authorizationToken, + const result = await this.executeGraphqlRequestWithOptionalRefresh({ + operation: form, + url: this.metadataUrl, + headers: {}, + requestInit: { + method: 'POST', }, - body: form, }); - const result = await response.json(); - if (result.errors) { throw new GenqlError(result.errors, result.data); } - return result.data.uploadFilesFieldFileByUniversalIdentifier; + const data = result.data as Record; + + return data.uploadFilesFieldFileByUniversalIdentifier as { + id: string; + path: string; + size: number; + createdAt: string; + url: string; + } + } + + private async executeGraphqlRequestWithOptionalRefresh({ + operation, + url = this.url, + headers, + requestInit, + }: { + operation: GraphqlOperation | GraphqlOperation[] | FormData; + url?: string; + headers?: HeadersInit; + requestInit?: RequestInit; + }) { + const firstResponse = await this.executeGraphqlRequest({ + operation, + url, + headers, + requestInit, + token: this.authorizationToken, + }); + + if (this.shouldRefreshToken(firstResponse)) { + const refreshedAccessToken = await this.requestRefreshedAccessToken(); + + if (refreshedAccessToken) { + const retryResponse = await this.executeGraphqlRequest({ + operation, + url, + headers, + requestInit, + token: refreshedAccessToken, + }); + + return this.assertResponseIsSuccessful(retryResponse); + } + } + + return this.assertResponseIsSuccessful(firstResponse); + } + + private async executeGraphqlRequest({ + operation, + url, + headers, + requestInit, + token, + }: { + operation: GraphqlOperation | GraphqlOperation[] | FormData; + url: string; + headers?: HeadersInit; + requestInit?: RequestInit; + token: string | null; + }): Promise { + if (!this.fetchImplementation) { + throw new Error( + 'Global \`fetch\` function is not available, pass a fetch implementation to the Twenty client', + ); + } + + const resolvedHeaders = await this.resolveHeaders(); + const requestHeaders = new Headers(resolvedHeaders); + + if (headers) { + new Headers(headers).forEach((value, key) => requestHeaders.set(key, value)); + } + + if (operation instanceof FormData) { + requestHeaders.delete('Content-Type'); + } else { + requestHeaders.set('Content-Type', 'application/json'); + } + + if (token) { + requestHeaders.set('Authorization', \`Bearer \${token}\`); + } else { + requestHeaders.delete('Authorization'); + } + + const response = await this.fetchImplementation.call(globalThis, url, { + ...this.requestOptions, + ...requestInit, + method: requestInit?.method ?? 'POST', + headers: requestHeaders, + body: operation instanceof FormData ? operation : JSON.stringify(operation), + }); + + const rawBody = await response.text(); + let payload: GraphqlResponsePayload | null = null; + + if (rawBody.trim().length > 0) { + try { + payload = JSON.parse(rawBody) as GraphqlResponsePayload; + } catch { + payload = null; + } + } + + return { + status: response.status, + statusText: response.statusText, + payload, + rawBody, + } + } + + private async resolveHeaders(): Promise { + if (typeof this.headers === 'function') { + return (await this.headers()) ?? {}; + } + + return this.headers ?? {}; + } + + private shouldRefreshToken(response: GraphqlResponse): boolean { + if (response.status === 401) { + return true; + } + + return hasAuthenticationErrorInGraphqlPayload(response.payload); + } + + private assertResponseIsSuccessful(response: GraphqlResponse) { + if (response.status < 200 || response.status >= 300) { + throw new Error(\`\${response.statusText}: \${response.rawBody}\`); + } + + if (response.payload === null) { + throw new Error('Invalid JSON response'); + } + + return response.payload; + } + + private async requestRefreshedAccessToken(): Promise { + const refreshAccessTokenFunction = ( + globalThis as { + frontComponentHostCommunicationApi?: { + requestAccessTokenRefresh?: () => Promise + } + } + ).frontComponentHostCommunicationApi?.requestAccessTokenRefresh; + + if (typeof refreshAccessTokenFunction !== 'function') { + return null; + } + + if (!this.refreshAccessTokenPromise) { + this.refreshAccessTokenPromise = refreshAccessTokenFunction() + .then((refreshedAccessToken) => { + if ( + typeof refreshedAccessToken !== 'string' || + refreshedAccessToken.length === 0 + ) { + return null; + } + + this.setAuthorizationToken(refreshedAccessToken); + + return refreshedAccessToken; + }) + .catch((error) => { + console.error('Twenty client: token refresh failed', error); + + return null; + }) + .finally(() => { + this.refreshAccessTokenPromise = null; + }); + } + + return this.refreshAccessTokenPromise; + } + + private setAuthorizationToken(token: string) { + this.authorizationToken = token; + + const processEnvironment = getProcessEnvironment(); + + processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token; + processEnvironment[API_KEY_ENV_KEY] = token; } } diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts index a52f31064e..3ab7a6fef3 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts +++ b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts @@ -45,13 +45,15 @@ const render: WorkerExports['render'] = async ( document.body.append(root); installStyleBridge(root); - if ( - isDefined(renderContext.applicationAccessToken) && - isDefined(renderContext.apiUrl) - ) { + if (isDefined(renderContext.apiUrl)) { + setWorkerEnv({ + TWENTY_API_URL: renderContext.apiUrl, + }); + } + + if (isDefined(renderContext.applicationAccessToken)) { setWorkerEnv({ TWENTY_APP_ACCESS_TOKEN: renderContext.applicationAccessToken, - TWENTY_API_URL: renderContext.apiUrl, }); } @@ -91,6 +93,8 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication ThreadWebWorker.self.import(); frontComponentHostCommunicationApi.navigate = hostApi.navigate; + frontComponentHostCommunicationApi.requestAccessTokenRefresh = + hostApi.requestAccessTokenRefresh; frontComponentHostCommunicationApi.openSidePanelPage = hostApi.openSidePanelPage; frontComponentHostCommunicationApi.unmountFrontComponent = diff --git a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts index e6013ab949..e744834d7a 100644 --- a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts @@ -3,11 +3,13 @@ import { type EnqueueSnackbarFunction, type NavigateFunction, type OpenSidePanelPageFunction, + type RequestAccessTokenRefreshFunction, type UnmountFrontComponentFunction, } from '../../sdk/front-component-api/globals/frontComponentHostCommunicationApi'; export type FrontComponentHostCommunicationApi = { navigate: NavigateFunction; + requestAccessTokenRefresh: RequestAccessTokenRefreshFunction; openSidePanelPage: OpenSidePanelPageFunction; unmountFrontComponent: UnmountFrontComponentFunction; enqueueSnackbar: EnqueueSnackbarFunction; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts index d9e5f48f0c..4cfa533b3b 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts @@ -28,8 +28,11 @@ export type EnqueueSnackbarFunction = ( export type CloseSidePanelFunction = () => Promise; +export type RequestAccessTokenRefreshFunction = () => Promise; + export type FrontComponentHostCommunicationApiStore = { navigate?: NavigateFunction; + requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction; openSidePanelPage?: OpenSidePanelPageFunction; unmountFrontComponent?: UnmountFrontComponentFunction; enqueueSnackbar?: EnqueueSnackbarFunction; 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 7875fdfe45..353ca05e6f 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 @@ -11,6 +11,7 @@ import { PermissionFlagType } from 'twenty-shared/constants'; 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 { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter'; +import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { ApplicationException, ApplicationExceptionCode, @@ -37,7 +38,7 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo @UsePipes(ResolverValidationPipe) @MetadataResolver() @UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor) -@UseFilters(ApplicationExceptionFilter) +@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter) @UseGuards(WorkspaceAuthGuard) export class ApplicationResolver { constructor( diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts index 888050e099..ef406b5f49 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts @@ -19,6 +19,8 @@ export const AuthExceptionCode = appendCommonExceptionCode({ FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION', INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES', UNAUTHENTICATED: 'UNAUTHENTICATED', + APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED: + 'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED', INVALID_DATA: 'INVALID_DATA', OAUTH_ACCESS_DENIED: 'OAUTH_ACCESS_DENIED', SSO_AUTH_FAILED: 'SSO_AUTH_FAILED', @@ -56,6 +58,7 @@ const getAuthExceptionUserFriendlyMessage = ( case AuthExceptionCode.INSUFFICIENT_SCOPES: return msg`Insufficient permissions.`; case AuthExceptionCode.UNAUTHENTICATED: + case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED: return msg`You must be authenticated to perform this action.`; case AuthExceptionCode.OAUTH_ACCESS_DENIED: return msg`OAuth access was denied.`; 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 86682c4303..7519a53abf 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 @@ -5,7 +5,10 @@ import { Repository } from 'typeorm'; 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 { + AuthException, + AuthExceptionCode, +} 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'; @@ -205,18 +208,49 @@ describe('ApplicationTokenService', () => { expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow( AuthException, ); + + try { + service.validateApplicationRefreshToken(mockToken); + } catch (error) { + expect((error as AuthException).code).toBe( + AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED, + ); + } }); - it('should throw when token verification fails', () => { + it('should throw dedicated code when token verification fails', () => { const mockToken = 'invalid-token'; jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => { - throw new Error('Invalid token'); + throw new AuthException( + 'Token has expired.', + AuthExceptionCode.UNAUTHENTICATED, + ); }); - expect(() => - service.validateApplicationRefreshToken(mockToken), - ).toThrow(); + expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow( + AuthException, + ); + + try { + service.validateApplicationRefreshToken(mockToken); + } catch (error) { + expect((error as AuthException).code).toBe( + AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED, + ); + } + }); + + it('should rethrow unexpected token verification errors', () => { + const mockToken = 'invalid-token'; + + jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => { + throw new Error('Unexpected verification error'); + }); + + expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow( + 'Unexpected verification error', + ); }); }); 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 5a0e8bf161..d8da0feeed 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 @@ -27,6 +27,8 @@ import { const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800; const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days +const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE = + 'Application refresh token invalid or expired'; @Injectable() export class ApplicationTokenService { @@ -107,22 +109,37 @@ export class ApplicationTokenService { validateApplicationRefreshToken( refreshToken: string, ): ApplicationRefreshTokenJwtPayload { - this.jwtWrapperService.verifyJwtToken(refreshToken); + try { + this.jwtWrapperService.verifyJwtToken(refreshToken); - const payload = - this.jwtWrapperService.decode( - refreshToken, - { json: true }, - ); + 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, - ); + if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) { + throw new AuthException( + 'Expected an application refresh token', + AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, + ); + } + + return payload; + } catch (error) { + if ( + error instanceof AuthException && + (error.code === AuthExceptionCode.UNAUTHENTICATED || + error.code === AuthExceptionCode.INVALID_JWT_TOKEN_TYPE) + ) { + throw new AuthException( + APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE, + AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED, + ); + } + + throw error; } - - return payload; } async renewApplicationTokens(payload: { diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts index 1fcead36d2..1f8b1099e9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts @@ -46,6 +46,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => { subCode: exception.code, }); case AuthExceptionCode.UNAUTHENTICATED: + case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED: throw new AuthenticationError(exception.message, { userFriendlyMessage: msg`You must be authenticated to perform this action.`, subCode: exception.code, diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts index ff9459e616..1eea3216d1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts @@ -28,6 +28,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => { case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED: case AuthExceptionCode.INVALID_DATA: case AuthExceptionCode.UNAUTHENTICATED: + case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED: case AuthExceptionCode.USER_NOT_FOUND: case AuthExceptionCode.WORKSPACE_NOT_FOUND: case AuthExceptionCode.APPLICATION_NOT_FOUND: 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 1e86053411..39664b7200 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 @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { + DEFAULT_APP_ACCESS_TOKEN_NAME, DEFAULT_API_KEY_NAME, DEFAULT_API_URL_NAME, } from 'twenty-shared/application'; @@ -192,6 +193,7 @@ export class LogicFunctionExecutorService { return { [DEFAULT_API_URL_NAME]: baseUrl ?? '', + [DEFAULT_APP_ACCESS_TOKEN_NAME]: applicationAccessToken.token, [DEFAULT_API_KEY_NAME]: applicationAccessToken.token, ...buildEnvVar(flatApplicationVariables, this.secretEncryptionService), }; diff --git a/packages/twenty-shared/src/application/constants/DefaultAppAccessTokenName.ts b/packages/twenty-shared/src/application/constants/DefaultAppAccessTokenName.ts new file mode 100644 index 0000000000..213a1e2b30 --- /dev/null +++ b/packages/twenty-shared/src/application/constants/DefaultAppAccessTokenName.ts @@ -0,0 +1 @@ +export const DEFAULT_APP_ACCESS_TOKEN_NAME = 'TWENTY_APP_ACCESS_TOKEN'; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 97715199c7..39bca33494 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -17,6 +17,7 @@ export { API_CLIENT_DIR } from './constants/ApiClientDirectory'; export { ASSETS_DIR } from './constants/AssetDirectory'; export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName'; export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName'; +export { DEFAULT_APP_ACCESS_TOKEN_NAME } from './constants/DefaultAppAccessTokenName'; export { GENERATED_DIR } from './constants/GeneratedDirectory'; export { NODE_ESM_CJS_BANNER } from './constants/NodeEsmCjsBanner'; export { OUTPUT_DIR } from './constants/OutputDirectory';