Skip to content

Commit

Permalink
Merge pull request quarkusio#26868 from sberyozkin/oidc_client_revoke…
Browse files Browse the repository at this point in the history
…_token

Enhance OIDC Client to support the token revocation
  • Loading branch information
sberyozkin authored Sep 12, 2022
2 parents fc5d630 + a1c3921 commit 4694171
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,15 @@ If the access token needs to be refreshed but no refresh token is available then

Please note that some OpenID Connect Providers will not return a refresh token in a `client_credentials` grant response. For example, starting from Keycloak 12 a refresh token will not be returned by default for `client_credentials`. The providers may also restrict a number of times a refresh token can be used.

[[revoke-access-tokens]]
=== Revoking Access Tokens

If your OpenId Connect provider such as Keycloak supports a token revocation endpoint then `OidcClient#revokeAccessToken` can be used to revoke the current access token. The revocation endpoint URL will be discovered alongside the token request URI or can be configured with `quarkus.oidc-client.revoke-path`.

You may want to have the access token revoked if using this token with a REST client fails with HTTP `401` or the access token has already been used for a long time and you'd like to refresh it.

This can be achieved by requesting a token refresh using a refresh token. However, if the refresh token is not available then you can refresh it by revoking it first and then request a new access token.

[[oidc-client-authentication]]
=== OidcClient Authentication

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,35 @@
public interface OidcClient extends Closeable {

/**
* Returns the grant tokens
* Get the grant access and refresh tokens.
*/
default Uni<Tokens> getTokens() {
return getTokens(Collections.emptyMap());
}

/**
* Returns the grant tokens
* Get the grant access and refresh tokens with additional grant parameters.
*
* @param additionalGrantParameters additional grant parameters
* @return Uni<Tokens>
*/
Uni<Tokens> getTokens(Map<String, String> additionalGrantParameters);

/**
* Refreshes the grant tokens
* Refresh and return a new pair of access and refresh tokens.
* Note a refresh token grant will typically return not only a new access token but also a new refresh token.
*
* @param refreshToken refresh token
* @return Uni<Tokens>
*/
Uni<Tokens> refreshTokens(String refreshToken);

/**
* Revoke the access token.
*
* @param refreshToken access token which needs to be revoked
* @return Uni<Boolean> true if the token has been revoked or found already being invalidated,
* false if the token can not be currently revoked in which case a revocation request might be retried.
*/
Uni<Boolean> revokeAccessToken(String accessToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.groups.UniOnItem;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.DecodeException;
import io.vertx.core.json.JsonObject;
Expand All @@ -37,6 +38,7 @@ public class OidcClientImpl implements OidcClient {

private final WebClient client;
private final String tokenRequestUri;
private final String tokenRevokeUri;
private final MultiMap tokenGrantParams;
private final MultiMap commonRefreshGrantParams;
private final String grantType;
Expand All @@ -45,10 +47,11 @@ public class OidcClientImpl implements OidcClient {
private final OidcClientConfig oidcConfig;
private volatile boolean closed;

public OidcClientImpl(WebClient client, String tokenRequestUri, String grantType,
public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevokeUri, String grantType,
MultiMap tokenGrantParams, MultiMap commonRefreshGrantParams, OidcClientConfig oidcClientConfig) {
this.client = client;
this.tokenRequestUri = tokenRequestUri;
this.tokenRevokeUri = tokenRevokeUri;
this.tokenGrantParams = tokenGrantParams;
this.commonRefreshGrantParams = commonRefreshGrantParams;
this.grantType = grantType;
Expand Down Expand Up @@ -78,61 +81,97 @@ public Uni<Tokens> refreshTokens(String refreshToken) {
return getJsonResponse(refreshGrantParams, Collections.emptyMap(), true);
}

@Override
public Uni<Boolean> revokeAccessToken(String accessToken) {
checkClosed();
if (accessToken == null) {
throw new OidcClientException("Access token is null");
}
if (tokenRevokeUri != null) {
MultiMap tokenRevokeParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap());
tokenRevokeParams.set(OidcConstants.REVOCATION_TOKEN, accessToken);
return postRequest(client.postAbs(tokenRevokeUri), tokenRevokeParams, Map.of(), false)
.transform(resp -> toRevokeResponse(resp));
} else {
LOG.debugf("%s OidcClient can not revoke the access token because the revocation endpoint URL is not set");
return Uni.createFrom().item(false);
}

}

private Boolean toRevokeResponse(HttpResponse<Buffer> resp) {
// Per RFC7009, 200 is returned if a token has been revoked successfully or if the client submitted an
// invalid token, https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.
// 503 is at least theoretically possible if the OIDC server declines and suggests to Retry-After some period of time.
// However this period of time can be set to unpredictable value.
return resp.statusCode() == 503 ? false : true;
}

private Uni<Tokens> getJsonResponse(MultiMap formBody, Map<String, String> additionalGrantParameters, boolean refresh) {
//Uni needs to be lazy by default, we don't send the request unless
//something has subscribed to it. This is important for the CAS state
//management in TokensHelper
return Uni.createFrom().deferred(new Supplier<Uni<? extends Tokens>>() {
@Override
public Uni<Tokens> get() {
MultiMap body = formBody;
HttpRequest<Buffer> request = client.postAbs(tokenRequestUri);
request.putHeader(HttpHeaders.CONTENT_TYPE.toString(),
HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString());
if (oidcConfig.headers != null) {
for (Map.Entry<String, String> headerEntry : oidcConfig.headers.entrySet()) {
request.putHeader(headerEntry.getKey(), headerEntry.getValue());
}
}
if (clientSecretBasicAuthScheme != null) {
request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme);
} else if (clientJwtKey != null) {
// if it is a refresh then a map has already been copied
body = !refresh ? copyMultiMap(body) : body;
String jwt = OidcCommonUtils.signJwtWithKey(oidcConfig, tokenRequestUri, clientJwtKey);

if (OidcCommonUtils.isClientSecretPostJwtAuthRequired(oidcConfig.credentials)) {
body.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get());
body.add(OidcConstants.CLIENT_SECRET, jwt);
} else {
body.add(OidcConstants.CLIENT_ASSERTION_TYPE, OidcConstants.JWT_BEARER_CLIENT_ASSERTION_TYPE);
body.add(OidcConstants.CLIENT_ASSERTION, jwt);
}
} else if (!OidcCommonUtils.isClientSecretPostAuthRequired(oidcConfig.credentials)) {
body = copyMultiMap(body).set(OidcConstants.CLIENT_ID, oidcConfig.clientId.get());
}
if (!additionalGrantParameters.isEmpty()) {
body = copyMultiMap(body);
for (Map.Entry<String, String> entry : additionalGrantParameters.entrySet()) {
body.add(entry.getKey(), entry.getValue());
}
}
// Retry up to three times with a one-second delay between the retries if the connection is closed
Uni<HttpResponse<Buffer>> response = request.sendBuffer(OidcCommonUtils.encodeForm(body))
.onFailure(ConnectException.class)
.retry()
.atMost(oidcConfig.connectionRetryCount)
.onFailure().transform(t -> {
LOG.warn("OIDC Server is not available:", t.getCause() != null ? t.getCause() : t);
// don't wrap it to avoid information leak
return new OidcClientException("OIDC Server is not available");
});
return response.onItem()
return postRequest(client.postAbs(tokenRequestUri), formBody, additionalGrantParameters, refresh)
.transform(resp -> emitGrantTokens(resp, refresh));
}
});
}

private UniOnItem<HttpResponse<Buffer>> postRequest(HttpRequest<Buffer> request, MultiMap formBody,
Map<String, String> additionalGrantParameters,
boolean refresh) {
MultiMap body = formBody;
request.putHeader(HttpHeaders.CONTENT_TYPE.toString(),
HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString());
if (oidcConfig.headers != null) {
for (Map.Entry<String, String> headerEntry : oidcConfig.headers.entrySet()) {
request.putHeader(headerEntry.getKey(), headerEntry.getValue());
}
}
if (clientSecretBasicAuthScheme != null) {
request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme);
} else if (clientJwtKey != null) {
// if it is a refresh then a map has already been copied
body = !refresh ? copyMultiMap(body) : body;
String jwt = OidcCommonUtils.signJwtWithKey(oidcConfig, tokenRequestUri, clientJwtKey);

if (OidcCommonUtils.isClientSecretPostJwtAuthRequired(oidcConfig.credentials)) {
body.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get());
body.add(OidcConstants.CLIENT_SECRET, jwt);
} else {
body.add(OidcConstants.CLIENT_ASSERTION_TYPE, OidcConstants.JWT_BEARER_CLIENT_ASSERTION_TYPE);
body.add(OidcConstants.CLIENT_ASSERTION, jwt);
}
} else if (OidcCommonUtils.isClientSecretPostAuthRequired(oidcConfig.credentials)) {
body = !refresh ? copyMultiMap(body) : body;
body.set(OidcConstants.CLIENT_ID, oidcConfig.clientId.get());
body.set(OidcConstants.CLIENT_SECRET, OidcCommonUtils.clientSecret(oidcConfig.credentials));
} else {
body = !refresh ? copyMultiMap(body) : body;
body = copyMultiMap(body).set(OidcConstants.CLIENT_ID, oidcConfig.clientId.get());
}
if (!additionalGrantParameters.isEmpty()) {
body = copyMultiMap(body);
for (Map.Entry<String, String> entry : additionalGrantParameters.entrySet()) {
body.add(entry.getKey(), entry.getValue());
}
}
// Retry up to three times with a one-second delay between the retries if the connection is closed
Uni<HttpResponse<Buffer>> response = request.sendBuffer(OidcCommonUtils.encodeForm(body))
.onFailure(ConnectException.class)
.retry()
.atMost(oidcConfig.connectionRetryCount)
.onFailure().transform(t -> {
LOG.warn("OIDC Server is not available:", t.getCause() != null ? t.getCause() : t);
// don't wrap it to avoid information leak
return new OidcClientException("OIDC Server is not available");
});
return response.onItem();
}

private Tokens emitGrantTokens(HttpResponse<Buffer> resp, boolean refresh) {
if (resp.statusCode() == 200) {
LOG.debugf("%s OidcClient has %s the tokens", oidcConfig.getId().get(), (refresh ? "refreshed" : "acquired"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import io.quarkus.oidc.client.OidcClientException;
import io.quarkus.oidc.client.OidcClients;
import io.quarkus.oidc.client.Tokens;
import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.runtime.TlsConfig;
Expand Down Expand Up @@ -121,28 +120,32 @@ protected static Uni<OidcClient> createOidcClientUni(OidcClientConfig oidcConfig

WebClient client = WebClient.create(new io.vertx.mutiny.core.Vertx(vertx.get()), options);

Uni<String> tokenRequestUriUni = null;
Uni<OidcConfigurationMetadata> tokenUrisUni = null;
if (OidcCommonUtils.isAbsoluteUrl(oidcConfig.tokenPath)) {
tokenRequestUriUni = Uni.createFrom().item(oidcConfig.tokenPath.get());
tokenUrisUni = Uni.createFrom().item(
new OidcConfigurationMetadata(oidcConfig.tokenPath.get(),
OidcCommonUtils.isAbsoluteUrl(oidcConfig.revokePath) ? oidcConfig.revokePath.get() : null));
} else {
String authServerUriString = OidcCommonUtils.getAuthServerUrl(oidcConfig);
if (!oidcConfig.discoveryEnabled.orElse(true)) {
tokenRequestUriUni = Uni.createFrom()
.item(OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.tokenPath));
tokenUrisUni = Uni.createFrom()
.item(new OidcConfigurationMetadata(
OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.tokenPath),
OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.revokePath)));
} else {
tokenRequestUriUni = discoverTokenRequestUri(client, authServerUriString.toString(), oidcConfig);
tokenUrisUni = discoverTokenUris(client, authServerUriString.toString(), oidcConfig);
}
}
return tokenRequestUriUni.onItemOrFailure()
.transform(new BiFunction<String, Throwable, OidcClient>() {
return tokenUrisUni.onItemOrFailure()
.transform(new BiFunction<OidcConfigurationMetadata, Throwable, OidcClient>() {

@Override
public OidcClient apply(String tokenRequestUri, Throwable t) {
public OidcClient apply(OidcConfigurationMetadata metadata, Throwable t) {
if (t != null) {
throw toOidcClientException(getEndpointUrl(oidcConfig), t);
}

if (tokenRequestUri == null) {
if (metadata.tokenRequestUri == null) {
throw new ConfigurationException(
"OpenId Connect Provider token endpoint URL is not configured and can not be discovered");
}
Expand Down Expand Up @@ -182,7 +185,8 @@ public OidcClient apply(String tokenRequestUri, Throwable t) {
MultiMap commonRefreshGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap());
setGrantClientParams(oidcConfig, commonRefreshGrantParams, OidcConstants.REFRESH_TOKEN_GRANT);

return new OidcClientImpl(client, tokenRequestUri, grantType, tokenGrantParams,
return new OidcClientImpl(client, metadata.tokenRequestUri, metadata.tokenRevokeUri, grantType,
tokenGrantParams,
commonRefreshGrantParams,
oidcConfig);
}
Expand All @@ -196,20 +200,17 @@ private static String getEndpointUrl(OidcClientConfig oidcConfig) {

private static void setGrantClientParams(OidcClientConfig oidcConfig, MultiMap grantParams, String grantType) {
grantParams.add(OidcConstants.GRANT_TYPE, grantType);
Credentials creds = oidcConfig.getCredentials();
if (OidcCommonUtils.isClientSecretPostAuthRequired(creds)) {
grantParams.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get());
grantParams.add(OidcConstants.CLIENT_SECRET, OidcCommonUtils.clientSecret(creds));
}
if (oidcConfig.getScopes().isPresent()) {
grantParams.add(OidcConstants.TOKEN_SCOPE, oidcConfig.getScopes().get().stream().collect(Collectors.joining(" ")));
}
}

private static Uni<String> discoverTokenRequestUri(WebClient client, String authServerUrl, OidcClientConfig oidcConfig) {
private static Uni<OidcConfigurationMetadata> discoverTokenUris(WebClient client, String authServerUrl,
OidcClientConfig oidcConfig) {
final long connectionDelayInMillisecs = OidcCommonUtils.getConnectionDelayInMillis(oidcConfig);
return OidcCommonUtils.discoverMetadata(client, authServerUrl, connectionDelayInMillisecs)
.onItem().transform(json -> json.getString("token_endpoint"));
.onItem().transform(json -> new OidcConfigurationMetadata(json.getString("token_endpoint"),
json.getString("revocation_endpoint")));
}

protected static OidcClientException toOidcClientException(String authServerUrlString, Throwable cause) {
Expand All @@ -233,8 +234,24 @@ public Uni<Tokens> refreshTokens(String refreshToken) {
throw new DisabledOidcClientException(message);
}

@Override
public Uni<Boolean> revokeAccessToken(String accessToken) {
throw new DisabledOidcClientException(message);
}

@Override
public void close() throws IOException {
}

}

private static class OidcConfigurationMetadata {
private final String tokenRequestUri;
private final String tokenRevokeUri;

OidcConfigurationMetadata(String tokenRequestUri, String tokenRevokeUri) {
this.tokenRequestUri = tokenRequestUri;
this.tokenRevokeUri = tokenRevokeUri;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public class OidcCommonConfig {
@ConfigItem
public Optional<String> tokenPath = Optional.empty();

/**
* Relative path or absolute URL of the OIDC token revocation endpoint.
*/
@ConfigItem
public Optional<String> revokePath = Optional.empty();

/**
* The client-id of the application. Each application has a client-id that is used to identify the application
*/
Expand Down Expand Up @@ -605,6 +611,14 @@ public void setTokenPath(String tokenPath) {
this.tokenPath = Optional.of(tokenPath);
}

public Optional<String> getRevokePath() {
return revokePath;
}

public void setRevokePath(String revokePath) {
this.revokePath = Optional.of(revokePath);
}

public Optional<String> getClientId() {
return clientId;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public final class OidcConstants {
public static final String INTROSPECTION_TOKEN_USERNAME = "username";
public static final String INTROSPECTION_TOKEN_SUB = "sub";

public static final String REVOCATION_TOKEN = "token";

public static final String PASSWORD_GRANT_USERNAME = "username";
public static final String PASSWORD_GRANT_PASSWORD = "password";

Expand Down
Loading

0 comments on commit 4694171

Please sign in to comment.