Fork me on GitHub

JWT Authority Tutorial

This tutorial demonstrates providing a JWT authority server.

The example used in this tutorial has the end points:

  • POST /login to login to retrieve the JWT refresh and access tokens
  • POST /refresh to refresh the access token
  • GET /jwks.json to retrieve the JWT keys as per JWKS

Tutorial Source

WoOF configuration

The configuration is the following:

JwtAuthorityHttpServer screen shot.

with the following objects:

<objects>

	<managed-object source="net.officefloor.web.jwt.authority.JwtAuthorityManagedObjectSource">
		<property name="identity.class" value="net.officefloor.tutorial.jwtauthorityhttpserver.Identity" />
	</managed-object>

	<managed-object source="net.officefloor.tutorial.jwtauthorityhttpserver.InMemoryJwtAuthorityRepositoryManagedObjectSource" />

</objects>

JWT Authority

As per the objects above, the JwtAuthorityManagedObjectSource provides the JwtAuthority. This provides the necessary means for creating and refreshing the tokens for a JWT authority server.

This requires persisting the keys and depends on a JwtAuthorityRepository to provide this persistence. This allows a cluster of JWT authority servers sharing the same persistent storage of keys.

Login

Login is specific to the JWT authentication server. It can use it's own store of credentials. It may use third party Open ID servers. Once authenticated, then the tokens may be created:

	public static final String REFRESH_TOKEN_COOKIE_NAME = "RefreshToken";

	@Data
	@HttpObject
	@RequiredArgsConstructor
	@AllArgsConstructor
	public static class Credentials {
		private String username;
		private String password;
	}

	@Data
	@HttpObject
	@AllArgsConstructor
	@RequiredArgsConstructor
	public static class Token {
		private String token;
	}

	public void login(Credentials credentials, JwtAuthority<Identity> authority, ObjectResponse<Token> response,
			ServerHttpConnection connection) {

		// Mock authentication
		// (production solution would restrict tries and check appropriate user store)
		// (or use potential OpenId third party login)
		if ((credentials.getUsername() == null) || (!credentials.getUsername().equals(credentials.getPassword()))) {
			throw new HttpException(HttpStatus.UNAUTHORIZED);
		}

		// Create the refresh token from identity
		Identity identity = new Identity(credentials.username);
		RefreshToken refreshToken = authority.createRefreshToken(identity);

		// Provide refresh token
		connection.getResponse().getCookies().setCookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken.getToken())
				.setHttpOnly(true).setSecure(true).setExpires(Instant.ofEpochSecond(refreshToken.getExpireTime()));

		// Create the access token
		Claims claims = createClaims(credentials.username);
		AccessToken accessToken = authority.createAccessToken(claims);

		// Send response
		response.send(new Token(accessToken.getToken()));
	}

	private static Claims createClaims(String username) {

		// Mock claims
		// (claim information should be pulled from user store)
		String[] roles = new String[] { "tutorial" };

		// Provide random value (so access tokens are different)
		// (not necessary but due to speed of tests, gets same access token)
		int randomValue = ThreadLocalRandom.current().nextInt();

		// Return the claims
		return new Claims(username, randomValue, roles);
	}

Refreshing Access Tokens

Access tokens, as per JWT, should be short lived. Refresh tokens maintain the length of the session for the user. Using a refresh token, access tokens can be obtained until the refresh token expires:

	public void refreshAccessToken(ServerHttpConnection connection, JwtAuthority<Identity> authority,
			ObjectResponse<Token> response) {

		// Obtain the refresh token
		HttpRequestCookie cookie = connection.getRequest().getCookies().getCookie(REFRESH_TOKEN_COOKIE_NAME);
		if (cookie == null) {
			throw new HttpException(HttpStatus.UNAUTHORIZED);
		}
		String refreshToken = cookie.getValue();

		// Obtain the identity from refresh token
		Identity identity = authority.decodeRefreshToken(refreshToken);

		// Create a new access token
		Claims claims = createClaims(identity.getId());
		AccessToken accessToken = authority.createAccessToken(claims);

		// Send refreshed access token
		response.send(new Token(accessToken.getToken()));
	}

Once the refresh token is expired, the user is likely required to re-authenticate. However, this again is application specific, as refresh tokens may be recreated also.

JWKS publishing

RFC 7517 defines a format for publishing keys. The tutorial uses the default JwksPublishSectionSource that adheres to this format to publish keys.

Testing

The following shows the ease of using the JWT authority:

public class JwtAuthorityHttpServerTest {

	@RegisterExtension
	public MockWoofServerExtension server = new MockWoofServerExtension();

	private String refreshToken;

	private String accessToken;

	@Test
	public void login() throws Exception {

		// Undertake login
		Credentials credentials = new Credentials("daniel", "daniel");
		MockWoofResponse response = this.server
				.send(MockWoofServer.mockJsonRequest(HttpMethod.POST, "/login", credentials).secure(true));
		assertEquals(200, response.getStatus().getStatusCode(), "Should be successful");

		// Extract the refresh token
		WritableHttpCookie cookie = response.getCookie(JwtTokens.REFRESH_TOKEN_COOKIE_NAME);
		assertNotNull(cookie, "Should have refresh token");

		// Extract the access token
		Token accessToken = response.getJson(200, Token.class);
		assertNotNull(accessToken.getToken(), "Should have access token");

		// Capture for other tests
		this.refreshToken = cookie.getValue();
		this.accessToken = accessToken.getToken();
	}

	@Test
	public void refreshAccessToken() throws Exception {

		// Undertake login to obtain refresh token
		this.login();

		// Attempt to obtain access token without refresh token
		MockWoofResponse response = this.server
				.send(MockWoofServer.mockRequest("/refresh").secure(true).method(HttpMethod.POST));
		assertEquals(401, response.getStatus().getStatusCode(), "Should not be authorised");

		// Obtain new access token with refresh token
		response = this.server.send(MockWoofServer.mockRequest("/refresh").secure(true).method(HttpMethod.POST)
				.cookie(JwtTokens.REFRESH_TOKEN_COOKIE_NAME, this.refreshToken));
		assertEquals(200, response.getStatus().getStatusCode(), "Should be successful");

		// Extract the access token
		Token token = response.getJson(200, Token.class);
		assertNotNull(token.getToken(), "Should have access token");
		assertNotEquals(this.accessToken, token.getToken(), "Should be new access token");
	}

	@Test
	public void jwksPublishing() throws Exception {

		// Publish keys via JWKS
		MockWoofResponse response = this.server.send(MockWoofServer.mockRequest("/jwks.json").secure(true));

		// Should have two keys available (one active and one in future rotation)
		JwksKeys keys = response.getJson(200, JwksKeys.class);
		assertEquals(2, keys.getKeys().size(), "Incorrect number of keys");
	}

	@Data
	public static class JwksKeys {
		private List<RsaJwksKey> keys;
	}

	@Data
	public static class RsaJwksKey {

		// As per RFC 7517 for RSA public key
		private String kty;
		private String n;
		private String e;

		// Additional to allow rotating keys
		private Long nbf; // epoch start time in seconds
		private Long exp; // epoch expire time in seconds
	}

}

Next

The next tutorial covers combining JWT security and JWT authority server together for smaller applications.