Skip to content

Commit

Permalink
Support for OIDC FrontChannel logout
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Jun 28, 2022
1 parent bddfa06 commit 3dd5f84
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,25 @@ quarkus.oidc.logout.backchannel.path=/back-channel-logout

Absolute `Back-Channel Logout` URL is calculated by adding `quarkus.oidc.back-channel-logout.path` to the current endpoint URL, for example, `http://localhost:8080/back-channel-logout`. You will need to configure this URL in the Admin Console of your OpenID Connect Provider.

[[front-channel-logout]]
==== Front-Channel Logout

link:https://openid.net/specs/openid-connect-frontchannel-1_0.html[Front-Channel Logout] can be used to logout the current user directly from the user agent.

You can configure Quarkus to support `Front-Channel Logout` as follows:

[source,properties]
----
quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.client-id=frontend
quarkus.oidc.credentials.secret=secret
quarkus.oidc.application-type=web-app
quarkus.oidc.logout.frontchannel.path=/front-channel-logout
----

This path will be compared against the current request's path and the user willbe logged out if these paths match.

[[local-logout]]
==== Local Logout

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public class OidcTenantConfig extends OidcCommonConfig {
public Token token = new Token();

/**
* RP Initiated and BackChannel Logout configuration
* RP Initiated, BackChannel and FrontChannel Logout configuration
*/
@ConfigItem
public Logout logout = new Logout();
Expand Down Expand Up @@ -177,6 +177,12 @@ public static class Logout {
@ConfigItem
public Backchannel backchannel = new Backchannel();

/**
* Front-Channel Logout configuration
*/
@ConfigItem
public Frontchannel frontchannel = new Frontchannel();

public void setPath(Optional<String> path) {
this.path = path;
}
Expand Down Expand Up @@ -216,6 +222,14 @@ public Backchannel getBackchannel() {
public void setBackchannel(Backchannel backchannel) {
this.backchannel = backchannel;
}

public Frontchannel getFrontchannel() {
return frontchannel;
}

public void setFrontchannel(Frontchannel frontchannel) {
this.frontchannel = frontchannel;
}
}

@ConfigGroup
Expand All @@ -235,6 +249,23 @@ public String getPath() {
}
}

@ConfigGroup
public static class Frontchannel {
/**
* The relative path of the Front-Channel Logout endpoint at the application.
*/
@ConfigItem
public Optional<String> path = Optional.empty();

public void setPath(Optional<String> path) {
this.path = path;
}

public String getPath() {
return path.get();
}
}

/**
* Default Authorization Code token state manager configuration
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ public enum Type {
/**
* OIDC Logout event is reported when the current user has started an RP-initiated OIDC logout flow.
*/
OIDC_LOGOUT_RP_INITIATED
OIDC_LOGOUT_RP_INITIATED,
/**
* OIDC BackChannel Logout event is reported when the current user's session has been removed due to a pending OIDC
* BackChannel logout request.
*/
OIDC_BACKCHANNEL_LOGOUT_COMPLETED,
/**
* OIDC FrontChannel Logout event is reported when the current user's session has been removed due to an OIDC
* FrontChannel logout request.
*/
OIDC_FRONTCHANNEL_LOGOUT_COMPLETED
}

private final Type eventType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.regex.Pattern;

import org.eclipse.microprofile.jwt.Claims;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import org.jose4j.jwt.consumer.ErrorCodes;
import org.jose4j.jwt.consumer.InvalidJwtException;
Expand Down Expand Up @@ -200,19 +201,6 @@ private Uni<SecurityIdentity> reAuthenticate(Cookie sessionCookie,
.chain(new Function<AuthorizationCodeTokens, Uni<? extends SecurityIdentity>>() {
@Override
public Uni<? extends SecurityIdentity> apply(AuthorizationCodeTokens session) {
if (isBackChannelLogoutPendingAndValid(configContext, session.getIdToken())) {
return OidcUtils
.removeSessionCookie(context, configContext.oidcConfig, sessionCookie.getName(),
resolver.getTokenStateManager())
.chain(new Function<Void, Uni<? extends SecurityIdentity>>() {
@Override
public Uni<SecurityIdentity> apply(Void t) {
return Uni.createFrom().nullItem();
}
});

}

context.put(OidcConstants.ACCESS_TOKEN_VALUE, session.getAccessToken());
context.put(AuthorizationCodeTokens.class.getName(), session);
return authenticate(identityProviderManager, context,
Expand All @@ -226,6 +214,19 @@ public Uni<Void> apply(SecurityIdentity identity) {
return buildLogoutRedirectUriUni(context, configContext,
session.getIdToken());
}
if (isBackChannelLogoutPendingAndValid(configContext, identity)
|| isFrontChannelLogoutValid(context, configContext,
identity)) {
return removeSessionCookie(context, configContext.oidcConfig)
.map(new Function<Void, Void>() {
@Override
public Void apply(Void t) {
// force a challenge which will redirect the user to authenticate
throw new AuthenticationFailedException();
}
});

}
return VOID_UNI;
}
}).onFailure()
Expand Down Expand Up @@ -268,47 +269,65 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
});
}

