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
@@ -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,
});