Securing and Testing Quarkus Applications using Keycloak and Wiremock
Wednesday, February 17, 2021 |Obviously, web apps need to be secured. If you’re brave (and some might say foolish), you can roll your own security. Unless you have compelling reasons to do so, however, you probably shouldn’t. Almost as if by design (nyuk nyuk), Quarkus makes it easy to use any OpenID Connect server. One such server is Keycloak, an open source offering also from Red Hat. If your experience is like mine, though, securing endpoints makes testing a touch more complicated. In this post, I’d like to present and walk through a complete example of a secured Quarkus app, using Keycloak, JUnit and Wiremock.
To begin, let’s set up a very simple Quarkus application. All it contains is a single resource, SampleResource
, with two endpoints: one for admins, and one for users. In the interest of completeness, we start by setting up the project’s POM:
The Application
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.steeplesoft</groupId>
<artifactId>quarkus-keycloak-wiremock</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<version.quarkus.platform>1.11.3.Final</version.quarkus.platform>
<version.compiler-plugin>3.8.1</version.compiler-plugin>
<version.surefire-plugin>2.22.2</version.surefire-plugin>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${version.quarkus.platform}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${version.quarkus.platform}</version>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</build>
</project>
There’s typically more in a Quarkus POM, but I’ve stripped this down to the bare minimum. Next, our simple resource:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
@Path("/sample")
public class SampleResource {
@GET
@Path("admin")
@RolesAllowed("admin")
public String admin() {
return "admin";
}
@GET
@Path("user")
@RolesAllowed("user")
public String user() {
return "user";
}
}
Before we can start the app, we need to configure the OIDC support:
1
2
3
quarkus.oidc.auth-server-url=${OIDC_URL:https://localhost:8180/auth/realms/quarkus-demo}
quarkus.oidc.client-id=${OIDC_CLIENT_ID:backend-service}
quarkus.oidc.credentials.secret=${OIDC_SECRET:51ebd5dc-5f2e-403c-be60-60fed3a75c47}
We are now ready, or so we might think, to run our project: mvn compile quarkus:dev
. Assuming you have Keycloak running on localhost
but haven’t configured it, you should see an error like this:
1
Caused by: io.vertx.core.impl.NoStackTraceThrowable: Not Found: {"error":"Realm does not exist"}
Since we can’t run our app just yet, let’s configure Keycloak.
Keycloak
Create the realm
For a more complete Getting Started, you can visit the Keycloak docs[https://www.keycloak.org/getting-started/getting-started-zip]. For our purposes, we’ll be brief:
-
Download the latest version of Keycloak
-
Extract the zip
-
Start Keycloak with a port offset to avoid conflicts with our application:
$KEYCLOAK_DIR/bin/standalone.sh -Djboss.socket.binding.port-offset=100
-
Create an admin user: http://localhost:8180
-
User: admin
-
Password: admin
-
-
Log on to the admin console by clicking on the
Administration Console
link -
Add a realm
-
Move your mouse over
Master
in the left nav bar -
Click
Add Realm
-
Click
Select File
-
Navigate to and select
quarkus-realm.json
that we downloaded above -
Set the realm name to
quarkus-demo
-
Click
Create
-
We now have a realm for our demo, so next we need to configure the roles and add a user.
Configure roles and users
Ordinarily, we would need to add these, but since we imported a realm, that work has been done for us. To verify:
-
Make sure the realm
quarkus-demo
is selected at the top the left nav bar. -
Click
Roles
in the nav bar -
In the list, you should see
admin
anduser
as well as a few others.
Similarly, we don’t need to add users, as the import handled that for us. To verify that:
-
Click
Users
under theManage section
in the nav bar -
In the list, you should see
admin
, alice`, andjdoe
-
To verify
admin
-
Click the UUID in the ID column
-
Click the
Role Mappings
tab -
Verify that
admin
anduser
are listed underAssigned Roles
-
Let’s change the password
-
Click the
Credentials
tab -
Enter "password" in the
Password
andPassword Confirmation
fields -
Set
Temporary
to "Off" -
Click
Reset Password
-
-
-
To view
alice
's roles-
Click the
Users
nav bar link to return to the user list -
Click the UUID in the ID column for
alice
-
Click the
Role Mapping
tab -
Verify that only
user
is listed underAssigned Roles
-
Change the password for
alice
as we did above.
-
Configure the client
We have one last step, configuring the client:
-
Click
Clients
in the left nav bar -
Click
backend-service
in the table -
Click the
Credentials
tab -
Click the
Regenerate Secret
button -
Copy the new value in the
Secret
field and updatequarkus.oidc.credentials.secret
inapplication.properties
Manually test the application
With our realm configured, we’re ready to test our application:
1
2
3
4
$ mvn compile quarkus:dev
...
INFO [io.quarkus] (Quarkus Main Thread) quarkus-keycloak-wiremock 1.0-SNAPSHOT on JVM (powered by Quarkus 1.11.3.Final)
started in 2.806s. Listening on: http://localhost:8080
And in another console (I’m using httpie here, btw):
1
2
3
4
5
6
7
$ http --form \
--auth backend-service:51ebd5dc-5f2e-403c-be60-60fed3a75c47 \
:8180/auth/realms/quarkus-demo/protocol/openid-connect/token \
'Content-Type:application/x-www-form-urlencoded' \
username=alice \
password=alice \
grant_type: password
That gets a not-small JSON response, but we only want a part, so we can use the JSON query tool, jq
, to help us extract the value:
1
2
3
4
5
6
7
8
9
$ export TOKEN=`http --form \
--auth backend-service:51ebd5dc-5f2e-403c-be60-60fed3a75c47\
:8180/auth/realms/quarkus-demo/protocol/openid-connect/token \
'Content-Type:application/x-www-form-urlencoded' \
username=alice \
password=password \
grant_type: password | jq --raw-output '.access_token'`
$ echo $TOKEN
eyJhbGciOiJSUzI1Ni....
Let’s try accessing the application now, first without a token, and then hitting each restricted endpoint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ http :8080/sample/user
HTTP/1.1 401 Unauthorized
Content-Length: 0
$ http :8080/sample/admin "Authorization:Bearer $TOKEN"
HTTP/1.1 403 Forbidden
Content-Length: 0
$ http :8080/sample/user "Authorization:Bearer $TOKEN"
HTTP/1.1 200 OK
Content-Length: 4
Content-Type: application/octet-stream
user
So we see unauthenticated users rejected, unauthorized users rejected, and authorized users allowed, exactly as expected. Let’s check an admin user:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ export TOKEN=`http --form \
--auth backend-service:51ebd5dc-5f2e-403c-be60-60fed3a75c47\
:8180/auth/realms/quarkus-demo/protocol/openid-connect/token \
'Content-Type:application/x-www-form-urlencoded' \
username=admin \
password=password \
grant_type: password | jq --raw-output '.access_token'`
$ http :8080/sample/admin "Authorization:Bearer $TOKEN"
HTTP/1.1 200 OK
Content-Length: 5
Content-Type: application/octet-stream
admin
$ http :8080/sample/user "Authorization:Bearer $TOKEN"
HTTP/1.1 200 OK
Content-Length: 4
Content-Type: application/octet-stream
user
We’ve manually tested the app, but that doesn’t scale, so let’s take a look at how to test this simple application programmatically.
Testing
Part of the trick in testing an OIDC-secured apps can be tricky. Given how the token is verified behind the scenes, intercepting those calls can be difficult. Fortunately, WireMock handles that for us. Setting up the project is easy. Here, we’re adding JUnit5, WireMock, and some supporting libraries:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.18.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.26.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>8.20</version>
<scope>test</scope>
</dependency>
The test itself is also pretty simple:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@QuarkusTest
@QuarkusTestResource(MockAuthorizationServer.class)
public class SampleResourceTest {
@Test
public void testUserAsUser() {
RestAssured.given()
.contentType("application/json")
.auth()
.oauth2(generateJWT("user"))
.get("/sample/user")
.then()
.statusCode(200);
}
// ...
private String generateJWT(String role) {
// Prepare JWT with claims set
SignedJWT signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID(MockAuthorizationServer.keyPair.getKeyID())
.type(JOSEObjectType.JWT)
.build(),
new JWTClaimsSet.Builder()
.subject("backend-service")
.issuer("https://wiremock")
.claim(
"realm_access",
new JWTClaimsSet.Builder()
.claim("roles", Arrays.asList(role))
.build()
.toJSONObject()
)
.claim("scope", "openid email profile")
.expirationTime(new Date(new Date().getTime() + 60 * 1000))
.build()
);
// Compute the RSA signature
try {
signedJWT.sign(new RSASSASigner(MockAuthorizationServer.keyPair.toRSAKey()));
} catch (JOSEException e) {
throw new RuntimeException(e);
}
return signedJWT.serialize();
}
Using REST Assured, we simply submit a request to server. The magic starts with the call to generateJWT()
. In this method, we create a signed JWT using the key pair from our mock authorization server (which we’ll look at next), we sign the key, and return it. REST Assured passes this as part of the request, which Quarkus will extract and pass to the authorization server to validate.
So what does the mock authorization server look like?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class MockAuthorizationServer implements QuarkusTestResourceLifecycleManager {
private WireMockServer wireMockServer;
public static RSAKey keyPair;
static {
try {
keyPair = new RSAKeyGenerator(2048)
.keyID("123")
.keyUse(KeyUse.SIGNATURE)
.generate();
} catch (JOSEException e) {
e.printStackTrace();
}
}
@Override
public Map<String, String> start() {
wireMockServer = new WireMockServer();
wireMockServer.start();
postStubMapping(oidcConfigurationStub());
postStubMapping(publicKeysStub(keyPair.toPublicJWK().toJSONString()));
Map<String,String> props = new HashMap<>();
props.put("quarkus.oidc.auth-server-url", wireMockServer.baseUrl() + "/mock-server");
props.put("wiremock.url", wireMockServer.baseUrl());
return props;
}
@Override
public void stop() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
private ResponseBody<?> postStubMapping(String request) {
RestAssured.baseURI = wireMockServer.baseUrl();
return RestAssured.given()
.body(request)
.post("/__admin/mappings")
.then()
.statusCode(Response.Status.CREATED.getStatusCode())
.extract()
.response()
.body();
}
private String oidcConfigurationStub() {
return readFile("/oidcconfig.json")
.replace("$baseUrl", wireMockServer.baseUrl());
}
private String publicKeysStub(String keys) {
return readFile("/publickey.json")
.replace("$keys", keys);
}
private String readFile(String fileName) {
return new Scanner(getClass()
.getResourceAsStream(fileName), "UTF-8")
.useDelimiter("\\A")
.next();
}
}
There’s a lot going on here, and I’m not going to pretend to be an expert. In effect, we’re setting up a mock server, configuring two endpoints, defined in oidcconfig.json
and publickey.json
, and those files look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"name": "oidc_configuration",
"request": {
"method": "GET",
"url": "/mock-server/.well-known/openid-configuration"
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json;charset=UTF-8" },
"jsonBody": {
"issuer": "$baseUrl/mock-server",
"authorization_endpoint": "$baseUrl/v1/authorize",
"token_endpoint": "$baseUrl/v1/token",
"userinfo_endpoint": "$baseUrl/v1/userinfo",
"registration_endpoint": "$baseUrl/v1/clients",
"jwks_uri": "$baseUrl/v1/keys",
"response_types_supported": ["code", "id_token", "code id_token", "code token", "id_token token", "code id_token token"],
"response_modes_supported": ["query", "fragment", "form_post", "okta_post_message"],
"grant_types_supported": ["authorization_code", "implicit", "refresh_token", "password"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["sms", "openid", "profile", "email", "address", "phone", "offline_access"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
"claims_supported": ["iss", "ver", "sub", "aud", "iat", "exp", "jti", "auth_time", "amr", "idp", "nonce", "name", "nickname", "preferred_username", "given_name", "middle_name", "family_name", "email", "email_verified", "profile", "zoneinfo", "locale", "address", "phone_number", "picture", "website", "gender", "birthdate", "updated_at", "at_hash", "c_hash"],
"code_challenge_methods_supported": ["S256"],
"introspection_endpoint": "$baseUrl/v1/introspect",
"introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
"revocation_endpoint": "$baseUrl/v1/revoke",
"revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
"end_session_endpoint": "$baseUrl/v1/logout",
"request_parameter_supported": true,
"request_object_signing_alg_values_supported": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "public_keys_stub",
"request": {
"method": "GET",
"url": "/v1/keys"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json;charset=UTF-8"
},
"jsonBody": {
"keys": [
$keys
]
}
}
}
These are basically mock objects, but representing requests. When a request for request.url
comes in, WireMock returns response
. Before passing the values to WireMock, we do a simple string replace to configure the responses to look how they should for a given request. We tie, so to speak, the Quarkus test to our MockAuthorizatioServer
(which is a QuarkusTestResourceLifecycleManager
) via the @QuarkusTestResource
annotation on our test class. All that’s left is to run it.
And there you have it. A complete, albeit absurdly simple, Quarkus application, secured with OIDC via Keycloak, and tested with WireMock. It’s a simple example, but it’s a working one, so hopefully it will be enough to get you started. If you find any interesting tips or tricks, be sure to drop a comment below! You can find the full source for the project here.