private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configContext, String idToken) {
private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configContext, SecurityIdentity identity) {
TokenVerificationResult backChannelLogoutTokenResult = resolver.getBackChannelLogoutTokens()
.remove(configContext.oidcConfig.getTenantId().get());
if (backChannelLogoutTokenResult != null) {
// Verify IdToken signature first before comparing the claim values
try {
TokenVerificationResult idTokenResult = configContext.provider.verifyJwtToken(idToken);

String idTokenIss = idTokenResult.localVerificationResult.getString(Claims.iss.name());
String logoutTokenIss = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.iss.name());
if (logoutTokenIss != null && !logoutTokenIss.equals(idTokenIss)) {
LOG.debugf("Logout token issuer does not match the ID token issuer");
return false;
}
String idTokenSub = idTokenResult.localVerificationResult.getString(Claims.sub.name());
String logoutTokenSub = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.sub.name());
if (logoutTokenSub != null && idTokenSub != null && !logoutTokenSub.equals(idTokenSub)) {
LOG.debugf("Logout token subject does not match the ID token subject");
return false;
}
String idTokenSid = idTokenResult.localVerificationResult
.getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM);
String logoutTokenSid = backChannelLogoutTokenResult.localVerificationResult
.getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM);
if (logoutTokenSid != null && idTokenSid != null && !logoutTokenSid.equals(idTokenSid)) {
LOG.debugf("Logout token session id does not match the ID token session id");
return false;
}
} catch (InvalidJwtException ex) {
// Let IdentityProvider deal with it again, but just removing the session cookie without
// doing a logout token check against a verified ID token is not possible.
LOG.debugf("Unable to complete the back channel logout request for the tenant %s",
configContext.oidcConfig.tenantId.get());
JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken());
String idTokenIss = idTokenJson.getString(Claims.iss.name());
String logoutTokenIss = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.iss.name());
if (logoutTokenIss != null && !logoutTokenIss.equals(idTokenIss)) {
LOG.debugf("Logout token issuer does not match the ID token issuer");
return false;
}
String idTokenSub = idTokenJson.getString(Claims.sub.name());
String logoutTokenSub = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.sub.name());
if (logoutTokenSub != null && idTokenSub != null && !logoutTokenSub.equals(idTokenSub)) {
LOG.debugf("Logout token subject does not match the ID token subject");
return false;
}
String idTokenSid = idTokenJson.getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM);
String logoutTokenSid = backChannelLogoutTokenResult.localVerificationResult
.getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM);
if (logoutTokenSid != null && idTokenSid != null && !logoutTokenSid.equals(idTokenSid)) {
LOG.debugf("Logout token session id does not match the ID token session id");
return false;
}
LOG.debugf("Frontchannel logout request for the tenant %s has been completed",
configContext.oidcConfig.tenantId.get());

fireEvent(SecurityEvent.Type.OIDC_BACKCHANNEL_LOGOUT_COMPLETED, identity);

return true;
}
return false;
}

private boolean isFrontChannelLogoutValid(RoutingContext context, TenantConfigContext configContext,
SecurityIdentity identity) {
if (isEqualToRequestPath(configContext.oidcConfig.logout.frontchannel.path, context, configContext)) {
JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken());

String idTokenIss = idTokenJson.getString(Claims.iss.name());
List<String> frontChannelIss = context.queryParam(Claims.iss.name());
if (frontChannelIss != null && frontChannelIss.size() == 1 && !frontChannelIss.get(0).equals(idTokenIss)) {
LOG.debugf("Frontchannel issuer parameter does not match the ID token issuer");
return false;
}
String idTokenSid = idTokenJson.getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM);
List<String> frontChannelSid = context.queryParam(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM);
if (frontChannelSid != null && frontChannelSid.size() == 1 && !frontChannelSid.get(0).equals(idTokenSid)) {
LOG.debugf("Frontchannel session id parameter does not match the ID token session id");
return false;
}
LOG.debugf("Frontchannel logout request for the tenant %s has been completed",
configContext.oidcConfig.tenantId.get());
fireEvent(SecurityEvent.Type.OIDC_FRONTCHANNEL_LOGOUT_COMPLETED, identity);
return true;
}
return false;
}

