Securing microservices with JWT refresh tokens

Hi. This is the thirty-first part of the diary about developing the “Programmers’ diary” blog. Previous part: https://hashnode.programmersdiary.com/making-posts-versions-visible. The open source code of this project is on: https://github.com/TheProgrammersDiary. The first diary entry explains why this project was done: https://medium.com/@vievaldas/developing-a-website-with-microservices-part-1-the-idea-fe6e0a7a96b5.
Next entry:
—————
2024-02-10
This day brings JWT refresh token. In previous setup, we had JWT token which lasted 10 minutes. Refresh token is an additional token which lasts for 2 weeks. When login endpoint gets called refresh token is added as a cookie and JWT short lived token is returned in body. Using short lived token, user can call endpoints which require authorization. When a short lived token expires, refresh token can be called to get a new short lived token.
Let’s start from the Security lib.
First, let’s add tests for short lived token:
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
public class JwtShortLivedTokenTests {
private static final SecretKey key = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(
"b936cee86c9f87aa5d3c6f2e84cb5a4239a5fe50480a6ec66b70ab5b1f4ac6730c6c51542" +
"1b327ec1d69402e53dfb49ad7381eb067b338fd7b0cb22247225d47"
)
);
@Test
void createsToken() {
JwtShortLivedToken token = JwtShortLivedToken.create(
JwtRefreshToken.create(new FakeAuthentication("tester@gmail.com", null), key), key
);
assertEquals("tester@gmail.com", token.email());
assertTrue(token.expirationDate().after(new Date(System.currentTimeMillis())));
assertTrue(token.tokenIsValid());
}
@Test
void readsExistingTokenFromAuthorizationHeader() {
HttpServletRequest httpRequest = new FakeHttpServletRequest(
Map.of(
"Authorization",
"Bearer " + JwtShortLivedToken.create(
JwtRefreshToken.create(new FakeAuthentication("tester@gmail.com", null), key),
key
).value()
)
);
Optional<JwtShortLivedToken> token = JwtShortLivedToken.existing(httpRequest, key);
assertTrue(token.isPresent());
assertEquals("tester@gmail.com", token.get().email());
assertTrue(token.get().expirationDate().after(new Date(System.currentTimeMillis())));
assertTrue(token.get().tokenIsValid());
}
@Test
void readsExistingTokenFromCookies() {
HttpServletRequest httpRequest = new FakeHttpServletRequest(
Map.of(
"Authorization",
"Bearer " + JwtShortLivedToken.create(
JwtRefreshToken.create(new FakeAuthentication("tester@gmail.com", null), key),
key
).value()
)
);
Optional<JwtShortLivedToken> token = JwtShortLivedToken.existing(httpRequest, key);
assertTrue(token.isPresent());
assertEquals("tester@gmail.com", token.get().email());
assertTrue(token.get().expirationDate().after(new Date(System.currentTimeMillis())));
assertTrue(token.get().tokenIsValid());
}
@ParameterizedTest
@ValueSource(strings = {
"",
"short",
"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0ZXIiLCJpYXQiOjE3MDUxNjg1OTQsI" +
"mV4cCI6MTcwNTE2OTE5NH0.0BKl5k3IJ7PlfpuuPjRO0qqb7_IVal6IqqjvkPLm3Yhk" +
"rqsn5SMYtkNqN321ChofuxvhhfdfJdROuh10_hyflg" // expired token
})
void rejectsInvalidToken(String jwt) {
assertFalse(
JwtShortLivedToken
.existing(new FakeHttpServletRequest(Map.of("Authorization", jwt)), key)
.isPresent()
);
}
@Test
void rejectsInvalidToken() {
assertFalse(JwtShortLivedToken.existing(new FakeHttpServletRequest(Map.of()), key).isPresent());
}
}
and make a class for short lived one:
public final class JwtShortLivedToken {
private static final Logger logger = LoggerFactory.getLogger(JwtShortLivedToken.class);
private final String token;
private final SecretKey key;
public static JwtShortLivedToken create(JwtRefreshToken refreshToken, SecretKey key) {
int expirationMs = 1000 * 60 * 10; // 10 minutes
return new JwtShortLivedToken(
Jwts.builder()
.subject(refreshToken.email())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key)
.compact(),
key
);
}
public static Optional<JwtShortLivedToken> existing(HttpServletRequest request, SecretKey key) {
return parseJwt(request).map(token -> new JwtShortLivedToken(token, key));
}
private JwtShortLivedToken(String token, SecretKey key) {
this.token = token;
this.key = key;
}
private static Optional<String> parseJwt(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader("Authorization"))
.flatMap(JwtShortLivedToken::parseJwtFromHeader);
}
private static Optional<String> parseJwtFromHeader(String token) {
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
return Optional.of(token.substring(7));
}
return Optional.empty();
}
public String value() {
return token;
}
public String email() {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public Date expirationDate() {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration();
}
public boolean tokenIsValid() {
try {
Jwts.parser().verifyWith(key).build().parse(token);
return true;
} catch (ExpiredJwtException | MalformedJwtException | SecurityException | IllegalArgumentException e) {
logger.error("Exception while trying to validate JWT token: {}", e.getMessage());
return false;
}
}
}
When we can move on to the refresh token tests:
package com.evalvis.security;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
public class JwtRefreshTokenTests {
private static final SecretKey key = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(
"b936cee86c9f87aa5d3c6f2e84cb5a4239a5fe50480a6ec66b70ab5b1f4ac6730c6c51542" +
"1b327ec1d69402e53dfb49ad7381eb067b338fd7b0cb22247225d47"
)
);
@Test
void createsRefreshToken() {
JwtRefreshToken token = JwtRefreshToken.create(
new FakeAuthentication("tester@gmail.com", null), key
);
assertEquals("tester@gmail.com", token.email());
assertTrue(token.expirationDate().after(new Date(System.currentTimeMillis())));
assertTrue(token.tokenIsValid());
}
@Test
void readsExistingTokenFromAuthorizationHeader() {
HttpServletRequest httpRequest = new FakeHttpServletRequest(
new Cookie[] {
new Cookie(
"jwt",
JwtRefreshToken
.create(new FakeAuthentication("tester@gmail.com", null), key)
.value()
)
}
);
Optional<JwtRefreshToken> token = JwtRefreshToken.existing(httpRequest, key);
assertTrue(token.isPresent());
assertEquals("tester@gmail.com", token.get().email());
assertTrue(token.get().expirationDate().after(new Date(System.currentTimeMillis())));
assertTrue(token.get().tokenIsValid());
}
@Test
void readsExistingTokenFromCookies() {
HttpServletRequest httpRequest = new FakeHttpServletRequest(
new Cookie[] {
new Cookie(
"jwt",
JwtRefreshToken
.create(new FakeAuthentication("tester@gmail.com", null), key)
.value()
)
}
);
Optional<JwtRefreshToken> token = JwtRefreshToken.existing(httpRequest, key);
assertTrue(token.isPresent());
assertEquals("tester@gmail.com", token.get().email());
assertTrue(token.get().expirationDate().after(new Date(System.currentTimeMillis())));
assertTrue(token.get().tokenIsValid());
}
@ParameterizedTest
@ValueSource(strings = {
"",
"short",
"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0ZXIiLCJpYXQiOjE3MDUxNjg1OTQsI" +
"mV4cCI6MTcwNTE2OTE5NH0.0BKl5k3IJ7PlfpuuPjRO0qqb7_IVal6IqqjvkPLm3Yhk" +
"rqsn5SMYtkNqN321ChofuxvhhfdfJdROuh10_hyflg" // expired token
})
void rejectsInvalidToken(String jwt) {
assertFalse(
JwtRefreshToken
.existing(new FakeHttpServletRequest(new Cookie[] { new Cookie("jwt", jwt) }), key)
.get()
.tokenIsValid()
);
}
@Test
void rejectsInvalidToken() {
assertFalse(JwtRefreshToken.existing(new FakeHttpServletRequest((Cookie[]) null), key).isPresent());
}
}
and make the tests pass by creating a refresh token class:
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SecurityException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import javax.crypto.SecretKey;
import java.util.Arrays;
import java.util.Date;
import java.util.Optional;
public final class JwtRefreshToken {
private static final Logger logger = LoggerFactory.getLogger(JwtRefreshToken.class);
private final String token;
private final SecretKey key;
public static JwtRefreshToken create(Authentication authentication, SecretKey key) {
int expirationMs = 1000 * 60 * 60 * 24 * 14; // 14 days
return new JwtRefreshToken(
Jwts.builder()
.subject(((User) authentication.getPrincipal()).getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key)
.compact(),
key
);
}
public static Optional<JwtRefreshToken> existing(HttpServletRequest request, SecretKey key) {
return parseJwt(request).map(token -> new JwtRefreshToken(token, key));
}
private JwtRefreshToken(String token, SecretKey key) {
this.token = token;
this.key = key;
}
private static Optional<String> parseJwt(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
return Arrays.stream(cookies)
.filter(cookie -> "jwt".equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue);
}
public String value() {
return token;
}
public String email() {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public Date expirationDate() {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration();
}
public boolean tokenIsValid() {
try {
Jwts.parser().verifyWith(key).build().parse(token);
return true;
} catch (ExpiredJwtException | MalformedJwtException | SecurityException | IllegalArgumentException e) {
logger.error("Failed to validate JWT token: {}", e.getMessage());
return false;
}
}
}
With that, the major changes for refresh token functionality in Security lib are done. Here is the full commit to see minor changes: https://github.com/TheProgrammersDiary/Security/commit/7f6041e7d7d1645ae66da1c9261b2f3b4ef6c649.
Since this is microservice architecture and our two services are coupled with this lib we will need to update them.
As said with refresh token feature authorization will be made by short lived token. So the most important change in Post microservice is like this in PostController class:
As you see blacklistedJwtTokenRepository is no longer used. We don’t need to blacklist tokens on user logout since our short-lived token will only be active for max of 10 minutes and we delete the token from frontend on logout.
If you want to see some trivial changes here they are: https://github.com/TheProgrammersDiary/Post/commit/3d65368594fa74d1b195d23f050b9be8bbbfd7b8 [Note from the future: I apologize for mixing up some of unrelated changes. This was made before I have read Kent Beck’s book Tidy first which now helps me structure the commits better.]
In Blog microservice we will need to workout more, since we will not only be received short-lived token but also a refresh token. So let’s add few tests:
@Test
void refreshesToken() {
JwtRefreshToken refreshToken = JwtRefreshToken.create(
new FakeAuthentication("user@gmail.com", null), jwtKey.value()
);
String jwtShortLivedToken = assertDoesNotThrow(
() -> controller.refreshJwt(
new FakeHttpServletRequest(
new Cookie[]{
new Cookie("jwt", refreshToken.value())
}
)
).getBody()
);
assertNotNull(jwtShortLivedToken);
}
@Test
void logsOut() {
mother.loginNewUser();
HttpServletResponse response = new FakeHttpServletResponse();
controller.logout(response);
assertTrue(response.getHeader("Set-Cookie").contains("jwt=; Path=/; Max-Age=0;"));
}
We test if user is able to receive new short lived token by calling /refresh endpoint and if logging out clears the refresh token in HTTP only cookie.
[Note from the future: Whoops! There is no tests for failing to refresh the token if the token is invalid. I hope we don’t have a security issue.]
Here is the /refresh endpoint:
@PostMapping("/refresh")
ResponseEntity<String> refreshJwt(HttpServletRequest request) {
return JwtRefreshToken
.existing(request, key.value())
.map(refreshToken -> ResponseEntity.ok(
JwtShortLivedToken.create(refreshToken, key.value()).value()
))
.orElseThrow(() -> new UnauthorizedException("Refresh token is not valid."));
}
We also need to change the login endpoint:
and OAuth endpoint:
Here are again only most important changes. Full list of changes here: https://github.com/TheProgrammersDiary/Blog/commit/a42dbe56108913a1ed42eea4f888cfc075b33fff
Finally, let’s update our Frontend to use the refresh token:
'se client';
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { blogUrl } from "../next.config";
import { setJwt } from "../redux/actions";
export default function RefreshToken({ children }: { children: React.ReactNode }): React.ReactNode {
const dispatch = useDispatch();
useEffect(() => {
const effect = async () => {
try {
const response = await fetch(blogUrl + "/users/refresh", {
method: "POST",
credentials: "include",
});
if (response.ok) {
const jwt = await response.text();
dispatch(setJwt(jwt));
}
} catch (error) {
}
};
effect();
}, [dispatch]);
return <>{children}</>;
}
The component is used in the layout like this:
This ensures that then the page is reloaded the short-lived token is not gone. Since there are lots of decentralized changes for storing short-lived JWT token please refer to https://github.com/TheProgrammersDiary/Frontend/commit/b6be14f200bca6183b690591b9074d550f64d3e0 for full frontend changes.
With that refresh token functionality is created.
Note that with this change double-submit CSRF is not needed so I removed the functionality which was added in https://hashnode.programmersdiary.com/implementing-double-submit-csrf-in-microservices. Give yourself a brain workout why we don’t need it now.
One more funny thing. I have also implemented a security alert sent to email in case somebody logged in while the refresh token was not expired. Here is the implementation: https://github.com/TheProgrammersDiary/Blog/commit/3d460a396e2a0bf00f565e15b089f55fea5fd414. When user logs in database creates entry in table stores encrypted refresh token, loginDate, expirationDate and logoutDate. When user logs out the logoutDate gets filled. If user or someone else logs in again without logging out (or refresh token expiring) the service will detect a still valid token and send an email to user alerting of a possible data breach. [Note from the future: My reasoning was this: if refresh token is still not expired that means you don’t need to login. But I did not account that user could use private browser window or login from another computer or phone (well maybe I did, but did not think it can be done frequently and that the alerts will be very annoying). While I was testing I received lots of alerts. This functionality was eventually removed.]
Enough funny stuff. Let’s call it a day.
—————
Thanks for reading.
The project logged in this diary is open source, so if you would like to code or suggest changes, please visit https://github.com/TheProgrammersDiary.
Next part is coming soon.
Subscribe to my newsletter
Read articles from Evaldas Visockas directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
