Skip to content

Commit

Permalink
Merge pull request #45131 from michalvavrik/feature/oidc-client-jwt-b…
Browse files Browse the repository at this point in the history
…earer-auth

OIDC and OIDC Client: Support JWT bearer client authentication using client assertion loaded from filesystem
  • Loading branch information
sberyozkin authored Dec 15, 2024
2 parents 75c5f5e + 869c7ba commit 5f476ac
Show file tree
Hide file tree
Showing 36 changed files with 667 additions and 110 deletions.
12 changes: 12 additions & 0 deletions docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ quarkus.oidc.credentials.jwt.key-id=mykeyAlias

Using `client_secret_jwt` or `private_key_jwt` authentication methods ensures that a client secret does not get sent to the OIDC provider, therefore avoiding the risk of a secret being intercepted by a 'man-in-the-middle' attack.

.Example how JWT Bearer token can be used to authenticate client

[source,properties]
----
quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.source=bearer <1>
quarkus.oidc.credentials.jwt.token-path=/var/run/secrets/tokens <2>
----
<1> Use JWT bearer token to authenticate OIDC provider client, see the link:https://www.rfc-editor.org/rfc/rfc7523#section-2.2[Using JWTs for Client Authentication] section for more information.
<2> Path to a JWT bearer token. Quarkus loads a new token from a filesystem and reloads it when the token has expired.

==== Additional JWT authentication options

If `client_secret_jwt`, `private_key_jwt`, or an Apple `post_jwt` authentication methods are used, then you can customize the JWT signature algorithm, key identifier, audience, subject and issuer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,17 @@ 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..."))`.
Quarkus can load the JWT bearer token from a file system.
For example, in Kubernetes, a service account token projection can be mounted to a `/var/run/secrets/tokens` path.
Then all you need to do is configure a JWT bearer token path as follows:

[source,properties]
----
quarkus.oidc-client.credentials.jwt.token-path=/var/run/secrets/tokens <1>
----
<1> Path to a JWT bearer token. Quarkus loads a new token from a filesystem and reload it when the token has expired.

Your other option is to 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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.common.QuarkusTestResource;
import io.restassured.RestAssured;
Expand Down Expand Up @@ -48,7 +48,7 @@ private void validateTokens(String token1, String token2) {
}

private String preferredUserOf(String token) {
return OidcUtils.decodeJwtContent(token).getString("preferred_username");
return OidcCommonUtils.decodeJwtContent(token).getString("preferred_username");
}

private String doTestGetTokenByNamedClient(String clientId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.oidc.client.runtime.OidcClientsConfig;
import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;

Expand Down Expand Up @@ -67,7 +67,7 @@ private void validateTokens(String token1, String token2) {
}

private String upn(String token) {
return OidcUtils.decodeJwtContent(token).getString("upn");
return OidcCommonUtils.decodeJwtContent(token).getString("upn");
}

private String doTestGetTokenByNamedClient(String clientId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
import io.quarkus.oidc.common.OidcRequestFilter;
import io.quarkus.oidc.common.OidcRequestFilter.OidcRequestContext;
import io.quarkus.oidc.common.OidcResponseFilter;
import io.quarkus.oidc.common.runtime.ClientAssertionProvider;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.Jwt.Source;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.groups.UniOnItem;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.DecodeException;
import io.vertx.core.json.JsonObject;
Expand Down Expand Up @@ -55,12 +57,13 @@ public class OidcClientImpl implements OidcClient {
private final OidcClientConfig oidcConfig;
private final Map<OidcEndpoint.Type, List<OidcRequestFilter>> requestFilters;
private final Map<OidcEndpoint.Type, List<OidcResponseFilter>> responseFilters;
private final ClientAssertionProvider clientAssertionProvider;
private volatile boolean closed;

public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevokeUri, String grantType,
OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevokeUri, String grantType,
MultiMap tokenGrantParams, MultiMap commonRefreshGrantParams, OidcClientConfig oidcClientConfig,
Map<OidcEndpoint.Type, List<OidcRequestFilter>> requestFilters,
Map<OidcEndpoint.Type, List<OidcResponseFilter>> responseFilters) {
Map<OidcEndpoint.Type, List<OidcResponseFilter>> responseFilters, Vertx vertx) {
this.client = client;
this.tokenRequestUri = tokenRequestUri;
this.tokenRevokeUri = tokenRevokeUri;
Expand All @@ -73,6 +76,16 @@ public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevo
this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcClientConfig);
this.jwtBearerAuthentication = oidcClientConfig.credentials().jwt().source() == Source.BEARER;
this.clientJwtKey = jwtBearerAuthentication ? null : OidcCommonUtils.initClientJwtKey(oidcClientConfig, false);
if (jwtBearerAuthentication && oidcClientConfig.credentials().jwt().tokenPath().isPresent()) {
this.clientAssertionProvider = new ClientAssertionProvider(vertx,
oidcClientConfig.credentials().jwt().tokenPath().get());
if (this.clientAssertionProvider.getClientAssertion() == null) {
throw new OidcClientException("Cannot find a valid JWT bearer token at path: "
+ oidcClientConfig.credentials().jwt().tokenPath().get());
}
} else {
this.clientAssertionProvider = null;
}
}

@Override
Expand Down Expand Up @@ -177,7 +190,14 @@ private UniOnItem<HttpResponse<Buffer>> postRequest(
if (clientSecretBasicAuthScheme != null) {
request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme);
} else if (jwtBearerAuthentication) {
if (!additionalGrantParameters.containsKey(OidcConstants.CLIENT_ASSERTION)) {
String clientAssertion = additionalGrantParameters.get(OidcConstants.CLIENT_ASSERTION);
if (clientAssertion == null && clientAssertionProvider != null) {
clientAssertion = clientAssertionProvider.getClientAssertion();
if (clientAssertion != null) {
body.add(OidcConstants.CLIENT_ASSERTION, clientAssertion);
}
}
if (clientAssertion == null) {
String errorMessage = String.format(
"%s OidcClient can not complete the %s grant request because a JWT bearer client_assertion is missing",
oidcConfig.id().get(), (refresh ? OidcConstants.REFRESH_TOKEN_GRANT : grantType));
Expand Down Expand Up @@ -319,6 +339,9 @@ private static MultiMap copyMultiMap(MultiMap oldMap) {
public void close() throws IOException {
if (!closed) {
client.close();
if (clientAssertionProvider != null) {
clientAssertionProvider.close();
}
closed = true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,8 @@ public OidcClient apply(OidcConfigurationMetadata metadata, Throwable t) {
setGrantClientParams(oidcConfig, commonRefreshGrantParams, OidcConstants.REFRESH_TOKEN_GRANT);

return new OidcClientImpl(client, metadata.tokenRequestUri, metadata.tokenRevokeUri, grantType,
tokenGrantParams,
commonRefreshGrantParams,
oidcConfig,
oidcRequestFilters,
oidcResponseFilters);
tokenGrantParams, commonRefreshGrantParams, oidcConfig, oidcRequestFilters,
oidcResponseFilters, vertx.get());
}

});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -84,6 +85,7 @@ public void testDefaultValues() {
assertTrue(jwt.signatureAlgorithm().isEmpty());
assertEquals(10, jwt.lifespan());
assertFalse(jwt.assertion());
assertFalse(jwt.tokenPath().isPresent());

// OidcCommonConfig methods
assertTrue(config.authServerUrl().isEmpty());
Expand Down Expand Up @@ -154,6 +156,7 @@ public void testSetEveryProperty() {
.end()
.jwt()
.source(Source.BEARER)
.tokenPath(Path.of("janitor"))
.secretProvider()
.keyringName("jwt-keyring-name-yep")
.key("jwt-key-yep")
Expand Down Expand Up @@ -249,6 +252,7 @@ public void testSetEveryProperty() {
assertNotNull(jwt);
assertEquals(Source.BEARER, jwt.source());
assertEquals("jwt-secret-yep", jwt.secret().orElse(null));
assertEquals("janitor", jwt.tokenPath().map(Path::toString).orElse(null));
provider = jwt.secretProvider();
assertNotNull(provider);
assertEquals("jwt-keyring-name-yep", provider.keyringName().orElse(null));
Expand Down Expand Up @@ -460,6 +464,7 @@ public void testCopyOidcClientCommonConfigProperties() {
.end()
.jwt()
.source(Source.BEARER)
.tokenPath(Path.of("robot"))
.secretProvider()
.keyringName("jwt-keyring-name-yep")
.key("jwt-key-yep")
Expand Down Expand Up @@ -507,6 +512,7 @@ public void testCopyOidcClientCommonConfigProperties() {
assertNotNull(jwt);
assertEquals(Source.BEARER, jwt.source());
assertEquals("jwt-secret-yep", jwt.secret().orElse(null));
assertEquals("robot", jwt.tokenPath().map(Path::toString).orElse(null));
provider = jwt.secretProvider();
assertNotNull(provider);
assertEquals("jwt-keyring-name-yep", provider.keyringName().orElse(null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ enum ConfigMappingMethods {
CREDENTIALS_JWT_LIFESPAN,
CREDENTIALS_JWT_ASSERTION,
CREDENTIALS_JWT_AUDIENCE,
CREDENTIALS_JWT_TOKEN_ID
CREDENTIALS_JWT_TOKEN_ID,
JWT_BEARER_TOKEN_PATH
}

final Map<ConfigMappingMethods, Boolean> invocationsRecorder = new EnumMap<>(ConfigMappingMethods.class);
Expand Down Expand Up @@ -182,6 +183,12 @@ public Source source() {
return Source.BEARER;
}

@Override
public Optional<Path> tokenPath() {
invocationsRecorder.put(ConfigMappingMethods.JWT_BEARER_TOKEN_PATH, true);
return Optional.empty();
}

@Override
public Optional<String> secret() {
invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_SECRET, true);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package io.quarkus.oidc.common.runtime;

import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import org.eclipse.microprofile.jwt.Claims;
import org.jboss.logging.Logger;

import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;

public final class ClientAssertionProvider implements Closeable {

private record ClientAssertion(String bearerToken, long expiresAt, long timerId) {
private boolean isInvalid() {
final long nowSecs = System.currentTimeMillis() / 1000;
return nowSecs > expiresAt;
}
}

private static final Logger LOG = Logger.getLogger(ClientAssertionProvider.class);
private final Vertx vertx;
private final Path bearerTokenPath;
private volatile ClientAssertion clientAssertion;

public ClientAssertionProvider(Vertx vertx, Path bearerTokenPath) {
this.vertx = vertx;
this.bearerTokenPath = bearerTokenPath;
this.clientAssertion = loadFromFileSystem();
}

public String getClientAssertion() {
ClientAssertion clientAssertion = this.clientAssertion;
if (isInvalid(clientAssertion)) {
clientAssertion = loadClientAssertion();
}
return clientAssertion == null ? null : clientAssertion.bearerToken;
}

@Override
public void close() {
cancelRefresh();
clientAssertion = null;
}

private synchronized ClientAssertion loadClientAssertion() {
if (isInvalid(clientAssertion)) {
cancelRefresh();
clientAssertion = loadFromFileSystem();
}
return clientAssertion;
}

private long scheduleRefresh(long expiresAt) {
// in K8 and OCP, tokens are proactively rotated at 80 % of their TTL
long delay = (long) (expiresAt * 0.85);
return vertx.setTimer(delay, new Handler<Long>() {
@Override
public void handle(Long ignored) {
ClientAssertionProvider.this.clientAssertion = loadFromFileSystem();
}
});
}

private void cancelRefresh() {
if (clientAssertion != null) {
vertx.cancelTimer(clientAssertion.timerId);
}
}

private ClientAssertion loadFromFileSystem() {
if (Files.exists(bearerTokenPath)) {
try {
String bearerToken = Files.readString(bearerTokenPath).trim();
Long expiresAt = getExpiresAtFromExpClaim(bearerToken);
if (expiresAt != null) {
return new ClientAssertion(bearerToken, expiresAt, scheduleRefresh(expiresAt));
} else {
LOG.error("Bearer token or its expiry claim is invalid");
}
} catch (IOException e) {
LOG.error("Failed to read file with a bearer token at path: " + bearerTokenPath, e);
}
} else {
LOG.warn("Cannot find a file with a bearer token at path: " + bearerTokenPath);
}
return null;
}

private static boolean isInvalid(ClientAssertion clientAssertion) {
return clientAssertion == null || clientAssertion.isInvalid();
}

private static Long getExpiresAtFromExpClaim(String bearerToken) {
JsonObject claims = OidcCommonUtils.decodeJwtContent(bearerToken);
if (claims == null || !claims.containsKey(Claims.exp.name())) {
return null;
}
try {
return claims.getLong(Claims.exp.name());
} catch (IllegalArgumentException ex) {
LOG.debug("Bearer token expiry claim can not be converted to Long");
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.oidc.common.runtime;

import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -283,6 +284,11 @@ public io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.
.valueOf(source.toString());
}

@Override
public Optional<Path> tokenPath() {
return tokenPath;
}

@Override
public Optional<String> secret() {
return secret;
Expand Down Expand Up @@ -363,6 +369,8 @@ public boolean assertion() {
return assertion;
}

private Optional<Path> tokenPath = Optional.empty();

public static enum Source {
// JWT token is generated by the OIDC provider client to support
// `client_secret_jwt` and `private_key_jwt` authentication methods
Expand Down Expand Up @@ -578,6 +586,7 @@ private void addConfigMappingValues(
signatureAlgorithm = mapping.signatureAlgorithm();
lifespan = mapping.lifespan();
assertion = mapping.assertion();
tokenPath = mapping.tokenPath();
}
}

Expand Down
Loading

0 comments on commit 5f476ac

Please sign in to comment.