Authenticating web API requests with JWT

JSON Web Tokens (RFC 7519) is a standard for issuing JSON-encoded tokens containing claims, typically identifying a user and permissions, although claims can be just about anything.

A token is issued by a server and it is signed with the server key. A client can send a token back along with subsequent requests: both the client and the server can check that a token is authentic and unaltered.

Warning
While a JWT token is signed, its content is not encrypted. It must be transported over a secure channel (e.g., HTTPS) and it should never have sensitive data as a claim (e.g., passwords, private API keys, etc).

Adding JWT support

We start by adding the vertx-auth-jwt module to the Maven dependencies:

  1. <dependency>
  2. <groupId>io.vertx</groupId>
  3. <artifactId>vertx-auth-jwt</artifactId>
  4. </dependency>

We will have a JCEKS keystore to hold the keys for our tests. Here is how to generate a keystore.jceks with the suitable keys of various lengths:

  1. keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA256 -keysize 2048 -alias HS256 -keypass secret
  2. keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA384 -keysize 2048 -alias HS384 -keypass secret
  3. keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA512 -keysize 2048 -alias HS512 -keypass secret
  4. keytool -genkey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg RSA -keysize 2048 -alias RS256 -keypass secret -sigalg SHA256withRSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
  5. keytool -genkey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg RSA -keysize 2048 -alias RS384 -keypass secret -sigalg SHA384withRSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
  6. keytool -genkey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg RSA -keysize 2048 -alias RS512 -keypass secret -sigalg SHA512withRSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
  7. keytool -genkeypair -keystore keystore.jceks -storetype jceks -storepass secret -keyalg EC -keysize 256 -alias ES256 -keypass secret -sigalg SHA256withECDSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
  8. keytool -genkeypair -keystore keystore.jceks -storetype jceks -storepass secret -keyalg EC -keysize 256 -alias ES384 -keypass secret -sigalg SHA384withECDSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
  9. keytool -genkeypair -keystore keystore.jceks -storetype jceks -storepass secret -keyalg EC -keysize 256 -alias ES512 -keypass secret -sigalg SHA512withECDSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360

We need to install a JWT token handler on API routes:

  1. Router apiRouter = Router.router(vertx);
  2. JWTAuth jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions()
  3. .setKeyStore(new KeyStoreOptions()
  4. .setPath("keystore.jceks")
  5. .setType("jceks")
  6. .setPassword("secret")));
  7. apiRouter.route().handler(JWTAuthHandler.create(jwtAuth, "/api/token"));

We pass /api/token as a parameter for the JWTAuthHandler object creation to specify that this URL shall be ignored. Indeed, this URL is being used to generate new JWT tokens:

  1. apiRouter.get("/token").handler(context -> {
  2. JsonObject creds = new JsonObject()
  3. .put("username", context.request().getHeader("login"))
  4. .put("password", context.request().getHeader("password"));
  5. auth.authenticate(creds, authResult -> { (1)
  6. if (authResult.succeeded()) {
  7. User user = authResult.result();
  8. user.isAuthorized("create", canCreate -> { (2)
  9. user.isAuthorized("delete", canDelete -> {
  10. user.isAuthorized("update", canUpdate -> {
  11. String token = jwtAuth.generateToken( (3)
  12. new JsonObject()
  13. .put("username", context.request().getHeader("login"))
  14. .put("canCreate", canCreate.succeeded() && canCreate.result())
  15. .put("canDelete", canDelete.succeeded() && canDelete.result())
  16. .put("canUpdate", canUpdate.succeeded() && canUpdate.result()),
  17. new JWTOptions()
  18. .setSubject("Wiki API")
  19. .setIssuer("Vert.x"));
  20. context.response().putHeader("Content-Type", "text/plain").end(token);
  21. });
  22. });
  23. });
  24. } else {
  25. context.fail(401);
  26. }
  27. });
  28. });
  1. We expect login and password information to have been passed through HTTP request headers, and we authenticate using the authentication provider of the previous section.

  2. Upon success we can query for roles.

  3. We generate a token with username, canCreate, canDelete and canUpdate claims.

Each API handler method can now query the current user principal and claims. Here is how the apiDeletePage does it:

  1. private void apiDeletePage(RoutingContext context) {
  2. if (context.user().principal().getBoolean("canDelete", false)) {
  3. int id = Integer.valueOf(context.request().getParam("id"));
  4. dbService.deletePage(id, reply -> {
  5. handleSimpleDbReply(context, reply);
  6. });
  7. } else {
  8. context.fail(401);
  9. }
  10. }

Using JWT tokens

