diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index c599ccbbd862f..b0889217339b6 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -279,6 +279,11 @@ quarkus.oidc.introspection-credentials.name=introspection-user-name quarkus.oidc.introspection-credentials.secret=introspection-user-secret ---- +[[oidc-client-filters]] +==== OIDC client request customization + +You can customize OIDC client requests by registering one or more `OidcClientRequestFiler` implementations which can update or add new request headers, please see xref:security-openid-connect-client-reference#oidc-client-filters[Client request customization] for more information. + ==== Redirecting to and from the OIDC provider When a user is redirected to the OpenID Connect provider to authenticate, the redirect URL includes a `redirect_uri` query parameter, which indicates to the provider where the user has to be redirected to when the authentication is complete. 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 38baf3b9a6bf8..c45c0bb707dcf 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -872,6 +872,42 @@ quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientRecorder".level=T quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientRecorder".min-level=TRACE ---- +[[oidc-client-filters]] +== Client request customization + +You can customize OIDC client requests by registering one or more `OidcClientRequestFiler` implementations which can update or add new request headers, for example, a filter can analyze the request body and add its digest as a new header value: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.common.OidcClientRequestFilter; +import io.vertx.core.http.HttpMethod; +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; + +@ApplicationScoped +@Unremovable +public class OidcClientRequestCustomizer implements OidcClientRequestFilter { + + @Override + public void filter(HttpRequest request, Buffer buffer) { + HttpMethod method = request.method(); + String uri = request.uri(); + if (method == HttpMethod.POST && uri.endsWith("/service") && buffer != null) { + request.putHeader("Digest", calculateDigest(buffer.toString())); + } + } + + private String calculateDigest(String bodyString) { + // Apply the required digest algorithm to the body string + } +} +---- + [[token-propagation-reactive]] == Token Propagation Reactive diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java index dcce97651f78f..58e8018629860 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java @@ -6,6 +6,7 @@ import java.security.Key; import java.time.Instant; import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -16,6 +17,7 @@ import io.quarkus.oidc.client.OidcClientConfig; import io.quarkus.oidc.client.OidcClientException; import io.quarkus.oidc.client.Tokens; +import io.quarkus.oidc.common.OidcClientRequestFilter; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.smallrye.mutiny.Uni; @@ -44,10 +46,12 @@ public class OidcClientImpl implements OidcClient { private final String clientSecretBasicAuthScheme; private final Key clientJwtKey; private final OidcClientConfig oidcConfig; + private final List filters; private volatile boolean closed; public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevokeUri, String grantType, - MultiMap tokenGrantParams, MultiMap commonRefreshGrantParams, OidcClientConfig oidcClientConfig) { + MultiMap tokenGrantParams, MultiMap commonRefreshGrantParams, OidcClientConfig oidcClientConfig, + List filters) { this.client = client; this.tokenRequestUri = tokenRequestUri; this.tokenRevokeUri = tokenRevokeUri; @@ -55,6 +59,7 @@ public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevo this.commonRefreshGrantParams = commonRefreshGrantParams; this.grantType = grantType; this.oidcConfig = oidcClientConfig; + this.filters = filters; this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcClientConfig); this.clientJwtKey = OidcCommonUtils.initClientJwtKey(oidcClientConfig); } @@ -159,7 +164,8 @@ private UniOnItem> postRequest(HttpRequest request, } } // Retry up to three times with a one-second delay between the retries if the connection is closed - Uni> response = request.sendBuffer(OidcCommonUtils.encodeForm(body)) + Buffer buffer = OidcCommonUtils.encodeForm(body); + Uni> response = filter(request, buffer).sendBuffer(buffer) .onFailure(ConnectException.class) .retry() .atMost(oidcConfig.connectionRetryCount) @@ -252,4 +258,11 @@ private void checkClosed() { throw new IllegalStateException("OidcClient " + oidcConfig.getId().get() + " is closed"); } } + + private HttpRequest filter(HttpRequest request, Buffer body) { + for (OidcClientRequestFilter filter : filters) { + filter.filter(request, body); + } + return request; + } } diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java index cd48083b0a953..771f8621401b2 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; @@ -16,6 +17,7 @@ import io.quarkus.oidc.client.OidcClientException; import io.quarkus.oidc.client.OidcClients; import io.quarkus.oidc.client.Tokens; +import io.quarkus.oidc.common.OidcClientRequestFilter; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.runtime.TlsConfig; @@ -120,6 +122,8 @@ protected static Uni createOidcClientUni(OidcClientConfig oidcConfig WebClient client = WebClient.create(new io.vertx.mutiny.core.Vertx(vertx.get()), options); + List clientRequestFilters = OidcCommonUtils.getClientRequestCustomizer(); + Uni tokenUrisUni = null; if (OidcCommonUtils.isAbsoluteUrl(oidcConfig.tokenPath)) { tokenUrisUni = Uni.createFrom().item( @@ -133,7 +137,7 @@ protected static Uni createOidcClientUni(OidcClientConfig oidcConfig OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.tokenPath), OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.revokePath))); } else { - tokenUrisUni = discoverTokenUris(client, authServerUriString.toString(), oidcConfig); + tokenUrisUni = discoverTokenUris(client, clientRequestFilters, authServerUriString.toString(), oidcConfig); } } return tokenUrisUni.onItemOrFailure() @@ -188,7 +192,8 @@ public OidcClient apply(OidcConfigurationMetadata metadata, Throwable t) { return new OidcClientImpl(client, metadata.tokenRequestUri, metadata.tokenRevokeUri, grantType, tokenGrantParams, commonRefreshGrantParams, - oidcConfig); + oidcConfig, + clientRequestFilters); } }); @@ -205,10 +210,11 @@ private static void setGrantClientParams(OidcClientConfig oidcConfig, MultiMap g } } - private static Uni discoverTokenUris(WebClient client, String authServerUrl, - OidcClientConfig oidcConfig) { + private static Uni discoverTokenUris(WebClient client, + List clientRequestFilters, + String authServerUrl, OidcClientConfig oidcConfig) { final long connectionDelayInMillisecs = OidcCommonUtils.getConnectionDelayInMillis(oidcConfig); - return OidcCommonUtils.discoverMetadata(client, authServerUrl, connectionDelayInMillisecs) + return OidcCommonUtils.discoverMetadata(client, clientRequestFilters, authServerUrl, connectionDelayInMillisecs) .onItem().transform(json -> new OidcConfigurationMetadata(json.getString("token_endpoint"), json.getString("revocation_endpoint"))); } diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcClientRequestFilter.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcClientRequestFilter.java new file mode 100644 index 0000000000000..d8d653381329a --- /dev/null +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcClientRequestFilter.java @@ -0,0 +1,17 @@ +package io.quarkus.oidc.common; + +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; + +/** + * Request filter which can be used to customize OIDC client requests + */ +public interface OidcClientRequestFilter { + /** + * Filter OIDC client requests + * + * @param request HTTP request + * @param body request body, will be null for HTTP GET methods, may be null for other HTTP methods + */ + void filter(HttpRequest request, Buffer body); +} diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java index 30e29928faa1b..f5c74bdbb4c42 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java @@ -14,18 +14,23 @@ import java.security.PrivateKey; import java.time.Duration; import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.OptionalInt; import java.util.function.Predicate; import java.util.function.Supplier; +import java.util.stream.Collectors; import javax.crypto.SecretKey; import org.jboss.logging.Logger; +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; import io.quarkus.credentials.CredentialsProvider; import io.quarkus.credentials.runtime.CredentialsProviderFinder; +import io.quarkus.oidc.common.OidcClientRequestFilter; import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials; import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials.Provider; import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials.Secret; @@ -45,6 +50,7 @@ import io.vertx.core.net.ProxyOptions; import io.vertx.mutiny.core.MultiMap; import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; import io.vertx.mutiny.ext.web.client.WebClient; public class OidcCommonUtils { @@ -421,9 +427,14 @@ public static Predicate oidcEndpointNotAvailable() { || (t instanceof OidcEndpointAccessException && ((OidcEndpointAccessException) t).getErrorStatus() == 404)); } - public static Uni discoverMetadata(WebClient client, String authServerUrl, long connectionDelayInMillisecs) { + public static Uni discoverMetadata(WebClient client, List filters, + String authServerUrl, long connectionDelayInMillisecs) { final String discoveryUrl = authServerUrl + OidcConstants.WELL_KNOWN_CONFIGURATION; - return client.getAbs(discoveryUrl).send().onItem().transform(resp -> { + HttpRequest request = client.getAbs(discoveryUrl); + for (OidcClientRequestFilter filter : filters) { + filter.filter(request, null); + } + return request.send().onItem().transform(resp -> { if (resp.statusCode() == 200) { return resp.bodyAsJsonObject(); } else { @@ -466,4 +477,13 @@ private static byte[] doRead(InputStream is) throws IOException { } return out.toByteArray(); } + + public static List getClientRequestCustomizer() { + ArcContainer container = Arc.container(); + if (container != null) { + return container.listAll(OidcClientRequestFilter.class).stream().map(handle -> handle.get()) + .collect(Collectors.toList()); + } + return List.of(); + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java index 3b61daa132d4f..77ba52c65ebeb 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java @@ -4,6 +4,7 @@ import java.net.ConnectException; import java.nio.charset.StandardCharsets; import java.security.Key; +import java.util.List; import java.util.Map; import org.jboss.logging.Logger; @@ -14,6 +15,7 @@ import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.common.OidcClientRequestFilter; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.common.runtime.OidcEndpointAccessException; @@ -43,16 +45,19 @@ public class OidcProviderClient implements Closeable { private final String clientSecretBasicAuthScheme; private final String introspectionBasicAuthScheme; private final Key clientJwtKey; + private final List filters; public OidcProviderClient(WebClient client, OidcConfigurationMetadata metadata, - OidcTenantConfig oidcConfig) { + OidcTenantConfig oidcConfig, + List filters) { this.client = client; this.metadata = metadata; this.oidcConfig = oidcConfig; this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcConfig); this.clientJwtKey = OidcCommonUtils.initClientJwtKey(oidcConfig); this.introspectionBasicAuthScheme = initIntrospectionBasicAuthScheme(oidcConfig); + this.filters = filters; } private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConfig) { @@ -70,13 +75,13 @@ public OidcConfigurationMetadata getMetadata() { } public Uni getJsonWebKeySet() { - return client.getAbs(metadata.getJsonWebKeySetUri()).send().onItem() + return filter(client.getAbs(metadata.getJsonWebKeySetUri()), null).send().onItem() .transform(resp -> getJsonWebKeySet(resp)); } public Uni getUserInfo(String token) { LOG.debugf("Get UserInfo on: %s auth: %s", metadata.getUserInfoUri(), OidcConstants.BEARER_SCHEME + " " + token); - return client.getAbs(metadata.getUserInfoUri()) + return filter(client.getAbs(metadata.getUserInfoUri()), null) .putHeader(AUTHORIZATION_HEADER, OidcConstants.BEARER_SCHEME + " " + token) .send().onItem().transform(resp -> getUserInfo(resp)); } @@ -157,7 +162,8 @@ private UniOnItem> getHttpResponse(String uri, MultiMap for } LOG.debugf("Get token on: %s params: %s headers: %s", metadata.getTokenUri(), formBody, request.headers()); // Retry up to three times with a one-second delay between the retries if the connection is closed. - Uni> response = request.sendBuffer(OidcCommonUtils.encodeForm(formBody)) + Buffer buffer = OidcCommonUtils.encodeForm(formBody); + Uni> response = filter(request, buffer).sendBuffer(buffer) .onFailure(ConnectException.class) .retry() .atMost(oidcConfig.connectionRetryCount).onFailure().transform(t -> t.getCause()); @@ -212,4 +218,11 @@ public void close() { public Key getClientJwtKey() { return clientJwtKey; } + + private HttpRequest filter(HttpRequest request, Buffer body) { + for (OidcClientRequestFilter filter : filters) { + filter.filter(request, body); + } + return request; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index 854ade5b40788..f073070c4c0df 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -25,6 +25,7 @@ import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.oidc.OidcTenantConfig.TokenStateManager.Strategy; import io.quarkus.oidc.TenantConfigResolver; +import io.quarkus.oidc.common.OidcClientRequestFilter; import io.quarkus.oidc.common.runtime.OidcCommonConfig; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.runtime.LaunchMode; @@ -424,12 +425,15 @@ protected static Uni createOidcClientUni(OidcTenantConfig oi WebClient client = WebClient.create(new io.vertx.mutiny.core.Vertx(vertx), options); + List clientRequestFilters = OidcCommonUtils.getClientRequestCustomizer(); + Uni metadataUni = null; if (!oidcConfig.discoveryEnabled.orElse(true)) { metadataUni = Uni.createFrom().item(createLocalMetadata(oidcConfig, authServerUriString)); } else { final long connectionDelayInMillisecs = OidcCommonUtils.getConnectionDelayInMillis(oidcConfig); - metadataUni = OidcCommonUtils.discoverMetadata(client, authServerUriString, connectionDelayInMillisecs) + metadataUni = OidcCommonUtils + .discoverMetadata(client, clientRequestFilters, authServerUriString, connectionDelayInMillisecs) .onItem() .transform(new Function() { @Override @@ -465,7 +469,8 @@ public Uni apply(OidcConfigurationMetadata metadata, Throwab "UserInfo is required but the OpenID Provider UserInfo endpoint is not configured." + " Use 'quarkus.oidc.user-info-path' if the discovery is disabled.")); } - return Uni.createFrom().item(new OidcProviderClient(client, metadata, oidcConfig)); + return Uni.createFrom() + .item(new OidcProviderClient(client, metadata, oidcConfig, clientRequestFilters)); } }); diff --git a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/OidcRequestCustomizer.java b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/OidcRequestCustomizer.java new file mode 100644 index 0000000000000..6f9df50263111 --- /dev/null +++ b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/OidcRequestCustomizer.java @@ -0,0 +1,30 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.common.OidcClientRequestFilter; +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; + +@ApplicationScoped +@Unremovable +public class OidcRequestCustomizer implements OidcClientRequestFilter { + + @Override + public void filter(HttpRequest request, Buffer buffer) { + String uri = request.uri(); + if (uri.endsWith("/non-standard-tokens")) { + request.putHeader("GrantType", getGrantType(buffer.toString())); + } + } + + private String getGrantType(String formString) { + for (String formValue : formString.split("&")) { + if (formValue.startsWith("grant_type=")) { + return formValue.substring("grant_type=".length()); + } + } + return ""; + } +} diff --git a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java index ce654079e8b54..ffab020e1ad41 100644 --- a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java +++ b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java @@ -44,6 +44,7 @@ public Map start() { "{\"access_token\":\"access_token_public_client\", \"expires_in\":20}"))); server.stubFor(WireMock.post("/non-standard-tokens") .withHeader("X-Custom", matching("XCustomHeaderValue")) + .withHeader("GrantType", matching("password")) .withRequestBody(matching("grant_type=password&username=alice&password=alice&extra_param=extra_param_value")) .willReturn(WireMock .aResponse() diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcRequestCustomizer.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcRequestCustomizer.java new file mode 100644 index 0000000000000..071409f1cea53 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcRequestCustomizer.java @@ -0,0 +1,24 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.common.OidcClientRequestFilter; +import io.vertx.core.http.HttpMethod; +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; + +@ApplicationScoped +@Unremovable +public class OidcRequestCustomizer implements OidcClientRequestFilter { + + @Override + public void filter(HttpRequest request, Buffer buffer) { + HttpMethod method = request.method(); + String uri = request.uri(); + if (method == HttpMethod.GET && uri.endsWith("/auth/azure/jwk")) { + request.putHeader("Authorization", "ID token"); + } + } + +} 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 1eaabbee74ef2..91e768fc55d8d 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 @@ -49,6 +49,7 @@ public void testSecureAccessSuccessPreferredUsername() { public void testAccessResourceAzure() throws Exception { String azureJwk = readFile("jwks.json"); wireMockServer.stubFor(WireMock.get("/auth/azure/jwk") + .withHeader("Authorization", matching("ID token")) .willReturn(WireMock.aResponse().withBody(azureJwk))); String azureToken = readFile("token.txt"); RestAssured.given().auth().oauth2(azureToken)