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'));
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}`);
}
});