Skip to content

Commit

Permalink
Merge pull request #38541 from sberyozkin/oidc_client_jwt_bearer_auth
Browse files Browse the repository at this point in the history
Support OIDC Client JWT Bearer authentication
  • Loading branch information
sberyozkin authored Feb 12, 2024
2 parents 432b59a + 5f8d63f commit 44672eb
Show file tree
Hide file tree
Showing 11 changed files with 200 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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<String, String> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.oidc.client.runtime;

import java.util.Map;
import java.util.Objects;
import java.util.Optional;

Expand Down Expand Up @@ -45,7 +46,7 @@ public void init() {

protected void initTokens() {
if (earlyTokenAcquisition) {
tokensHelper.initTokens(oidcClient);
tokensHelper.initTokens(oidcClient, additionalParameters());
}
}

Expand All @@ -56,7 +57,7 @@ public Uni<Tokens> 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() {
Expand All @@ -78,4 +79,11 @@ protected Optional<String> clientId() {
protected boolean isForceNewTokens() {
return false;
}

/**
* @return Additional parameters which will be used during the token acquisition or refresh methods.
*/
protected Map<String, String> additionalParameters() {
return Map.of();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<OidcEndpoint.Type, List<OidcRequestFilter>> filters;
private volatile boolean closed;
Expand All @@ -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
Expand Down Expand Up @@ -143,6 +146,15 @@ private UniOnItem<HttpResponse<Buffer>> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<String, String> 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<Tokens> getTokens(OidcClient oidcClient) {
return getTokens(oidcClient, false);
return getTokens(oidcClient, Map.of(), false);
}

public Uni<Tokens> getTokens(OidcClient oidcClient, boolean forceNewTokens) {
public Uni<Tokens> getTokens(OidcClient oidcClient, Map<String, String> additionalParameters, boolean forceNewTokens) {
TokenRequestState currentState = null;
TokenRequestState newState = null;
//if the tokens are expired we refresh them in an async manner
Expand All @@ -34,7 +40,7 @@ public Uni<Tokens> 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;
}
Expand All @@ -46,8 +52,8 @@ public Uni<Tokens> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication">https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication</a>
*/
@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.
*/
Expand Down Expand Up @@ -391,6 +408,14 @@ public void setClaims(Map<String, String> claims) {
this.claims = claims;
}

public Source getSource() {
return source;
}

public void setSource(Source source) {
this.source = source;
}

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public class FrontendResource {
@RestClient
ProtectedResourceServiceOidcClient protectedResourceServiceOidcClient;

@Inject
@RestClient
JwtBearerAuthenticationOidcClient jwtBearerAuthenticationOidcClient;

@Inject
@NamedOidcClient("non-standard-response")
Tokens tokens;
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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<String, String> additionalParameters() {
return Map.of(OidcConstants.CLIENT_ASSERTION, "123456");
}

@Override
protected Optional<String> clientId() {
return Optional.of("jwtbearer");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ public Map<String, String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 44672eb

Please sign in to comment.