Skip to content

Commit

Permalink
Support OidcProviderClient token revocation
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Dec 8, 2024
1 parent e7b3e25 commit 0282ab0
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public final class OidcConstants {
public static final String INTROSPECTION_TOKEN_ISS = "iss";

public static final String REVOCATION_TOKEN = "token";
public static final String REVOCATION_TOKEN_TYPE_HINT = "token_type_hint";

public static final String PASSWORD_GRANT_USERNAME = "username";
public static final String PASSWORD_GRANT_PASSWORD = "password";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class OidcConfigurationMetadata {
public static final String USERINFO_ENDPOINT = "userinfo_endpoint";
public static final String END_SESSION_ENDPOINT = "end_session_endpoint";
private static final String REGISTRATION_ENDPOINT = "registration_endpoint";
private static final String REVOCATION_ENDPOINT = "revocation_endpoint";
public static final String SCOPES_SUPPORTED = "scopes_supported";

private final String discoveryUri;
Expand All @@ -26,6 +27,7 @@ public class OidcConfigurationMetadata {
private final String userInfoUri;
private final String endSessionUri;
private final String registrationUri;
private final String revocationUri;
private final String issuer;
private final JsonObject json;

Expand All @@ -36,6 +38,7 @@ public OidcConfigurationMetadata(String tokenUri,
String userInfoUri,
String endSessionUri,
String registrationUri,
String revocationUri,
String issuer) {
this.discoveryUri = null;
this.tokenUri = tokenUri;
Expand All @@ -45,6 +48,7 @@ public OidcConfigurationMetadata(String tokenUri,
this.userInfoUri = userInfoUri;
this.endSessionUri = endSessionUri;
this.registrationUri = registrationUri;
this.revocationUri = revocationUri;
this.issuer = issuer;
this.json = null;
}
Expand All @@ -70,6 +74,8 @@ public OidcConfigurationMetadata(JsonObject wellKnownConfig, OidcConfigurationMe
localMetadataConfig == null ? null : localMetadataConfig.endSessionUri);
this.registrationUri = getMetadataValue(wellKnownConfig, REGISTRATION_ENDPOINT,
localMetadataConfig == null ? null : localMetadataConfig.registrationUri);
this.revocationUri = getMetadataValue(wellKnownConfig, REVOCATION_ENDPOINT,
localMetadataConfig == null ? null : localMetadataConfig.revocationUri);
this.issuer = getMetadataValue(wellKnownConfig, ISSUER,
localMetadataConfig == null ? null : localMetadataConfig.issuer);
this.json = wellKnownConfig;
Expand All @@ -87,6 +93,10 @@ public String getTokenUri() {
return tokenUri;
}

public String getRevocationUri() {
return revocationUri;
}

public String getIntrospectionUri() {
return introspectionUri;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class OidcConfigurationMetadataProducer {

@Produces
@RequestScoped
OidcConfigurationMetadata produce() {
OidcConfigurationMetadata produceMetadata() {
OidcConfigurationMetadata configMetadata = OidcUtils.getAttribute(identity, OidcUtils.CONFIG_METADATA_ATTRIBUTE);

if (configMetadata == null && tenantConfig.getDefaultTenant().oidcConfig().tenantEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ public String getName() {
var vertxContext = getRoutingContextAttribute(request);
OidcUtils.setBlockingApiAttribute(builder, vertxContext);
OidcUtils.setRoutingContextAttribute(builder, vertxContext);
OidcUtils.setOidcProviderClientAttribute(builder, resolvedContext.getOidcProviderClient());
SecurityIdentity identity = builder.build();
// If the primary token is a bearer access token then there's no point of checking if
// it should be refreshed as RT is only available for the code flow tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ private boolean isTokenExpired(Long exp) {
}

private int getLifespanGrace() {
return client.getOidcConfig().token().lifespanGrace().isPresent()
? client.getOidcConfig().token().lifespanGrace().getAsInt()
return oidcConfig.token().lifespanGrace().isPresent()
? oidcConfig.token().lifespanGrace().getAsInt()
: 0;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConf
}
}

public OidcConfigurationMetadata getMetadata() {
OidcConfigurationMetadata getMetadata() {
return metadata;
}

Expand Down Expand Up @@ -116,18 +116,18 @@ private Uni<JsonWebKeySet> doGetJsonWebKeySet(OidcRequestContextProperties reque
.transform(resp -> getJsonWebKeySet(requestProps, resp));
}

public Uni<UserInfoResponse> getUserInfo(final String token) {
public Uni<UserInfoResponse> getUserInfo(final String accessToken) {

final OidcRequestContextProperties requestProps = getRequestProps(null, null);

return doGetUserInfo(requestProps, token, List.of())
return doGetUserInfo(requestProps, accessToken, List.of())
.onFailure(OidcCommonUtils.validOidcClientRedirect(metadata.getUserInfoUri()))
.recoverWithUni(
new Function<Throwable, Uni<? extends UserInfoResponse>>() {
@Override
public Uni<UserInfoResponse> apply(Throwable t) {
OidcClientRedirectException ex = (OidcClientRedirectException) t;
return doGetUserInfo(requestProps, token, ex.getCookies());
return doGetUserInfo(requestProps, accessToken, ex.getCookies());
}
});
}
Expand Down Expand Up @@ -160,10 +160,6 @@ private JsonWebKeySet getJsonWebKeySet(OidcRequestContextProperties requestProps
return new JsonWebKeySet(getString(requestProps, metadata.getJsonWebKeySetUri(), resp, OidcEndpoint.Type.JWKS));
}

public OidcTenantConfig getOidcConfig() {
return oidcConfig;
}

public Uni<AuthorizationCodeTokens> getAuthorizationCodeTokens(String code, String redirectUri, String codeVerifier) {
final MultiMap codeGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap());
codeGrantParams.add(OidcConstants.GRANT_TYPE, OidcConstants.AUTHORIZATION_CODE);
Expand All @@ -189,6 +185,41 @@ public Uni<AuthorizationCodeTokens> refreshAuthorizationCodeTokens(String refres
.transform(resp -> getAuthorizationCodeTokens(requestProps, resp));
}

public Uni<Boolean> revokeAccessToken(String accessToken) {
return revokeToken(accessToken, OidcConstants.ACCESS_TOKEN_VALUE);
}

public Uni<Boolean> revokeRefreshToken(String refreshToken) {
return revokeToken(refreshToken, OidcConstants.REFRESH_TOKEN_VALUE);
}

private Uni<Boolean> revokeToken(String token, String tokenTypeHint) {

if (metadata.getRevocationUri() != null) {
OidcRequestContextProperties requestProps = getRequestProps(null, null);
MultiMap tokenRevokeParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap());
tokenRevokeParams.set(OidcConstants.REVOCATION_TOKEN, token);
tokenRevokeParams.set(OidcConstants.REVOCATION_TOKEN_TYPE_HINT, tokenTypeHint);

return getHttpResponse(requestProps, metadata.getRevocationUri(), tokenRevokeParams, false)
.transform(resp -> toRevokeResponse(requestProps, resp));
} else {
LOG.debugf("The %s token can not be revoked because the revocation endpoint URL is not set", tokenTypeHint);
return Uni.createFrom().item(false);
}

}

private Boolean toRevokeResponse(OidcRequestContextProperties requestProps, 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.
Buffer buffer = resp.body();
OidcCommonUtils.filterHttpResponse(requestProps, resp, buffer, responseFilters, OidcEndpoint.Type.TOKEN_REVOCATION);
return resp.statusCode() == 503 ? false : true;
}

private UniOnItem<HttpResponse<Buffer>> getHttpResponse(OidcRequestContextProperties requestProps, String uri,
MultiMap formBody, boolean introspect) {
HttpRequest<Buffer> request = client.postAbs(uri);
Expand Down Expand Up @@ -320,7 +351,7 @@ public void close() {
client.close();
}

public Key getClientJwtKey() {
Key getClientJwtKey() {
return clientJwtKey;
}

Expand Down Expand Up @@ -357,15 +388,15 @@ private OidcRequestContextProperties getRequestProps(OidcRequestContextPropertie
return new OidcRequestContextProperties(newProperties);
}

public Vertx getVertx() {
Vertx getVertx() {
return vertx;
}

public WebClient getWebClient() {
WebClient getWebClient() {
return client;
}

static record UserInfoResponse(String contentType, String data) {
public static record UserInfoResponse(String contentType, String data) {
};

}
Original file line number Diff line number Diff line change
Expand Up @@ -615,9 +615,10 @@ private static OidcConfigurationMetadata createLocalMetadata(OidcTenantConfig oi
String jwksUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.jwksPath());
String userInfoUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.userInfoPath());
String endSessionUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.endSessionPath());
String registrationUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.registrationPath());
String registrationUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.revokePath());
String revocationUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.revokePath());
return new OidcConfigurationMetadata(tokenUri,
introspectionUri, authorizationUri, jwksUri, userInfoUri, endSessionUri, registrationUri,
introspectionUri, authorizationUri, jwksUri, userInfoUri, endSessionUri, registrationUri, revocationUri,
oidcConfig.token().issuer().orElse(null));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(Map<String, Object> req
builder.setPrincipal(jwtPrincipal);
var vertxContext = getRoutingContextAttribute(request);
setRoutingContextAttribute(builder, vertxContext);
OidcUtils.setOidcProviderClientAttribute(builder, resolvedContext.getOidcProviderClient());
setSecurityIdentityRoles(builder, config, rolesJson);
setSecurityIdentityPermissions(builder, config, rolesJson);
setSecurityIdentityUserInfo(builder, userInfo);
Expand Down Expand Up @@ -419,6 +420,11 @@ public static void setRoutingContextAttribute(QuarkusSecurityIdentity.Builder bu
builder.addAttribute(RoutingContext.class.getName(), routingContext);
}

public static void setOidcProviderClientAttribute(QuarkusSecurityIdentity.Builder builder,
OidcProviderClient oidcProviderClient) {
builder.addAttribute(OidcProviderClient.class.getName(), oidcProviderClient);
}

public static void setSecurityIdentityUserInfo(QuarkusSecurityIdentity.Builder builder, UserInfo userInfo) {
if (userInfo != null) {
builder.addAttribute(USER_INFO_ATTRIBUTE, userInfo);
Expand Down
5 changes: 5 additions & 0 deletions integration-tests/oidc-wiremock-logout/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.ObservesAsync;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.RefreshToken;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.runtime.OidcProviderClient;

@ApplicationScoped
public class SecurityEventListener {

public void event(@ObservesAsync SecurityEvent event) {
if (SecurityEvent.Type.OIDC_BACKCHANNEL_LOGOUT_COMPLETED == event.getEventType()) {
OidcProviderClient oidcProvider = event.getSecurityIdentity().getAttribute(OidcProviderClient.class.getName());
String accessToken = event.getSecurityIdentity().getCredential(AccessTokenCredential.class).getToken();
String refreshToken = event.getSecurityIdentity().getCredential(RefreshToken.class).getToken();
oidcProvider.revokeAccessToken(accessToken).await().indefinitely();
oidcProvider.revokeRefreshToken(refreshToken).await().indefinitely();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ quarkus.oidc.code-flow-form-post.discovery-enabled=false
quarkus.oidc.code-flow-form-post.authorization-path=/
# reuse the wiremock access token stub for the `quarkus` realm - it is the same for the query and form post response mode
quarkus.oidc.code-flow-form-post.token-path=${keycloak.url}/realms/quarkus/token
quarkus.oidc.code-flow-form-post.revoke-path=${keycloak.url}/realms/quarkus/revoke
# reuse the wiremock JWK endpoint stub for the `quarkus` realm - it is the same for the query and form post response mode
quarkus.oidc.code-flow-form-post.jwks-path=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs
quarkus.oidc.code-flow-form-post.logout.backchannel.path=/back-channel-logout
quarkus.oidc.code-flow-form-post.token.audience=https://server.example.com,https://id.server.example.com

quarkus.native.additional-build-args=-H:IncludeResources=private.*\\.*,-H:IncludeResources=.*\\.p12

quarkus.http.root-path=/service
quarkus.http.root-path=/service
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package io.quarkus.it.keycloak;

import static com.github.tomakehurst.wiremock.client.WireMock.containing;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;

import org.htmlunit.SilentCssErrorHandler;
import org.htmlunit.TextPage;
Expand All @@ -17,8 +23,12 @@
import org.htmlunit.util.Cookie;
import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;

import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWireMock;
import io.quarkus.test.oidc.server.OidcWiremockTestResource;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
Expand All @@ -27,8 +37,12 @@
@QuarkusTestResource(OidcWiremockTestResource.class)
public class CodeFlowAuthorizationTest {

@OidcWireMock
WireMockServer wireMockServer;

@Test
public void testCodeFlowFormPostAndBackChannelLogout() throws IOException {
public void testCodeFlowFormPostAndBackChannelLogout() throws Exception {
defineRevokeTokenStubs();
try (final WebClient webClient = createWebClient()) {
webClient.getOptions().setRedirectEnabled(true);
HtmlPage page = webClient.getPage("http://localhost:8081/service/code-flow-form-post");
Expand Down Expand Up @@ -64,6 +78,9 @@ public void testCodeFlowFormPostAndBackChannelLogout() throws IOException {
// Session is still active
assertNotNull(getSessionCookie(webClient, "code-flow-form-post"));

wireMockServer.verify(0,
postRequestedFor(urlPathMatching("/auth/realms/quarkus/revoke")));

// request a back channel logout for the same subject
RestAssured.given()
.when().contentType(ContentType.URLENC).body("logout_token="
Expand All @@ -80,10 +97,44 @@ public void testCodeFlowFormPostAndBackChannelLogout() throws IOException {

assertNull(getSessionCookie(webClient, "code-flow-form-post"));

await().atMost(10, TimeUnit.SECONDS)
.pollInterval(Duration.ofSeconds(3))
.until(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
try {
wireMockServer.verify(2,
postRequestedFor(urlPathMatching("/auth/realms/quarkus/revoke")));
return true;
} catch (Throwable t) {
return false;
}
}
});

wireMockServer.verify(2,
postRequestedFor(urlPathMatching("/auth/realms/quarkus/revoke")));
wireMockServer.resetRequests();

webClient.getCookieManager().clearCookies();
}
}

private void defineRevokeTokenStubs() {
wireMockServer
.stubFor(WireMock.post("/auth/realms/quarkus/revoke")
.withRequestBody(containing("token"))
.withRequestBody(containing("token_type_hint=access_token"))
.willReturn(WireMock.aResponse()
.withStatus(200)));
wireMockServer
.stubFor(WireMock.post("/auth/realms/quarkus/revoke")
.withRequestBody(containing("token"))
.withRequestBody(containing("token_type_hint=refresh_token"))
.willReturn(WireMock.aResponse()
.withStatus(200)));
}

private WebClient createWebClient() {
WebClient webClient = new WebClient();
webClient.setCssErrorHandler(new SilentCssErrorHandler());
Expand Down

0 comments on commit 0282ab0

Please sign in to comment.