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 3938d8a629d4e..07bee9c86e2f9 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -702,6 +702,71 @@ quarkus.oidc-client.credentials.jwt.subject=custom-subject quarkus.oidc-client.credentials.jwt.issuer=custom-issuer ---- +==== JWT Bearer + +link:https://www.rfc-editor.org/rfc/rfc7523[RFC7523] explains how JWT Bearer tokens can be used to authenticate clients, see the link:https://www.rfc-editor.org/rfc/rfc7523#section-2.2[Using JWTs for Client Authentication] section for more information. + +It can be enabled as follows: + +[source,properties] +---- +quarkus.oidc-client.auth-server-url=${auth-server-url} +quarkus.oidc-client.client-id=quarkus-app +quarkus.oidc-client.credentials.jwt.source=bearer +---- + +Next, the JWT bearer token must be provided as a `client_assertion` parameter to the OIDC client. + +You can use `OidcClient` methods for acquiring or refreshing tokens which accept additional grant parameters, for example, `oidcClient.getTokens(Map.of("client_assertion", "ey..."))`. + +If you work work with the OIDC client filters then you must register a custom filter which will provide this assertion. + +Here is an example of the RestEasy Reactive custom filter: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import java.util.Map; + +import io.quarkus.oidc.client.reactive.filter.runtime.AbstractOidcClientRequestReactiveFilter; +import io.quarkus.oidc.common.runtime.OidcConstants; +import jakarta.annotation.Priority; +import jakarta.ws.rs.Priorities; + +@Priority(Priorities.AUTHENTICATION) +public class OidcClientRequestCustomFilter extends AbstractOidcClientRequestReactiveFilter { + + @Override + protected Map additionalParameters() { + return Map.of(OidcConstants.CLIENT_ASSERTION, "ey..."); + } +} +---- + +Here is an example of the RestEasy Classic custom filter: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import java.util.Map; + +import io.quarkus.oidc.client.filter.runtime.AbstractOidcClientRequestFilter; +import io.quarkus.oidc.common.runtime.OidcConstants; +import jakarta.annotation.Priority; +import jakarta.ws.rs.Priorities; + +@Priority(Priorities.AUTHENTICATION) +public class OidcClientRequestCustomFilter extends AbstractOidcClientRequestFilter { + + @Override + protected Map additionalParameters() { + return Map.of(OidcConstants.CLIENT_ASSERTION, "ey..."); + } +} +---- + ==== Apple POST JWT Apple OpenID Connect Provider uses a `client_secret_post` method where a secret is a JWT produced with a `private_key_jwt` authentication method but with Apple account-specific issuer and subject properties. diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java index 3ef2337efc737..9697ec9d150dc 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java @@ -1,5 +1,6 @@ package io.quarkus.oidc.client.runtime; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -45,7 +46,7 @@ public void init() { protected void initTokens() { if (earlyTokenAcquisition) { - tokensHelper.initTokens(oidcClient); + tokensHelper.initTokens(oidcClient, additionalParameters()); } } @@ -56,7 +57,7 @@ public Uni getTokens() { LOG.debugf("%s OidcClient will discard the current access and refresh tokens", clientId.orElse(DEFAULT_OIDC_CLIENT_ID)); } - return tokensHelper.getTokens(oidcClient, forceNewTokens); + return tokensHelper.getTokens(oidcClient, additionalParameters(), forceNewTokens); } public Tokens awaitTokens() { @@ -78,4 +79,11 @@ protected Optional clientId() { protected boolean isForceNewTokens() { return false; } + + /** + * @return Additional parameters which will be used during the token acquisition or refresh methods. + */ + protected Map additionalParameters() { + return Map.of(); + } } 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 8dcf143c6cadb..3683eae39d305 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 @@ -20,6 +20,7 @@ import io.quarkus.oidc.common.OidcEndpoint; import io.quarkus.oidc.common.OidcRequestContextProperties; import io.quarkus.oidc.common.OidcRequestFilter; +import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials.Jwt.Source; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.smallrye.mutiny.Uni; @@ -47,6 +48,7 @@ public class OidcClientImpl implements OidcClient { private final String grantType; private final String clientSecretBasicAuthScheme; private final Key clientJwtKey; + private final boolean jwtBearerAuthentication; private final OidcClientConfig oidcConfig; private final Map> filters; private volatile boolean closed; @@ -63,7 +65,8 @@ public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevo this.oidcConfig = oidcClientConfig; this.filters = filters; this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcClientConfig); - this.clientJwtKey = OidcCommonUtils.initClientJwtKey(oidcClientConfig); + this.jwtBearerAuthentication = oidcClientConfig.credentials.jwt.source == Source.BEARER; + this.clientJwtKey = jwtBearerAuthentication ? null : OidcCommonUtils.initClientJwtKey(oidcClientConfig); } @Override @@ -143,6 +146,15 @@ private UniOnItem> postRequest(OidcEndpoint.Type endpointTy } if (clientSecretBasicAuthScheme != null) { request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme); + } else if (jwtBearerAuthentication) { + if (!additionalGrantParameters.containsKey(OidcConstants.CLIENT_ASSERTION)) { + String errorMessage = String.format( + "%s OidcClient can not complete the %s grant request because a JWT bearer client_assertion is missing", + oidcConfig.getId().get(), (refresh ? OidcConstants.REFRESH_TOKEN_GRANT : grantType)); + LOG.error(errorMessage); + throw new OidcClientException(errorMessage); + } + body.add(OidcConstants.CLIENT_ASSERTION_TYPE, OidcConstants.JWT_BEARER_CLIENT_ASSERTION_TYPE); } else if (clientJwtKey != null) { // if it is a refresh then a map has already been copied body = !refresh ? copyMultiMap(body) : body; diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java index 1e347ba937918..4074c0b2f767b 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java @@ -1,5 +1,6 @@ package io.quarkus.oidc.client.runtime; +import java.util.Map; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.function.BiConsumer; @@ -16,15 +17,20 @@ public class TokensHelper { .newUpdater(TokensHelper.class, TokenRequestState.class, "tokenRequestState"); public void initTokens(OidcClient oidcClient) { + initTokens(oidcClient, Map.of()); + } + + public void initTokens(OidcClient oidcClient, Map additionalParameters) { //init the tokens, this just happens in a blocking manner for now - tokenRequestStateUpdater.set(this, new TokenRequestState(oidcClient.getTokens().await().indefinitely())); + tokenRequestStateUpdater.set(this, + new TokenRequestState(oidcClient.getTokens(additionalParameters).await().indefinitely())); } public Uni getTokens(OidcClient oidcClient) { - return getTokens(oidcClient, false); + return getTokens(oidcClient, Map.of(), false); } - public Uni getTokens(OidcClient oidcClient, boolean forceNewTokens) { + public Uni getTokens(OidcClient oidcClient, Map additionalParameters, boolean forceNewTokens) { TokenRequestState currentState = null; TokenRequestState newState = null; //if the tokens are expired we refresh them in an async manner @@ -34,7 +40,7 @@ public Uni getTokens(OidcClient oidcClient, boolean forceNewTokens) { if (currentState == null) { //init the initial state //note that this can still happen at runtime as if there is an error then the state will be null - newState = new TokenRequestState(prepareUni(oidcClient.getTokens())); + newState = new TokenRequestState(prepareUni(oidcClient.getTokens(additionalParameters))); if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) { return newState.tokenUni; } @@ -46,8 +52,8 @@ public Uni getTokens(OidcClient oidcClient, boolean forceNewTokens) { if (forceNewTokens || tokens.isAccessTokenExpired() || tokens.isAccessTokenWithinRefreshInterval()) { newState = new TokenRequestState( prepareUni((!forceNewTokens && tokens.getRefreshToken() != null && !tokens.isRefreshTokenExpired()) - ? oidcClient.refreshTokens(tokens.getRefreshToken()) - : oidcClient.getTokens())); + ? oidcClient.refreshTokens(tokens.getRefreshToken(), additionalParameters) + : oidcClient.getTokens(additionalParameters))); if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) { return newState.tokenUni; } diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index b3c9f05ff21d4..e34edfd5733c2 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -178,7 +178,7 @@ public static enum Method { POST_JWT, /** - * client id and secret are submitted as HTTP query parameters. This option is only supported for the OIDC + * client id and secret are submitted as HTTP query parameters. This option is only supported by the OIDC * extension. */ QUERY @@ -232,12 +232,29 @@ public void setSecretProvider(Provider secretProvider) { /** * Supports the client authentication `client_secret_jwt` and `private_key_jwt` methods, which involves sending a JWT * token assertion signed with a client secret or private key. + * JWT Bearer client authentication is also supported. * * @see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication */ @ConfigGroup public static class Jwt { + + public static enum Source { + // JWT token is generated by the OIDC provider client to support + // `client_secret_jwt` and `private_key_jwt` authentication methods + CLIENT, + // JWT bearer token as used as a client assertion: https://www.rfc-editor.org/rfc/rfc7523#section-2.2 + // This option is only supported by the OIDC client extension. + BEARER + } + + /** + * JWT token source: OIDC provider client or an existing JWT bearer token. + */ + @ConfigItem(defaultValue = "client") + public Source source = Source.CLIENT; + /** * If provided, indicates that JWT is signed using a secret key. */ @@ -391,6 +408,14 @@ public void setClaims(Map claims) { this.claims = claims; } + public Source getSource() { + return source; + } + + public void setSource(Source source) { + this.source = source; + } + } /** diff --git a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java index 350179511ff0b..3af26b2b9896c 100644 --- a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java +++ b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java @@ -25,6 +25,10 @@ public class FrontendResource { @RestClient ProtectedResourceServiceOidcClient protectedResourceServiceOidcClient; + @Inject + @RestClient + JwtBearerAuthenticationOidcClient jwtBearerAuthenticationOidcClient; + @Inject @NamedOidcClient("non-standard-response") Tokens tokens; @@ -42,6 +46,12 @@ public String echoToken() { return protectedResourceServiceOidcClient.echoToken(); } + @GET + @Path("echoTokenJwtBearerAuthentication") + public String echoTokenJwtBearerAuthentication() { + return jwtBearerAuthenticationOidcClient.echoToken(); + } + @GET @Path("echoTokenNonStandardResponse") public String echoTokenNonStandardResponse() { diff --git a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/JwtBearerAuthenticationOidcClient.java b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/JwtBearerAuthenticationOidcClient.java new file mode 100644 index 0000000000000..6c0df1301eea2 --- /dev/null +++ b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/JwtBearerAuthenticationOidcClient.java @@ -0,0 +1,16 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient +@RegisterProvider(value = OidcClientRequestCustomFilter.class) +@Path("/") +public interface JwtBearerAuthenticationOidcClient { + + @GET + String echoToken(); +} diff --git a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/OidcClientRequestCustomFilter.java b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/OidcClientRequestCustomFilter.java new file mode 100644 index 0000000000000..8df21f430325e --- /dev/null +++ b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/OidcClientRequestCustomFilter.java @@ -0,0 +1,24 @@ +package io.quarkus.it.keycloak; + +import java.util.Map; +import java.util.Optional; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.Priorities; + +import io.quarkus.oidc.client.filter.runtime.AbstractOidcClientRequestFilter; +import io.quarkus.oidc.common.runtime.OidcConstants; + +@Priority(Priorities.AUTHENTICATION) +public class OidcClientRequestCustomFilter extends AbstractOidcClientRequestFilter { + + @Override + protected Map additionalParameters() { + return Map.of(OidcConstants.CLIENT_ASSERTION, "123456"); + } + + @Override + protected Optional clientId() { + return Optional.of("jwtbearer"); + } +} diff --git a/integration-tests/oidc-client-wiremock/src/main/resources/application.properties b/integration-tests/oidc-client-wiremock/src/main/resources/application.properties index cba53b337eb5f..3886134478912 100644 --- a/integration-tests/oidc-client-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-client-wiremock/src/main/resources/application.properties @@ -7,6 +7,12 @@ quarkus.oidc-client.grant.type=password quarkus.oidc-client.grant-options.password.username=alice quarkus.oidc-client.grant-options.password.password=alice +quarkus.oidc-client.jwtbearer.auth-server-url=${keycloak.url} +quarkus.oidc-client.jwtbearer.discovery-enabled=false +quarkus.oidc-client.jwtbearer.token-path=/tokens-jwtbearer +quarkus.oidc-client.jwtbearer.client-id=quarkus-app +quarkus.oidc-client.jwtbearer.credentials.jwt.source=bearer + quarkus.oidc-client.password-grant-public-client.token-path=${keycloak.url}/tokens_public_client quarkus.oidc-client.password-grant-public-client.client-id=quarkus-app quarkus.oidc-client.password-grant-public-client.grant.type=password @@ -51,6 +57,7 @@ quarkus.oidc-client.ciba-grant.credentials.client-secret.method=POST quarkus.oidc-client.ciba-grant.grant.type=ciba io.quarkus.it.keycloak.ProtectedResourceServiceOidcClient/mp-rest/url=http://localhost:8081/protected +io.quarkus.it.keycloak.JwtBearerAuthenticationOidcClient/mp-rest/url=http://localhost:8081/protected quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientImpl".min-level=TRACE quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientImpl".level=TRACE 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 ffab020e1ad41..db66c625039a9 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 @@ -35,6 +35,15 @@ public Map start() { .withHeader("Content-Type", MediaType.APPLICATION_JSON) .withBody( "{\"access_token\":\"access_token_1\", \"expires_in\":4, \"refresh_token\":\"refresh_token_1\"}"))); + server.stubFor(WireMock.post("/tokens-jwtbearer") + .withRequestBody(matching("grant_type=client_credentials&" + + "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&" + + "client_assertion=123456")) + .willReturn(WireMock + .aResponse() + .withHeader("Content-Type", MediaType.APPLICATION_JSON) + .withBody( + "{\"access_token\":\"access_token_jwt_bearer\", \"expires_in\":4, \"refresh_token\":\"refresh_token_jwt_bearer\"}"))); server.stubFor(WireMock.post("/tokens_public_client") .withRequestBody(matching("grant_type=password&username=alice&password=alice&client_id=quarkus-app")) .willReturn(WireMock diff --git a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java index e98e42e2cd9b9..65456351396bb 100644 --- a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java +++ b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java @@ -33,6 +33,14 @@ public class OidcClientTest { @InjectWireMock WireMockServer server; + @Test + public void testEchoTokensJwtBearerAuthentication() { + RestAssured.when().get("/frontend/echoTokenJwtBearerAuthentication") + .then() + .statusCode(200) + .body(equalTo("access_token_jwt_bearer")); + } + @Test public void testEchoAndRefreshTokens() { // access_token_1 and refresh_token_1 are acquired using a password grant request.