I use Java 21, Jakarta EE 11 with Wildfly 34 Preview (Jakarta EE compatible) on the server side. I use HTML, CSS and plain Javascript with no third party library on the client side. I've tried to find a working example, the most helpful source I've found is in Wildfly's quickstart.
However, the client code doesn't cover my case even though it's useful to test in command line. How should I use the JSON web token in Javascript and HTML? What are the good practices to achieve that in terms of security? I use HTTPS and I plan to sign and encrypt the tokens with an asymmetric algorithm (RSA, elliptic curves), the secret key would remain on the server, the public key would be sent by the server to the client, only the server would be able to decrypt the tokens as long as the secret key isn't compromised.
For now, this is what I have done so far: I use an HTML form, I can either use HTTP POST and a form action to pass the username and the encrypted password, both are URL encoded, the server knows the referrer and uses a redirection with Response.seeOther​(URI) to implement the POST/redirect/GET pattern or I can use the attribute "onsubmit" of the form to call a Javascript method that performs an asynchronous fetch request using the HTTP POST method with the fetch API and returns false to prevent the validation, the server doesn't use a redirect, it returns the signed and encrypted JSON web token with an expiry date on success. I currently use the second approach, are they equivalent in terms of security?
Now that I have the JSON web token, I have to use the fetch API to put this token into the HTTP header each time I have to access to a potentially protected resource: Authorization: Bearer <JWT>. If things go well, the server will reply HTTP 200 OK and will return the resource in the body.
I plan to store the token in the client side locally in the Javascript code (the main part of my webapp is in a single web page), I will use neither localStorage nor sessionStorage nor a secure cookie. I'll probably use localStorage, sessionStorage or a service worker where I need to handle several pages as in this example, this other one and this last one.
Am I on the good road? Sorry for the newbie question, it's the very first time I use token based authentication.
Please note that I won't use Spring, JQuery, Vue, Angular, etc. I need to understand the concepts and their implications in terms of security.
P.S: I'll have to implement mostly the same kind of authentication with Jetty >= 12. Therefore, Jetty specific solutions are welcome too.
What are the good practices to achieve that in terms of security?
Set JWT as a HttpOnly cookie. The best security measure is to not allow JavaScript to access it in first place. Simply pass that cookie during JS fetch calls and have server figure out it and let client rely on response statuses. In other words, do not use JS local/session storage at all and use the API endpoint response status as a single source of truth.
I use Java 21, Jakarta EE 11 with Wildfly 34 Preview
WildFly already provides MicroProfile JWT Auth out the box next to Jakarta EE. In Maven terms, that are the following dependencies:
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-web-api</artifactId>
<version>11.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.jwt</groupId>
<artifactId>microprofile-jwt-auth-api</artifactId>
<version>2.1</version>
<scope>provided</scope>
</dependency>
You can activate the MicroProfile JWT Auth using @LoginConfig("MP-JWT")
:
@ApplicationPath("/api")
@LoginConfig(authMethod="MP-JWT")
public class RestConfig extends Application {}
You see I also activated Jakarta REST as well on the path /api
via @ApplicationPath
. To be clear, you do not need any additional configuration unlike mentioned in the WildFly QuickStart link you found. You can keep the standalone.xml
default as it is. This way you'll be able to effortlessly switch to any other JEE+MP capable server such as Payara and OpenLiberty without any code/configuration changes.
You can use jakarta.annotation.security
annotations such as @RolesAllowed
to restrict access by roles. However as WildFly ships with JBoss RESTEasy as Jakarta REST implementation, you need an additional web.xml
context param resteasy.role.based.security
with value of true
to explicitly instruct it to respect these annotations, as they are by default ignored in favor of having these on EJBs instead. This is not necessary when using e.g. Eclipse Jersey as Jakarta REST implementation.
<?xml version="1.0" encoding="UTF-8"?>
<web-app
xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
version="6.0"
>
<context-param>
<param-name>resteasy.role.based.security</param-name>
<param-value>true</param-value>
</context-param>
<security-role>
<role-name>admin</role-name>
</security-role>
<security-role>
<role-name>user</role-name>
</security-role>
</web-app>
You see I also declared two example security roles there as well.
In order to activate MicroProfile JWT Auth, you'll need a src/main/resources/META-INF/microprofile-config.properties
file with the following entries:
mp.jwt.verify.publickey.location=META-INF/publickey.pem
mp.jwt.verify.publickey.algorithm=ES256
mp.jwt.verify.issuer=yourJwtIssuerName
mp.jwt.verify.token.age=600
mp.jwt.token.header=Cookie
mp.jwt.token.cookie=yourJwtCookieName
Clearly, yourJwtIssuerName
and yourJwtCookieName
are free to your choice. When omitting the mp.jwt.verify.publickey.algorithm
, then it defaults to RS256
, RSA-256. I'm just explicitly using ES256
because you requested so. This example configuration expects the publickey.pem
and privatekey.pem
files in the same src/main/resources/META-INF
folder.
Here's an example Jakarta REST resource which performs login and logout:
@Path("/auth")
public class AuthResource {
private PrivateKey privateKey;
@Inject @ConfigProperty(name = "mp.jwt.verify.issuer")
private String issuer;
@Inject @ConfigProperty(name = "mp.jwt.verify.token.age")
private Integer tokenAge;
@Inject @ConfigProperty(name = "mp.jwt.token.cookie")
private String cookieName;
@PostConstruct
public void init() {
try {
privateKey = KeyUtils.readPrivateKey("META-INF/privatekey.pem", SignatureAlgorithm.ES256);
}
catch (Exception e) {
throw new IllegalStateException(e);
}
}
public record LoginRequest(String username, String password) {}
public record LoginResponse(String username) {}
@Path("/login") @POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response login(LoginRequest request, @Context HttpServletRequest httpRequest) {
// TODO: verify against users database instead of a hardcoded user!
if (!("admin".equals(request.username()) && "P4ssW0rd!".equals(request.password()))) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
var username = request.username(); // TODO: get from users database!
var groups = Set.of("admin", "user"); // TODO: get from users database!
var token = createToken(username, groups);
var cookie = createCookie(httpRequest, token);
return Response.ok(new LoginResponse(username)).cookie(cookie).build();
}
@Path("/logout") @POST
public Response logout(@Context HttpServletRequest httpRequest) {
var cookie = createCookie(httpRequest, null);
return Response.ok().cookie(cookie).build();
}
private String createToken(String username, Set<String> groups) {
var now = Instant.now();
return Jwt
.subject(username)
.claim("groups", groups)
.issuer(issuer)
.issuedAt(now)
.expiresAt(now.plusSeconds(tokenAge))
.sign(privateKey);
}
private NewCookie createCookie(HttpServletRequest httpRequest, String token) {
return new NewCookie.Builder(cookieName)
.value(token)
.path(httpRequest.getContextPath())
.secure(httpRequest.isSecure())
.httpOnly(true)
.sameSite(NewCookie.SameSite.STRICT)
.maxAge(token == null ? 0 : tokenAge)
.build();
}
}
You see that it basically creates a new token on login and sets it as a HttpOnly Strict cookie with the same max age as the JWT expiration time. The logout basically removes that cookie. Also please note the TODO
s in login method, you'll need to implement your own logic to find the requested user and validate the password and extract the username and groups from there.
Oh, you need one additional runtime dependency in order to get this code with the useful io.smallrye.jwt.build.Jwt
and io.smallrye.jwt.util.KeyUtils
helper classes to compile and run against WildFly:
<dependency>
<groupId>io.smallrye</groupId>
<artifactId>smallrye-jwt-build</artifactId>
<version>4.3.1</version>
</dependency>
The rest of the dependencies are already provided by WildFly via stock Java SE 17+, Jakarta EE 11 as well as MicroProfile JWT Auth.
In JavaScript side, you need to create a helper function to pass all active cookies to the fetch call. This can be achieved by adding the credentials:'same-origin'
option:
async function apiCall(path, options = {}) {
return fetch(`api/${path}`, {
...options,
credentials: 'same-origin'
});
}
To login, run this basic kickoff example of a plain vanilla HTML/CSS/JS based SPA:
<div id="loginPage" class="page">
<form id="loginForm">
<label>Username: <input type="text" name="username" required></label>
<label>Password: <input type="password" name="password" required></label>
<button type="submit">Login</button>
</form>
<div id="loginMessage" class="message"></div>
</div>
<div id="mainPage" class="page">
...
</div>
.page { display: none; }
.page.active { display: block; }
label { display: block; margin: .25em 0; }
const loginPage = document.getElementById('loginPage');
const loginForm = document.getElementById('loginForm');
const loginMessage = document.getElementById('loginMessage');
const mainPage = document.getElementById('mainPage');
async function login(event) {
event.preventDefault();
loginMessage.textContent = '';
try {
const response = await apiCall('auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(loginForm).entries()))
});
if (!response.ok) {
throw new Error('Invalid login');
}
show(mainPage);
}
catch (err) {
loginMessage.textContent = err.message;
}
}
loginForm.addEventListener('submit', login);
Along with this mini router:
function show(page) {
document.querySelectorAll('.page.active').forEach(page => page.classList.remove('active'));
page.querySelectorAll('form').forEach(form => form.reset());
page.querySelectorAll('.message').forEach(message => message.textContent = '');
page.classList.add('active');
}
To logout, run:
<div id="mainPage">
...
<button id="logoutButton">Logout</button>
</div>
const logoutButton = document.getElementById('logoutButton');
async function logout() {
await apiCall('auth/logout', { method: 'POST' });
show(loginPage);
}
logoutButton.addEventListener('click', logout);
To retrieve user info, add this protected endpoint:
@Path("/user")
public class UserResource {
public record UserInfoResponse(String username, Set<String> groups, long expirationTime) {}
@GET
@Path("/info")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("user")
public Response info(@Context jakarta.ws.rs.core.SecurityContext context) {
var jwt = (JsonWebToken) context.getUserPrincipal();
return Response.ok(new UserInfoResponse(jwt.getName(), jwt.getGroups(), jwt.getExpirationTime())).build();
}
}
Note the @RolesAllowed
annotation here. It'll return 401
when the user principal doesn't have the user
role, which is in turn derived from the groups
claim of the JWT. And it'll return 403
when the user isn't logged-in in first place.
You can use it during app init to verify if user is still logged in:
const response = await apiCall('user/info').catch(() => null);
show(response?.ok ? mainPage : loginPage);
P.S: I'll have to implement mostly the same kind of authentication with Jetty >= 12. Therefore, Jetty specific solutions are welcome too.
Unfortunately, implementing this while targeting barebones servletcontainers is going to be complex. You'll need a boatload of additional dependencies and manual configuration classes/files primarily because these barebones servletcontainers don't support JACC in first place (i.e. MicroProfile JWT Auth won't ever work). I strongly suggest to look at Quarkus instead of Jetty/Tomcat/etc in case you merely have a "light-weight cloud-ready executable" in mind. Quarkus natively supports MicroProfile JWT Auth out the box, leaving basically nothing to manually install/configure.