Search code examples
javascriptreactjsgraphqlapolloapollo-client

How to refresh a token asynchronously using Apollo


I use postMessage to get the token from the mobile client. Js client sends request using requestRefresh function with postMessage to receive JWT token. Mobile clients execute JS code using a method call, which is described inside the WebView. I save the token in a cookie when I receive it in webview. I successfully get a token the first time I render a component, but I have a problem with updating the token asynchronously in the Apollo client when an "Unauthenticated" or "Invalid jwt token" error occurs.

I don't understand how I can call the call and response function from the mobile client asynchronously inside the onError handler and save the token.

I've reviewed several resources on this, StackOverflow answers 1, 2, Github examples 3, 4, and this blog post 5, but didn't find similar case .

index.tsx

import React, { Suspense, useState, useEffect, useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { ApolloClient, InMemoryCache, createHttpLink, ApolloProvider, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { App } from 'app';
import Cookies from 'universal-cookie';

const cookies = new Cookies();

const httpLink = createHttpLink({
    uri: process.env.API_HOST,
});

const authLink = setContext((_, { headers }) => {
    const token = cookies.get('token');

    return {
        headers: {
            ...headers,
            authorization: token ? `Bearer ${token}` : '',
        },
    };
});

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
    if (graphQLErrors)
        for (const err of graphQLErrors) {
            switch (err?.extensions?.code) {
                case 'UNAUTHENTICATED':
                  // error code is set to UNAUTHENTICATED


           //How to call and process a response from a mobile client here?
    
    
            const oldHeaders = operation.getContext().headers;
            operation.setContext({
              headers: {
                ...oldHeaders,
                authorization: token ? `Bearer ${token}` : '',
              },
            });
            // retry the request, returning the new observable
            return forward(operation);
            }
        }
});


const client = new ApolloClient({
    cache: new InMemoryCache({
        typePolicies: {
            User: {
                keyFields: ['userId'],
            },
        },
    }),
    link: from([errorLink, authLink, httpLink]),
    uri: process.env.API_HOST,
    connectToDevTools: process.env.NODE_ENV === 'development',
});


import React, { useEffect, useState, useCallback, useMemo } from 'react';
import Cookies from 'universal-cookie';

interface IMessage {
    action: string;
    data?: {
        text: string;
        old_jwt?: string;
    };
}

enum Actions {
    refreshJWT = 'refresh_jwt',
}

interface IMessageEventData {
    action: Actions;
    success: boolean;
    payload: {
        data: string;
    };
}

export const Index: React.FC = () => {
    const cookies = new Cookies();
    const token = cookies.get('token');

    const message = useMemo((): IMessage => {
        return {
            action: Actions.refreshJWT,
            data: {
                text: 'Hello from JS',
                ...(token && { old_jwt: token }),
            },
        };
    }, [token]);

    const requestRefresh = useCallback(() => {
        if (typeof Android !== 'undefined') {
            Android.postMessage(JSON.stringify(message));
        } else {
            window?.webkit?.messageHandlers.iosHandler.postMessage(message);
        }
    }, [message]);

    // @ts-ignore
    window.callWebView = useCallback(
        ({ success, payload, action }: IMessageEventData) => {
            if (success) {
                if (action === Actions.refreshJWT) {
                    cookies.set('token', payload.data, { path: '/' });
                }
            } else {
                throw new Error(payload.data);
            }
        },
        [requestRefresh]
    );

    useEffect(() => {
        requestRefresh();
    }, []);

    return (
        <BrowserRouter>
            <ApolloProvider client={client}>
                 <App />
            </ApolloProvider>
        </BrowserRouter>
    );
};

ReactDOM.render(<Index />, document.getElementById('root'));


