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 1, 2024
1 parent dfe434e commit 95ac52d
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 8 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 to acquire or refresh 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": 62}}}, "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> additionalGrantParameters() {
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> additionalGrantParameters() {
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 @@ -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, additionalGrantParameters(), forceNewTokens);
}

public Tokens awaitTokens() {
Expand All @@ -78,4 +79,11 @@ protected Optional<String> clientId() {
protected boolean isForceNewTokens() {
return false;
}

/**
* @return Additional grant parameters
*/
protected Map<String, String> additionalGrantParameters() {
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 @@ -21,10 +22,10 @@ public void initTokens(OidcClient oidcClient) {
}

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> additionalGrantParameters, boolean forceNewTokens) {
TokenRequestState currentState = null;
TokenRequestState newState = null;
//if the tokens are expired we refresh them in an async manner
Expand All @@ -34,7 +35,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(additionalGrantParameters)));
if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) {
return newState.tokenUni;
}
Expand All @@ -46,8 +47,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(), additionalGrantParameters)
: oidcClient.getTokens(additionalGrantParameters)));
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> additionalGrantParameters() {
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
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=12346"))
.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_auth"));
}

@Test
public void testEchoAndRefreshTokens() {
// access_token_1 and refresh_token_1 are acquired using a password grant request.
Expand Down

0 comments on commit 95ac52d

Please sign in to comment.