Skip to content

Commit

Permalink
Support OIDC Client JWT Bearer authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Feb 6, 2024
1 parent 99d258c commit 5f8d63f
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

Check warning on line 705 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'JWT Bearer'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'JWT Bearer'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 705, "column": 6}}}, "severity": "INFO"}

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:

Check warning on line 709 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 709, "column": 19}}}, "severity": "INFO"}

[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.

Check warning on line 718 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 718, "column": 45}}}, "severity": "INFO"}

You can use `OidcClient` methods for acquiring or refreshing tokens which accept additional grant parameters, for example, `oidcClient.getTokens(Map.of("client_assertion", "ey..."))`.

Check warning on line 720 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 720, "column": 68}}}, "severity": "INFO"}

If you work work with the OIDC client filters then you must register a custom filter which will provide this assertion.

Check failure on line 722 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.RepeatedWords] 'work' is repeated! Raw Output: {"message": "[Quarkus.RepeatedWords] 'work' is repeated!", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 722, "column": 8}}}, "severity": "ERROR"}

Check warning on line 722 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 722, "column": 85}}}, "severity": "INFO"}

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

Check warning on line 770 in docs/src/main/asciidoc/security-openid-connect-client-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'Apple POST JWT'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'Apple POST JWT'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-client-reference.adoc", "range": {"start": {"line": 770, "column": 6}}}, "severity": "INFO"}

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 5f8d63f

Please sign in to comment.