Skip to content

Commit

Permalink
Generate RSA-256 keys on dev and test mode
Browse files Browse the repository at this point in the history
  • Loading branch information
mcruzdev committed Nov 12, 2024
1 parent 30d733f commit 0b84f66
Show file tree
Hide file tree
Showing 11 changed files with 529 additions and 1 deletion.
22 changes: 22 additions & 0 deletions docs/src/main/asciidoc/security-jwt.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,28 @@ Please see the xref:security-openid-connect-client-reference.adoc#token-propagat
[[integration-testing]]
=== Testing

[[auto-generated-keys]]
==== Automatic Key Generation

If you prefer not to manually generate and configure a public/private key pair, the {extension-name} extension automatically provides an RSA-256 key pair for use in development and test modes.

[WARNING]
====
This configuration is only active in development and test modes. For production environment, you have to set it.
====

You can disable automatic key generation by setting at least one of the following properties:

* `mp.jwt.verify.publickey.location`
* `mp.jwt.verify.publickey`
* `smallrye.jwt.sign.key.location`
* `smallrye.jwt.sign.key`

[NOTE]
====
Additionally, if you do not specify the issuer information (using the `mp.jwt.verify.issuer` property), the {extension-name} extension will set a default issuer as `https://quarkus.io/issuer`.
====

[[integration-testing-wiremock]]
==== Wiremock

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class SmallRyeJwtProcessor {

private static final Logger log = Logger.getLogger(SmallRyeJwtProcessor.class.getName());

private static final String MP_JWT_VERIFY_KEY_LOCATION = "mp.jwt.verify.publickey.location";
static final String MP_JWT_VERIFY_KEY_LOCATION = "mp.jwt.verify.publickey.location";
private static final String MP_JWT_DECRYPT_KEY_LOCATION = "mp.jwt.decrypt.key.location";

private static final DotName CLAIM_NAME = DotName.createSimple(Claim.class.getName());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package io.quarkus.smallrye.jwt.deployment;

import static io.quarkus.smallrye.jwt.deployment.SmallRyeJwtProcessor.MP_JWT_VERIFY_KEY_LOCATION;

import java.security.Key;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.eclipse.microprofile.config.ConfigProvider;
import org.jboss.logging.Logger;

import io.quarkus.deployment.Feature;
import io.quarkus.deployment.IsNormal;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.builditem.DevServicesResultBuildItem;
import io.quarkus.deployment.builditem.LiveReloadBuildItem;
import io.smallrye.jwt.util.KeyUtils;

public class SmallryeJwtDevServiceProcessor {

private static final Logger LOGGER = Logger.getLogger(SmallryeJwtDevServiceProcessor.class);

private static final String MP_JWT_VERIFY_PUBLIC_KEY = "mp.jwt.verify.publickey";
private static final String MP_JWT_VERIFY_ISSUER = "mp.jwt.verify.issuer";

private static final String SMALLRYE_JWT_NEW_TOKEN_ISSUER = "smallrye.jwt.new-token.issuer";
private static final String SMALLRYE_JWT_SIGN_KEY_LOCATION = "smallrye.jwt.sign.key.location";
private static final String SMALLRYE_JWT_SIGN_KEY = "smallrye.jwt.sign.key";

private static final String NONE = "NONE";
private static final String DEFAULT_ISSUER = "https://quarkus.io/issuer";

private static final int KEY_SIZE = 2048;

private static final Set<String> JWT_SIGN_KEY_PROPERTIES = Set.of(
MP_JWT_VERIFY_KEY_LOCATION,
MP_JWT_VERIFY_PUBLIC_KEY,
SMALLRYE_JWT_SIGN_KEY_LOCATION,
SMALLRYE_JWT_SIGN_KEY);

/**
* This build step generates an RSA-256 key pair for development and test modes.
* <p>
* The key pair is generated only if the user has not set any of the {@code *.key} or {@code *.location} properties.
* <p>
* Additionally, if the user has not provided the {@code mp.jwt.verify.issuer} and {@code smallrye.jwt.new-token.issuer}
* properties,
* this build step will add a default issuer, regardless of the above condition.
*
* @throws NoSuchAlgorithmException if RSA-256 key generation fails.
*/
@BuildStep(onlyIfNot = { IsNormal.class })
void generateSignKeys(BuildProducer<DevServicesResultBuildItem> devServices,
LiveReloadBuildItem liveReloadBuildItem) throws NoSuchAlgorithmException {

Set<String> userProps = JWT_SIGN_KEY_PROPERTIES
.stream()
.filter(this::isConfigPresent)
.collect(Collectors.toSet());

if (!userProps.isEmpty()) {
// If the user has set the property, we need to avoid adding or overriding it with the
// smallrye default configuration
Map<String, String> devServiceProps = addDefaultSmallryePropertiesIfMissing(userProps);

if (!isConfigPresent(MP_JWT_VERIFY_ISSUER) && !isConfigPresent(SMALLRYE_JWT_NEW_TOKEN_ISSUER)) {
devServiceProps.put(MP_JWT_VERIFY_ISSUER, DEFAULT_ISSUER);
devServiceProps.put(SMALLRYE_JWT_NEW_TOKEN_ISSUER, DEFAULT_ISSUER);
}

devServices.produce(smallryeJwtDevServiceWith(devServiceProps));
return;
}

KeyPairContext ctx = liveReloadBuildItem.getContextObject(KeyPairContext.class);

LOGGER.info("The smallrye-jwt extension has configured an in-memory key pair, which is not enabled in production. " +
"Please ensure the correct keys/locations are set in production to avoid potential issues.");
if (ctx == null && !liveReloadBuildItem.isLiveReload()) {
// first execution
KeyPair keyPair = KeyUtils.generateKeyPair(KEY_SIZE);
String publicKey = getStringKey(keyPair.getPublic());
String privateKey = getStringKey(keyPair.getPrivate());

Map<String, String> devServiceProps = generateDevServiceProperties(publicKey, privateKey);

if (!isConfigPresent(MP_JWT_VERIFY_ISSUER) && !isConfigPresent(SMALLRYE_JWT_NEW_TOKEN_ISSUER)) {
devServiceProps.put(MP_JWT_VERIFY_ISSUER, DEFAULT_ISSUER);
devServiceProps.put(SMALLRYE_JWT_NEW_TOKEN_ISSUER, DEFAULT_ISSUER);
}

liveReloadBuildItem.setContextObject(KeyPairContext.class, new KeyPairContext(
devServiceProps));

devServices.produce(smallryeJwtDevServiceWith(devServiceProps));
}

if (ctx != null && liveReloadBuildItem.isLiveReload()) {
devServices.produce(smallryeJwtDevServiceWith(ctx.properties()));
}
}

private Map<String, String> addDefaultSmallryePropertiesIfMissing(Set<String> userConfigs) {
HashMap<String, String> devServiceConfigs = new HashMap<>();
if (!userConfigs.contains(SMALLRYE_JWT_SIGN_KEY)) {
devServiceConfigs.put(SMALLRYE_JWT_SIGN_KEY, NONE);
}

if (!devServiceConfigs.containsKey(MP_JWT_VERIFY_PUBLIC_KEY)) {
devServiceConfigs.put(MP_JWT_VERIFY_PUBLIC_KEY, NONE);
}

return devServiceConfigs;
}

private boolean isConfigPresent(String property) {
return ConfigProvider.getConfig().getOptionalValue(property, String.class)
.isPresent();
}

private DevServicesResultBuildItem smallryeJwtDevServiceWith(Map<String, String> properties) {
return new DevServicesResultBuildItem(
Feature.SMALLRYE_JWT.name(), null, properties);
}

private static Map<String, String> generateDevServiceProperties(String publicKey, String privateKey) {
HashMap<String, String> properties = new HashMap<>();
properties.put(MP_JWT_VERIFY_PUBLIC_KEY, publicKey);
properties.put(SMALLRYE_JWT_SIGN_KEY, privateKey);
return properties;
}

private static String getStringKey(Key key) {
return Base64.getEncoder()
.encodeToString(key.getEncoded());
}

record KeyPairContext(Map<String, String> properties) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.quarkus.jwt.test;

import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/only-user")
public class GreetingResource {

@GET
@Produces(MediaType.TEXT_PLAIN)
@RolesAllowed({ "User" })
public String hello() {
return "Hello from Quarkus REST";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package io.quarkus.jwt.test.dev;

import static org.junit.jupiter.api.Assertions.fail;

import java.io.IOException;

import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

import org.eclipse.microprofile.jwt.Claims;
import org.hamcrest.Matchers;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.jwt.test.GreetingResource;
import io.quarkus.test.QuarkusDevModeTest;
import io.restassured.RestAssured;
import io.restassured.http.Header;
import io.smallrye.jwt.build.Jwt;
import io.smallrye.jwt.util.KeyUtils;

public class SmallryeJwtProcessorDevModeTest {

@RegisterExtension
static QuarkusDevModeTest devMode = new QuarkusDevModeTest()
.withApplicationRoot((jar) -> jar.addClasses(
GreetingResource.class, TokenResource.class).addAsResource(
new StringAsset(""),
"application.properties"));

@Test
public void shouldNotBeNecessaryToAddSignKeysOnApplicationProperties() {
String token = RestAssured.given()
.header(new Header("Accept", "text/plain"))
.get("/token")
.andReturn()
.body()
.asString();

RestAssured.given()
.header(new Header("Authorization", "Bearer " + token))
.get("/only-user")
.then().assertThat().statusCode(200);
}

@Test
public void shouldUseTheSameTokenEvenWhenTheUserChangesTheConfiguration() {
String token = RestAssured.given()
.header(new Header("Accept", "text/plain"))
.get("/token")
.andReturn()
.body()
.asString();

// there is no need to get another token
RestAssured.given()
.header(new Header("Authorization", "Bearer " + token))
.get("/only-user")
.then().assertThat().statusCode(200)
.body(Matchers.containsString("Hello from Quarkus REST"));

devMode.modifyResourceFile("application.properties", s -> """
smallrye.jwt.sign.key.location=invalidLocation.pem
mp.jwt.verify.publickey.location=invalidLocation.pem
""");

// should throw error because the private/public are invalid
String newToken = RestAssured.given()
.header(new Header("Accept", "text/plain"))
.get("/token")
.andReturn()
.body()
.asString();

// should return 500 because the location is invalid
RestAssured.given()
.header(new Header("Authorization", "Bearer " + newToken))
.get("/only-user")
.then().assertThat().statusCode(500);

devMode.modifyResourceFile("application.properties", s -> "");

// there is no need to get another token
// should work with old token
RestAssured.given()
.header(new Header("Authorization", "Bearer " + token))
.get("/only-user")
.then().assertThat().statusCode(200)
.body(Matchers.containsString("Hello from Quarkus REST"));
}

@Test
public void shouldUseTheSameKeyPairOnLiveReload() {
String token = RestAssured.given()
.header(new Header("Accept", "text/plain"))
.get("/token")
.andReturn()
.body()
.asString();

devMode.modifySourceFile("GreetingResource.java", s -> s.replace("Hello from Quarkus", "Hello from JWT"));

// there is no need to get another token
RestAssured.given()
.header(new Header("Authorization", "Bearer " + token))
.get("/only-user")
.then().assertThat().statusCode(200)
.body(Matchers.containsString("Hello from JWT"));
}

@Test
public void shouldUseTheSameTokenEvenWhenTheUserChangesTheConfigWithKeyProps() {

devMode.modifyResourceFile("application.properties", s -> "");

String token = RestAssured.given()
.header(new Header("Accept", "text/plain"))
.get("/token")
.andReturn()
.body()
.asString();

// there is no need to get another token
RestAssured.given()
.header(new Header("Authorization", "Bearer " + token))
.get("/only-user")
.then().assertThat().statusCode(200)
.body(Matchers.containsString("Hello from Quarkus REST"));

try {
String privateKey = KeyUtils.readKeyContent("/privateKey.pem");
String publicKey = KeyUtils.readKeyContent("/publicKey.pem");
devMode.modifyResourceFile("application.properties", s -> """
smallrye.jwt.sign.key=%s
mp.jwt.verify.publickey=%s
""".formatted(privateKey, publicKey));
} catch (IOException e) {
fail("Was not possible for reading keys from resource");
}

// should throw error because the private/public are invalid
String newToken = RestAssured.given()
.header(new Header("Accept", "text/plain"))
.get("/token")
.andReturn()
.body()
.asString();

// should return 200 because the keys are valid
RestAssured.given()
.header(new Header("Authorization", "Bearer " + newToken))
.get("/only-user")
.then().assertThat().statusCode(200);

devMode.modifyResourceFile("application.properties", s -> "");

// there is no need to get another token
// should work with old token
RestAssured.given()
.header(new Header("Authorization", "Bearer " + token))
.get("/only-user")
.then().assertThat().statusCode(200)
.body(Matchers.containsString("Hello from Quarkus REST"));
}

@Path("/token")
static class TokenResource {

@GET
@Produces(MediaType.TEXT_PLAIN)
@PermitAll
public String hello() {
return Jwt.upn("[email protected]")
.groups("User")
.claim(Claims.birthdate.name(), "2001-07-13")
.sign();
}
}
}
1 change: 1 addition & 0 deletions integration-tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
<module>oidc-token-propagation</module>
<module>oidc-token-propagation-reactive</module>
<module>openapi</module>
<module>smallrye-jwt-with-jwt-build</module>
<module>smallrye-jwt-oidc-webapp</module>
<module>smallrye-jwt-token-propagation</module>
<module>oidc-code-flow</module>
Expand Down
Loading

0 comments on commit 0b84f66

Please sign in to comment.