Apache Meecrowave is a light CDI+JAXRS+JSON container based on an Apache stack (Tomcat, OpenWebBeans, Johnzon, CXF and Log4j2). It also brings some interesting extensions like a proxy and an OAuth2 modules. This last one is based on CXF OAuth2 services. Until 1.2.9 it was not supporting PKCE flow without some customizations which were not always trivial. The comin 1.2.10 solves that and it is now a matter of configuration. Let see how to set it up.

To setup PKCE flow it is mainly a configuration matter. This post will use the programmatic approach but the CLI/meecrowave.properties/.... approaches are working too. Also note that this post will use the JWT approach for the flow (vs the identifier one) which means the deployments will be really stateless enabling a better scalability in general.

PKCE flow

The PKCE flow is composed of these steps (more details at oauth.com):

  1. Trigger an authentication on authorize endpoint sending a code_challenge (+ its method which specifies if it is a plain string or a digested one), redirect uri, and client id. The response_type is code as in authorization_code flow since PKCE is an extension of this flow designed for SPA and mobile applications.
  2. If needed (ie not already logged), this endpoint will redirect you on another page to actually log in (enter your credentials). Once done you will submit a form with some of the previous informations (client_id, redirect_uri, often a state to forward from previous endpoint, ...).
  3. Finally, you are redirected back on the original application with a query parameter "code" containing a value enabling you to call token endpoint to obtain an access token and finally call protected resources.

Meecrowave OAuth2 setup

Meecrowave OAuth2 module requires a set of properties to be configured in PKCE mode:

final var properties = new Properties();
properties.setProperty("oauth2-use-jwt-format-for-access-token", "true"); // 1
properties.setProperty("oauth2-jwt-issuer", "myissuer"); // 2
properties.setProperty("oauth2-authorization-code-support", "true"); // 3
properties.setProperty("oauth2-forward-role-as-jwt-claims", "true"); // 4
properties.setProperty("oauth2-support-public-client", "true"); // 5
properties.setProperty("oauth2-use-s256-code-challenge", "true"); // 6
properties.setProperty("oauth2.cxf.rs.security.keystore.type", "jks"); // 7
properties.setProperty("oauth2.cxf.rs.security.keystore.password", keystorePwd); // 8
properties.setProperty("oauth2.cxf.rs.security.keystore.alias", "oauth2"); // 9
properties.setProperty("oauth2.cxf.rs.security.keystore.file", "/path/to/keystore.jks"); // 10
properties.setProperty("oauth2.cxf.rs.security.key.password", keystorePwd); // 11

mwConfiguration.setProperties(properties); // 12
  1. Configure Meecowave OAuth2 to use JWT tokens
  2. Set the issuer to use by default
  3. Enable authorization_code flow (it is not enabled by default for applications using pasword/refresh only flows)
  4. (optional) forward principal roles to the jwt (enables to test it in an UI or so to toggle features visually too)
  5. (optional) depends how clients are provisionned but this enables to not require the client_secret in the flow
  6. Current CXF version only supports a single challenge type (plain or S256), here we force the S256 mode
  7. Set the type of keystore containing the key/certificate for the JWT signing/verifying steps
  8. Set the keystore password
  9. Set the keystore alias containing the JWT key
  10. Set the keystore path
  11. Set the key password for the JWT key
  12. Finally set these properties on Meecrowave configuration

TIP: you can read more about meecrowave CLI or programmatic configuration on the official website.

To generate a keystore with a valid key you can use Bouncycastle - bcpkix-jdk15on - (or any tool but this one enables to do it programmatically which is quite nice for tests):

private void createKeystore(final Path keystore, final String pwd) throws Exception {
    CryptoUtils.installBouncyCastleProvider();

    final var ks = KeyStore.getInstance("JKS");
    ks.load(null, pwd.toCharArray());

    final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
    kpg.initialize(2048);

    final KeyPair kp = kpg.generateKeyPair();

    final var name = new org.bouncycastle.asn1.x500.X500Name("cn=whatever");
    final var subPubKeyInfo = SubjectPublicKeyInfo.getInstance(kp.getPublic().getEncoded());
    final Date start = new Date();
    final Date until = Date.from(LocalDate.now().plus(365, ChronoUnit.DAYS).atStartOfDay().toInstant(ZoneOffset.UTC));
    final var builder = new X509v3CertificateBuilder(name, new BigInteger(10, new SecureRandom()), start, until, name, subPubKeyInfo);
    final var signer = new JcaContentSignerBuilder("SHA256WithRSA").setProvider("BC").build(kp.getPrivate());
    final var holder = builder.build(signer);

    final var cert = new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);

    ks.setKeyEntry("oauth2", kp.getPrivate(), pwd.toCharArray(), new Certificate[]{cert});

    Files.createDirectories(keystore.getParent());
    try (final OutputStream os = Files.newOutputStream(keystore)) {
        ks.store(os, pwd.toCharArray());
    }
}

