In early 2022, I worked on a React project with GraphQL at my company. It was my first time using GraphQL and, it was also my last time. I uploaded a post titled React Apollo: JWT & Refresh Token, one of my most-views posts. A couple of weeks ago, someone commented on the post(thank you Andrew) that a part of the code written on my post doesn't work as expected.
Since it has been a long time since I used GraphQL, almost two years ago, I couldn't give a clear answer for that. I just assumed that it may not have worked because something had changed in the latest version of Apollo
or just logic.
I replied I would go over the code if they gave me the code that had the problem. But then, I thought that it would be better to write the new version of the post about refreshing a token and I also wondered how it could be implemented in the latest version, there may not changes about it though. I started a new project from scratch. It was also good to brush up on what I learned before.
I read Apollo documentation and set up a project by following the step-by-step guide on the docs. The documentation is well-written and has rich content. I reimplemented the logic to refresh a token. However, I ended up reaching the same solution I did before.
We can catch the Unauthorized
error using the onError
link. We can not apply await
to the function that passes to onError
as it can not return Promise
. However, to fetch a new access token from the server, we need to request and get a response from the server and the process is going asynchronously.
This code is what I found and wrote in my previous post. It returns an instance that is created from Observable. In the function of Observable, we can call forward an operation using forward(operation).subscribe
Since it is called from the function that is passed to the constructor of Observable, it means we can call it asynchronously. So, inside the function, I defined a function with the async
keyword and called forward.subscribe
in it.
However, I used it without fully understanding.
As I found the same solution, I planned to dig into it more deeply. But then I found something in the ApolloLink
section in the documentation.
It seemed like in other links, we could process the logic using Promise
and I came up with the idea that processes asynchronous logic in the next link after onError
. I tried it and it worked. In this post, I am going to share this implementation.
Contents
Server Code
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';
import jwt from 'jsonwebtoken';
const typeDefs = `
type Auth {
accessToken: String!
refreshToken: String!
}
type RefreshTokenResponse {
accessToken: String!
}
type Query {
ping: String!
}
type Mutation {
signIn: Auth!
refreshToken(token: String!): RefreshTokenResponse!
}
`;
const secretKey = {
accessToken: 'secret-key-a',
refreshToken: 'secret-key-b',
};
const tokenExpireDateTime = {
accessToken: '5s',
refreshToken: '1d',
};
type User = {
id: number;
};
type TokenType = 'accessToken' | 'refreshToken';
const generateToken = (user: User, type: TokenType) => {
return jwt.sign(user, secretKey[type], {
algorithm: 'HS256',
expiresIn: tokenExpireDateTime[type],
});
};
const verifyToken = async (token: string, type: TokenType): Promise<User> => {
const decoded = await jwt.verify(token, secretKey[type]);
return { id: (decoded as User)?.id };
};
const authResolver = (resolver) => {
return (...args) => {
const { user } = args[2];
if (!user) {
throw new GraphQLError('User is not authenticated', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
}
return resolver(...args);
};
};
const refreshToken = async (_, { token }: { token: string }) => {
try {
const user = await verifyToken(token, 'refreshToken');
const accessToken = generateToken(user, 'accessToken');
return { accessToken };
} catch {
throw new GraphQLError('User is not authenticated', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
}
};
const signIn = () => {
const user = { id: Math.random() };
const accessToken = generateToken(user, 'accessToken');
const refreshToken = generateToken(user, 'refreshToken');
return {
accessToken,
refreshToken,
};
};
const ping = () => {
return 'ok';
};
const resolvers = {
Query: {
ping: authResolver(ping),
},
Mutation: {
refreshToken,
signIn,
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const authorization = req.headers.authorization || '';
const token = authorization.substring('Bearer '.length);
try {
const user = await verifyToken(token, 'accessToken');
return { user };
} catch {
return {};
}
},
});
console.log(`Server ready at: ${url}`);
Type Definition
const typeDefs = `
type Auth {
accessToken: String!
refreshToken: String!
}
type RefreshTokenResponse {
accessToken: String!
}
type Query {
ping: String!
}
type Mutation {
signIn: Auth!
refreshToken(token: String!): RefreshTokenResponse!
}
`;
there are two types used as a response type. Auth
and RefreshTokenResponse
.
signIn
mutation returns Auth
that has two properties accessToken
and refreshToken
.
refreshToken
mutation receives a refresh token as a parameter, and will verify it then will return a new accessToken
. It could've been replaced with String!
but having a JSON result for this function seemed natural to me, so I defined the type.
ping
query is used to check whether the access token is verified or not. A middleware will work for the verification. It will be discussed below in the middleware section.
Token (JWT)
const secretKey = {
accessToken: 'secret-key-a',
refreshToken: 'secret-key-b',
};
const tokenExpireDateTime = {
accessToken: '5s',
refreshToken: '1d',
};
type User = {
id: number;
};
type TokenType = 'accessToken' | 'refreshToken';
const generateToken = (user: User, type: TokenType) => {
return jwt.sign(user, secretKey[type], {
algorithm: 'HS256',
expiresIn: tokenExpireDateTime[type],
});
};
const verifyToken = async (token: string, type: TokenType): Promise<User> => {
const decoded = await jwt.verify(token, secretKey[type]);
return { id: (decoded as User)?.id };
};
I used a different secret key to generate and verify the access token and the refresh token. I gave a 5-second expiration time to the access token to ease the test process. The data, id, is not necessary I just put it a random number.
Resolver
const authResolver = (resolver) => {
return (...args) => {
const { user } = args[2];
if (!user) {
throw new GraphQLError('User is not authenticated', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
}
return resolver(...args);
};
};
const refreshToken = async (_, { token }: { token: string }) => {
try {
const user = await verifyToken(token, 'refreshToken');
const accessToken = generateToken(user, 'accessToken');
return { accessToken };
} catch {
throw new GraphQLError('User is not authenticated', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
}
};
const signIn = () => {
const user = { id: Math.random() };
const accessToken = generateToken(user, 'accessToken');
const refreshToken = generateToken(user, 'refreshToken');
return {
accessToken,
refreshToken,
};
};
const ping = () => {
return 'ok';
};
const resolvers = {
Query: {
ping: authResolver(ping),
},
Mutation: {
refreshToken,
signIn,
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
authResolver
receives a resolver as a parameter and fetches user
from the context. If user
exists, it calls the resolver it received as a parameter, which means the token is verified as middleware passes user
by extracting from the access token. If not, it throws the 401 error.
refreshToken
verifies the refresh token and if it is verified successfully, it generates a new access token and then returns it, otherwise it returns the 401 error.
ping
returns a string 'ok'. It exists only for testing of a request.
In resolvers, ping
is wrapped by authResolver
and before ping
is called, authResolver
will check whether the user is verified or not by checking the user
data from the context that is passed by middleware.
Middleware (authorization)
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const authorization = req.headers.authorization || '';
const token = authorization.substring('Bearer '.length);
try {
const user = await verifyToken(token, 'accessToken');
return { user };
} catch {
return {};
}
},
});
console.log(`Server ready at: ${url}`);
startStandaloneServer
receives the context function as an optional parameter.
The context function extracts the authorization
field from the header and verifies it by using verifyToken
then will pass the user, if it fails it returns an empty JSON object, which means if the user is passed, the token is verified.
Client Code
UI
function AuthDisplay() {
const SIGNIN = gql`
mutation SignIn {
signIn {
accessToken
refreshToken
}
}
`;
const [tokens, setTokens] = useState<
| {
accessToken: string;
refreshToken: string;
}
| undefined
>();
const updateToken = () => {
const accessToken = localStorage.getItem('accessToken') ?? '';
const refreshToken = localStorage.getItem('refreshToken') ?? '';
setTokens({ accessToken, refreshToken });
};
const [signIn, { data }] = useMutation(SIGNIN);
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
updateToken();
};
useEffect(() => {
updateToken();
const tmShowToken = setInterval(updateToken, 1000);
return () => {
clearInterval(tmShowToken);
};
}, []);
useEffect(() => {
if (!data?.signIn) return;
localStorage.setItem('accessToken', data.signIn.accessToken);
localStorage.setItem('refreshToken', data.signIn.refreshToken);
updateToken();
}, [data]);
return (
<div className="bg-white p-2 rounded">
<div className="flex gap-x-2 flex-wrap mb-2">
<div className="flex gap-x-2">
<label className="font-bold">accessToken</label>
<p className="max-w-60 break-words text-sm">{tokens?.accessToken}</p>
</div>
<div className="flex gap-x-2">
<label className="font-bold">refreshToken</label>
<p className="max-w-60 break-words text-sm">{tokens?.refreshToken}</p>
</div>
</div>
<div className="flex gap-x-2">
<button
type="button"
className="py-2 px-4 bg-sky-800 text-white rounded font-bold transition-all hover:bg-sky-700"
onClick={() => signIn()}
>
Sign In
</button>
<button
type="button"
className="py-2 px-4 bg-orange-600 text-white rounded font-bold transition-all hover:bg-orange-500"
onClick={logout}
>
Logout
</button>
</div>
</div>
);
}
function Ping() {
const PING = gql`
query Ping {
ping
}
`;
const [ping, { error }] = useLazyQuery(PING, {
fetchPolicy: 'network-only',
onCompleted: () => setStatus('success'),
onError: () => setStatus('error'),
});
const [status, setStatus] = useState('');
return (
<div className="p-2 bg-white rounded">
<div className="mb-2">
<label className="uppercase font-bold">ping result: </label>
{status && (
<span
className={`${
error ? 'bg-red-600' : 'bg-green-600'
} text-white py-1 px-2 rounded w-fit font-bold text-sm`}
>
{status}
</span>
)}
</div>
<button
type="button"
className="py-2 px-4 bg-rose-800 text-white rounded font-bold transition-all hover:bg-rose-700"
onClick={() => ping()}
>
Ping
</button>
</div>
);
}
function App() {
return (
<ApolloProvider client={client}>
<div className="w-screen h-screen bg-slate-900 p-4">
<AuthDisplay />
<div className="h-4" />
<Ping />
</div>
</ApolloProvider>
);
}
export default App;
Ping Component
const PING = gql`
query Ping {
ping
}
`;
const [ping, { error }] = useLazyQuery(PING, {
fetchPolicy: 'network-only',
onCompleted: () => setStatus('success'),
onError: () => setStatus('error'),
});
const [status, setStatus] = useState('');
// ...
<button
type="button"
className="py-2 px-4 bg-rose-800 text-white rounded font-bold transition-all hover:bg-rose-700"
onClick={() => ping()}
>
Ping
</button>
As ping
doesn't need to be called when a component is loaded, it is called by useLazyQuery
. When the button is clicked, it requests the query. Since we don't need to use cache, network-only
is used as fetch policy.
AuthDisplay Component
const SIGNIN = gql`
mutation SignIn {
signIn {
accessToken
refreshToken
}
}
`;
//...
const [signIn, { data }] = useMutation(SIGNIN);
}, [data]);
//...
useEffect(() => {
if (!data?.signIn) return;
localStorage.setItem('accessToken', data.signIn.accessToken);
localStorage.setItem('refreshToken', data.signIn.refreshToken);
updateToken();
}, [data]);
//...
const updateToken = () => {
const accessToken = localStorage.getItem('accessToken') ?? '';
const refreshToken = localStorage.getItem('refreshToken') ?? '';
setTokens({ accessToken, refreshToken });
};
When the login button is clicked, it requests the signIn
mutation. As it gets a response from the server, the data will be changed.
useEffect
detects the data
changes and sets accessToken
and refreshToken
from the storage to the data received from the server.
updateToken
sets the state inside the component to display the tokens to users.
Apollo
const generateRefreshTokenLinkOnUnauthError = ({
refreshTokenPathName,
refreshTokenRequestFunc,
}: {
refreshTokenPathName: string;
refreshTokenRequestFunc: () => Promise<void>;
}) => {
return [
onError(({ graphQLErrors, operation, forward }) => {
if (!graphQLErrors) return;
for (const { path, extensions } of graphQLErrors) {
if (extensions.code !== 'UNAUTHENTICATED' || !path) continue;
if (path.includes(refreshTokenPathName)) break;
const { getContext, setContext } = operation;
const context = getContext();
setContext({
...context,
headers: {
...context?.headers,
_needsRefresh: true,
},
});
return forward(operation);
}
}),
setContext(async (_, previousContext) => {
if (previousContext?.headers?._needsRefresh) {
await refreshTokenRequestFunc();
}
return previousContext;
}),
];
};
const uri = 'http://localhost:4000';
const httpLink = new HttpLink({ uri });
const authLink = setContext((_, previousContext) => {
const token = localStorage.getItem('accessToken');
return {
...previousContext,
headers: {
...previousContext?.headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const refreshTokenReq = async () => {
const refreshToken = localStorage.getItem('refreshToken') || '';
const response = await client.mutate({
mutation: gql`
mutation RefreshToken($token: String!) {
refreshToken(token: $token) {
accessToken
}
}
`,
variables: {
token: refreshToken,
},
});
const { accessToken } = response.data?.refreshToken || {};
if (accessToken) localStorage.setItem('accessToken', accessToken);
};
const client = new ApolloClient({
link: from([
...generateRefreshTokenLinkOnUnauthError({
refreshTokenPathName: 'refreshToken',
refreshTokenRequestFunc: refreshTokenReq,
}),
authLink,
httpLink,
]),
cache: new InMemoryCache(),
});
Error Link
onError(({ graphQLErrors, operation, forward }) => {
if (!graphQLErrors) return;
for (const { path, extensions } of graphQLErrors) {
if (extensions.code !== 'UNAUTHENTICATED' || !path) continue;
if (path.includes(refreshTokenPathName)) break;
const { getContext, setContext } = operation;
const context = getContext();
setContext({
...context,
headers: {
...context?.headers,
_needsRefresh: true,
},
});
return forward(operation);
}
})
It finds the UNATHENTICATED
error from the graphQLErrors
.
If the error comes from the request for the refresh token, it ignores it.
If the UNATHENTICATED
error is found, it puts the flag _needsRefresh
into the headers in the context and then forwards the operation, which means passing it to the next link.
Refresh Token Link
setContext(async (_, previousContext) => {
if (previousContext?.headers?._needsRefresh) {
await refreshTokenRequestFunc();
}
return previousContext;
})
//...
const refreshTokenReq = async () => {
const refreshToken = localStorage.getItem('refreshToken') || '';
const response = await client.mutate({
mutation: gql`
mutation RefreshToken($token: String!) {
refreshToken(token: $token) {
accessToken
}
}
`,
variables: {
token: refreshToken,
},
});
const { accessToken } = response.data?.refreshToken || {};
if (accessToken) localStorage.setItem('accessToken', accessToken);
};
If the flag _needsRefresh
is found from the context, it calls the refreshTokenReqeustFunc
function.
The refreshTokenReq
function is the function that will be passed to the refresh token link.
In the function, it requests the refresh token mutation to the server and updates the access token in the storage with the access token received from the server.
Auth Link
const authLink = setContext((_, previousContext) => {
const token = localStorage.getItem('accessToken');
return {
...previousContext,
headers: {
...previousContext?.headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
authLink
retrieves the access token from the storage and puts it into the headers.
Apollo Client Link
const client = new ApolloClient({
link: from([
...generateRefreshTokenLinkOnUnauthError({
refreshTokenPathName: 'refreshToken',
refreshTokenRequestFunc: refreshTokenReq,
}),
authLink,
httpLink,
]),
cache: new InMemoryCache(),
});
I defined the generateRefreshTokenLinkOnUnauthError
function to generate onError
link and the refresh token link. To use this code in your project, just pass your resolver path name and the function to generateRefreshTokenLinkOnUnauthError
.
Wrap Up
You can check the test result and the full code from my github repository.
Since the repository I implemented the request token logic using Observable
received 11 stars, To make this code accessible for the people, I pushed this code to a new branch link
of the repository. I am really glad that somebody found it helpful. I think this is why people share their experiences in the public. It is genuine enjoyment, right?
Also, although I don't use GraphQL, it was fun to explore the new technology. I hope I find a good place to work soon. I am already excited about the opportunity to learn new things.
I hope you found it helpful as well and Happy Coding!
Top comments (4)
Fantastic!! ππππ
Thank you π
Thanks a lot.
I'm glad you found it helpful. :)