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

Support for OIDC FrontChannel logout #25343

Merged
merged 1 commit into from
Nov 25, 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 @@ -508,6 +508,25 @@ Absolute `Back-Channel Logout` URL is calculated by adding `quarkus.oidc.back-ch

Note that you will also need to configure a token age property for the logout token verification to succeed if your OpenID Connect Provider does not set an expiry claim in the current logout token, for example, `quarkus.oidc.token.age=10S` sets a number of seconds that must not elapse since the logout token's `iat` (issued at) time to 10.

[[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 @@ -65,4 +65,6 @@ public final class OidcConstants {
public static final String BACK_CHANNEL_EVENTS_CLAIM = "events";
public static final String BACK_CHANNEL_EVENT_NAME = "http://schemas.openid.net/event/backchannel-logout";
public static final String BACK_CHANNEL_LOGOUT_SID_CLAIM = "sid";
public static final String FRONT_CHANNEL_LOGOUT_SID_PARAM = "sid";
public static final String ID_TOKEN_SID_CLAIM = "sid";
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public void setIncludeClientId(boolean includeClientId) {
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 @@ -241,6 +241,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 @@ -280,6 +286,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 @@ -299,6 +313,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
@@ -1,5 +1,7 @@
package io.quarkus.oidc;

import java.util.Map;

import io.quarkus.security.identity.SecurityIdentity;

/**
Expand All @@ -26,15 +28,41 @@ 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 initiated event is reported when the BackChannel logout request to logout the current user
* has been received.
*/
OIDC_BACKCHANNEL_LOGOUT_INITIATED,

/**
* OIDC BackChannel Logout completed 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;
private final SecurityIdentity securityIdentity;
private final Map<String, Object> eventProperties;

public SecurityEvent(Type eventType, SecurityIdentity securityIdentity) {
this.eventType = eventType;
this.securityIdentity = securityIdentity;
this.eventProperties = Map.of();
}

public SecurityEvent(Type eventType, Map<String, Object> eventProperties) {
this.eventType = eventType;
this.securityIdentity = null;
this.eventProperties = eventProperties;
}

public Type getEventType() {
Expand All @@ -44,4 +72,8 @@ public Type getEventType() {
public SecurityIdentity getSecurityIdentity() {
return securityIdentity;
}

public Map<String, Object> getEventProperties() {
return eventProperties;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.oidc.runtime;

import java.util.Map;
import java.util.function.Consumer;

import javax.enterprise.event.Observes;
Expand All @@ -10,6 +11,8 @@
import org.jose4j.jwt.consumer.InvalidJwtException;

import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.SecurityEvent.Type;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.vertx.core.Handler;
import io.vertx.core.MultiMap;
Expand Down Expand Up @@ -84,6 +87,11 @@ public void accept(MultiMap form) {
if (verifyLogoutTokenClaims(result)) {
resolver.getBackChannelLogoutTokens().put(oidcTenantConfig.tenantId.get(),
result);
if (resolver.isSecurityEventObserved()) {
resolver.getSecurityEvent().fire(
new SecurityEvent(Type.OIDC_BACKCHANNEL_LOGOUT_INITIATED,
Map.of(OidcConstants.BACK_CHANNEL_LOGOUT_TOKEN, result)));
}
context.response().setStatusCode(200);
} else {
context.response().setStatusCode(400);
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 @@ -232,20 +233,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())) {
LOG.debug("Performing a requested back-channel logout");
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);
// Default token state manager may have encrypted ID token when it was saved in a cookie
Expand All @@ -262,6 +249,21 @@ public Uni<Void> apply(SecurityIdentity identity) {
return buildLogoutRedirectUriUni(context, configContext,
session.getIdToken());
}
if (isBackChannelLogoutPendingAndValid(configContext, identity)
|| isFrontChannelLogoutValid(context, configContext,
identity)) {
return OidcUtils
.removeSessionCookie(context, configContext.oidcConfig,
sessionCookie.getName(),
resolver.getTokenStateManager())
.map(new Function<Void, Void>() {
@Override
public Void apply(Void t) {
throw new LogoutException();
}
});

}
return VOID_UNI;
}
}).onFailure()
Expand All @@ -272,6 +274,10 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
LOG.debug("Redirecting after the reauthentication");
return Uni.createFrom().failure((AuthenticationRedirectException) t);
}
if (t instanceof LogoutException) {
LOG.debugf("User has been logged out, authentication challenge is required");
return Uni.createFrom().failure(new AuthenticationFailedException(t));
}

if (!(t instanceof TokenAutoRefreshException)) {
boolean expired = (t.getCause() instanceof InvalidJwtException)
Expand Down Expand Up @@ -336,47 +342,65 @@ private static String decryptIdTokenIfEncryptedByProvider(TenantConfigContext re
return token;
}

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.ID_TOKEN_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.ID_TOKEN_SID_CLAIM);
List<String> frontChannelSid = context.queryParam(OidcConstants.FRONT_CHANNEL_LOGOUT_SID_PARAM);
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 @@ -888,11 +912,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
@@ -0,0 +1,5 @@
package io.quarkus.oidc.runtime;

public class LogoutException extends RuntimeException {

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public String resolve(RoutingContext context) {
if (path.endsWith("code-flow-encrypted-id-token-pem")) {
return "code-flow-encrypted-id-token-pem";
}
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 @@ -47,6 +47,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 @@ -127,6 +128,9 @@ quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClient".level=TRACE
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

Expand Down
Loading