Now, OAuth2 part is set up but we miss the authentication setup. Meecrowave supports two kind of authentications:

  • JAAS - on the JVM
  • Servlet/Tomcat realm

We will use the last one which is simpler to setup since it only requires to configure tomcat.

Still in programmatic mode we can configure an in memory realm through Meecrowave configuration directly - but you can also configure a real custom realm if you need to for production:

configuration.setUsers(Map.of("user1", "password1"));
configuration.setRoles(Map.of("user1", "adminrole"));

Tip: you can put as much users you want in the maps.

Now our realm is setup, we must enable an authenticator on the application. The simplest is basic and it can be enabled this way:

configuration.setLoginConfig(
  new Meecrowave.LoginConfigBuilder()
    .realmName("My App")
     .basic());

Lastly, with this setup, we must force the second flow call (the form submit with the username/password) to be authenticated (to go through the authenticator). Using basic authenticator, the username/password will go through the Authorization header but this is a detail of this simple setup, using another one it will go through other parts of the HTTP request:

configuration.setSecurityConstraints(List.of(new Meecrowave.SecurityConstaintBuilder()
        .authConstraint(true)
        .addAuthRole("**")
        .addCollection("secured", "/oauth2/authorize/decision")));

Now we are all almost good to log in on our server but we miss an OAuth2 client!

Note: once again, all this programmatic configuration can be done through the CLI or properties without any line of code!

Create a client

To work, previous setup must be able to retrieve a client. A client is more or less the configuration for one flow.

Meecrowave OAuth2 supports multiple modes but the two mains are JCache and JPA. By default JCache will be used so to store a client - potentially hardcoded if you include the OAuth2 module as part of your application, you just need to put it in the map CXF uses to read the clients.

Here is a code doing that:

Cache<String, Client> cache;

// 1
final var provider = Caching.getCachingProvider();
final CacheManager cacheManager;
try { // these parameters must match the ones used by CXF, it matches the configuration of jcache providers in oauth2 module
      // see http://openwebbeans.apache.org/meecrowave/meecrowave-oauth2/index.html and --oauth2-jcache-config
    cacheManager = provider.getCacheManager(
            provider.getDefaultClassLoader().getResource("default-oauth2.jcs").toURI(),
            provider.getDefaultClassLoader(),
            provider.getDefaultProperties());
} catch (final URISyntaxException e) {
    throw new IllegalStateException(e);
}


// 2
try {
    cache = cacheManager
            .createCache(JCacheCodeDataProvider.CLIENT_CACHE_KEY, new MutableConfiguration<String, Client>()
                    .setTypes(String.class, org.apache.cxf.rs.security.oauth2.common.Client.class)
                    .setStoreByValue(true));
} catch (final RuntimeException re) {
    cache = cacheManager.getCache(JCacheCodeDataProvider.CLIENT_CACHE_KEY, String.class, org.apache.cxf.rs.security.oauth2.common.Client.class);
}

// 3
final Client value = new Client("c1", null, true);
value.setConfidential(false); // 4
value.setRedirectUris(singletonList("http://localhost:1234/redirected")); // 5
cache.put("c1", value); // 6
  1. we lookup the cache manager, here it must use the same configuration than the CXF setup so ensure to match the configuration you used or defaults (as in the previous snippet)
  2. lookup cxf client cache (create if not exist pattern)
  3. create a client defining its name, enforcing a null client_secret (otherwise PKCE flow will fail without passing the secret)
  4. Set this client as public (if part of the application it is simpler and require less parameters in the authentication dance),
  5. Set the expected redirect URi you will use in the flow
  6. Finally store the client in the cache

TIP: you can also set a cache loader on the cache (during startup, before CXF is initialized) and therefore read the client from a database (SQL or NoSQL) or a file.

