Host-remote refresh token implementation (#18044)
This commit is contained in:
+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,
|
||||
});
|
||||
Reference in New Issue
Block a user