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 c8b4e5d3ab4d0..6b2e4b37b1d38 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -11,7 +11,7 @@ include::_attributes.adoc[] This reference guide explains how to use: - `quarkus-oidc-client`, `quarkus-oidc-client-reactive-filter` and `quarkus-oidc-client-filter` extensions to acquire and refresh access tokens from OpenID Connect and OAuth 2.0 compliant Authorization Servers such as https://www.keycloak.org[Keycloak] - - `quarkus-oidc-token-propagation` and `quarkus-oidc-token-propagation-reactive` extensions to propagate the current `Bearer` or `Authorization Code Flow` access tokens + - `quarkus-oidc-token-propagation-reactive` and `quarkus-oidc-token-propagation` extensions to propagate the current `Bearer` or `Authorization Code Flow` access tokens The access tokens managed by these extensions can be used as HTTP Authorization Bearer tokens to access the remote services. @@ -110,7 +110,7 @@ It can be further customized using a `quarkus.oidc-client.grant-options.password ==== Other Grants -`OidcClient` can also help with acquiring the tokens using the grants which require some extra input parameters which can not be captured in the configuration. These grants are `refresh token` (with the external refresh token), `token exchange` and `authorization code`. +`OidcClient` can also help with acquiring the tokens using the grants which require some extra input parameters which can not be captured in the configuration. These grants are `refresh_token` (with the external refresh token), `authorization_code`, as well as two grants which can be used to exchange the current access token, `urn:ietf:params:oauth:grant-type:token-exchange` and `urn:ietf:params:oauth:grant-type:jwt-bearer`. Using the `refresh_token` grant which uses an out-of-band refresh token to acquire a new set of tokens will be required if the existing refresh token has been posted to the current Quarkus endpoint for it to acquire the access token. In this case `OidcClient` needs to be configured as follows: @@ -124,7 +124,7 @@ quarkus.oidc-client.grant.type=refresh and then you can use `OidcClient.refreshTokens` method with a provided refresh token to get the access token. -Using the `token exchange` grant may be required if you are building a complex microservices application and would like to avoid the same `Bearer` token be propagated to and used by more than one service. Please see <> for more details. +Using the `urn:ietf:params:oauth:grant-type:token-exchange` or `urn:ietf:params:oauth:grant-type:jwt-bearer` grants may be required if you are building a complex microservices application and would like to avoid the same `Bearer` token be propagated to and used by more than one service. Please see <> and <> for more details. Using `OidcClient` to support the `authorization code` grant might be required if for some reasons you can not use the xref:security-openid-connect-web-authentication.adoc[Quarkus OpenID Connect extension] to support Authorization Code Flow. If there is a very good reason for you to implement Authorization Code Flow then you can configure `OidcClient` as follows: @@ -823,6 +823,64 @@ quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientRecorder".level=T quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientRecorder".min-level=TRACE ---- +[[token-propagation-reactive]] +== Token Propagation Reactive + +The `quarkus-oidc-token-propagation-reactive` extension provides RestEasy Reactive Client `io.quarkus.oidc.token.propagation.reactive.AccessTokenRequestReactiveFilter` that simplifies the propagation of authentication information by propagating the xref:security-openid-connect.adoc[Bearer] token present in the current active request or the token acquired from the xref:security-openid-connect-web-authentication.adoc[Authorization Code Flow], as the HTTP `Authorization` header's `Bearer` scheme value. + +You can selectively register `AccessTokenRequestReactiveFilter` using `org.eclipse.microprofile.rest.client.annotation.RegisterProvider` annotation, for example: + +[source,java] +---- +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import io.quarkus.oidc.token.propagation.reactive.AccessTokenRequestReactiveFilter; + +@RegisterRestClient +@RegisterProvider(AccessTokenRequestReactiveFilter.class) +@Path("/") +public interface ProtectedResourceService { + + @GET + String getUserName(); +} +---- + + +Additionally, `AccessTokenRequestReactiveFilter` can support a complex application that needs to exchange the tokens before propagating them. + +If you work with link:https://www.keycloak.org/docs/latest/securing_apps/#_token-exchange[Keycloak] or other OpenID Connect Providers which support a link:https://tools.ietf.org/html/rfc8693[Token Exchange] token grant then you can configure `AccessTokenRequestReactiveFilter` to exchange the token like this: + +[source,properties] +---- +quarkus.oidc-client.auth-server-url=http://localhost:8180/auth/realms/quarkus +quarkus.oidc-client.client-id=quarkus-app +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 +---- + +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. + +If you work with providers such as `Azure` that link:https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#example[require using] link:https://www.rfc-editor.org/rfc/rfc7523#section-2.1[JWT bearer token grant] to exhange the current token then you can configure `AccessTokenRequestReactiveFilter` to exchange the token like this: + +[source,properties] +---- +quarkus.oidc-client.auth-server-url=${azure.provider.url} +quarkus.oidc-client.client-id=quarkus-app +quarkus.oidc-client.credentials.secret=secret + +quarkus.oidc-client.grant.type=jwt +quarkus.oidc-client.grant-options.jwt.requested_token_use=on_behalf_of +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. + [[token-propagation]] == Token Propagation @@ -893,6 +951,21 @@ quarkus.oidc-client.grant-options.exchange.audience=quarkus-app-exchange quarkus.oidc-token-propagation.exchange-token=true ---- +If you work with providers such as `Azure` that link:https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#example[require using] link:https://www.rfc-editor.org/rfc/rfc7523#section-2.1[JWT bearer token grant] to exhange the current token then you can configure `AccessTokenRequestFilter` to exchange the token like this: + +[source,properties] +---- +quarkus.oidc-client.auth-server-url=${azure.provider.url} +quarkus.oidc-client.client-id=quarkus-app +quarkus.oidc-client.credentials.secret=secret + +quarkus.oidc-client.grant.type=jwt +quarkus.oidc-client.grant-options.jwt.requested_token_use=on_behalf_of +quarkus.oidc-client.scopes=https://graph.microsoft.com/user.read,offline_access + +quarkus.oidc-token-propagation.exchange-token=true +---- + Note `AccessTokenRequestFilter` 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. `AccessTokenRequestFilter` uses a default `OidcClient` by default. A named `OidcClient` can be selected with a `quarkus.oidc-token-propagation.client-name` configuration property. diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java index 394ea44616d98..d29b39c387123 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java @@ -71,7 +71,11 @@ public static enum Type { * at least 'subject_token' parameter which must be passed to OidcClient at the token request time. */ EXCHANGE("urn:ietf:params:oauth:grant-type:token-exchange"), - + /** + * 'urn:ietf:params:oauth:grant-type:jwt-bearer' grant requiring an OIDC client authentication as well as + * at least an 'assertion' parameter which must be passed to OidcClient at the token request time. + */ + JWT("urn:ietf:params:oauth:grant-type:jwt-bearer"), /** * 'refresh_token' grant requiring an OIDC client authentication and a refresh token. * Note, OidcClient supports this grant by default if an access token acquisition response contained a refresh diff --git a/extensions/oidc-token-propagation-reactive/deployment/pom.xml b/extensions/oidc-token-propagation-reactive/deployment/pom.xml index 743eec1ded259..b2da0cf50cd25 100644 --- a/extensions/oidc-token-propagation-reactive/deployment/pom.xml +++ b/extensions/oidc-token-propagation-reactive/deployment/pom.xml @@ -26,6 +26,36 @@ io.quarkus quarkus-rest-client-reactive-deployment + + io.quarkus + quarkus-oidc-client-deployment + + + + io.quarkus + quarkus-test-oidc-server + test + + + io.quarkus + quarkus-resteasy-reactive-deployment + test + + + io.quarkus + quarkus-oidc-deployment + test + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + @@ -56,4 +86,24 @@ + + + test-keycloak + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + + + diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenPropagationService.java b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenPropagationService.java new file mode 100644 index 0000000000000..55b8995ed119b --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/AccessTokenPropagationService.java @@ -0,0 +1,16 @@ +package io.quarkus.oidc.token.propagation.reactive; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient +@RegisterProvider(AccessTokenRequestReactiveFilter.class) +@Path("/") +public interface AccessTokenPropagationService { + + @GET + String getUserName(); +} diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/FrontendResource.java b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/FrontendResource.java new file mode 100644 index 0000000000000..44e2f651cc43b --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/FrontendResource.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.token.propagation.reactive; + +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +@Path("/frontend") +public class FrontendResource { + @Inject + @RestClient + AccessTokenPropagationService accessTokenPropagationService; + + @Inject + JsonWebToken jwt; + + @GET + @Path("token-propagation") + @RolesAllowed("admin") + public String userNameTokenPropagation() { + if ("alice".equals(jwt.getName())) { + return "Token issued to " + jwt.getName() + " has been exchanged, new user name: " + + accessTokenPropagationService.getUserName(); + } else { + throw new RuntimeException(); + } + } +} diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationTest.java b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationTest.java new file mode 100644 index 0000000000000..ae0489140f12a --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationTest.java @@ -0,0 +1,43 @@ +package io.quarkus.oidc.token.propagation.reactive; + +import static org.hamcrest.Matchers.equalTo; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import io.restassured.RestAssured; + +@QuarkusTestResource(OidcWiremockTestResource.class) +public class OidcTokenPropagationTest { + + private static Class[] testClasses = { + FrontendResource.class, + ProtectedResource.class, + AccessTokenPropagationService.class + }; + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(testClasses) + .addAsResource("application.properties")); + + @Test + public void testGetUserNameWithTokenPropagation() { + RestAssured.given().auth().oauth2(getBearerAccessToken()) + .when().get("/frontend/token-propagation") + .then() + .statusCode(200) + .body(equalTo("Token issued to alice has been exchanged, new user name: bob")); + } + + public String getBearerAccessToken() { + return OidcWiremockTestResource.getAccessToken("alice", Set.of("admin")); + } + +} diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/ProtectedResource.java b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/ProtectedResource.java new file mode 100644 index 0000000000000..e790aa37fe9a5 --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/deployment/src/test/java/io/quarkus/oidc/token/propagation/reactive/ProtectedResource.java @@ -0,0 +1,24 @@ +package io.quarkus.oidc.token.propagation.reactive; + +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.security.Authenticated; + +@Path("/protected") +@Authenticated +public class ProtectedResource { + + @Inject + JsonWebToken jwt; + + @GET + @RolesAllowed("user") + public String principalName() { + return jwt.getName(); + } +} diff --git a/extensions/oidc-token-propagation-reactive/deployment/src/test/resources/application.properties b/extensions/oidc-token-propagation-reactive/deployment/src/test/resources/application.properties new file mode 100644 index 0000000000000..04051ed31aa29 --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/deployment/src/test/resources/application.properties @@ -0,0 +1,16 @@ +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-token-propagation-reactive.exchange-token=true + +io.quarkus.oidc.token.propagation.reactive.AccessTokenPropagationService/mp-rest/uri=http://localhost:8081/protected diff --git a/extensions/oidc-token-propagation-reactive/runtime/pom.xml b/extensions/oidc-token-propagation-reactive/runtime/pom.xml index e2c1b52ad1feb..be3f74d370277 100644 --- a/extensions/oidc-token-propagation-reactive/runtime/pom.xml +++ b/extensions/oidc-token-propagation-reactive/runtime/pom.xml @@ -22,6 +22,10 @@ io.quarkus quarkus-rest-client-reactive + + io.quarkus + quarkus-oidc-client + 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 f6ca2d506ff3e..6977a08708bf2 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 @@ -1,5 +1,10 @@ package io.quarkus.oidc.token.propagation.reactive; +import java.util.Collections; +import java.util.Optional; +import java.util.function.Consumer; + +import javax.annotation.PostConstruct; import javax.annotation.Priority; import javax.enterprise.inject.Instance; import javax.inject.Inject; @@ -7,11 +12,20 @@ import javax.ws.rs.core.HttpHeaders; import javax.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; +import io.quarkus.arc.Arc; +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.OidcClientConfig.Grant; +import io.quarkus.oidc.client.OidcClients; +import io.quarkus.oidc.client.runtime.DisabledOidcClientException; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.credential.TokenCredential; +import io.smallrye.mutiny.Uni; @Priority(Priorities.AUTHENTICATION) public class AccessTokenRequestReactiveFilter implements ResteasyReactiveClientRequestFilter { @@ -21,18 +35,77 @@ public class AccessTokenRequestReactiveFilter implements ResteasyReactiveClientR @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; + + OidcClient exchangeTokenClient; + String exchangeTokenProperty; + + @PostConstruct + public void initExchangeTokenClient() { + if (exchangeToken) { + OidcClients clients = Arc.container().instance(OidcClients.class).get(); + exchangeTokenClient = oidcClientName.isPresent() ? clients.getClient(oidcClientName.get()) : clients.getClient(); + Grant.Type exchangeTokenGrantType = ConfigProvider.getConfig() + .getValue( + "quarkus.oidc-client." + (oidcClientName.isPresent() ? oidcClientName.get() + "." : "") + + "grant.type", + Grant.Type.class); + if (exchangeTokenGrantType == Grant.Type.EXCHANGE) { + exchangeTokenProperty = "subject_token"; + } else if (exchangeTokenGrantType == Grant.Type.JWT) { + exchangeTokenProperty = "assertion"; + } else { + throw new ConfigurationException("Token exchange is required but OIDC client is configured " + + "to use the " + exchangeTokenGrantType.getGrantType() + " grantType"); + } + } + } + @Override public void filter(ResteasyReactiveClientRequestContext requestContext) { if (verifyTokenInstance(requestContext)) { - propagateToken(requestContext); + if (exchangeTokenClient != null) { + + requestContext.suspend(); + + exchangeToken(accessToken.get().getToken()).subscribe().with(new Consumer<>() { + @Override + public void accept(String token) { + propagateToken(requestContext, token); + requestContext.resume(); + } + }, new Consumer<>() { + @Override + public void accept(Throwable t) { + if (t instanceof DisabledOidcClientException) { + LOG.debug("Client is disabled"); + requestContext.abortWith(Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()); + } else { + LOG.debugf("Access token is not available, aborting the request with HTTP 401 error: %s", + t.getMessage()); + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + } + requestContext.resume(); + } + }); + } else { + propagateToken(requestContext, accessToken.get().getToken()); + } + } else { + abortRequest(requestContext); } } - public void propagateToken(ResteasyReactiveClientRequestContext requestContext) { - if (accessToken.get().getToken() != null) { - requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, BEARER_SCHEME_WITH_SPACE + accessToken.get().getToken()); + public void propagateToken(ResteasyReactiveClientRequestContext requestContext, String accessToken) { + if (accessToken != null) { + requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, BEARER_SCHEME_WITH_SPACE + accessToken); } else { - LOG.debugf("Injected access token is null, aborting the request with HTTP 401 error"); + LOG.debugf("Access token is null, aborting the request with HTTP 401 error"); abortRequest(requestContext); } } @@ -40,18 +113,25 @@ public void propagateToken(ResteasyReactiveClientRequestContext requestContext) protected boolean verifyTokenInstance(ResteasyReactiveClientRequestContext requestContext) { if (!accessToken.isResolvable()) { LOG.debugf("Access token is not injected, aborting the request with HTTP 401 error"); - abortRequest(requestContext); return false; } if (accessToken.isAmbiguous()) { LOG.debugf("More than one access token instance is available, aborting the request with HTTP 401 error"); - abortRequest(requestContext); return false; } - + if (accessToken.get().getToken() == null) { + LOG.debugf("Injected access token is null, aborting the request with HTTP 401 error"); + return false; + } return true; } + private Uni exchangeToken(String token) { + return exchangeTokenClient.getTokens(Collections.singletonMap(exchangeTokenProperty, token)) + .onItem().transform(t -> t.getAccessToken()); + + } + protected void abortRequest(ResteasyReactiveClientRequestContext requestContext) { requestContext.abortWith(Response.status(401).build()); } diff --git a/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveConfig.java b/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveConfig.java new file mode 100644 index 0000000000000..0add9be9da3bf --- /dev/null +++ b/extensions/oidc-token-propagation-reactive/runtime/src/main/java/io/quarkus/oidc/token/propagation/reactive/OidcTokenPropagationReactiveConfig.java @@ -0,0 +1,26 @@ +package io.quarkus.oidc.token.propagation.reactive; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +@ConfigRoot(name = "oidc-token-propagation-reactive", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public class OidcTokenPropagationReactiveConfig { + /** + * Exchange the current token with OpenId Connect Provider for a new token using either + * "urn:ietf:params:oauth:grant-type:token-exchange" or "urn:ietf:params:oauth:grant-type:jwt-bearer" token grant + * before propagating it. + */ + @ConfigItem(defaultValue = "false") + public boolean exchangeToken; + + /** + * Name of the configured OidcClient. + * + * Note this property is only used if the `exchangeToken` property is enabled. + */ + @ConfigItem + public Optional clientName; +} diff --git a/extensions/oidc-token-propagation/deployment/pom.xml b/extensions/oidc-token-propagation/deployment/pom.xml index 0198d605beba4..497b072c222ec 100644 --- a/extensions/oidc-token-propagation/deployment/pom.xml +++ b/extensions/oidc-token-propagation/deployment/pom.xml @@ -34,15 +34,35 @@ io.quarkus quarkus-smallrye-jwt-build-deployment + + + io.quarkus + quarkus-test-oidc-server + test + + + io.quarkus + quarkus-resteasy-deployment + test + + + io.quarkus + quarkus-oidc-deployment + test + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + - - - src/test/resources - true - - maven-compiler-plugin @@ -64,5 +84,24 @@ - + + + test-keycloak + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + + + diff --git a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/AccessTokenPropagationService.java b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/AccessTokenPropagationService.java new file mode 100644 index 0000000000000..bb19f6a55b633 --- /dev/null +++ b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/AccessTokenPropagationService.java @@ -0,0 +1,15 @@ +package io.quarkus.oidc.token.propagation; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient +@AccessToken +@Path("/") +public interface AccessTokenPropagationService { + + @GET + String getUserName(); +} diff --git a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/FrontendResource.java b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/FrontendResource.java new file mode 100644 index 0000000000000..a88077a3a89f4 --- /dev/null +++ b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/FrontendResource.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.token.propagation; + +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +@Path("/frontend") +public class FrontendResource { + @Inject + @RestClient + AccessTokenPropagationService accessTokenPropagationService; + + @Inject + JsonWebToken jwt; + + @GET + @Path("token-propagation") + @RolesAllowed("admin") + public String userNameTokenPropagation() { + if ("alice".equals(jwt.getName())) { + return "Token issued to " + jwt.getName() + " has been exchanged, new user name: " + + accessTokenPropagationService.getUserName(); + } else { + throw new RuntimeException(); + } + } +} diff --git a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java new file mode 100644 index 0000000000000..74af148742129 --- /dev/null +++ b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java @@ -0,0 +1,43 @@ +package io.quarkus.oidc.token.propagation; + +import static org.hamcrest.Matchers.equalTo; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import io.restassured.RestAssured; + +@QuarkusTestResource(OidcWiremockTestResource.class) +public class OidcTokenPropagationTest { + + private static Class[] testClasses = { + FrontendResource.class, + ProtectedResource.class, + AccessTokenPropagationService.class + }; + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(testClasses) + .addAsResource("application.properties")); + + @Test + public void testGetUserNameWithTokenPropagation() { + RestAssured.given().auth().oauth2(getBearerAccessToken()) + .when().get("/frontend/token-propagation") + .then() + .statusCode(200) + .body(equalTo("Token issued to alice has been exchanged, new user name: bob")); + } + + public String getBearerAccessToken() { + return OidcWiremockTestResource.getAccessToken("alice", Set.of("admin")); + } + +} diff --git a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/ProtectedResource.java b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/ProtectedResource.java new file mode 100644 index 0000000000000..369dca4ed35f8 --- /dev/null +++ b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/ProtectedResource.java @@ -0,0 +1,24 @@ +package io.quarkus.oidc.token.propagation; + +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.security.Authenticated; + +@Path("/protected") +@Authenticated +public class ProtectedResource { + + @Inject + JsonWebToken jwt; + + @GET + @RolesAllowed("user") + public String principalName() { + return jwt.getName(); + } +} diff --git a/extensions/oidc-token-propagation/deployment/src/test/resources/application.properties b/extensions/oidc-token-propagation/deployment/src/test/resources/application.properties new file mode 100644 index 0000000000000..b871c0f9a126f --- /dev/null +++ b/extensions/oidc-token-propagation/deployment/src/test/resources/application.properties @@ -0,0 +1,16 @@ +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-token-propagation.exchange-token=true + +io.quarkus.oidc.token.propagation.AccessTokenPropagationService/mp-rest/uri=http://localhost:8081/protected 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 0ec0b0877ae9b..d7774a1034462 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 @@ -9,17 +9,18 @@ import javax.inject.Inject; import javax.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; +import io.quarkus.oidc.client.OidcClientConfig.Grant; import io.quarkus.oidc.client.OidcClients; import io.quarkus.oidc.token.propagation.runtime.AbstractTokenRequestFilter; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.credential.TokenCredential; public class AccessTokenRequestFilter extends AbstractTokenRequestFilter { - private static final String EXCHANGE_SUBJECT_TOKEN = "subject_token"; - // note: We can't use constructor injection for these fields because they are registered by RESTEasy // which doesn't know about CDI at the point of registration @@ -28,18 +29,32 @@ public class AccessTokenRequestFilter extends AbstractTokenRequestFilter { @Inject @ConfigProperty(name = "quarkus.oidc-token-propagation.client-name") - Optional clientName; + Optional oidcClientName; @Inject @ConfigProperty(name = "quarkus.oidc-token-propagation.exchange-token") boolean exchangeToken; OidcClient exchangeTokenClient; + String exchangeTokenProperty; @PostConstruct public void initExchangeTokenClient() { if (exchangeToken) { OidcClients clients = Arc.container().instance(OidcClients.class).get(); - exchangeTokenClient = clientName.isPresent() ? clients.getClient(clientName.get()) : clients.getClient(); + exchangeTokenClient = oidcClientName.isPresent() ? clients.getClient(oidcClientName.get()) : clients.getClient(); + Grant.Type exchangeTokenGrantType = ConfigProvider.getConfig() + .getValue( + "quarkus.oidc-client." + (oidcClientName.isPresent() ? oidcClientName.get() + "." : "") + + "grant.type", + Grant.Type.class); + if (exchangeTokenGrantType == Grant.Type.EXCHANGE) { + exchangeTokenProperty = "subject_token"; + } else if (exchangeTokenGrantType == Grant.Type.JWT) { + exchangeTokenProperty = "assertion"; + } else { + throw new ConfigurationException("Token exchange is required but OIDC client is configured " + + "to use the " + exchangeTokenGrantType.getGrantType() + " grantType"); + } } } @@ -53,7 +68,7 @@ public void filter(ClientRequestContext requestContext) throws IOException { private String exchangeTokenIfNeeded(String token) { if (exchangeTokenClient != null) { // more dynamic parameters can be configured if required - return exchangeTokenClient.getTokens(Collections.singletonMap(EXCHANGE_SUBJECT_TOKEN, token)) + return exchangeTokenClient.getTokens(Collections.singletonMap(exchangeTokenProperty, token)) .await().indefinitely().getAccessToken(); } else { return token; diff --git a/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/runtime/OidcTokenPropagationConfig.java b/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/runtime/OidcTokenPropagationConfig.java index 9a7b6bdeb2850..607b7a369eb58 100644 --- a/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/runtime/OidcTokenPropagationConfig.java +++ b/extensions/oidc-token-propagation/runtime/src/main/java/io/quarkus/oidc/token/propagation/runtime/OidcTokenPropagationConfig.java @@ -42,7 +42,9 @@ public class OidcTokenPropagationConfig { public boolean secureJsonWebToken; /** - * Exchange the current token with OpenId Connect Provider for a new token before propagating it. + * Exchange the current token with OpenId Connect Provider for a new token using either + * "urn:ietf:params:oauth:grant-type:token-exchange" or "urn:ietf:params:oauth:grant-type:jwt-bearer" token grant + * before propagating it. * * Note this property is injected into AccessTokenRequestFilter. */ diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java index 40947a09aa47d..0678b90538cd3 100644 --- a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java @@ -142,6 +142,9 @@ public Map start() { defineCodeFlowAuthorizationMockTokenStub(); defineCodeFlowAuthorizationMockEncryptedTokenStub(); + //JWT bearer token grant + defineJwtBearerGrantTokenStub(); + // Login Page server.stubFor( get(urlPathMatching("/auth/realms/quarkus[/]?")) @@ -242,6 +245,22 @@ private void defineInvalidIntrospectionMockTokenStubForUserWithRoles(String user + "\",\"iat\":1562315654,\"exp\":1,\"expires_in\":1,\"client_id\":\"my_client_id\"}"))); } + private void defineJwtBearerGrantTokenStub() { + server.stubFor(WireMock.post("/auth/realms/quarkus/jwt-bearer-token") + .withRequestBody(containing("client_id=quarkus-app")) + .withRequestBody(containing("client_secret=secret")) + .withRequestBody(containing("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer")) + .withRequestBody(containing("scope=https%3A%2F%2Fgraph.microsoft.com%2Fuser.read+offline_access")) + .withRequestBody(containing("requested_token_use=on_behalf_of")) + .withRequestBody(containing("assertion")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"" + + getAccessToken("bob", getUserRoles()) + "\"" + + "}"))); + } + private void defineCodeFlowAuthorizationMockTokenStub() { server.stubFor(WireMock.post("/auth/realms/quarkus/token") .withRequestBody(containing("authorization_code"))