Test the server

Previous setup - including hardcoded users - can be tested with the following code:

final int httpPort = configuration.getHttpPort();

final var client = ClientBuilder.newClient()
        .property(Message.MAINTAIN_SESSION, true)
        .register(new OAuthJSONProvider());
final var digest = MessageDigest.getInstance("SHA-256");
final var codeVerifier = UUID.randomUUID().toString();
digest.update(codeVerifier.getBytes(StandardCharsets.UTF_8));

try {
    final var target = client.target("http://localhost:" + httpPort);

    // 1
    final var authorization = target
            .path("oauth2/authorize")
            .queryParam(OAuthConstants.GRANT_TYPE, OAuthConstants.AUTHORIZATION_CODE_GRANT)
            .queryParam(OAuthConstants.RESPONSE_TYPE, OAuthConstants.CODE_RESPONSE_TYPE)
            .queryParam(OAuthConstants.CLIENT_ID, "c1")
            .queryParam(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE, Base64.getUrlEncoder().withoutPadding().encodeToString(digest.digest()))
            .queryParam(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE_METHOD, "S256")
            .queryParam(OAuthConstants.REDIRECT_URI, "http://localhost:" + httpPort + "/redirected")
            .request(APPLICATION_JSON_TYPE)
            .get();
    assertEquals(Response.Status.OK.getStatusCode(), authorization.getStatus(), () -> authorization.getHeaderString("Location"));
    final var data = authorization.readEntity(OAuthAuthorizationData.class);

    // 2
    final var decision = target
            .path("oauth2/authorize/decision")
            .request(APPLICATION_JSON_TYPE)
            .cookie(authorization.getCookies().get("JSESSIONID"))// 3
            .header(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString("test:testpwd".getBytes(StandardCharsets.UTF_8))) // 4
            .post(entity(new Form()
                    .param(OAuthConstants.AUTHORIZATION_DECISION_KEY, "allow") // 5
                    .param(OAuthConstants.SESSION_AUTHENTICITY_TOKEN, data.getAuthenticityToken()) // 5
                    .param(OAuthConstants.CLIENT_ID, "c1")
                    .param(OAuthConstants.REDIRECT_URI, "http://localhost:" + httpPort + "/redirected"), APPLICATION_FORM_URLENCODED_TYPE));
    assertEquals(Response.Status.SEE_OTHER.getStatusCode(), decision.getStatus());
    assertTrue(decision.getLocation().toASCIIString().startsWith("http://localhost:" + httpPort + "/redirected?code="), decision.getLocation().toASCIIString()); // 6

    // 7
    final var token = target
            .path("oauth2/token")
            .request(APPLICATION_JSON_TYPE)
            .post(entity(new Form()
                    .param(OAuthConstants.GRANT_TYPE, OAuthConstants.AUTHORIZATION_CODE_GRANT)
                    .param(OAuthConstants.CODE_RESPONSE_TYPE, decision.getLocation().getRawQuery().substring("code=".length()))
                    .param(OAuthConstants.REDIRECT_URI, "http://localhost:" + httpPort + "/redirected")
                    .param(OAuthConstants.CLIENT_ID, "c1")
                    .param(OAuthConstants.AUTHORIZATION_CODE_VERIFIER, codeVerifier), APPLICATION_FORM_URLENCODED_TYPE), ClientAccessToken.class);
    assertNotNull(token);
} finally {
    client.close();
}
  1. Trigger the authorization, you can see the requested parameters and the endpoint path
  2. Previous call redirected us on the login page, let's submit the decision in phase #2
  3. We forward the session - cases CXF stores data in it
  4. We submit our credentials to let the authenticator log us in
  5. CXF uses these two parameters to ensure the call source/authenticity
  6. Finally we are redirected on the original application (thanks redirect_uri) and can extract the code from the final URI
  7. Now we have the code we can call the token endpoint to get the access token passing the code verifier we used originally (sent as challenge form) to validate it comes from us and nobody stole our code.

Conclusion

As always the security is not a trivial topic but Meecrowave OAuth2 makes OAuth2 easier to get and really light to run. it enables to have an OAuth2 server or embed it in your application avoiding to have to maintain the code but also yet another deployment with no real reason when the OAuth2 server is dedicated to it and scalability does not require to split it.

From the same author:

In the same category: