Summary
I have implemented next-auth to get the accessToken from the provider and pass the same as argument to my mutation, then use the token generated from our backend on consecutive API calls.
The token rotation has been implemented based on the next-auth doc refresh token rotation
Problem
After 1 min (my token expiry time) if the session is untouched/running in background then probably it calls the api and updates with the new refreshToken (works as intended) but when we refresh/reload the page after one minute it signs out of the session.
When I check the terminal, it looks like when I reload the page, the old refreshToken is not getting updated with new refreshToken. It still has the same refreshToken (the one session had before reload) in client but no the issue when we leave the app untouched 😕
Is there any work around for this? Or what am I doing wrong here?
Thanks in advance!
Snippet
here's the pages/api/[..nextauth].tsx
import NextAuth, { NextAuthOptions } from "next-auth"
import { JWT } from "next-auth/jwt"
import GithubProvider from "next-auth/providers/github"
import { Provider, RefreshToken } from "../../../generated/graphql"
const loginQuery = `mutation login($AccessToken: String!, $Provider: Provider!) {
login(accessToken: $AccessToken, provider: $Provider) {
refreshToken
token
tokenExpiry
}
}`
const refreshTokenQuery = `mutation refreshToken($RefreshToken: String!) {
refreshToken(refreshToken: $RefreshToken) {
refreshToken
token
tokenExpiry
}
}`
const authMutation = (query, variables) : Promise<RefreshToken | null> => {
return fetch(process.env.NEXT_PUBLIC_API_URL, {
method: 'POST',
body: JSON.stringify({
query: query,
variables: variables
})
})
.then(res => res.json())
.then(data => {
if (data.errors) {
return null;
} else {
let response = data.data.refreshToken || data.data.login;
if (response.refreshToken === undefined ||
response.tokenExpiry === undefined ||
response.token === undefined) return null
return <RefreshToken> response;
}
})
.catch(error => {
console.log(error);
return null;
});
};
async function login(account, user: any) {
let provider: Provider;
if (account?.provider === 'github') provider = Provider.GitHub
if(provider === undefined) return
const variables = {
AccessToken: account.access_token,
Provider: provider
};
const response = await authMutation(loginQuery, variables)
if (response === null) return
console.log("new refresh token..", response.refreshToken)
return {
accessToken: response.token,
accessTokenExpires: response.tokenExpiry * 1000,
refreshToken: response.refreshToken,
user,
}
}
async function refreshAccessToken(token: any) {
const variables = {
RefreshToken: token.refreshToken,
};
console.log("variables", variables)
const response = await authMutation(refreshTokenQuery, variables)
if (response === null) return
console.log("new refresh token..", response.refreshToken)
return {
...token,
accessToken: response.token,
accessTokenExpires: response.tokenExpiry * 1000,
refreshToken: response.refreshToken,
}
}
export const authOptions: NextAuthOptions = {
providers: [
GithubProvider({
clientId: process.env.NEXT_PUBLIC_GITHUB_OAUTH_CLIENT_ID,
clientSecret: process.env.NEXT_PUBLIC_GITHUB_OAUTH_CLIENT_SECRET,
}),
],
theme: {
colorScheme: "light",
},
callbacks: {
jwt: async ({ account, token, user }: any): Promise<JWT> => {
console.log("current refreshToken", token.refreshToken, token.accessTokenExpires)
// Initial Sign In
if (account && user) {
console.log("initial login", account)
return login(account, user)
}
// Return previous token if the access token has not expired yet
if (Date.now() < token.accessTokenExpires){
console.log("token not expired")
return token
}
// Rotate refresh_token and fetch new access_token if current one is expired
if (token){
console.log("token expired")
return refreshAccessToken(token)
}
},
session: async ({ session, token }: any) => {
if (!session?.user || !token?.accessToken) return
session.accessToken = token.accessToken as string
session.error = token.error as string | undefined
session.user = token.user
return session
},
},
}
export default NextAuth(authOptions)
and this how _app.tsx looks like,
import "../styles/globals.css"
import { SessionProvider as NextSessionProvider } from "next-auth/react"
import UrqlProvider from "../lib/provider"
import { AppProps } from "next/app"
function App({ Component, pageProps }: AppProps) {
const { session } = pageProps
return (
<>
<NextSessionProvider session={session}>
<UrqlProvider>
<Component {...pageProps} />
</UrqlProvider>
</NextSessionProvider>
</>
)
}
export default App
Related
So I have a project using the latest Next js 13, React 18, Urql 3, and using typescript
Currently, I have issues when trying to query the urql from the getstaticprops function. My urql request needs a guest token, and I'm storing the token on session storage(other suggestions ?).
It has no issue when the query is running on the client, but I have it when querying inside the function.
My concern is related to the token reading, so the server cannot read the session storage value.
I'm asking what is the better and simplest way to make this work.
Does use cookies to store guest tokens will make this work?
Or the configuration that doesn't work?
This is my current config for urql.ts
import {
createClient,
ssrExchange,
dedupExchange,
cacheExchange,
fetchExchange,
} from "urql";
import { GRAPH_URL } from "#lib/constant/env";
import type { TypedDocumentNode } from "#urql/core";
const isServerSide = typeof window === "undefined";
const ssrCache = ssrExchange({
isClient: !isServerSide,
});
const client = createClient({
url: GRAPH_URL,
exchanges: [dedupExchange, cacheExchange, ssrCache, fetchExchange],
fetchOptions: () => {
const token = sessionStorage.getItem("accessToken");
return {
headers: {
authorization: token ? `Bearer ${token}` : "",
},
};
},
});
const query = async (
query: TypedDocumentNode<any, object>,
variables?: Record<string, string | string[] | unknown>
) => {
try {
const response = await client.query(query, variables as any).toPromise();
return response;
} catch (error) {
if (error instanceof Error) console.error(error.message);
}
};
const mutation = async (
mutation: TypedDocumentNode<any, object>,
variables?: Record<string, string | string[] | unknown>
) => {
try {
const response = await client
.mutation(mutation, variables as any)
.toPromise();
return response;
} catch (error) {
if (error instanceof Error) console.error(error.message);
}
};
export { client, query, mutation, ssrCache };
And this some of the code for the blog index page
export const getStaticProps = async () => {
await fetchArticlesSummary();
return {
props: {
urqlState: ssrCache.extractData(),
},
revalidate: 600,
};
};
export default withUrqlClient(() => ({
url: GRAPH_URL,
}))(BlogPage);
This is for the fetchArticlesSummary
export const fetchArticlesSummary = async () => {
try {
const {
data: { listArticles },
}: any = await query(getListArticle);
return listArticles.items;
} catch (error) {
return {
notFound: true,
};
}
};
I also doing a setup on _app.tsx
export default function App({ Component, pageProps }: AppProps) {
if (pageProps.urqlState) {
ssrCache.restoreData(pageProps.urqlState);
}
return (
<Provider value={client}>
<Component {...pageProps} />
</Provider>
);
}
Thank you
I have followed urql documentation about server-side configuration and many others but still don't have any solutions.
I'm building a website witch has some protected routes that I want to prevent non-authenticated users from accessing it, So I'm using Laravel Sanctum for that purpose. I was testing this using postman, but now I want to actually use it in production from backend, So how I suppose to do that token that was generated after login!
Thanks in advance.
I think the answer is late, but it may be useful to someone else
You will set your login routes
// Login Route
Route::middleware('guest')->group(function () {
Route::post('/login', LoginAction::class)->name('auth.login');
});
// Logout Route
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', LogoutAction::class)->name('auth.logout');
});
public function __invoke(AuthRequest $request)
{
$username = $request->input('username');
$password = $request->input('password');
$remember = $request->input('remember');
// I use filter_var to know username is email or just the regular username
$field = filter_var($username, FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
// User::where('username', $username)->first();
// User::where('email', $username)->first();
$user = User::where($field, $username)->first();
if (
!$user || !Hash::check($password, $user->password)
|| !Auth::attempt(
[
$field => $username,
'password' => $password
],
$remember
)
) {
// Return Error Message
return $this->error(['message' => trans('auth.failed')], Response::HTTP_UNAUTHORIZED);
}
// After Check Useranme And Password Create Token
$token = $user->createToken($request->device)->plainTextToken;
// Return Success Message With Token
return $this->success(['message' => trans('auth.login', ['user' => $user->username]), 'token' => $token, 'user' => $user]);
in vuex Actions
export const loginAction = ({ commit }, userInfo) => {
let urlParams = new URLSearchParams(window.location.search);
return new Promise((resolve, reject) => {
login(userInfo)
.then((response) => {
if (response.success) {
commit("SET_LOGIN_USER", response.payload.user);
// Set Token Mutaion
commit("SET_TOKEN", response.payload.token);
// Notify User for success logged in
Notify.create({
type: "positive",
message: `welcome ${response.payload.user.username}`,
});
// Redirect to dashboard
router.push(
urlParams.get("redirect") || { name: "dashboard" }
);
}
resolve(response);
})
.catch((error) => {
// For any error remove any auth user details and notify him
Notify.create({
type: "negative",
message: `${error.response?.data.payload}`,
});
commit("REMOVE_AUTH_DETAILS");
reject(error);
});
});
};
in vuex mutations 3 methods
// Helpers Fuctions
import { setLoginUser, setToken, removeToken, removeLoginUser } from '../../../utils/auth';
export const SET_LOGIN_USER = (state, user) => {
state.loginUser = user
setLoginUser(user)
}
export const SET_TOKEN = (state, token) => {
state.token = token
setToken(token)
}
export const REMOVE_AUTH_DETAILS = (state) => {
state.loginUser = null
state.token = null
removeLoginUser()
removeToken()
}
My Helper Functions
// I use Quasar Framework But you can use any cookie package like js-cookie
import { Cookies } from "quasar";
const TokenName = "TOKEN";
const LoginUser = "LOGIN_USER";
export function setToken(token) {
return Cookies.set(TokenName, token);
}
export function getToken() {
return Cookies.get(TokenName);
}
export function removeToken() {
return Cookies.remove(TokenName);
}
export function setLoginUser(loginUser) {
return Cookies.set(LoginUser, JSON.stringify(loginUser));
}
export function getLoginUser() {
return Cookies.get(LoginUser);
}
export function removeLoginUser() {
return Cookies.remove(LoginUser);
}
I use Vue js for frontend and axios for making requests
in axios file request.js
// It's helper function to get token from cookie
import { getToken } from "./auth";
import router from "../router";
import store from "../store";
import axios from "axios";
// Create axios instance
const service = axios.create({
baseURL: "/api/v1/",
timeout: 10000 // Request timeout
});
// Request intercepter
service.interceptors.request.use(
config => {
if (getToken()) {
config.headers["Authorization"] = "Bearer " + getToken(); // Set Token
}
return config;
},
error => {
// Do something with request error
console.log("error-axios", error); // for debug
Promise.reject(error);
}
);
// response pre-processing
service.interceptors.response.use(
response => {
return response.data;
},
error => {
// remove auth user informations from cookies and return to login page if unauthorized
if ([401].includes(error.response.status)) {
store.commit('auth/REMOVE_AUTH_DETAILS')
router.push({ name: "login" });
}
return Promise.reject(error);
}
);
export default service;
// Logout Fuction in controller or action
public function __invoke(Request $request)
{
if(! $request->user()) {
return $this->error(['message' => trans('auth.no_auth_user')]);
}
// Remove Token
$request->user()->tokens()->delete();
return $this->success([
'message' => trans('auth.logout', ['user' => auth()->user()->username])
], Response::HTTP_OK);
}
// Logout mutation (vuex)
export const logoutAction = ({ commit }) => {
return new Promise((resolve, reject) => {
logout()
.then(() => {
Notify.create({
type: "negative",
message: `see you later ${getLoginUser().username}`,
});
commit("REMOVE_AUTH_DETAILS");
router.push({ name: "login" });
resolve();
})
.catch((error) => {
router.push({ name: "login" });
commit("REMOVE_AUTH_DETAILS");
reject(error);
});
});
};
Any other help I will not be late
This question extends my last question and is primarily for me who might get stuck again. I'm trying to rebuild an app in Next.js that has this login form using JWT tokens. On the previous app, I store the access token in memory using context API and the refresh token in an httpOnly cookie. However, I discovered in Next.js that you can use something called _middleware that runs on every request. I thought of just storing both in an httpOnly cookie then verify each request. I'm still testing it out for bugs but it runs well for the time being. Answer below.
In this code I'm using a library called jose instead of jsonwebtoken because Native Node.js APIs are not supported and you will get the following error . Using Jose comes from this stack overflow answer.
_Middlesware.tsx
import { NextResponse } from "next/server";
import { generateAccessToken, verifyRefreshToken } from "../_operations/jwt/jwt";
export default async function (
req: {
url?: any;
cookies?: any;
}
): Promise<NextResponse | void> {
const { cookies } = req;
const url: string = req.url;
const refreshToken: string | undefined = cookies?.refresh_token_extreme;
const accessToken: string | undefined = cookies?.access_token_extreme;
const baseUrl: string = "http://localhost:3000";
// vercel.svg is used in /login
const unprotectedPaths: string[] = [
`${baseUrl}/login`,
`${baseUrl}/signup`,
`${baseUrl}/favicon.ico`,
`${baseUrl}/vercel.svg`,
`${baseUrl}/_next/webpack-hmr`,
`${baseUrl}/attachables/campus-images/image1.jpg`,
`${baseUrl}/attachables/mnhs-images/logos/login_logo.png`,
`${baseUrl}/attachables/mnhs-images/logos/mnhs_favicon_og.ico`,
];
if (unprotectedPaths.includes(url)) return void 0;
if (!refreshToken && url === `${baseUrl}/api/login`)
return NextResponse.next();
if (!accessToken && !refreshToken)
return NextResponse.redirect(`${baseUrl}/login`);
if (!accessToken && refreshToken) {
const verifiedToken = await verifyRefreshToken(refreshToken)
const newToken = await generateAccessToken(verifiedToken as any)
return NextResponse.next().cookie('access_token_extreme', newToken, {
secure: true,
sameSite: "strict",
httpOnly: true,
path: "/",
expires: new Date(Date.now() + 1000 * 60 * 10) // 10 minutes
});
}
return NextResponse.next();
}
jwt.ts
For why do I have uprotectedPaths, refer to my last question
import * as jose from "jose";
export function generateAccessToken(user: {}): Promise<string> {
return new Promise(async (resolve) => {
const accessToken = await new jose.SignJWT({ user })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("10m")
.sign(new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET));
return resolve(accessToken);
});
}
export function generateRefreshToken(user: {}): Promise<string> {
return new Promise(async (resolve) => {
const refreshToken = await new jose.SignJWT({ user })
.setProtectedHeader({ alg: "HS256" })
.sign(new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET));
return resolve(refreshToken);
});
}
export function verifyRefreshToken(token: string) {
return new Promise(async (resolve) => {
const { payload: newToken } = await jose.jwtVerify(
token,
new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET)
);
return resolve(newToken);
});
}
i have made a custom hook to fetch data from api, but the token only valid for 5 second.
so i made this hook
the problem is when i call the hooks from my page it called many time and the refresh token already expired
when i access the api i will check the response first if the token invalid i tried to refresh my token using handleRefreshToken
nb : im using useContext for my state management
import React, {useEffect, useState, useContext} from 'react';
import {View, StyleSheet} from 'react-native';
import {AuthContext} from '../Auth/Context';
import AsyncStorage from '#react-native-community/async-storage';
import {urlLogin, URLREFRESHTOKEN} from '../Configs/GlobaUrl';
const FetchData = () => {
const {loginState, authContext} = useContext(AuthContext);
const [data, setData] = useState([]);
const [message, setMessage] = useState('');
const [loading, setIsLoading] = useState(false);
const {dispatchRefreshToken} = authContext;
const handleRefreshToken = async (callbackUrl, callbackBody) => {
const refBody = {
client_id: loginState.ipAddress,
ipAddress: loginState.ipAddress,
employee_id: loginState.userData.Pegawai_Id,
jwttoken: loginState.userToken,
refresh_tokenn: loginState.refreshToken,
};
console.log('======refreshtokencalled==========');
console.log(refBody.refresh_tokenn, '<=refresh token');
console.log(refBody.jwttoken, '<=jwt token');
let response = await fetch(URLREFRESHTOKEN, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(refBody),
redirect: 'follow',
});
let result = await response.json();
console.log(result, ' ini result');
if (
result.item3 !== 'refresh token gagal' &&
result.item3 !== 'refresh token sudah tidak berlaku'
) {
let refresh = result.item2;
let token = result.item1;
// the backend doesnt send any succes / error code only item1 for token, //item2 refresh token and item3 for error
dispatchRefreshToken(token, refresh);
await AsyncStorage.setItem('refreshToken', refresh);
await AsyncStorage.setItem('token', token);
return getData(callbackUrl, callbackBody);
} else {
return null;
}
};
const getData = async (url, body) => {
setIsLoading(true);
let result;
try {
let response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${loginState.userToken}`,
},
body: JSON.stringify(body),
redirect: 'follow',
});
if (response.status == '401') {
let refreshResult = await handleRefreshToken(url, body);
console.log(refreshResult);
} else {
result = await response.json();
console.log(result);
console.log(loginState.refreshToken);
if (result.code == '1') {
setData(result.data);
setIsLoading(false);
} else {
throw result;
}
}
} catch (err) {
setData([]);
console.log(err, 'masuk error usefetchbybutton');
console.log(err.message, err.code);
setIsLoading(false);
setMessage(err);
}
};
return {
data: data,
message: message,
loading: loading,
getData: getData,
};
};
export default FetchData;
this is my dispatch refresh token
const authContext = useMemo(
() => ({
logIn: async (token, userData, refreshToken) => {
console.log(token, '<>', refreshToken, 'ini memoisa');
dispatch({
type: 'LOGIN',
token: token,
userData: userData,
refreshToken: refreshToken,
});
},
logOut: () => {
AsyncStorage.clear((error) => {
console.log(error);
});
dispatch({type: 'LOGOUT'});
},
dispatchRefreshToken: (userToken, refreshToken) => {
console.log(refreshToken, '=refresh dispatch=');
console.log(userToken, '=userToken dispatch=');
dispatch({
type: 'REFRESHTOKEN',
userToken: userToken,
refreshToken: refreshToken,
});
},
}),
[],
);
my reducer function
const loginReducer = (prevState, action) => {
switch (action.type) {
some case ...
case 'REFRESHTOKEN':
return {
...prevState,
userToken: action.userToken,
refreshToken: action.refreshToken,
};
}
};
Use recursion. The pseudo code is as follows
const getData = async (args, times) => {
// try to fetch data
const data = await Api.fetch(args);
// if token need to be refreshed.
if (check401(data)) {
// Use variable times to prevent stack overflow.
if (times > 0) {
// refresh the token
await refreshToken()
// try again
return getData(args, times - 1);
} else {
throw new Error("The appropriate error message")
}
}
return dealWith(data)
}
The logical above can be encapsulated to all your api. Like this
const wrapApi = (api) => {
const wrappedApi = async (args, times) => {
const data = await api(args);
// if token need to be refreshed.
if (check401(data)) {
// Use variable times to prevent stack overflow.
if (times > 0) {
// refresh the token
await refreshToken()
// try again
return wrappedApi(args, times - 1);
} else {
throw new Error("The appropriate error message")
}
}
return dealWith(data)
}
return wrappedApi;
}
So we're creating a React-Native app using Apollo and GraphQL. I'm using JWT based authentication(when user logs in both an activeToken and refreshToken is created), and want to implement a flow where the token gets refreshed automatically when the server notices it's been expired.
The Apollo Docs for Apollo-Link-Error provides a good starting point to catch the error from the ApolloClient:
onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
// modify the operation context with a new token
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: getNewToken(),
},
});
// retry the request, returning the new observable
return forward(operation);
}
}
}
})
However, I am really struggling to figure out how to implement getNewToken().
My GraphQL endpoint has the resolver to create new tokens, but I can't call it from Apollo-Link-Error right?
So how do you refresh the token if the Token is created in the GraphQL endpoint that your Apollo Client will connect to?
The example given in the the Apollo Error Link documentation is a good starting point but assumes that the getNewToken() operation is synchronous.
In your case, you have to hit your GraphQL endpoint to retrieve a new access token. This is an asynchronous operation and you have to use the fromPromise utility function from the apollo-link package to transform your Promise to an Observable.
import React from "react";
import { AppRegistry } from 'react-native';
import { onError } from "apollo-link-error";
import { fromPromise, ApolloLink } from "apollo-link";
import { ApolloClient } from "apollo-client";
let apolloClient;
const getNewToken = () => {
return apolloClient.query({ query: GET_TOKEN_QUERY }).then((response) => {
// extract your accessToken from your response data and return it
const { accessToken } = response.data;
return accessToken;
});
};
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case "UNAUTHENTICATED":
return fromPromise(
getNewToken().catch((error) => {
// Handle token refresh errors e.g clear stored tokens, redirect to login
return;
})
)
.filter((value) => Boolean(value))
.flatMap((accessToken) => {
const oldHeaders = operation.getContext().headers;
// modify the operation context with a new token
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
// retry the request, returning the new observable
return forward(operation);
});
}
}
}
}
);
apolloClient = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, httpLink]),
});
const App = () => (
<ApolloProvider client={apolloClient}>
<MyRootComponent />
</ApolloProvider>
);
AppRegistry.registerComponent('MyApplication', () => App);
You can stop at the above implementation which worked correctly until two or more requests failed concurrently. So, to handle concurrent requests failure on token expiration, have a look at this post.
Update - Jan 2022
you can see basic React JWT Authentication Setup from: https://github.com/bilguun-zorigt/React-GraphQL-JWT-Authentication-Example
I've also added the safety points to consider when setting up authentication on both the frontend and backend on the Readme section of the repository. (XSS attack, csrf attack etc...)
Original answer - Dec 2021
My solution:
Works with concurrent requests (by using single promise for all requests)
Doesn't wait for error to happen
Used second client for refresh mutation
import { setContext } from '#apollo/client/link/context';
async function getRefreshedAccessTokenPromise() {
try {
const { data } = await apolloClientAuth.mutate({ mutation: REFRESH })
// maybe dispatch result to redux or something
return data.refreshToken.token
} catch (error) {
// logout, show alert or something
return error
}
}
let pendingAccessTokenPromise = null
export function getAccessTokenPromise() {
const authTokenState = reduxStoreMain.getState().authToken
const currentNumericDate = Math.round(Date.now() / 1000)
if (authTokenState && authTokenState.token && authTokenState.payload &&
currentNumericDate + 1 * 60 <= authTokenState.payload.exp) {
//if (currentNumericDate + 3 * 60 >= authTokenState.payload.exp) getRefreshedAccessTokenPromise()
return new Promise(resolve => resolve(authTokenState.token))
}
if (!pendingAccessTokenPromise) pendingAccessTokenPromise = getRefreshedAccessTokenPromise().finally(() => pendingAccessTokenPromise = null)
return pendingAccessTokenPromise
}
export const linkTokenHeader = setContext(async (_, { headers }) => {
const accessToken = await getAccessTokenPromise()
return {
headers: {
...headers,
Authorization: accessToken ? `JWT ${accessToken}` : '',
}
}
})
export const apolloClientMain = new ApolloClient({
link: ApolloLink.from([
linkError,
linkTokenHeader,
linkMain
]),
cache: inMemoryCache
});
If you are using JWT, you should be able to detect when your JWT token is about to expire or if it is already expired.
Therefore, you do not need to make a request that will always fail with 401 unauthorized.
You can simplify the implementation this way:
const REFRESH_TOKEN_LEGROOM = 5 * 60
export function getTokenState(token?: string | null) {
if (!token) {
return { valid: false, needRefresh: true }
}
const decoded = decode(token)
if (!decoded) {
return { valid: false, needRefresh: true }
} else if (decoded.exp && (timestamp() + REFRESH_TOKEN_LEGROOM) > decoded.exp) {
return { valid: true, needRefresh: true }
} else {
return { valid: true, needRefresh: false }
}
}
export let apolloClient : ApolloClient<NormalizedCacheObject>
const refreshAuthToken = async () => {
return apolloClient.mutate({
mutation: gql```
query refreshAuthToken {
refreshAuthToken {
value
}```,
}).then((res) => {
const newAccessToken = res.data?.refreshAuthToken?.value
localStorage.setString('accessToken', newAccessToken);
return newAccessToken
})
}
const apolloHttpLink = createHttpLink({
uri: Config.graphqlUrl
})
const apolloAuthLink = setContext(async (request, { headers }) => {
// set token as refreshToken for refreshing token request
if (request.operationName === 'refreshAuthToken') {
let refreshToken = localStorage.getString("refreshToken")
if (refreshToken) {
return {
headers: {
...headers,
authorization: `Bearer ${refreshToken}`,
}
}
} else {
return { headers }
}
}
let token = localStorage.getString("accessToken")
const tokenState = getTokenState(token)
if (token && tokenState.needRefresh) {
const refreshPromise = refreshAuthToken()
if (tokenState.valid === false) {
token = await refreshPromise
}
}
if (token) {
return {
headers: {
...headers,
authorization: `Bearer ${token}`,
}
}
} else {
return { headers }
}
})
apolloClient = new ApolloClient({
link: apolloAuthLink.concat(apolloHttpLink),
cache: new InMemoryCache()
})
The advantage of this implementation:
If the access token is about to expire (REFRESH_TOKEN_LEGROOM), it will request a refresh token without stopping the current query. Which should be invisible to your user
If the access token is already expired, it will refresh the token and wait for the response to update it. Much faster than waiting for the error back
The disadvantage:
If you make many requests at once, it may request several times a refresh. You can easily protect against it by waiting a global promise for example. But you will have to implement a proper race condition check if you want to guaranty only one refresh.
after checking this topic and some others very good on internet, my code worked with the following solution
ApolloClient,
NormalizedCacheObject,
gql,
createHttpLink,
InMemoryCache,
} from '#apollo/client';
import { setContext } from '#apollo/client/link/context';
import jwt_decode, { JwtPayload } from 'jwt-decode';
import {
getStorageData,
setStorageData,
STORAGE_CONTANTS,
} from '../utils/local';
export function isRefreshNeeded(token?: string | null) {
if (!token) {
return { valid: false, needRefresh: true };
}
const decoded = jwt_decode<JwtPayload>(token);
if (!decoded) {
return { valid: false, needRefresh: true };
}
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
return { valid: false, needRefresh: true };
}
return { valid: true, needRefresh: false };
}
export let client: ApolloClient<NormalizedCacheObject>;
const refreshAuthToken = async () => {
const refreshToken = getStorageData(STORAGE_CONTANTS.REFRESHTOKEN);
const newToken = await client
.mutate({
mutation: gql`
mutation RefreshToken($refreshAccessTokenRefreshToken: String!) {
refreshAccessToken(refreshToken: $refreshAccessTokenRefreshToken) {
accessToken
status
}
}
`,
variables: { refreshAccessTokenRefreshToken: refreshToken },
})
.then(res => {
const newAccessToken = res.data?.refreshAccessToken?.accessToken;
setStorageData(STORAGE_CONTANTS.AUTHTOKEN, newAccessToken, true);
return newAccessToken;
});
return newToken;
};
const apolloHttpLink = createHttpLink({
uri: process.env.REACT_APP_API_URL,
});
const apolloAuthLink = setContext(async (request, { headers }) => {
if (request.operationName !== 'RefreshToken') {
let token = getStorageData(STORAGE_CONTANTS.AUTHTOKEN);
const shouldRefresh = isRefreshNeeded(token);
if (token && shouldRefresh.needRefresh) {
const refreshPromise = await refreshAuthToken();
if (shouldRefresh.valid === false) {
token = await refreshPromise;
}
}
if (token) {
return {
headers: {
...headers,
authorization: `${token}`,
},
};
}
return { headers };
}
return { headers };
});
client = new ApolloClient({
link: apolloAuthLink.concat(apolloHttpLink),
cache: new InMemoryCache(),
});
A much simpler solution is using RetryLink. retryIf supports async operations so one could do something like this:
class GraphQLClient {
constructor() {
const httpLink = new HttpLink({ uri: '<graphql-endpoint>', fetch: fetch })
const authLink = setContext((_, { headers }) => this._getAuthHeaders(headers))
const retryLink = new RetryLink({
delay: { initial: 300, max: Infinity, jitter: false },
attempts: {
max: 3,
retryIf: (error, operation) => this._handleRetry(error, operation)
}})
this.client = new ApolloClient({
link: ApolloLink.from([ authLink, retryLink, httpLink ]),
cache: new InMemoryCache()
})
}
async _handleRetry(error, operation) {
let requiresRetry = false
if (error.statusCode === 401) {
requiresRetry = true
if (!this.refreshingToken) {
this.refreshingToken = true
await this.requestNewAccessToken()
operation.setContext(({ headers = {} }) => this._getAuthHeaders(headers))
this.refreshingToken = false
}
}
return requiresRetry
}
async requestNewAccessToken() {
// get new access token
}
_getAuthHeaders(headers) {
// return headers
}
}