diff --git a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc index 5aec76dea3521..155aa0516065f 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -1031,8 +1031,9 @@ quarkus.oidc-client.credentials.secret=secret quarkus.oidc-client.grant.type=exchange quarkus.oidc-client.grant-options.exchange.audience=quarkus-app-exchange -quarkus.oidc-token-propagation.exchange-token=true +quarkus.oidc-token-propagation.exchange-token=true <1> ---- +<1> Please note that the `exchange-token` configuration property is ignored when the OidcClient name is set with the `io.quarkus.oidc.token.propagation.AccessToken#exchangeTokenClient` annotation attribute. Note `AccessTokenRequestReactiveFilter` will use `OidcClient` to exchange the current token, and you can use `quarkus.oidc-client.grant-options.exchange` to set the additional exchange properties expected by your OpenID Connect Provider. @@ -1051,7 +1052,7 @@ quarkus.oidc-client.scopes=https://graph.microsoft.com/user.read,offline_access quarkus.oidc-token-propagation-reactive.exchange-token=true ---- -`AccessTokenRequestReactiveFilter` uses a default `OidcClient` by default. A named `OidcClient` can be selected with a `quarkus.oidc-token-propagation-reactive.client-name` configuration property. +`AccessTokenRequestReactiveFilter` uses a default `OidcClient` by default. A named `OidcClient` can be selected with a `quarkus.oidc-token-propagation-reactive.client-name` configuration property or with the `io.quarkus.oidc.token.propagation.AccessToken#exchangeTokenClient` annotation attribute. [[token-propagation]] == Token Propagation diff --git a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/AccessTokenInstanceBuildItem.java b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/AccessTokenInstanceBuildItem.java new file mode 100644 index 0000000000000..91a19805c755b --- /dev/null +++ b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/AccessTokenInstanceBuildItem.java @@ -0,0 +1,39 @@ +package io.quarkus.oidc.client.deployment; + +import java.util.Objects; + +import org.jboss.jandex.AnnotationTarget; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Represents one {@link io.quarkus.oidc.token.propagation.AccessToken} annotation instance. + */ +public final class AccessTokenInstanceBuildItem extends MultiBuildItem { + + private final String clientName; + private final boolean tokenExchange; + private final AnnotationTarget annotationTarget; + + AccessTokenInstanceBuildItem(String clientName, Boolean tokenExchange, AnnotationTarget annotationTarget) { + this.clientName = Objects.requireNonNull(clientName); + this.tokenExchange = tokenExchange; + this.annotationTarget = Objects.requireNonNull(annotationTarget); + } + + public String getClientName() { + return clientName; + } + + public boolean exchangeTokenActivated() { + return tokenExchange; + } + + public AnnotationTarget getAnnotationTarget() { + return annotationTarget; + } + + public String targetClass() { + return annotationTarget.asClass().name().toString(); + } +} diff --git a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/AccessTokenRequestFilterGenerator.java b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/AccessTokenRequestFilterGenerator.java new file mode 100644 index 0000000000000..d515193e36bf2 --- /dev/null +++ b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/AccessTokenRequestFilterGenerator.java @@ -0,0 +1,95 @@ +package io.quarkus.oidc.client.deployment; + +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; + +import jakarta.annotation.Priority; +import jakarta.inject.Singleton; + +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.gizmo.ClassCreator; + +public final class AccessTokenRequestFilterGenerator { + + private static final int AUTHENTICATION = 1000; + + private record ClientNameAndExchangeToken(String clientName, boolean exchangeTokenActivated) { + } + + private final BuildProducer unremovableBeansProducer; + private final BuildProducer reflectiveClassProducer; + private final BuildProducer generatedBeanProducer; + private final Class requestFilterClass; + private final Map cache = new HashMap<>(); + + public AccessTokenRequestFilterGenerator(BuildProducer unremovableBeansProducer, + BuildProducer reflectiveClassProducer, + BuildProducer generatedBeanProducer, Class requestFilterClass) { + this.unremovableBeansProducer = unremovableBeansProducer; + this.reflectiveClassProducer = reflectiveClassProducer; + this.generatedBeanProducer = generatedBeanProducer; + this.requestFilterClass = requestFilterClass; + } + + public String generateClass(AccessTokenInstanceBuildItem instance) { + return cache.computeIfAbsent( + new ClientNameAndExchangeToken(instance.getClientName(), instance.exchangeTokenActivated()), i -> { + var adaptor = new GeneratedBeanGizmoAdaptor(generatedBeanProducer); + String className = createUniqueClassName(i); + try (ClassCreator classCreator = ClassCreator.builder() + .className(className) + .superClass(requestFilterClass) + .classOutput(adaptor) + .build()) { + classCreator.addAnnotation(Priority.class).add("value", AUTHENTICATION); + classCreator.addAnnotation(Singleton.class); + + if (!i.clientName().isEmpty()) { + try (var methodCreator = classCreator.getMethodCreator("getClientName", String.class)) { + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.setModifiers(Modifier.PROTECTED); + methodCreator.returnValue(methodCreator.load(i.clientName())); + } + } + if (i.exchangeTokenActivated()) { + try (var methodCreator = classCreator.getMethodCreator("isExchangeToken", boolean.class)) { + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.setModifiers(Modifier.PROTECTED); + methodCreator.returnBoolean(true); + } + } + } + unremovableBeansProducer.produce(UnremovableBeanBuildItem.beanClassNames(className)); + reflectiveClassProducer + .produce(ReflectiveClassBuildItem.builder(className).methods().fields().constructors().build()); + return className; + }); + } + + private String createUniqueClassName(ClientNameAndExchangeToken i) { + return "%s_%sClient_%sTokenExchange".formatted(requestFilterClass.getName(), clientName(i.clientName()), + exchangeTokenName(i.exchangeTokenActivated())); + } + + private static String clientName(String clientName) { + if (clientName.isEmpty()) { + return "Default"; + } else { + return clientName; + } + } + + private static String exchangeTokenName(boolean enabled) { + if (enabled) { + return "Enabled"; + } else { + return "Default"; + } + } +} diff --git a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java index 04ffea66bf3b8..7019e54ec2c58 100644 --- a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java +++ b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java @@ -3,6 +3,7 @@ import static io.quarkus.oidc.client.deployment.OidcClientFilterDeploymentHelper.sanitize; import java.lang.reflect.Modifier; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -12,6 +13,7 @@ import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Singleton; +import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.DotName; import io.quarkus.arc.BeanDestroyer; @@ -28,6 +30,7 @@ import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationArchivesBuildItem; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.gizmo.ClassCreator; @@ -45,12 +48,15 @@ import io.quarkus.oidc.client.runtime.OidcClientsConfig; import io.quarkus.oidc.client.runtime.TokensHelper; import io.quarkus.oidc.client.runtime.TokensProducer; +import io.quarkus.oidc.token.propagation.AccessToken; import io.quarkus.runtime.TlsConfig; import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; @BuildSteps(onlyIf = OidcClientBuildStep.IsEnabled.class) public class OidcClientBuildStep { + private static final DotName ACCESS_TOKEN = DotName.createSimple(AccessToken.class.getName()); + @BuildStep ExtensionSslNativeSupportBuildItem enableSslInNative() { return new ExtensionSslNativeSupportBuildItem(Feature.OIDC_CLIENT); @@ -149,6 +155,26 @@ public void createNonDefaultTokensProducers( } } + @BuildStep + public List collectAccessTokenInstances(CombinedIndexBuildItem index) { + record ItemBuilder(AnnotationInstance instance) { + + private String toClientName() { + var value = instance.value("exchangeTokenClient"); + return value == null || value.asString().equals("Default") ? "" : value.asString(); + } + + private boolean toExchangeToken() { + return instance.value("exchangeTokenClient") != null; + } + + private AccessTokenInstanceBuildItem build() { + return new AccessTokenInstanceBuildItem(toClientName(), toExchangeToken(), instance.target()); + } + } + return index.getIndex().getAnnotations(ACCESS_TOKEN).stream().map(ItemBuilder::new).map(ItemBuilder::build).toList(); + } + /** * Creates a Tokens producer class like follows: * diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessToken.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessToken.java index 2debaf34ecd77..25965449f7290 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessToken.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessToken.java @@ -17,4 +17,13 @@ @Retention(RetentionPolicy.RUNTIME) @Documented public @interface AccessToken { + + /** + * Selects name of the configured OidcClient and activates token exchange for the annotated REST client. + * Please note that the default OidcClient's name is `Default`. You do not have to enable this attribute + * if you use the default OidcClient and already have either 'quarkus.oidc-token-propagation.exchange-token' + * or 'quarkus.oidc-token-propagation-reactive.exchange-token' property set to 'true' + */ + String exchangeTokenClient() default ""; + } diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveBuildStep.java b/extensions/oidc-token-propagation-reactive/deployment/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveBuildStep.java index e3862e6a1e077..53cc81dbebe44 100644 --- a/extensions/oidc-token-propagation-reactive/deployment/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveBuildStep.java +++ b/extensions/oidc-token-propagation-reactive/deployment/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveBuildStep.java @@ -3,26 +3,29 @@ import static io.quarkus.oidc.token.propagation.TokenPropagationConstants.JWT_PROPAGATE_TOKEN_CREDENTIAL; import static io.quarkus.oidc.token.propagation.TokenPropagationConstants.OIDC_PROPAGATE_TOKEN_CREDENTIAL; -import java.util.Collection; import java.util.List; import java.util.function.BooleanSupplier; +import jakarta.ws.rs.Priorities; + import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.DotName; import org.jboss.jandex.Type; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.builditem.AdditionalIndexedClassesBuildItem; -import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; -import io.quarkus.oidc.token.propagation.AccessToken; +import io.quarkus.oidc.client.deployment.AccessTokenInstanceBuildItem; +import io.quarkus.oidc.client.deployment.AccessTokenRequestFilterGenerator; import io.quarkus.rest.client.reactive.deployment.DotNames; import io.quarkus.rest.client.reactive.deployment.RegisterProviderAnnotationInstanceBuildItem; import io.quarkus.runtime.configuration.ConfigurationException; @@ -30,19 +33,25 @@ @BuildSteps(onlyIf = OidcTokenPropagationReactiveBuildStep.IsEnabled.class) public class OidcTokenPropagationReactiveBuildStep { - private static final DotName ACCESS_TOKEN = DotName.createSimple(AccessToken.class.getName()); - private static final DotName ACCESS_TOKEN_REQUEST_REACTIVE_FILTER = DotName - .createSimple(AccessTokenRequestReactiveFilter.class.getName()); - @BuildStep - void oidcClientFilterSupport(CombinedIndexBuildItem indexBuildItem, - BuildProducer producer) { - Collection instances = indexBuildItem.getIndex().getAnnotations(ACCESS_TOKEN); - for (AnnotationInstance instance : instances) { - String targetClass = instance.target().asClass().name().toString(); - producer.produce(new RegisterProviderAnnotationInstanceBuildItem(targetClass, AnnotationInstance.create( - DotNames.REGISTER_PROVIDER, instance.target(), List.of(AnnotationValue.createClassValue("value", - Type.create(ACCESS_TOKEN_REQUEST_REACTIVE_FILTER, org.jboss.jandex.Type.Kind.CLASS)))))); + void oidcClientFilterSupport(List accessTokenInstances, + BuildProducer unremovableBeans, + BuildProducer reflectiveClass, + BuildProducer generatedBean, + BuildProducer providerProducer) { + if (!accessTokenInstances.isEmpty()) { + var filterGenerator = new AccessTokenRequestFilterGenerator(unremovableBeans, reflectiveClass, generatedBean, + AccessTokenRequestReactiveFilter.class); + for (AccessTokenInstanceBuildItem instance : accessTokenInstances) { + String providerClass = filterGenerator.generateClass(instance); + providerProducer + .produce(new RegisterProviderAnnotationInstanceBuildItem(instance.targetClass(), + AnnotationInstance.create(DotNames.REGISTER_PROVIDER, instance.getAnnotationTarget(), List.of( + AnnotationValue.createClassValue("value", + Type.create(DotName.createSimple(providerClass), + org.jboss.jandex.Type.Kind.CLASS)), + AnnotationValue.createIntegerValue("priority", Priorities.AUTHENTICATION))))); + } } } @@ -55,7 +64,6 @@ void registerProvider(BuildProducer additionalBeans, ReflectiveClassBuildItem.builder(AccessTokenRequestReactiveFilter.class).methods().fields().build()); additionalIndexedClassesBuildItem .produce(new AdditionalIndexedClassesBuildItem(AccessTokenRequestReactiveFilter.class.getName())); - } @BuildStep(onlyIf = IsEnabledDuringAuth.class) diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenAnnotationTest.java b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenAnnotationTest.java new file mode 100644 index 0000000000000..e16074e725252 --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenAnnotationTest.java @@ -0,0 +1,178 @@ +package io.quarkus.oidc.token.propagation.reactive; + +import static org.hamcrest.Matchers.equalTo; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.token.propagation.AccessToken; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.oidc.client.OidcTestClient; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import io.restassured.RestAssured; + +@QuarkusTestResource(OidcWiremockTestResource.class) +public class AccessTokenAnnotationTest { + + final static OidcTestClient client = new OidcTestClient(); + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(DefaultClientDefaultExchange.class, DefaultClientEnabledExchange.class, + NamedClientDefaultExchange.class, MultiProviderFrontendResource.class, ProtectedResource.class, + CustomAccessTokenRequestFilter.class) + .addAsResource( + new StringAsset( + """ + quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus + quarkus.oidc.client-id=quarkus-app + quarkus.oidc.credentials.secret=secret + + quarkus.oidc-client.auth-server-url=${quarkus.oidc.auth-server-url} + quarkus.oidc-client.client-id=${quarkus.oidc.client-id} + quarkus.oidc-client.credentials.client-secret.value=${quarkus.oidc.credentials.secret} + quarkus.oidc-client.credentials.client-secret.method=post + quarkus.oidc-client.grant.type=jwt + quarkus.oidc-client.scopes=https://graph.microsoft.com/user.read,offline_access + quarkus.oidc-client.grant-options.jwt.requested_token_use=on_behalf_of + quarkus.oidc-client.token-path=${keycloak.url}/realms/quarkus/jwt-bearer-token + + quarkus.oidc-client.named.auth-server-url=${quarkus.oidc-client.auth-server-url} + quarkus.oidc-client.named.client-id=${quarkus.oidc-client.client-id} + quarkus.oidc-client.named.credentials.client-secret.value=${quarkus.oidc-client.credentials.client-secret.value} + quarkus.oidc-client.named.credentials.client-secret.method=${quarkus.oidc-client.credentials.client-secret.method} + quarkus.oidc-client.named.grant.type=${quarkus.oidc-client.grant.type} + quarkus.oidc-client.named.scopes=${quarkus.oidc-client.scopes} + quarkus.oidc-client.named.grant-options.jwt.requested_token_use=${quarkus.oidc-client.grant-options.jwt.requested_token_use} + quarkus.oidc-client.named.token-path=${quarkus.oidc-client.token-path} + """), + "application.properties")); + + @AfterAll + public static void close() { + client.close(); + } + + @Test + public void testDefaultClientEnabledTokenExchange() { + testRestClientTokenPropagation(true, "defaultClientEnabledExchange"); + } + + @Test + public void testDefaultClientDefaultTokenExchange() { + testRestClientTokenPropagation(false, "defaultClientDefaultExchange"); + } + + @Test + public void testNamedClientDefaultTokenExchange() { + testRestClientTokenPropagation(true, "namedClientDefaultExchange"); + } + + private void testRestClientTokenPropagation(boolean exchangeEnabled, String clientKey) { + String newTokenUsername = exchangeEnabled ? "bob" : "alice"; + RestAssured.given().auth().oauth2(getBearerAccessToken()) + .queryParam("client-key", clientKey) + .when().get("/frontend/token-propagation") + .then() + .statusCode(200) + .body(equalTo("original token username: alice new token username: " + newTokenUsername)); + } + + public String getBearerAccessToken() { + return client.getAccessToken("alice", "alice"); + } + + @RegisterRestClient(baseUri = "http://localhost:8081/protected") + @AccessToken + @Path("/") + public interface DefaultClientDefaultExchange { + @GET + String getUserName(); + } + + @RegisterRestClient(baseUri = "http://localhost:8081/protected") + @AccessToken(exchangeTokenClient = "Default") + @Path("/") + public interface DefaultClientEnabledExchange { + @GET + String getUserName(); + } + + @RegisterRestClient(baseUri = "http://localhost:8081/protected") + @AccessToken(exchangeTokenClient = "named") + @Path("/") + public interface NamedClientDefaultExchange { + @GET + String getUserName(); + } + + // tests no AmbiguousResolutionException is raised + @Singleton + @Unremovable + public static class CustomAccessTokenRequestFilter extends AccessTokenRequestReactiveFilter { + } + + @Path("/frontend") + public static class MultiProviderFrontendResource { + @Inject + @RestClient + DefaultClientDefaultExchange defaultClientDefaultExchange; + + @Inject + @RestClient + DefaultClientEnabledExchange defaultClientEnabledExchange; + + @Inject + @RestClient + NamedClientDefaultExchange namedClientDefaultExchange; + + @Inject + JsonWebToken jwt; + + @GET + @Path("token-propagation") + @RolesAllowed("admin") + public String userNameTokenPropagation(@QueryParam("client-key") String clientKey) { + return getResponseWithExchangedUsername(clientKey); + } + + @GET + @Path("token-propagation-with-augmentor") + @RolesAllowed("tester") // tester role is granted by SecurityIdentityAugmentor + public String userNameTokenPropagationWithSecIdentityAugmentor(@QueryParam("client-key") String clientKey) { + return getResponseWithExchangedUsername(clientKey); + } + + private String getResponseWithExchangedUsername(String clientKey) { + if ("alice".equals(jwt.getName())) { + return "original token username: " + jwt.getName() + " new token username: " + getUserName(clientKey); + } else { + throw new RuntimeException(); + } + } + + private String getUserName(String clientKey) { + return switch (clientKey) { + case "defaultClientDefaultExchange" -> defaultClientDefaultExchange.getUserName(); + case "defaultClientEnabledExchange" -> defaultClientEnabledExchange.getUserName(); + case "namedClientDefaultExchange" -> namedClientDefaultExchange.getUserName(); + default -> throw new IllegalArgumentException("Unknown client key"); + }; + } + } +} diff --git a/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenRequestReactiveFilter.java b/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenRequestReactiveFilter.java index 020ba75a00c7d..a4eabd43ae0f8 100644 --- a/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenRequestReactiveFilter.java +++ b/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenRequestReactiveFilter.java @@ -4,19 +4,17 @@ import static io.quarkus.oidc.token.propagation.TokenPropagationConstants.OIDC_PROPAGATE_TOKEN_CREDENTIAL; import java.util.Collections; -import java.util.Optional; import java.util.function.Consumer; import jakarta.annotation.PostConstruct; import jakarta.annotation.Priority; import jakarta.enterprise.inject.Instance; -import jakarta.inject.Inject; +import jakarta.enterprise.inject.spi.CDI; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.ConfigProvider; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.client.spi.ResteasyReactiveClientRequestContext; import org.jboss.resteasy.reactive.client.spi.ResteasyReactiveClientRequestFilter; @@ -38,16 +36,7 @@ public class AccessTokenRequestReactiveFilter implements ResteasyReactiveClientR private static final String BEARER_SCHEME_WITH_SPACE = "Bearer "; private static final String ERROR_MSG = "OIDC Token Propagation Reactive requires a safe (isolated) Vert.x sub-context because configuration property 'quarkus.oidc-token-propagation-reactive.enabled-during-authentication' has been set to true, but the current context hasn't been flagged as such."; private final boolean enabledDuringAuthentication; - - @Inject - Instance accessToken; - - @Inject - @ConfigProperty(name = "quarkus.oidc-token-propagation-reactive.client-name") - Optional oidcClientName; - @Inject - @ConfigProperty(name = "quarkus.oidc-token-propagation-reactive.exchange-token") - boolean exchangeToken; + private final Instance accessToken; OidcClient exchangeTokenClient; String exchangeTokenProperty; @@ -55,6 +44,7 @@ public class AccessTokenRequestReactiveFilter implements ResteasyReactiveClientR public AccessTokenRequestReactiveFilter() { this.enabledDuringAuthentication = Boolean.getBoolean(OIDC_PROPAGATE_TOKEN_CREDENTIAL) || Boolean.getBoolean(JWT_PROPAGATE_TOKEN_CREDENTIAL); + this.accessToken = CDI.current().select(TokenCredential.class); } @PostConstruct @@ -80,7 +70,8 @@ public void initExchangeTokenClient() { } protected boolean isExchangeToken() { - return exchangeToken; + return ConfigProvider.getConfig() + .getValue("quarkus.oidc-token-propagation-reactive.exchange-token", boolean.class); } @Override @@ -119,7 +110,10 @@ public void accept(Throwable t) { } protected String getClientName() { - return oidcClientName.orElse(null); + return ConfigProvider + .getConfig() + .getOptionalValue("quarkus.oidc-token-propagation-reactive.client-name", String.class) + .orElse(null); } public void propagateToken(ResteasyReactiveClientRequestContext requestContext, String accessToken) { diff --git a/extensions/oidc-token-propagation/deployment/src/main/java/io/quarkus/oidc/token/propagation/deployment/OidcTokenPropagationBuildStep.java b/extensions/oidc-token-propagation/deployment/src/main/java/io/quarkus/oidc/token/propagation/deployment/OidcTokenPropagationBuildStep.java index 9990ca1481ab8..127790bca9094 100644 --- a/extensions/oidc-token-propagation/deployment/src/main/java/io/quarkus/oidc/token/propagation/deployment/OidcTokenPropagationBuildStep.java +++ b/extensions/oidc-token-propagation/deployment/src/main/java/io/quarkus/oidc/token/propagation/deployment/OidcTokenPropagationBuildStep.java @@ -3,11 +3,14 @@ import static io.quarkus.oidc.token.propagation.TokenPropagationConstants.JWT_PROPAGATE_TOKEN_CREDENTIAL; import static io.quarkus.oidc.token.propagation.TokenPropagationConstants.OIDC_PROPAGATE_TOKEN_CREDENTIAL; +import java.util.List; import java.util.function.BooleanSupplier; import org.jboss.jandex.DotName; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; @@ -15,20 +18,21 @@ import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; -import io.quarkus.oidc.token.propagation.AccessToken; +import io.quarkus.oidc.client.deployment.AccessTokenInstanceBuildItem; +import io.quarkus.oidc.client.deployment.AccessTokenRequestFilterGenerator; import io.quarkus.oidc.token.propagation.AccessTokenRequestFilter; import io.quarkus.oidc.token.propagation.JsonWebToken; import io.quarkus.oidc.token.propagation.JsonWebTokenRequestFilter; import io.quarkus.oidc.token.propagation.runtime.OidcTokenPropagationBuildTimeConfig; import io.quarkus.oidc.token.propagation.runtime.OidcTokenPropagationConfig; import io.quarkus.restclient.deployment.RestClientAnnotationProviderBuildItem; +import io.quarkus.restclient.deployment.RestClientPredicateProviderBuildItem; import io.quarkus.resteasy.common.spi.ResteasyJaxrsProviderBuildItem; import io.quarkus.runtime.configuration.ConfigurationException; @BuildSteps(onlyIf = OidcTokenPropagationBuildStep.IsEnabled.class) public class OidcTokenPropagationBuildStep { - private static final DotName ACCESS_TOKEN_CREDENTIAL = DotName.createSimple(AccessToken.class.getName()); private static final DotName JWT_ACCESS_TOKEN_CREDENTIAL = DotName.createSimple(JsonWebToken.class.getName()); OidcTokenPropagationConfig config; @@ -37,6 +41,10 @@ public class OidcTokenPropagationBuildStep { void registerProvider(BuildProducer additionalBeans, BuildProducer reflectiveClass, BuildProducer jaxrsProviders, + BuildProducer providerPredicateProducer, + BuildProducer generatedBeanProducer, + BuildProducer unremovableBeanProducer, + List accessTokenInstances, BuildProducer restAnnotationProvider) { additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(AccessTokenRequestFilter.class)); additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(JsonWebTokenRequestFilter.class)); @@ -49,10 +57,17 @@ void registerProvider(BuildProducer additionalBeans, Class filterClass = config.jsonWebToken ? JsonWebTokenRequestFilter.class : AccessTokenRequestFilter.class; jaxrsProviders.produce(new ResteasyJaxrsProviderBuildItem(filterClass.getName())); } else { - restAnnotationProvider.produce(new RestClientAnnotationProviderBuildItem(ACCESS_TOKEN_CREDENTIAL, - AccessTokenRequestFilter.class)); restAnnotationProvider.produce(new RestClientAnnotationProviderBuildItem(JWT_ACCESS_TOKEN_CREDENTIAL, JsonWebTokenRequestFilter.class)); + if (!accessTokenInstances.isEmpty()) { + var filterGenerator = new AccessTokenRequestFilterGenerator(unremovableBeanProducer, reflectiveClass, + generatedBeanProducer, AccessTokenRequestFilter.class); + for (AccessTokenInstanceBuildItem instance : accessTokenInstances) { + String providerClass = filterGenerator.generateClass(instance); + providerPredicateProducer.produce(new RestClientPredicateProviderBuildItem(providerClass, + ci -> instance.targetClass().equals(ci.name().toString()))); + } + } } } diff --git a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/AccessTokenAnnotationTest.java b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/AccessTokenAnnotationTest.java new file mode 100644 index 0000000000000..bce883c705766 --- /dev/null +++ b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/AccessTokenAnnotationTest.java @@ -0,0 +1,177 @@ +package io.quarkus.oidc.token.propagation; + +import static org.hamcrest.Matchers.equalTo; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Unremovable; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.oidc.client.OidcTestClient; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import io.restassured.RestAssured; + +@QuarkusTestResource(OidcWiremockTestResource.class) +public class AccessTokenAnnotationTest { + + final static OidcTestClient client = new OidcTestClient(); + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(DefaultClientDefaultExchange.class, DefaultClientEnabledExchange.class, + NamedClientDefaultExchange.class, MultiProviderFrontendResource.class, ProtectedResource.class, + CustomAccessTokenRequestFilter.class) + .addAsResource( + new StringAsset( + """ + quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus + quarkus.oidc.client-id=quarkus-app + quarkus.oidc.credentials.secret=secret + + quarkus.oidc-client.auth-server-url=${quarkus.oidc.auth-server-url} + quarkus.oidc-client.client-id=${quarkus.oidc.client-id} + quarkus.oidc-client.credentials.client-secret.value=${quarkus.oidc.credentials.secret} + quarkus.oidc-client.credentials.client-secret.method=post + quarkus.oidc-client.grant.type=jwt + quarkus.oidc-client.scopes=https://graph.microsoft.com/user.read,offline_access + quarkus.oidc-client.grant-options.jwt.requested_token_use=on_behalf_of + quarkus.oidc-client.token-path=${keycloak.url}/realms/quarkus/jwt-bearer-token + + quarkus.oidc-client.named.auth-server-url=${quarkus.oidc-client.auth-server-url} + quarkus.oidc-client.named.client-id=${quarkus.oidc-client.client-id} + quarkus.oidc-client.named.credentials.client-secret.value=${quarkus.oidc-client.credentials.client-secret.value} + quarkus.oidc-client.named.credentials.client-secret.method=${quarkus.oidc-client.credentials.client-secret.method} + quarkus.oidc-client.named.grant.type=${quarkus.oidc-client.grant.type} + quarkus.oidc-client.named.scopes=${quarkus.oidc-client.scopes} + quarkus.oidc-client.named.grant-options.jwt.requested_token_use=${quarkus.oidc-client.grant-options.jwt.requested_token_use} + quarkus.oidc-client.named.token-path=${quarkus.oidc-client.token-path} + """), + "application.properties")); + + @AfterAll + public static void close() { + client.close(); + } + + @Test + public void testDefaultClientEnabledTokenExchange() { + testRestClientTokenPropagation(true, "defaultClientEnabledExchange"); + } + + @Test + public void testDefaultClientDefaultTokenExchange() { + testRestClientTokenPropagation(false, "defaultClientDefaultExchange"); + } + + @Test + public void testNamedClientDefaultTokenExchange() { + testRestClientTokenPropagation(true, "namedClientDefaultExchange"); + } + + private void testRestClientTokenPropagation(boolean exchangeEnabled, String clientKey) { + String newTokenUsername = exchangeEnabled ? "bob" : "alice"; + RestAssured.given().auth().oauth2(getBearerAccessToken()) + .queryParam("client-key", clientKey) + .when().get("/frontend/token-propagation") + .then() + .statusCode(200) + .body(equalTo("original token username: alice new token username: " + newTokenUsername)); + } + + public String getBearerAccessToken() { + return client.getAccessToken("alice", "alice"); + } + + @RegisterRestClient(baseUri = "http://localhost:8081/protected") + @AccessToken + @Path("/") + public interface DefaultClientDefaultExchange { + @GET + String getUserName(); + } + + @RegisterRestClient(baseUri = "http://localhost:8081/protected") + @AccessToken(exchangeTokenClient = "Default") + @Path("/") + public interface DefaultClientEnabledExchange { + @GET + String getUserName(); + } + + @RegisterRestClient(baseUri = "http://localhost:8081/protected") + @AccessToken(exchangeTokenClient = "named") + @Path("/") + public interface NamedClientDefaultExchange { + @GET + String getUserName(); + } + + // tests no AmbiguousResolutionException is raised + @Singleton + @Unremovable + public static class CustomAccessTokenRequestFilter extends AccessTokenRequestFilter { + } + + @Path("/frontend") + public static class MultiProviderFrontendResource { + @Inject + @RestClient + DefaultClientDefaultExchange defaultClientDefaultExchange; + + @Inject + @RestClient + DefaultClientEnabledExchange defaultClientEnabledExchange; + + @Inject + @RestClient + NamedClientDefaultExchange namedClientDefaultExchange; + + @Inject + JsonWebToken jwt; + + @GET + @Path("token-propagation") + @RolesAllowed("admin") + public String userNameTokenPropagation(@QueryParam("client-key") String clientKey) { + return getResponseWithExchangedUsername(clientKey); + } + + @GET + @Path("token-propagation-with-augmentor") + @RolesAllowed("tester") // tester role is granted by SecurityIdentityAugmentor + public String userNameTokenPropagationWithSecIdentityAugmentor(@QueryParam("client-key") String clientKey) { + return getResponseWithExchangedUsername(clientKey); + } + + private String getResponseWithExchangedUsername(String clientKey) { + if ("alice".equals(jwt.getName())) { + return "original token username: " + jwt.getName() + " new token username: " + getUserName(clientKey); + } else { + throw new RuntimeException(); + } + } + + private String getUserName(String clientKey) { + return switch (clientKey) { + case "defaultClientDefaultExchange" -> defaultClientDefaultExchange.getUserName(); + case "defaultClientEnabledExchange" -> defaultClientEnabledExchange.getUserName(); + case "namedClientDefaultExchange" -> namedClientDefaultExchange.getUserName(); + default -> throw new IllegalArgumentException("Unknown client key"); + }; + } + } +} diff --git a/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessTokenRequestFilter.java b/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessTokenRequestFilter.java index f5f90f8b2d283..9467ebcc6f12a 100644 --- a/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessTokenRequestFilter.java +++ b/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/AccessTokenRequestFilter.java @@ -5,15 +5,13 @@ import java.io.IOException; import java.util.Collections; -import java.util.Optional; import jakarta.annotation.PostConstruct; import jakarta.enterprise.inject.Instance; -import jakarta.inject.Inject; +import jakarta.enterprise.inject.spi.CDI; import jakarta.ws.rs.client.ClientRequestContext; import org.eclipse.microprofile.config.ConfigProvider; -import org.eclipse.microprofile.config.inject.ConfigProperty; import io.quarkus.arc.Arc; import io.quarkus.oidc.client.OidcClient; @@ -31,16 +29,7 @@ public class AccessTokenRequestFilter extends AbstractTokenRequestFilter { private static final String ERROR_MSG = "OIDC Token Propagation requires a safe (isolated) Vert.x sub-context because configuration property 'quarkus.oidc-token-propagation.enabled-during-authentication' has been set to true, but the current context hasn't been flagged as such."; private final boolean enabledDuringAuthentication; - - @Inject - Instance accessToken; - - @Inject - @ConfigProperty(name = "quarkus.oidc-token-propagation.client-name") - Optional oidcClientName; - @Inject - @ConfigProperty(name = "quarkus.oidc-token-propagation.exchange-token") - boolean exchangeToken; + private final Instance accessToken; OidcClient exchangeTokenClient; String exchangeTokenProperty; @@ -48,6 +37,7 @@ public class AccessTokenRequestFilter extends AbstractTokenRequestFilter { public AccessTokenRequestFilter() { this.enabledDuringAuthentication = Boolean.getBoolean(OIDC_PROPAGATE_TOKEN_CREDENTIAL) || Boolean.getBoolean(JWT_PROPAGATE_TOKEN_CREDENTIAL); + this.accessToken = CDI.current().select(TokenCredential.class); } @PostConstruct @@ -73,7 +63,7 @@ public void initExchangeTokenClient() { } protected boolean isExchangeToken() { - return exchangeToken; + return ConfigProvider.getConfig().getValue("quarkus.oidc-token-propagation.exchange-token", boolean.class); } @Override @@ -98,7 +88,8 @@ private String exchangeTokenIfNeeded(String token) { } protected String getClientName() { - return oidcClientName.orElse(null); + return ConfigProvider.getConfig().getOptionalValue("quarkus.oidc-token-propagation.client-name", String.class) + .orElse(null); } private boolean acquireTokenCredentialFromCtx(ClientRequestContext requestContext) {