Solution

  • This code finally solved my problem

    import { fromPromise } from '@apollo/client';
    import { setContext } from '@apollo/client/link/context';
    import { onError } from '@apollo/client/link/error';
    
    interface IMessage {
        action: string;
        data?: {
            text?: string;
            old_jwt?: string;
        };
    }
    
    interface IMessageEventData {
        action: Actions;
        success: boolean;
        payload: {
            data: string;
        };
    }
    
    enum Actions {
        refreshJWT = 'refresh_jwt',
    }
    
    let authToken = '';
    let time: NodeJS.Timeout;
    let countOfRequests = 0;
    let isRefreshing = false;
    
    const INTERVAL_BETWEEN_REQUESTS = 30000;
    const MAX_COUNT_REQUESTS = 2;
    
    let pendingRequests = [];
    
    const resolvePendingRequests = () => {
        pendingRequests.map((callback) => callback());
        pendingRequests = [];
    };
    
    
    const message = (): IMessage => {
        return {
            action: Actions.refreshJWT,
            data: {
                ...(authToken && { old_jwt: authToken }),
            },
        };
    };
    
    const asyncRequestRefresh = async () => {
        return new Promise((resolve) => {
            // @ts-ignore
            // eslint-disable-next-line no-undef
            if (typeof Android !== 'undefined') {
                // @ts-ignore
                // eslint-disable-next-line no-undef
                Android.postMessage(JSON.stringify(message()));
            } else {
                // @ts-ignore
                window?.webkit?.messageHandlers.inDriveriOSHandler.postMessage(message());
            }
            resolve('ok');
        });
    };
    
    const getNewToken = async () =>
        new Promise((resolve, reject) => {
            // @ts-ignore
            window.callWebView = ({ success, payload, action }: IMessageEventData) => {
                if (success) {
                    if (action === Actions.refreshJWT) {
                        authToken = payload.data;
                        resolve(payload.data);
                    }
                } else {
                    reject(payload.data);
                }
            };
        });
    
    export const getToken = async () => {
        countOfRequests++;
    
        time = setTimeout(() => {
            if (countOfRequests > MAX_COUNT_REQUESTS) {
                clearTimeout(time);
            } else {
                // eslint-disable-next-line @typescript-eslint/no-floating-promises
                getToken();
            }
        }, INTERVAL_BETWEEN_REQUESTS);
    
        return asyncRequestRefresh()
            .then(getNewToken)
            .then((token) => {
                clearTimeout(time);
                return token;
            })
            .catch((error) => {
                console.error(error);
            });
    };
    
    export const authLink = setContext((_, { headers }) => {
        return {
            headers: {
                ...headers,
                authorization: authToken ? `Bearer ${authToken}` : '',
            },
        };
    });
    
    export const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
        if (graphQLErrors) {
            for (const err of graphQLErrors) {
                switch (err?.extensions?.code) {
                    case 'FORBIDDEN':
                        // eslint-disable-next-line no-case-declarations
                        let innerForward;
    
                        if (!isRefreshing) {
                            isRefreshing = true;
                            innerForward = fromPromise(
                                getToken()
                                    // eslint-disable-next-line no-loop-func
                                    .then((token) => {
                                        resolvePendingRequests();
                                        console.info(`token refreshed: ${token}`);
                                        authToken = token;
                                        operation.setContext(({ headers = {} }) => ({
                                            headers: {
                                                // Re-add old headers
                                                ...headers,
                                                // Switch out old access token for new one
                                                authorization: `Bearer ${token}` || '',
                                            },
                                        }));
                                    })
                                    // eslint-disable-next-line no-loop-func
                                    .catch((error) => {
                                        console.error(error);
                                        pendingRequests = [];
                                    })
                                    // eslint-disable-next-line no-loop-func
                                    .finally(() => {
                                        isRefreshing = false;
                                    })
                            );
                        } else {
                            innerForward = fromPromise(
                                // eslint-disable-next-line no-loop-func
                                new Promise((resolve) => {
                                    pendingRequests.push(() => resolve());
                                })
                            );
                        }
    
                        return innerForward.flatMap(() => forward(operation));
                }
            }
        }
        if (networkError) {
            console.log(`[Network error]: ${networkError}`);
        }
    });