To illustrate how to work with JWT tokens, let’s create a new one for the root user:

  1. $ http --verbose --verify no GET https://localhost:8080/api/token login:root password:w00t
  2. GET /api/token HTTP/1.1
  3. Accept: */*
  4. Accept-Encoding: gzip, deflate
  5. Connection: keep-alive
  6. Host: localhost:8080
  7. User-Agent: HTTPie/0.9.8
  8. login: root
  9. password: w00t
  10.  
  11.  
  12.  
  13. HTTP/1.1 200 OK
  14. Content-Length: 242
  15. Content-Type: text/plain
  16. Set-Cookie: vertx-web.session=8cbb38ac4ce96737bfe31cc0ceaae2b9; Path=/
  17.  
  18. eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8=

The response text is the token value and shall be retained.

We can check that performing an API request without the token results in a denial of access:

  1. $ http --verbose --verify no GET https://localhost:8080/api/pages
  2. GET /api/pages HTTP/1.1
  3. Accept: */*
  4. Accept-Encoding: gzip, deflate
  5. Connection: keep-alive
  6. Host: localhost:8080
  7. User-Agent: HTTPie/0.9.8
  8.  
  9.  
  10.  
  11. HTTP/1.1 401 Unauthorized
  12. Content-Length: 12
  13.  
  14. Unauthorized

Sending a JWT token along with a request is done using a Authorization HTTP request header where the value must be Bearer <token value>. Here is how to fix the API request above by passing the JWT token that had been issued to us:

  1. $ http --verbose --verify no GET https://localhost:8080/api/pages Authorization:'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8='
  2. GET /api/pages HTTP/1.1
  3. Accept: */*
  4. Accept-Encoding: gzip, deflate
  5. Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8=
  6. Connection: keep-alive
  7. Host: localhost:8080
  8. User-Agent: HTTPie/0.9.8
  9.  
  10.  
  11.  
  12. HTTP/1.1 200 OK
  13. Content-Length: 99
  14. Content-Type: application/json
  15. Set-Cookie: vertx-web.session=0598697483371c7f3cb434fbe35f15e4; Path=/
  16.  
  17. {
  18. "pages": [
  19. {
  20. "id": 0,
  21. "name": "Hello"
  22. },
  23. {
  24. "id": 1,
  25. "name": "Apple"
  26. },
  27. {
  28. "id": 2,
  29. "name": "Vert.x"
  30. }
  31. ],
  32. "success": true
  33. }

Adapting the API test fixture

The ApiTest class needs to be updated to support JWT tokens.

We add a new field for retrieving the token value to be used in test cases:

  1. private String jwtTokenHeaderValue;

We add first step to retrieve a JTW token authenticated as user foo:

  1. @Test
  2. public void play_with_api(TestContext context) {
  3. Async async = context.async();
  4. Promise<HttpResponse<String>> tokenPromise = Promise.promise();
  5. webClient.get("/api/token")
  6. .putHeader("login", "foo") (1)
  7. .putHeader("password", "bar")
  8. .as(BodyCodec.string()) (2)
  9. .send(tokenPromise);
  10. Future<HttpResponse<String>> tokenFuture = tokenPromise.future(); (3)
  11. JsonObject page = new JsonObject()
  12. .put("name", "Sample")
  13. .put("markdown", "# A page");
  14. // (...)
  1. Credentials are passed as headers.

  2. The response payload is of text/plain type, so we use that for the body decoding codec.

  3. Upon success we complete the tokenRequest future with the token value.

Using the JWT token is now a matter of passing it back as a header to HTTP requests:

  1. Future<HttpResponse<JsonObject>> postPageFuture = tokenFuture.compose(tokenResponse -> {
  2. Promise<HttpResponse<JsonObject>> promise = Promise.promise();
  3. jwtTokenHeaderValue = "Bearer " + tokenResponse.body(); (1)
  4. webClient.post("/api/pages")
  5. .putHeader("Authorization", jwtTokenHeaderValue) (2)
  6. .as(BodyCodec.jsonObject())
  7. .sendJsonObject(page, promise);
  8. return promise.future();
  9. });
  10. Future<HttpResponse<JsonObject>> getPageFuture = postPageFuture.compose(resp -> {
  11. Promise<HttpResponse<JsonObject>> promise = Promise.promise();
  12. webClient.get("/api/pages")
  13. .putHeader("Authorization", jwtTokenHeaderValue)
  14. .as(BodyCodec.jsonObject())
  15. .send(promise);
  16. return promise.future();
  17. });
  18. // (...)
  1. We store the token with the Bearer prefix to the field for the next requests.

  2. We pass the token as a header.