These days, a lot of services choose JWT(JSON Web Token)
as their authentication. When you implement JWT, you would issue an access token and a refresh token.
AccessToken and RefreshToken
- AccessToken has a short expiration time(like 10~15min) and represents the authorization to access APIs.
- RefreshToken is used for issuing a new access token and has a longer expiration time than the access token.
Thanks to refresh tokens, you can manage more safe access tokens.
You might ask that 'What if a refresh token is leaked?'. There are many strategies that make us safer. like RTR(Refresh Token Rotation).
To put it simply, refresh API issues an access token and a refresh token and expires the refresh token. they assume tokens must've leaked if refresh tokens are used more than once.
I recommend reading this documentation auth0-refresh-token-rotation.
I won't talk about JWT in this post anymore, let's move on.
Refresh Token Implementation
I made a test server using NestJS
. There are three resolvers and two guards.
Guards
- JwtAuthGuard: Authorize if the access token is valid in the
Authorization
header. - JwtRefreshAuthGuard: Authorize if the refresh token is valid in the
Authorization
header.
Both tokens will be passed in the Authorization
header in each request and will be stored in localStorage.
For better security, you can use cookie
, with the httpOnly attribute and the SameSite attribute.
APIs
- createToken: issues an access token and a refresh token.
- ping: returns true if an access token is verified otherwise returns
401 error
. - refreshToken: returns an access token if a refresh token is verified otherwise returns
401 error
DTOs
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class CreateTokenResponse {
@Field()
accessToken: string;
@Field()
refreshToken: string;
}
@ObjectType()
export class RefreshTokenResponse {
@Field()
accessToken: string;
}
Resolvers
@Resolver()
export class AuthResolver {
constructor(private readonly authService: AuthService) {}
@Mutation(() => CreateTokenResponse)
async createToken(): Promise<CreateTokenResponse> {
return this.authService.createToken();
}
@UseGuards(JwtAuthGuard)
@Query(() => Boolean)
async ping() {
return true;
}
@UseGuards(JwtRefreshAuthGuard)
@Mutation(() => RefreshTokenResponse)
async refreshToken(): Promise<RefreshTokenResponse> {
return this.authService.refreshToken();
}
}
Scenario
In this scenario, there are six steps.
- Request createToken and get an access token and a refresh token from the server
- Request pass with an expired access token and get 401 error
- Request refreshToken
- Get a new access token
- Retry the failed request
- Success!
For the scenario, I set the expiration time of the access token to be 5s.
React Apollo Client
Types and Queries
/**
* Types
*/
interface Tokens {
accessToken: string;
refreshToken: string;
}
interface AccessToken {
accessToken: string;
}
/**
* Queries
*/
const CREATE_TOKEN = gql`
mutation createToken {
createToken {
accessToken
refreshToken
}
}
`;
const REFRESH_TOKEN = gql`
mutation refreshToken {
refreshToken {
accessToken
}
}
`;
const PING = gql`
query ping {
ping
}
`;
Page
/**
* React Components
*/
function App() {
const [createToken, { data: createTokenData }] = useMutation<{
createToken: Tokens;
}>(CREATE_TOKEN);
const [ping] = useLazyQuery(PING, {
fetchPolicy: 'network-only',
});
const requestToken = () => {
createToken();
};
const sendPing = () => {
ping();
};
useEffect(() => {
if (!createTokenData) return;
const { accessToken, refreshToken } = createTokenData.createToken;
// Save tokens in localStorage
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
}, [createTokenData]);
return (
<Container>
<button type="button" onClick={requestToken}>
login
</button>
<button type="button" onClick={sendPing}>
ping
</button>
</Container>
);
}
function ApolloWrapper() {
return (
<ApolloProvider client={client}>
<App />
</ApolloProvider>
);
}
/**
* Styles
*/
const Container = styled.div`
display: flex;
flex-direction: column;
row-gap: 12px;
padding: 24px;
> button {
width: 200px;
height: 24px;
}
`;
export default ApolloWrapper;
There are two buttons. one is for createToken
and another one is for pass
.
Requesting refreshToken and retrying failed request
/**
* Apollo Setup
*/
function isRefreshRequest(operation: GraphQLRequest) {
return operation.operationName === 'refreshToken';
}
// Returns accesstoken if opoeration is not a refresh token request
function returnTokenDependingOnOperation(operation: GraphQLRequest) {
if (isRefreshRequest(operation))
return localStorage.getItem('refreshToken') || '';
else return localStorage.getItem('accessToken') || '';
}
const httpLink = createHttpLink({
uri: 'http://localhost:3000/graphql',
});
const authLink = setContext((operation, { headers }) => {
let token = returnTokenDependingOnOperation(operation);
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// ignore 401 error for a refresh request
if (operation.operationName === 'refreshToken') return;
const observable = new Observable<FetchResult<Record<string, any>>>(
(observer) => {
// used an annonymous function for using an async function
(async () => {
try {
const accessToken = await refreshToken();
if (!accessToken) {
throw new GraphQLError('Empty AccessToken');
}
// Retry the failed request
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
};
forward(operation).subscribe(subscriber);
} catch (err) {
observer.error(err);
}
})();
}
);
return observable;
}
}
}
if (networkError) console.log(`[Network error]: ${networkError}`);
}
);
const client = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, httpLink]),
cache: new InMemoryCache(),
});
// Request a refresh token to then stores and returns the accessToken.
const refreshToken = async () => {
try {
const refreshResolverResponse = await client.mutate<{
refreshToken: AccessToken;
}>({
mutation: REFRESH_TOKEN,
});
const accessToken = refreshResolverResponse.data?.refreshToken.accessToken;
localStorage.setItem('accessToken', accessToken || '');
return accessToken;
} catch (err) {
localStorage.clear();
throw err;
}
};
It distinguishes if a request is for refreshToken
or not through operation.operationName
.
The point is that you can implement the retrying request logic in onError
with Observable
.
Return an Observable
object in onError
then in the function, get a new access token and retry a request using forward
Make sure the order of links is right as you want.
You can see the result as a gif image and code in this repository.
That's it, I hope it'll be helpful for someone.
Happy Coding!
Top comments (5)
It seems that observable is never called at my side
Since it was written almost 2 years ago, something could be different. If you can share the problems and the code with me through Github, I will check that out and make a pull request.
sure, I can share a few snippets Iβve come up with recently
I uploaded a new post, you can check it out here. I hope you found it helpful. If you prefer to implement it using
Observable
, just leave me the snippets, I will check it.I'm thinking about rewriting a post about this topic for the 2024 version. You maybe can check it later. I will work on it in the next few days. Thanks for your comment π