diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index dffe360fb5e0a..e33cab844b8c4 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -1249,6 +1249,16 @@ public static Token fromAudience(String... audience) { @ConfigItem(defaultValue = "true") public boolean allowOpaqueTokenIntrospection = true; + /** + * Token customizer name. + * + * Allows to select a tenant specific token customizer. + * If this property is not set then a default `io.quarkus.oidc.TokenCustomizer` bean + * will be used if registered. + */ + @ConfigItem + public Optional tokenCustomizer = Optional.empty(); + /** * Indirectly verify that the opaque (binary) access token is valid by using it to request UserInfo. * Opaque access token is considered valid if the provider accepted this token and returned a valid UserInfo. @@ -1386,6 +1396,14 @@ public boolean isRequireJwtIntrospectionOnly() { public void setRequireJwtIntrospectionOnly(boolean requireJwtIntrospectionOnly) { this.requireJwtIntrospectionOnly = requireJwtIntrospectionOnly; } + + public Optional getTokenCustomizer() { + return tokenCustomizer; + } + + public void setTokenCustomizer(String tokenCustomizer) { + this.tokenCustomizer = Optional.of(tokenCustomizer); + } } public static enum ApplicationType { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCustomizer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCustomizer.java new file mode 100644 index 0000000000000..d39662a2d5bda --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCustomizer.java @@ -0,0 +1,16 @@ +package io.quarkus.oidc; + +import jakarta.json.JsonObject; + +/** + * TokenCustomizer can be used to change token headers for the token verification to succeed + */ +public interface TokenCustomizer { + /** + * Customize token headers + * + * @param headers the token headers + * @return modified headers, null can be returned to indicate no modification has taken place + */ + JsonObject customizeHeaders(JsonObject headers); +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java index 6dc0e7d4a2ca5..9dc01cc51c156 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java @@ -73,8 +73,8 @@ protected String getNonNullJsonString() { return jsonString == null ? json.toString() : jsonString; } - private static JsonObject toJsonObject(String userInfoJson) { - try (JsonReader jsonReader = Json.createReader(new StringReader(userInfoJson))) { + static JsonObject toJsonObject(String json) { + try (JsonReader jsonReader = Json.createReader(new StringReader(json))) { return jsonReader.readObject(); } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index 8c40298b1ff29..df6f2fa80c9a5 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -1,13 +1,17 @@ package io.quarkus.oidc.runtime; import java.io.Closeable; +import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; +import jakarta.json.JsonObject; + import org.eclipse.microprofile.jwt.Claims; import org.jboss.logging.Logger; import org.jose4j.jwa.AlgorithmConstraints; @@ -29,6 +33,7 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcConfigurationMetadata; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenCustomizer; import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.common.runtime.OidcConstants; @@ -55,14 +60,21 @@ public class OidcProvider implements Closeable { final OidcProviderClient client; final RefreshableVerificationKeyResolver asymmetricKeyResolver; final OidcTenantConfig oidcConfig; + final TokenCustomizer tokenCustomizer; final String issuer; final String[] audience; final Map requiredClaims; final Key tokenDecryptionKey; public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) { + this(client, oidcConfig, jwks, findTokenCustomizer(oidcConfig), tokenDecryptionKey); + } + + public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, + TokenCustomizer tokenCustomizer, Key tokenDecryptionKey) { this.client = client; this.oidcConfig = oidcConfig; + this.tokenCustomizer = tokenCustomizer; this.asymmetricKeyResolver = jwks == null ? null : new JsonWebKeyResolver(jwks, oidcConfig.token.forcedJwkRefreshInterval); @@ -75,6 +87,7 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenDecryptionKey) { this.client = null; this.oidcConfig = oidcConfig; + this.tokenCustomizer = TokenCustomizerFinder.find(oidcConfig.token.getTokenCustomizer().orElse(null)); this.asymmetricKeyResolver = new LocalPublicKeyResolver(publicKeyEnc); this.issuer = checkIssuerProp(); this.audience = checkAudienceProp(); @@ -82,6 +95,11 @@ public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenD this.tokenDecryptionKey = tokenDecryptionKey; } + private static TokenCustomizer findTokenCustomizer(OidcTenantConfig config) { + + return config == null ? null : TokenCustomizerFinder.find(config.token.getTokenCustomizer().orElse(null)); + } + private String checkIssuerProp() { String issuerProp = null; if (oidcConfig != null) { @@ -107,7 +125,7 @@ public TokenVerificationResult verifySelfSignedJwtToken(String token) throws Inv } public TokenVerificationResult verifyJwtToken(String token) throws InvalidJwtException { - return verifyJwtTokenInternal(token, ASYMMETRIC_ALGORITHM_CONSTRAINTS, asymmetricKeyResolver, true); + return verifyJwtTokenInternal(customizeJwtToken(token), ASYMMETRIC_ALGORITHM_CONSTRAINTS, asymmetricKeyResolver, true); } public TokenVerificationResult verifyLogoutJwtToken(String token) throws InvalidJwtException { @@ -180,6 +198,22 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, AlgorithmCo return result; } + private String customizeJwtToken(String token) { + if (tokenCustomizer != null) { + JsonObject headers = AbstractJsonObjectResponse.toJsonObject( + OidcUtils.decodeJwtHeadersAsString(token)); + headers = tokenCustomizer.customizeHeaders(headers); + if (headers != null) { + String newHeaders = new String( + Base64.getUrlEncoder().withoutPadding().encode(headers.toString().getBytes()), + StandardCharsets.UTF_8); + int dotIndex = token.indexOf('.'); + return newHeaders + token.substring(dotIndex); + } + } + return token; + } + private void verifyTokenAge(Long iat) throws InvalidJwtException { if (oidcConfig.token.age.isPresent() && iat != null) { final long now = now() / 1000; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 8aa82db7da36b..6717016e37ad7 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -149,6 +149,11 @@ public static JsonObject decodeJwtHeaders(String jwt) { return decodeAsJsonObject(tokens.nextToken()); } + public static String decodeJwtHeadersAsString(String jwt) { + StringTokenizer tokens = new StringTokenizer(jwt, "."); + return base64UrlDecode(tokens.nextToken()); + } + public static List findRoles(String clientId, OidcTenantConfig.Roles rolesConfig, JsonObject json) { // If the user configured specific paths - check and enforce the claims at these paths exist if (rolesConfig.getRoleClaimPath().isPresent()) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java new file mode 100644 index 0000000000000..061acc215ad66 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java @@ -0,0 +1,25 @@ +package io.quarkus.oidc.runtime; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.TokenCustomizer; + +public class TokenCustomizerFinder { + + public static TokenCustomizer find(String name) { + ArcContainer container = Arc.container(); + TokenCustomizer tokenCustomizer = null; + if (container != null) { + tokenCustomizer = name != null + ? (TokenCustomizer) container.instance(name).get() + : container.instance(TokenCustomizer.class).get(); + } + if (tokenCustomizer == null && name != null) { + throw new OIDCException("Unable to find TokenCustomizer " + name); + } + + return tokenCustomizer; + } + +} diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java new file mode 100644 index 0000000000000..76ff31d5de6a9 --- /dev/null +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java @@ -0,0 +1,64 @@ +package io.quarkus.oidc.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import jakarta.json.Json; +import jakarta.json.JsonObject; + +import org.jose4j.jwk.RsaJsonWebKey; +import org.jose4j.jwk.RsaJwkGenerator; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenCustomizer; +import io.smallrye.jwt.build.Jwt; + +public class OidcProviderTest { + + @SuppressWarnings("resource") + @Test + public void testCustomizer() throws Exception { + + RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048); + rsaJsonWebKey.setKeyId("k1"); + + final String token = Jwt.issuer("http://keycloak/ream").jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey()); + final String newToken = replaceAlgorithm(token, "ES256"); + JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}"); + OidcTenantConfig oidcConfig = new OidcTenantConfig(); + + OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null); + try { + provider.verifyJwtToken(newToken); + fail("InvalidJwtException expected"); + } catch (InvalidJwtException ex) { + // continue + } + + provider = new OidcProvider(null, oidcConfig, jwkSet, new TokenCustomizer() { + + @Override + public JsonObject customizeHeaders(JsonObject headers) { + return Json.createObjectBuilder(headers).add("alg", "RS256").build(); + } + + }, null); + TokenVerificationResult result = provider.verifyJwtToken(newToken); + assertEquals("http://keycloak/ream", result.localVerificationResult.getString("iss")); + } + + private static String replaceAlgorithm(String token, String algorithm) { + io.vertx.core.json.JsonObject headers = OidcUtils.decodeJwtHeaders(token); + headers.put("alg", algorithm); + String newHeaders = new String( + Base64.getUrlEncoder().withoutPadding().encode(headers.toString().getBytes()), + StandardCharsets.UTF_8); + int dotIndex = token.indexOf('.'); + return newHeaders + token.substring(dotIndex); + } +}