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 dffe360fb5e0a3..e33cab844b8c44 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 00000000000000..d39662a2d5bdac --- /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 6dc0e7d4a2ca59..9dc01cc51c1569 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 eb993936834e5e..3a0288b1fc0f09 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 8aa82db7da36bd..6717016e37ad74 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 00000000000000..061acc215ad662 --- /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 00000000000000..b53ec46cfcf122 --- /dev/null +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java @@ -0,0 +1,114 @@ +package io.quarkus.oidc.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; + +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.deployment.util.FileUtil; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenCustomizer; +import io.smallrye.jwt.build.Jwt; +import jakarta.json.Json; +import jakarta.json.JsonObject; + +public class OidcProviderTest { + + @SuppressWarnings("resource") + @Test + public void testAzureTokenCustomizer() throws Exception { + + final String token = readFile("token.txt"); + final String jwkSetString = readFile("jwks.json"); + JsonWebKeySet jwkSet = new JsonWebKeySet(jwkSetString); + OidcTenantConfig oidcConfig = new OidcTenantConfig(); + oidcConfig.token.setLifespanGrace(Integer.MAX_VALUE); + + OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null); + try { + provider.verifyJwtToken(token); + fail("InvalidJwtException expected"); + } catch (InvalidJwtException ex) { + // continue + } + + provider = new OidcProvider(null, oidcConfig, jwkSet, new TokenCustomizer() { + + @Override + public JsonObject customizeHeaders(JsonObject headers) { + try { + String nonce = headers.getString("nonce"); + byte[] nonceSha256 = OidcUtils.getSha256Digest(nonce.getBytes(StandardCharsets.UTF_8)); + byte[] newNonceBytes = Base64.getUrlEncoder().withoutPadding().encode(nonceSha256); + return Json.createObjectBuilder(headers) + .add("nonce", new String(newNonceBytes, StandardCharsets.UTF_8)).build(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + }, null); + + TokenVerificationResult result = provider.verifyJwtToken(token); + assertEquals("https://sts.windows.net/e7861267-92c5-4a03-bdb2-2d3e491e7831/", + result.localVerificationResult.getString("iss")); + } + + private String readFile(String filePath) throws Exception { + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(filePath)) { + byte[] content = FileUtil.readFileContents(is); + return new String(content, StandardCharsets.UTF_8); + } + } + + @SuppressWarnings("resource") + @Test + public void testAlgorithmCustomizer() 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); + } + +} diff --git a/extensions/oidc/runtime/src/test/resources/jwks.json b/extensions/oidc/runtime/src/test/resources/jwks.json new file mode 100644 index 00000000000000..6409d6926c6a92 --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/jwks.json @@ -0,0 +1,14 @@ +{ + "keys": [ + { + "kty":"RSA", + "use":"sig", + "kid":"-KI3Q9nNR7bRofxmeZoXqbHZGew", + "x5t":"-KI3Q9nNR7bRofxmeZoXqbHZGew", + "n":"tJL6Wr2JUsxLyNezPQh1J6zn6wSoDAhgRYSDkaMuEHy75VikiB8wg25WuR96gdMpookdlRvh7SnRvtjQN9b5m4zJCMpSRcJ5DuXl4mcd7Cg3Zp1C5-JmMq8J7m7OS9HpUQbA1yhtCHqP7XA4UnQI28J-TnGiAa3viPLlq0663Cq6hQw7jYo5yNjdJcV5-FS-xNV7UHR4zAMRruMUHxte1IZJzbJmxjKoEjJwDTtcd6DkI3yrkmYt8GdQmu0YBHTJSZiz-M10CY3LbvLzf-tbBNKQ_gfnGGKF7MvRCmPA_YF_APynrIG7p4vPDRXhpG3_CIt317NyvGoIwiv0At83kQ", + "e":"AQAB", + "x5c":["MIIDBTCCAe2gAwIBAgIQGQ6YG6NleJxJGDRAwAd/ZTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIyMTAwMjE4MDY0OVoXDTI3MTAwMjE4MDY0OVowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALSS+lq9iVLMS8jXsz0IdSes5+sEqAwIYEWEg5GjLhB8u+VYpIgfMINuVrkfeoHTKaKJHZUb4e0p0b7Y0DfW+ZuMyQjKUkXCeQ7l5eJnHewoN2adQufiZjKvCe5uzkvR6VEGwNcobQh6j+1wOFJ0CNvCfk5xogGt74jy5atOutwquoUMO42KOcjY3SXFefhUvsTVe1B0eMwDEa7jFB8bXtSGSc2yZsYyqBIycA07XHeg5CN8q5JmLfBnUJrtGAR0yUmYs/jNdAmNy27y83/rWwTSkP4H5xhihezL0QpjwP2BfwD8p6yBu6eLzw0V4aRt/wiLd9ezcrxqCMIr9ALfN5ECAwEAAaMhMB8wHQYDVR0OBBYEFJcSH+6Eaqucndn9DDu7Pym7OA8rMA0GCSqGSIb3DQEBCwUAA4IBAQADKkY0PIyslgWGmRDKpp/5PqzzM9+TNDhXzk6pw8aESWoLPJo90RgTJVf8uIj3YSic89m4ftZdmGFXwHcFC91aFe3PiDgCiteDkeH8KrrpZSve1pcM4SNjxwwmIKlJdrbcaJfWRsSoGFjzbFgOecISiVaJ9ZWpb89/+BeAz1Zpmu8DSyY22dG/K6ZDx5qNFg8pehdOUYY24oMamd4J2u2lUgkCKGBZMQgBZFwk+q7H86B/byGuTDEizLjGPTY/sMms1FAX55xBydxrADAer/pKrOF1v7Dq9C1Z9QVcm5D9G4DcenyWUdMyK43NXbVQLPxLOng51KO9icp2j4U7pwHP"], + "issuer":"https://login.microsoftonline.com/e7861267-92c5-4a03-bdb2-2d3e491e7831/v2.0" + } + ] +} \ No newline at end of file diff --git a/extensions/oidc/runtime/src/test/resources/token.txt b/extensions/oidc/runtime/src/test/resources/token.txt new file mode 100644 index 00000000000000..4f06f926a3e913 --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/token.txt @@ -0,0 +1 @@ +eyJ0eXAiOiJKV1QiLCJub25jZSI6ImM0LXg2c0RzQ2FXeVV2a25leEozekpOVXdfRU56U3FmSjg2dnJrcUs5NTQiLCJhbGciOiJSUzI1NiIsIng1dCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyIsImtpZCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9lNzg2MTI2Ny05MmM1LTRhMDMtYmRiMi0yZDNlNDkxZTc4MzEvIiwiaWF0IjoxNjgzOTE2MTY1LCJuYmYiOjE2ODM5MTYxNjUsImV4cCI6MTY4MzkyMDgyNywiYWNjdCI6MCwiYWNyIjoiMSIsImFpbyI6IkFUUUF5LzhUQUFBQXJmc0hnb0NoL2o3Rkw0cGdMRGk0QktwTUNFRWZrWlBnQStGR3ltZUNqazIvbnV0clg3NGVuSVdQekRBbmw1ankiLCJhbXIiOlsicHdkIl0sImFwcF9kaXNwbGF5bmFtZSI6InRlc3QiLCJhcHBpZCI6IjRmYmEzODc5LWI1ZmItNDYzMy05ZGUzLTEyZGQ4ZTI2ZmE2NyIsImFwcGlkYWNyIjoiMSIsImlkdHlwIjoidXNlciIsImlwYWRkciI6IjI0MDY6NzQwMDo1MToyODExOjFjN2U6NGRjMjo4MjZiOmZhMzMiLCJuYW1lIjoidGVzdCIsIm9pZCI6ImM2MDMwM2FjLTEwYTQtNGM3OC05NTY0LTUwYTc2MTA0MzBlNSIsInBsYXRmIjoiNSIsInB1aWQiOiIxMDAzMjAwMkExMkQwMjFBIiwicmgiOiIwLkFWQUFaeEtHNThXU0EwcTlzaTAtU1I1NE1RTUFBQUFBQUFBQXdBQUFBQUFBQUFCX0FHay4iLCJzY3AiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInN1YiI6InA3V1RyMnFqSFdwbUZScWc3NHJVQVhtcHlEVGJjemM2RjY3RVhkd2dDcnciLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiTkEiLCJ0aWQiOiJlNzg2MTI2Ny05MmM1LTRhMDMtYmRiMi0yZDNlNDkxZTc4MzEiLCJ1bmlxdWVfbmFtZSI6ImpvbmRvZUBxdWFya3Vzb2lkY3Rlc3Qub25taWNyb3NvZnQuY29tIiwidXBuIjoiam9uZG9lQHF1YXJrdXNvaWRjdGVzdC5vbm1pY3Jvc29mdC5jb20iLCJ1dGkiOiJjQ1R1NmtmVlJFR3VBckJnLWpVWEFBIiwidmVyIjoiMS4wIiwid2lkcyI6WyJiNzlmYmY0ZC0zZWY5LTQ2ODktODE0My03NmIxOTRlODU1MDkiXSwieG1zX3N0Ijp7InN1YiI6IkNlSmk2bElucFRQSUtNWkdQRGFsT3NFUkhSZlZjLXdEZDNFZERsOHBEU0EifSwieG1zX3RjZHQiOjE2ODM5MTUyMTR9.pjAwut-ko5xjM-4i-AXG_5fenhRc-Q0QpeboWEEi2Yo8IFEIL-Qc3tcsO0PwmVguayklH9yO6tdIMoKMjSC7wAqKQooNaPxT62UJsBdjGBdTsNUhnJxr8EAn09BILp-tmHk_5P8t3n9t9PWrCRZ--NBpGg_q473OfGM8pfRU5lGDeEgZJ8T0ZgDPJ45qDU5FFZzZE6TougYtcr2ABD95woK6-rHqdXbanHEjp4ZWYpEw2hBm5cFhGQIU3fqEANt2DRJrGu3bv7VYvcn4U-LfOKyIFu6yCZOniREawzFmBwzztjgWw_v7Cs2vJdl8CpQ6edWplinJgybJIE8HGBojWA \ No newline at end of file