Host-remote refresh token implementation (#18044)

This commit is contained in:
nitin
2026-02-25 00:07:29 +05:30
committed by GitHub
parent b56f85f36a
commit 887371054a
22 changed files with 1234 additions and 59 deletions
@@ -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<typeof useUploadWorkflowFileMutation>;
export type UploadWorkflowFileMutationResult = Apollo.MutationResult<UploadWorkflowFileMutation>;
export type UploadWorkflowFileMutationOptions = Apollo.BaseMutationOptions<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>;
export const RenewApplicationTokenDocument = gql`
mutation RenewApplicationToken($applicationRefreshToken: String!) {
renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) {
applicationAccessToken {
token
expiresAt
}
applicationRefreshToken {
token
expiresAt
}
}
}
`;
export type RenewApplicationTokenMutationFn = Apollo.MutationFunction<RenewApplicationTokenMutation, RenewApplicationTokenMutationVariables>;
/**
* __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<RenewApplicationTokenMutation, RenewApplicationTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RenewApplicationTokenMutation, RenewApplicationTokenMutationVariables>(RenewApplicationTokenDocument, options);
}
export type RenewApplicationTokenMutationHookResult = ReturnType<typeof useRenewApplicationTokenMutation>;
export type RenewApplicationTokenMutationResult = Apollo.MutationResult<RenewApplicationTokenMutation>;
export type RenewApplicationTokenMutationOptions = Apollo.BaseMutationOptions<RenewApplicationTokenMutation, RenewApplicationTokenMutationVariables>;
export const FindManyFrontComponentsDocument = gql`
query FindManyFrontComponents {
frontComponents {
@@ -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 (
<SharedFrontComponentRenderer
theme={theme}
componentUrl={componentUrl}
applicationAccessToken={
data.frontComponent.applicationTokenPair.applicationAccessToken.token
}
apiUrl={REACT_APP_SERVER_BASE_URL}
executionContext={executionContext}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
onError={handleError}
/>
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
<SharedFrontComponentRenderer
theme={theme}
componentUrl={componentUrl}
applicationAccessToken={
applicationTokenPair.applicationAccessToken.token
}
apiUrl={REACT_APP_SERVER_BASE_URL}
executionContext={executionContext}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
onError={handleError}
/>
</FrontComponentRendererProvider>
);
};
@@ -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 (
<FrontComponentInstanceContext.Provider
value={{ instanceId: frontComponentId }}
>
{children}
</FrontComponentInstanceContext.Provider>
);
};
@@ -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
}
}
}
`;
@@ -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,
@@ -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<GraphQLFormattedError>,
): 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<string> => {
const refetchFrontComponentForNewTokenPair =
async (): Promise<string> => {
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 };
};
@@ -0,0 +1,3 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const FrontComponentInstanceContext = createComponentInstanceContext();
@@ -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<ApplicationTokenPair | null>({
key: 'frontComponentApplicationTokenPairComponentState',
defaultValue: null,
componentInstanceContext: FrontComponentInstanceContext,
});
@@ -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<string, unknown>) => Promise<unknown>;
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<string, unknown>
export type MutationGenqlSelection = Record<string, unknown>
export type GraphqlOperation = Record<string, unknown>
export type ClientOptions = {
url?: string
metadataUrl?: string
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise<unknown>
fetch?: typeof globalThis.fetch
batch?: unknown
}
export type Client = {
query: (request: QueryGenqlSelection & { __name?: string }) => Promise<unknown>
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>
}
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<void>;
}
).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<string, unknown>)
.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<string>>()
.mockResolvedValue('fresh-token');
(globalThis as Record<string, unknown>).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<string>>()
.mockImplementation(async () => {
await Promise.resolve();
return 'fresh-token';
});
(globalThis as Record<string, unknown>).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<string>>()
.mockResolvedValue('fresh-token');
(globalThis as Record<string, unknown>).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<string>>()
.mockRejectedValue(new Error('refresh failed'));
(globalThis as Record<string, unknown>).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<string>>()
.mockResolvedValue('');
(globalThis as Record<string, unknown>).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;
}
});
});
@@ -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<string, string | undefined>
type GraphqlError = {
message?: string;
extensions?: { code?: string };
}
type GraphqlResponsePayload = {
data?: Record<string, unknown>
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<string, string | undefined>;
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<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | 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<R extends QueryGenqlSelection>(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<string, unknown>;
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<GraphqlResponse> {
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<HeadersInit> {
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<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>
}
}
).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;
}
}
@@ -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>();
frontComponentHostCommunicationApi.navigate = hostApi.navigate;
frontComponentHostCommunicationApi.requestAccessTokenRefresh =
hostApi.requestAccessTokenRefresh;
frontComponentHostCommunicationApi.openSidePanelPage =
hostApi.openSidePanelPage;
frontComponentHostCommunicationApi.unmountFrontComponent =
@@ -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;
@@ -28,8 +28,11 @@ export type EnqueueSnackbarFunction = (
export type CloseSidePanelFunction = () => Promise<void>;
export type RequestAccessTokenRefreshFunction = () => Promise<string>;
export type FrontComponentHostCommunicationApiStore = {
navigate?: NavigateFunction;
requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction;
openSidePanelPage?: OpenSidePanelPageFunction;
unmountFrontComponent?: UnmountFrontComponentFunction;
enqueueSnackbar?: EnqueueSnackbarFunction;
@@ -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(
@@ -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.`;
@@ -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',
);
});
});
@@ -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<ApplicationRefreshTokenJwtPayload>(
refreshToken,
{ json: true },
);
const payload =
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
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: {
@@ -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,
@@ -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:
@@ -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),
};
@@ -0,0 +1 @@
export const DEFAULT_APP_ACCESS_TOKEN_NAME = 'TWENTY_APP_ACCESS_TOKEN';
@@ -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';