diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index 05c3f20801f34..e2f1acadb1504 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -38,6 +38,7 @@ import io.quarkus.oidc.runtime.OidcSessionImpl; import io.quarkus.oidc.runtime.OidcTokenCredentialProducer; import io.quarkus.oidc.runtime.TenantConfigBean; +import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer; import io.quarkus.runtime.TlsConfig; import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; import io.quarkus.vertx.http.deployment.SecurityInformationBuildItem; @@ -85,7 +86,8 @@ public void additionalBeans(BuildProducer additionalBea .addBeanClass(DefaultTenantConfigResolver.class) .addBeanClass(DefaultTokenStateManager.class) .addBeanClass(OidcSessionImpl.class) - .addBeanClass(BackChannelLogoutHandler.class); + .addBeanClass(BackChannelLogoutHandler.class) + .addBeanClass(AzureAccessTokenCustomizer.class); additionalBeans.produce(builder.build()); } 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 afa71a5ed1790..b6d3a78a10b2e 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 @@ -1293,6 +1293,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 as a named bean. + * Prefer using {@link Tenant} qualifier when registering custom {@link TokenCustomizer}. + * Use this property only to refer to `TokenCustomizer` implementations provided by this extension. + */ + @ConfigItem + public Optional customizerName = 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. @@ -1437,6 +1447,14 @@ public Optional getSignatureAlgorithm() { public void setSignatureAlgorithm(SignatureAlgorithm signatureAlgorithm) { this.signatureAlgorithm = Optional.of(signatureAlgorithm); + } + + public Optional getCustomizerName() { + return customizerName; + } + + public void setCustomizerName(String customizerName) { + this.customizerName = Optional.of(customizerName); } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Tenant.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Tenant.java new file mode 100644 index 0000000000000..67bf119d458f7 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Tenant.java @@ -0,0 +1,19 @@ +package io.quarkus.oidc; + +import static java.lang.annotation.ElementType.TYPE; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Qualifier which can be used to associate one or more OIDC features with a named tenant. + */ +@Target({ TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Tenant { + /** + * Identifies an OIDC tenant to which a given feature applies. + */ + String value(); +} 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..d19c3f81965c6 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCustomizer.java @@ -0,0 +1,32 @@ +package io.quarkus.oidc; + +import jakarta.json.JsonObject; + +/** + * TokenCustomizer can be used to change token headers to their original value for the token verification to succeed. + * + * Use it only if OIDC provider has changed some of the header values after the token signature has been created + * for security reasons. Changing the headers in all other cases will lead to the token signature verification failure. + * + * Please note that JSON canonicalization is not performed as part of JWT token signing process. + * It means that if OIDC provider adds ignorable characters such as spaces or newline characters to JSON + * which represents token headers then these characters will also be included as an additional input to + * the token signing process. In this case recreating exactly the same JSON token headers sequence after the headers + * have been modified by this customizer will not be possible and the signature verification will fail. + * + * Custom token customizers should be registered and discoverable as CDI beans. + * They should be bound to specific OIDC tenants with a {@link Tenant} qualifier. + * with the exception of named customizers provided by this extension which have to be selected with + * a `quarkus.oidc.token.customizer-name` property. + * + * Custom token customizers without a {@link Tenant} qualifier will be bound to all OIDC tenants. + */ +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 1e915dbab725d..2018ea240ef49 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; @@ -60,6 +65,7 @@ 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; @@ -67,8 +73,14 @@ public class OidcProvider implements Closeable { final AlgorithmConstraints requiredAlgorithmConstraints; public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) { + this(client, oidcConfig, jwks, TokenCustomizerFinder.find(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); @@ -82,6 +94,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); this.asymmetricKeyResolver = new LocalPublicKeyResolver(publicKeyEnc); this.issuer = checkIssuerProp(); this.audience = checkAudienceProp(); @@ -125,8 +138,8 @@ public TokenVerificationResult verifySelfSignedJwtToken(String token) throws Inv public TokenVerificationResult verifyJwtToken(String token, boolean enforceAudienceVerification) throws InvalidJwtException { - return verifyJwtTokenInternal(token, enforceAudienceVerification, - requiredAlgorithmConstraints != null ? requiredAlgorithmConstraints : ASYMMETRIC_ALGORITHM_CONSTRAINTS, + return verifyJwtTokenInternal(customizeJwtToken(token), enforceAudienceVerification, + (requiredAlgorithmConstraints != null ? requiredAlgorithmConstraints : ASYMMETRIC_ALGORITHM_CONSTRAINTS), asymmetricKeyResolver, true); } @@ -208,6 +221,23 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, boolean enf 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('.'); + String newToken = newHeaders + token.substring(dotIndex); + return newToken; + } + } + 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 d8c9995300588..2f3ad0f8aa2a9 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 @@ -158,6 +158,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()) { @@ -444,6 +449,9 @@ static OidcTenantConfig mergeTenantConfig(OidcTenantConfig tenant, OidcTenantCon if (tenant.token.verifyAccessTokenWithUserInfo.isEmpty()) { tenant.token.verifyAccessTokenWithUserInfo = provider.token.verifyAccessTokenWithUserInfo; } + if (tenant.token.customizerName.isEmpty()) { + tenant.token.customizerName = provider.token.customizerName; + } return tenant; } 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..24e9ddfbd171c --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java @@ -0,0 +1,39 @@ +package io.quarkus.oidc.runtime; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.Tenant; +import io.quarkus.oidc.TokenCustomizer; + +public class TokenCustomizerFinder { + + public static TokenCustomizer find(OidcTenantConfig oidcConfig) { + if (oidcConfig == null) { + return null; + } + ArcContainer container = Arc.container(); + if (container != null) { + String customizerName = oidcConfig.token.customizerName.orElse(null); + if (customizerName != null && !customizerName.isEmpty()) { + InstanceHandle tokenCustomizer = container.instance(customizerName); + if (tokenCustomizer.isAvailable()) { + return tokenCustomizer.get(); + } else { + throw new OIDCException("Unable to find TokenCustomizer " + customizerName); + } + } else { + for (InstanceHandle tokenCustomizer : container.listAll(TokenCustomizer.class)) { + Tenant tenantAnn = tokenCustomizer.get().getClass().getAnnotation(Tenant.class); + if (tenantAnn != null && oidcConfig.tenantId.get().equals(tenantAnn.value())) { + return tokenCustomizer.get(); + } + } + } + } + return null; + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java new file mode 100644 index 0000000000000..5d764fe560fff --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java @@ -0,0 +1,36 @@ +package io.quarkus.oidc.runtime.providers; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Named; +import jakarta.json.Json; +import jakarta.json.JsonObject; + +import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.TokenCustomizer; +import io.quarkus.oidc.runtime.OidcUtils; + +@Named("azure-access-token-customizer") +@ApplicationScoped +public class AzureAccessTokenCustomizer implements TokenCustomizer { + private static final String NONCE = "nonce"; + + @Override + public JsonObject customizeHeaders(JsonObject headers) { + try { + String nonce = headers.getString(NONCE); + if (nonce != null) { + 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(); + } + return null; + } catch (Exception ex) { + throw new OIDCException(ex); + } + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java index cd1f335783054..b3e0faaa91d91 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java @@ -75,6 +75,7 @@ private static OidcTenantConfig microsoft() { ret.setAuthServerUrl("https://login.microsoftonline.com/common/v2.0"); ret.setApplicationType(OidcTenantConfig.ApplicationType.WEB_APP); ret.getToken().setIssuer("any"); + ret.getToken().setCustomizerName("azure-access-token-customizer"); ret.getAuthentication().setScopes(List.of("openid", "email", "profile")); return ret; } 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..b1638dd5752ec --- /dev/null +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java @@ -0,0 +1,65 @@ +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 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, false); + 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, false); + 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/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index ec454f5a7fa49..c275363a6d4f3 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -238,6 +238,7 @@ public void testAcceptMicrosoftProperties() throws Exception { assertEquals("https://login.microsoftonline.com/common/v2.0", config.getAuthServerUrl().get()); assertEquals(List.of("openid", "email", "profile"), config.authentication.scopes.get()); assertEquals("any", config.getToken().getIssuer().get()); + assertEquals("azure-access-token-customizer", config.getToken().getCustomizerName().get()); } @Test @@ -248,6 +249,7 @@ public void testOverrideMicrosoftProperties() throws Exception { tenant.setApplicationType(ApplicationType.HYBRID); tenant.setAuthServerUrl("http://localhost/wiremock"); tenant.getToken().setIssuer("http://localhost/wiremock"); + tenant.getToken().setCustomizerName(""); tenant.authentication.setScopes(List.of("write")); tenant.authentication.setForceRedirectHttpsScheme(false); @@ -259,6 +261,7 @@ public void testOverrideMicrosoftProperties() throws Exception { assertEquals(List.of("write"), config.authentication.scopes.get()); assertEquals("http://localhost/wiremock", config.getToken().getIssuer().get()); assertFalse(config.authentication.forceRedirectHttpsScheme.get()); + assertTrue(config.getToken().getCustomizerName().get().isEmpty()); } @Test diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java index 078878f0b6417..665fbad54356a 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java @@ -7,6 +7,9 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; /** @@ -34,6 +37,14 @@ public String adminRequiredAlgorithm() { return "granted:" + identity.getRoles(); } + @Path("bearer-azure") + @GET + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public String adminAzure() { + return "Issuer:" + ((JsonWebToken) identity.getPrincipal()).getIssuer(); + } + @Path("bearer-no-introspection") @GET @RolesAllowed("admin") diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index eb053dd60814b..80ceb3d197f19 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -46,6 +46,9 @@ public String resolve(RoutingContext context) { } if (path.endsWith("bearer-required-algorithm")) { return "bearer-required-algorithm"; + } + if (path.endsWith("bearer-azure")) { + return "bearer-azure"; } if (path.endsWith("bearer-no-introspection")) { return "bearer-no-introspection"; diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/DefaultTenantTokenCustomizer.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/DefaultTenantTokenCustomizer.java new file mode 100644 index 0000000000000..5e02467a16ae4 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/DefaultTenantTokenCustomizer.java @@ -0,0 +1,27 @@ +package io.quarkus.it.keycloak; + +import jakarta.inject.Singleton; +import jakarta.json.Json; +import jakarta.json.JsonObject; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.Tenant; +import io.quarkus.oidc.TokenCustomizer; + +@Singleton +@Tenant("bearer") +@Unremovable +public class DefaultTenantTokenCustomizer implements TokenCustomizer { + + @Override + public JsonObject customizeHeaders(JsonObject headers) { + String alg = headers.getString("alg"); + if ("RS384".equals(alg)) { + return null; + } else if ("RS512".equals(alg)) { + return Json.createObjectBuilder(headers).add("alg", "RS256").build(); + } + return null; + } + +} diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index e05799cb38d5b..3a8a4d703028e 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -126,6 +126,12 @@ quarkus.oidc.bearer-required-algorithm.client-id=quarkus-app quarkus.oidc.bearer-required-algorithm.credentials.secret=secret quarkus.oidc.bearer-required-algorithm.token.signature-algorithm=PS256 +quarkus.oidc.bearer-azure.provider=microsoft +quarkus.oidc.bearer-azure.application-type=service +quarkus.oidc.bearer-azure.discovery-enabled=false +quarkus.oidc.bearer-azure.jwks-path=${keycloak.url}/azure/jwk +quarkus.oidc.bearer-azure.token.lifespan-grace=2147483647 + quarkus.oidc.bearer-role-claim-path.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.bearer-role-claim-path.client-id=quarkus-app quarkus.oidc.bearer-role-claim-path.credentials.secret=secret diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 7e9d08055cf0d..3e1b8851ed83b 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -3,8 +3,11 @@ import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static org.hamcrest.Matchers.equalTo; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Arrays; +import java.util.Base64; import java.util.HashSet; import java.util.Set; @@ -14,6 +17,8 @@ import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; +import io.quarkus.deployment.util.FileUtil; +import io.quarkus.oidc.runtime.OidcUtils; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.oidc.server.OidcWireMock; @@ -40,13 +45,58 @@ public void testSecureAccessSuccessPreferredUsername() { } } + @Test + public void testAccessResourceAzure() throws Exception { + String azureJwk = readFile("jwks.json"); + wireMockServer.stubFor(WireMock.get("/auth/azure/jwk") + .willReturn(WireMock.aResponse().withBody(azureJwk))); + String azureToken = readFile("token.txt"); + RestAssured.given().auth().oauth2(azureToken) + .when().get("/api/admin/bearer-azure") + .then() + .statusCode(200) + .body(Matchers.equalTo("Issuer:https://sts.windows.net/e7861267-92c5-4a03-bdb2-2d3e491e7831/")); + } + + 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); + } + } + @Test public void testAccessAdminResource() { - RestAssured.given().auth().oauth2(getAccessToken("admin", Set.of("admin"))) + String token = getAccessToken("admin", Set.of("admin")); + RestAssured.given().auth().oauth2(token) .when().get("/api/admin/bearer") .then() .statusCode(200) .body(Matchers.containsString("admin")); + + token = setTokenAlgorithm(token, "RS384"); + + RestAssured.given().auth().oauth2(token) + .when().get("/api/admin/bearer") + .then() + .statusCode(401); + + token = setTokenAlgorithm(token, "RS512"); + RestAssured.given().auth().oauth2(token) + .when().get("/api/admin/bearer") + .then() + .statusCode(200) + .body(Matchers.containsString("admin")); + } + + private static String setTokenAlgorithm(String token, String alg) { + io.vertx.core.json.JsonObject headers = OidcUtils.decodeJwtHeaders(token); + headers.put("alg", alg); + String newHeaders = new String( + Base64.getUrlEncoder().withoutPadding().encode(headers.toString().getBytes()), + StandardCharsets.UTF_8); + int dotIndex = token.indexOf('.'); + return newHeaders + token.substring(dotIndex); } @Test diff --git a/integration-tests/oidc-wiremock/src/test/resources/jwks.json b/integration-tests/oidc-wiremock/src/test/resources/jwks.json new file mode 100644 index 0000000000000..6409d6926c6a9 --- /dev/null +++ b/integration-tests/oidc-wiremock/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/integration-tests/oidc-wiremock/src/test/resources/token.txt b/integration-tests/oidc-wiremock/src/test/resources/token.txt new file mode 100644 index 0000000000000..4f06f926a3e91 --- /dev/null +++ b/integration-tests/oidc-wiremock/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