Delegating JWT Validation for Greater Flexibility
Java decoupled solution for validating JSON Web Tokens, using callbacks and thus promoting decoupling and flexibility.
Join the DZone community and get the full member experience.
Join For FreeIn my opinion, the purpose of all software applications that have been created so far, are being and will be developed should primarily be to make humans' day-to-day activities easier to fulfill. Humans are the most valuable creations, and software applications are great tools that at least could be used by them.
Nowadays, almost every software product exchanges data with at least one other peer software product, which results in huge amounts of data flowing among them. Usually, a request from one product to another needs to pass a set of preconditions before it is considered acceptable and trustworthy.
The purpose of this article is to showcase a simple and flexible yet efficient and decoupled solution for validating such prerequisites.
Setting the Stage
Let's consider the next simple and general use case:
- Service Provider and Client are two applications exchanging data.
- The Client calls the Service Provider.
- The operation invoked is executed only after the Client is identified by the Service Provider.
- The Client identification is done via a token included in the request and validated by the Service Provider.
As part of this article, a small Java project is built, and while doing this, the token validation strategy is explained.
As JSON Web Tokens (JWT) are widely used nowadays, especially when products need to identify among others, JWT validation was chosen as the concrete implementation. According to RFC7519, a JWT is a compact, encoded, URL-safe string representation of a JSON message.
Very briefly, a JWT has three sections - header, payload, and signature.
Encoded, it is a string with three sections, separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoY2QiLCJpc3MiOiJpc3N1ZXIiLCJhdWQiOiJhdWRpZW5jZSIsImV4cCI6MTY1MDU0OTg1OH0.rbs6NqNw9KZ4IGuCOjdPpdJqMswTXHn7oNADCzlQHL8
Decoded, it is in JSON format and thus, more readable:
Header - algorithm and type
{
"alg": "HS256",
"typ": "JWT"
}
Payload - data (claims)
{
"sub": "hcd",
"iss": "issuer",
"aud": "audience",
"exp": 1650549858
}
Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
the-256-bit-secret
)
These pieces of information are enough to have an idea about JWTs; let's start developing.
Initial Implementation
The sample project is built with Java 17 and Maven. The dependencies are very few:
- io.jsonwebtoken / jjwt - for JWT signing and verification
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
For exploring other available libraries, check https://jwt.io/libraries?language=Java.
- JUnit 5 and Mockito - for unit testing, the implementation
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.5.1</version>
<scope>test</scope>
</dependency>
JWT generation and verification is implemented using the following interface:
public interface JwtManager {
String generate(String sub, String iss, String aud);
boolean isValid(String jwt, String iss, String aud);
}
The former method uses the provided parameters (subject, issuer, and audience) to create and sign a valid a JWT. The latter checks whether the jwt is valid or not, using the provided issuer and audience.
The goal is to create an implementation and make the following test pass.
class JwtManagerTest {
private String iss;
private String aud;
private String jwt;
private JwtManager jwtManager;
@BeforeEach
void setUp() {
jwtManager = new JwtManagerImpl();
iss = "issuer";
aud = "audience";
jwt = jwtManager.generate("hcd", iss, aud);
Assertions.assertNotNull(jwt);
}
@Test
void isValid_coupled() {
final boolean valid = jwtManager.isValid(jwt, iss, aud);
Assertions.assertTrue(valid);
}
}
By leveraging the Jwts builder, the sub, iss, and aud are set, the token is configured to expire after 1 minute, and moreover, it is signed using the Service Provider secret key.
public String generate(String sub, String iss, String aud) {
final Date exp = new Date(System.currentTimeMillis() + 60_000);
return Jwts.builder()
.setSubject(sub)
.setIssuer(iss)
.setAudience(aud)
.setExpiration(exp)
.signWith(SignatureAlgorithm.HS256, "s1e2c3r4e5t6k7e8y9")
.compact();
}
In the other direction, the token is parsed using the same secret key, and if it hasn't expired yet, the payload claims are extracted.
public boolean isValid(String jwt, String iss, String aud) {
Claims body;
try {
body = Jwts.parser()
.setSigningKey("s1e2c3r4e5t6k7e8y9")
.parseClaimsJws(jwt)
.getBody();
} catch (JwtException e) {
return false;
}
return iss.equals(body.getIssuer()) &&
aud.equals(body.getAudience());
}
This is straightforward. Nevertheless, a custom assumption is made in addition to the standard (mandatory) token validations.
"A valid token is acceptable if the issuer and audience conform to specific values."
Basically, this is the plot of the article - how to implement the custom verification for a valid token, as flexible as possible.
If we run the test, it passes, and the implementation is correct, but unfortunately, not flexible enough.
At some point, the Service Provider that validates the Client's request changes the assumption that has been previously made. This obviously impacts isValid()
method, whose implementation should to be changed.
Final Implementation
It would be good if whenever the Service Provider makes a change to these preconditions, the standard part of the token validation remains in place. Then the code shall be flexible enough to allow deciding on the custom validation assumptions as late as possible. In order to accommodate this, the code needs to be refactored.
What's been stated it's enclosed in the next interface (even better, @FunctionalInterface
).
@FunctionalInterface
public interface ValidationStrategy {
boolean isValid(Claims body);
}
The strategy is implemented, and the last two lines in the isValid()
method are moved in the newly implemented strategy. Moreover, we may assume that this is the default validation strategy of the Service Provider.
public class DefaultValidationStrategy implements ValidationStrategy {
private final String iss;
private final String aud;
public DefaultValidationStrategy(String iss, String aud) {
this.iss = iss;
this.aud = aud;
}
@Override
public boolean isValid(Claims body) {
return iss.equals(body.getIssuer()) &&
aud.equals(body.getAudience());
}
}
The former method is first deprecated and soon replaced by the new implementation below.
public interface JwtManager {
String generate(String sub, String iss, String aud);
/**
* @deprecated in favor of {@link #isValid(String, ValidationStrategy)}
*/
@Deprecated(forRemoval = true)
boolean isValid(String jwt, String iss, String aud);
boolean isValid(String jwt, ValidationStrategy strategy);
}
Basically, the new method delegates to the ValidationStrategy
callback. Delegation (in programming) means exactly this; one entity passes something to another entity.
public boolean isValid(String jwt, ValidationStrategy strategy) {
Claims body;
try {
body = Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(jwt)
.getBody();
} catch (JwtException e) {
return false;
}
return strategy.isValid(body);
}
In use, the validation is performed as in the following unit test.
@Test
void isValid_looselyCoupled_defaultStrategy() {
final boolean valid = jwtManager.isValid(jwt,
new DefaultValidationStrategy(iss, aud));
Assertions.assertTrue(valid);
}
With these modifications, the code is flexible enough to accommodate potential changes in the validation strategy. For instance, if the Service Provider decides to check only the issuer, this can be achieved without needing to modify the code that handles the JWT standard part.
@Test
void isValid_looselyCoupled_customStrategy() {
final boolean valid = jwtManager.isValid(jwt,
body -> iss.equals(body.getIssuer()));
Assertions.assertTrue(valid);
}
If we have a look at the previous unit test, we see how handful it is to pass the ValidationStrategy
using lambda. Also, I suppose it's clear the reason for making the ValidationStrategy
a @FunctionalInterface
from the beginning.
In this article, a decoupled solution for validating JSON Web Tokens was implemented. This solution uses callbacks and thus promotes decoupling and flexibility.
Published at DZone with permission of Horatiu Dan. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments