Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance OIDC Client to support the token revocation #26868

Merged
merged 1 commit into from
Sep 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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