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:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-jwt</artifactId>
</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:
keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA256 -keysize 2048 -alias HS256 -keypass secret
keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA384 -keysize 2048 -alias HS384 -keypass secret
keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA512 -keysize 2048 -alias HS512 -keypass secret
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
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
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
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
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
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:
Router apiRouter = Router.router(vertx);
JWTAuth jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions()
.setKeyStore(new KeyStoreOptions()
.setPath("keystore.jceks")
.setType("jceks")
.setPassword("secret")));
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:
apiRouter.get("/token").handler(context -> {
JsonObject creds = new JsonObject()
.put("username", context.request().getHeader("login"))
.put("password", context.request().getHeader("password"));
auth.authenticate(creds, authResult -> { (1)
if (authResult.succeeded()) {
User user = authResult.result();
user.isAuthorized("create", canCreate -> { (2)
user.isAuthorized("delete", canDelete -> {
user.isAuthorized("update", canUpdate -> {
String token = jwtAuth.generateToken( (3)
new JsonObject()
.put("username", context.request().getHeader("login"))
.put("canCreate", canCreate.succeeded() && canCreate.result())
.put("canDelete", canDelete.succeeded() && canDelete.result())
.put("canUpdate", canUpdate.succeeded() && canUpdate.result()),
new JWTOptions()
.setSubject("Wiki API")
.setIssuer("Vert.x"));
context.response().putHeader("Content-Type", "text/plain").end(token);
});
});
});
} else {
context.fail(401);
}
});
});
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.
Upon success we can query for roles.
We generate a token with
username
,canCreate
,canDelete
andcanUpdate
claims.
Each API handler method can now query the current user principal and claims. Here is how the apiDeletePage
does it:
private void apiDeletePage(RoutingContext context) {
if (context.user().principal().getBoolean("canDelete", false)) {
int id = Integer.valueOf(context.request().getParam("id"));
dbService.deletePage(id, reply -> {
handleSimpleDbReply(context, reply);
});
} else {
context.fail(401);
}
}
Using JWT tokens
To illustrate how to work with JWT tokens, let’s create a new one for the root
user:
- $ http --verbose --verify no GET https://localhost:8080/api/token login:root password:w00t
- GET /api/token HTTP/1.1
- Accept: */*
- Accept-Encoding: gzip, deflate
- Connection: keep-alive
- Host: localhost:8080
- User-Agent: HTTPie/0.9.8
- login: root
- password: w00t
- HTTP/1.1 200 OK
- Content-Length: 242
- Content-Type: text/plain
- Set-Cookie: vertx-web.session=8cbb38ac4ce96737bfe31cc0ceaae2b9; Path=/
- 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:
- $ http --verbose --verify no GET https://localhost:8080/api/pages
- GET /api/pages HTTP/1.1
- Accept: */*
- Accept-Encoding: gzip, deflate
- Connection: keep-alive
- Host: localhost:8080
- User-Agent: HTTPie/0.9.8
- HTTP/1.1 401 Unauthorized
- Content-Length: 12
- 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:
- $ http --verbose --verify no GET https://localhost:8080/api/pages Authorization:'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8='
- GET /api/pages HTTP/1.1
- Accept: */*
- Accept-Encoding: gzip, deflate
- Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8=
- Connection: keep-alive
- Host: localhost:8080
- User-Agent: HTTPie/0.9.8
- HTTP/1.1 200 OK
- Content-Length: 99
- Content-Type: application/json
- Set-Cookie: vertx-web.session=0598697483371c7f3cb434fbe35f15e4; Path=/
- {
- "pages": [
- {
- "id": 0,
- "name": "Hello"
- },
- {
- "id": 1,
- "name": "Apple"
- },
- {
- "id": 2,
- "name": "Vert.x"
- }
- ],
- "success": true
- }
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:
private String jwtTokenHeaderValue;
We add first step to retrieve a JTW token authenticated as user foo
:
@Test
public void play_with_api(TestContext context) {
Async async = context.async();
Promise<HttpResponse<String>> tokenPromise = Promise.promise();
webClient.get("/api/token")
.putHeader("login", "foo") (1)
.putHeader("password", "bar")
.as(BodyCodec.string()) (2)
.send(tokenPromise);
Future<HttpResponse<String>> tokenFuture = tokenPromise.future(); (3)
JsonObject page = new JsonObject()
.put("name", "Sample")
.put("markdown", "# A page");
// (...)
Credentials are passed as headers.
The response payload is of
text/plain
type, so we use that for the body decoding codec.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:
Future<HttpResponse<JsonObject>> postPageFuture = tokenFuture.compose(tokenResponse -> {
Promise<HttpResponse<JsonObject>> promise = Promise.promise();
jwtTokenHeaderValue = "Bearer " + tokenResponse.body(); (1)
webClient.post("/api/pages")
.putHeader("Authorization", jwtTokenHeaderValue) (2)
.as(BodyCodec.jsonObject())
.sendJsonObject(page, promise);
return promise.future();
});
Future<HttpResponse<JsonObject>> getPageFuture = postPageFuture.compose(resp -> {
Promise<HttpResponse<JsonObject>> promise = Promise.promise();
webClient.get("/api/pages")
.putHeader("Authorization", jwtTokenHeaderValue)
.as(BodyCodec.jsonObject())
.send(promise);
return promise.future();
});
// (...)
We store the token with the
Bearer
prefix to the field for the next requests.We pass the token as a header.