Host-remote refresh token implementation (#18044)
This commit is contained in:
@@ -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 {
|
||||
|
||||
+33
-12
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+19
@@ -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>
|
||||
);
|
||||
};
|
||||
+16
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+5
@@ -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,
|
||||
|
||||
+138
@@ -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 };
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const FrontComponentInstanceContext = createComponentInstanceContext();
|
||||
+10
@@ -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,
|
||||
});
|
||||
+529
@@ -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 =
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+2
-1
@@ -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.`;
|
||||
|
||||
+40
-6
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+30
-13
@@ -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: {
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+2
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user