private boolean isInternalIdToken(String idToken, TenantConfigContext configContext) {
if (!configContext.oidcConfig.authentication.idTokenRequired.orElse(true)) {
JsonObject headers = OidcUtils.decodeJwtHeaders(idToken);
Expand Down Expand Up @@ -790,11 +809,12 @@ private String buildUri(RoutingContext context, boolean forceHttps, String autho
}

private boolean isLogout(RoutingContext context, TenantConfigContext configContext) {
Optional<String> logoutPath = configContext.oidcConfig.logout.path;
return isEqualToRequestPath(configContext.oidcConfig.logout.path, context, configContext);
}

if (logoutPath.isPresent()) {
return context.request().absoluteURI().equals(
buildUri(context, false, logoutPath.get()));
private boolean isEqualToRequestPath(Optional<String> path, RoutingContext context, TenantConfigContext configContext) {
if (path.isPresent()) {
return context.request().path().equals(path.get());
}

return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public String resolve(RoutingContext context) {
if (path.endsWith("code-flow") || path.endsWith("code-flow/logout")) {
return "code-flow";
}
if (path.endsWith("code-flow-form-post")) {
if (path.endsWith("code-flow-form-post") || path.endsWith("code-flow-form-post/front-channel-logout")) {
return "code-flow-form-post";
}
if (path.endsWith("code-flow-user-info-only")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ quarkus.oidc.code-flow-form-post.token-path=${keycloak.url}/realms/quarkus/token
# 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.logout.frontchannel.path=/code-flow-form-post/front-channel-logout


quarkus.oidc.code-flow-user-info-only.auth-server-url=${keycloak.url}/realms/quarkus/
Expand Down Expand Up @@ -103,5 +104,8 @@ quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".level
quarkus.http.auth.permission.logout.paths=/code-flow/logout
quarkus.http.auth.permission.logout.policy=authenticated

quarkus.http.auth.permission.front-channel-logout.paths=/code-flow-form-post/front-channel-logout
quarkus.http.auth.permission.front-channel-logout.policy=authenticated

quarkus.http.auth.permission.backchannellogout.paths=/back-channel-logout
quarkus.http.auth.permission.backchannellogout.policy=permit
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.util.Set;

import org.junit.jupiter.api.Test;
Expand All @@ -24,6 +25,7 @@
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;

import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
Expand Down Expand Up @@ -65,7 +67,7 @@ public void testCodeFlow() throws IOException {
}

@Test
public void testCodeFlowFormPost() throws IOException {
public void testCodeFlowFormPostAndBackChannelLogout() throws IOException {
defineCodeFlowLogoutStub();
try (final WebClient webClient = createWebClient()) {
webClient.getOptions().setRedirectEnabled(true);
Expand Down Expand Up @@ -106,6 +108,56 @@ public void testCodeFlowFormPost() throws IOException {
}
}

@Test
public void testCodeFlowFormPostAndFrontChannelLogout() throws IOException {
defineCodeFlowLogoutStub();
try (final WebClient webClient = createWebClient()) {
webClient.getOptions().setRedirectEnabled(true);
HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-form-post");

HtmlForm form = page.getFormByName("form");
form.getInputByName("username").type("alice");
form.getInputByName("password").type("alice");

page = form.getInputByValue("login").click();

assertEquals("alice", page.getBody().asText());

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

page = webClient.getPage("http://localhost:8081/code-flow-form-post");
assertEquals("alice", page.getBody().asText());

// Session is still active
Cookie sessionCookie = getSessionCookie(webClient, "code-flow-form-post");
assertNotNull(sessionCookie);
JsonObject idTokenClaims = OidcUtils.decodeJwtContent(sessionCookie.getValue().split("\\|")[0]);

webClient.getOptions().setRedirectEnabled(false);

// Confirm 302 is returned and the session cookie is null when the frontchannel logout URL is called
URL frontchannelUrl = URI.create("http://localhost:8081/code-flow-form-post/front-channel-logout"
+ "?sid=" + idTokenClaims.getString("sid") + "&iss="
+ OidcCommonUtils.urlEncode(idTokenClaims.getString("iss"))).toURL();
WebResponse webResponse = webClient.loadWebResponse(new WebRequest(frontchannelUrl));
assertEquals(302, webResponse.getStatusCode());

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

// remove the state cookie for Quarkus not to treat the next call as an expected redirect from OIDC
webClient.getCookieManager().clearCookies();

// Confirm 302 is returned and the session cookie is null when the endpoint is called
webResponse = webClient
.loadWebResponse(new WebRequest(URI.create("http://localhost:8081/code-flow-form-post").toURL()));
assertEquals(302, webResponse.getStatusCode());

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

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

@Test
public void testCodeFlowUserInfo() throws IOException {
defineCodeFlowAuthorizationOauth2TokenStub();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ public static String generateJwtToken(String userName, Set<String> groups) {
.groups(groups)
.issuer(TOKEN_ISSUER)
.audience(TOKEN_AUDIENCE)
.claim("sid", "session-id")
.subject("123456")
.jws()
.keyId("1")
Expand All @@ -295,6 +296,7 @@ public static String getLogoutToken() {
.audience(TOKEN_AUDIENCE)
.subject("123456")
.claim("events", createEventsClaim())
.claim("sid", "session-id")
.jws()
.keyId("1")
.sign("privateKey.jwk");
Expand Down

0 comments on commit 3dd5f84

Please sign in to comment.