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

Add support for OIDC BackChannel Logout #24611

Merged
merged 1 commit into from
Apr 13, 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 @@ -468,6 +468,25 @@ quarkus.oidc.logout.post-logout-uri-param=returnTo
quarkus.oidc.logout.extra-params.client_id=${quarkus.oidc.client-id}
----

[[back-channel-logout]]
==== Back-Channel Logout

link:https://openid.net/specs/openid-connect-backchannel-1_0.html[Back-Channel Logout] is used by OpenId Connect providers to logout the current user from all the applications this user is currently logged in, bypassing the user agent.

You can configure Quarkus to support `Back-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.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.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public void run() {
List<Credential> credentials = new ArrayList<>();
credentials.add(passwordCred);
String rawRoles = roleInfo.get(user);
String[] roles = rawRoles.split(",");
String[] roles = rawRoles != null ? rawRoles.split(",") : new String[0];
Attributes attributes = new MapAttributes();
for (String role : roles) {
attributes.addLast("groups", role);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,9 @@ public final class OidcConstants {

public static final String PKCE_CODE_CHALLENGE_METHOD = "code_challenge_method";
public static final String PKCE_CODE_CHALLENGE_S256 = "S256";

public static final String BACK_CHANNEL_LOGOUT_TOKEN = "logout_token";
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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.TokenIntrospectionCache;
import io.quarkus.oidc.UserInfoCache;
import io.quarkus.oidc.runtime.BackChannelLogoutHandler;
import io.quarkus.oidc.runtime.DefaultTenantConfigResolver;
import io.quarkus.oidc.runtime.DefaultTokenIntrospectionUserInfoCache;
import io.quarkus.oidc.runtime.DefaultTokenStateManager;
Expand Down Expand Up @@ -89,7 +90,8 @@ public void additionalBeans(BuildProducer<AdditionalBeanBuildItem> additionalBea
.addBeanClass(OidcIdentityProvider.class)
.addBeanClass(DefaultTenantConfigResolver.class)
.addBeanClass(DefaultTokenStateManager.class)
.addBeanClass(OidcSessionImpl.class);
.addBeanClass(OidcSessionImpl.class)
.addBeanClass(BackChannelLogoutHandler.class);
additionalBeans.produce(builder.build());
}

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();

/**
* Logout configuration
* RP Initiated and BackChannel Logout configuration
*/
@ConfigItem
public Logout logout = new Logout();
Expand Down Expand Up @@ -171,6 +171,12 @@ public static class Logout {
@ConfigItem
public Map<String, String> extraParams;

/**
* Back-Channel Logout configuration
*/
@ConfigItem
public Backchannel backchannel = new Backchannel();

public void setPath(Optional<String> path) {
this.path = path;
}
Expand Down Expand Up @@ -202,6 +208,31 @@ public String getPostLogoutUriParam() {
public void setPostLogoutUriParam(String postLogoutUriParam) {
this.postLogoutUriParam = postLogoutUriParam;
}

public Backchannel getBackchannel() {
return backchannel;
}

public void setBackchannel(Backchannel backchannel) {
this.backchannel = backchannel;
}
}

@ConfigGroup
public static class Backchannel {
/**
* The relative path of the Back-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();
}
}

/**
Expand Down Expand Up @@ -892,6 +923,24 @@ public static Token fromAudience(String... audience) {
@ConfigItem
public OptionalInt lifespanGrace = OptionalInt.empty();

/**
* Token age.
*
* It allows for the number of seconds to be specified that must not elapse since the `iat` (issued at) time.
* A small leeway to account for clock skew which can be configured with 'quarkus.oidc.token.lifespan-grace' to verify
* the token expiry time
* can also be used to verify the token age property.
*
* Note that setting this property does not relax the requirement that Bearer and Code Flow JWT tokens
* must have a valid ('exp') expiry claim value. The only exception where setting this property relaxes the requirement
* is when a logout token is sent with a back-channel logout request since the current
* OpenId Connect Back-Channel specification does not explicitly require the logout tokens to contain an 'exp' claim.
* However even if the current logout token is allowed to have no 'exp' claim, the `exp` claim will be still verified
* if the logout token contains it.
*/
@ConfigItem
public Optional<Duration> age = Optional.empty();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't make more sense to have this property available only for the backchannel logout config category?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about for a moment but then decided it could of common interest, the same extra restriction (that this token was not created 6 months ago :-) ) can be applied to bearer or even code flow tokens (smallrye-jwt has it and MP-JWT 2.1 will have it too).


/**
* Name of the claim which contains a principal name. By default, the 'upn', 'preferred_username' and `sub` claims are
* checked.
Expand Down Expand Up @@ -1045,6 +1094,14 @@ public boolean isAllowOpaqueTokenIntrospection() {
public void setAllowOpaqueTokenIntrospection(boolean allowOpaqueTokenIntrospection) {
this.allowOpaqueTokenIntrospection = allowOpaqueTokenIntrospection;
}

public Optional<Duration> getAge() {
return age;
}

public void setAge(Duration age) {
this.age = Optional.of(age);
}
}

public static enum ApplicationType {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package io.quarkus.oidc.runtime;

import java.util.function.Consumer;

import javax.enterprise.event.Observes;
import javax.inject.Inject;

import org.eclipse.microprofile.jwt.Claims;
import org.jboss.logging.Logger;
import org.jose4j.jwt.consumer.InvalidJwtException;

import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.vertx.core.Handler;
import io.vertx.core.MultiMap;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;

public class BackChannelLogoutHandler {
private static final Logger LOG = Logger.getLogger(BackChannelLogoutHandler.class);

@Inject
DefaultTenantConfigResolver resolver;

private final OidcConfig oidcConfig;

public BackChannelLogoutHandler(OidcConfig oidcConfig) {
this.oidcConfig = oidcConfig;
}

public void setup(@Observes Router router) {
addRoute(router, oidcConfig.defaultTenant);

for (OidcTenantConfig oidcTenantConfig : oidcConfig.namedTenants.values()) {
addRoute(router, oidcTenantConfig);
}
}

private void addRoute(Router router, OidcTenantConfig oidcTenantConfig) {
if (oidcTenantConfig.isTenantEnabled() && oidcTenantConfig.logout.backchannel.path.isPresent()) {
router.route(oidcTenantConfig.logout.backchannel.path.get()).handler(new RouteHandler(oidcTenantConfig));
}
}

class RouteHandler implements Handler<RoutingContext> {
private final OidcTenantConfig oidcTenantConfig;

RouteHandler(OidcTenantConfig oidcTenantConfig) {
this.oidcTenantConfig = oidcTenantConfig;
}

@Override
public void handle(RoutingContext context) {
LOG.debugf("Back channel logout request for the tenant %s received", oidcTenantConfig.getTenantId().get());
final TenantConfigContext tenantContext = getTenantConfigContext(context);
if (tenantContext == null) {
LOG.debugf(
"Tenant configuration for the tenant %s is not available or does not match the backchannel logout path",
oidcTenantConfig.getTenantId().get());
}

if (OidcUtils.isFormUrlEncodedRequest(context)) {
OidcUtils.getFormUrlEncodedData(context)
.subscribe().with(new Consumer<MultiMap>() {
@Override
public void accept(MultiMap form) {

String encodedLogoutToken = form.get(OidcConstants.BACK_CHANNEL_LOGOUT_TOKEN);
if (encodedLogoutToken == null) {
LOG.debug("Back channel logout token is missing");
context.response().setStatusCode(400);
} else {
try {
// Do the general validation of the logout token now, compare with the IDToken later
// Check the signature, as well the issuer and audience if it is configured
TokenVerificationResult result = tenantContext.provider
.verifyLogoutJwtToken(encodedLogoutToken);

if (verifyLogoutTokenClaims(result)) {
resolver.getBackChannelLogoutTokens().put(oidcTenantConfig.tenantId.get(),
result);
context.response().setStatusCode(200);
} else {
context.response().setStatusCode(400);
}
} catch (InvalidJwtException e) {
LOG.debug("Back channel logout token is invalid");
context.response().setStatusCode(400);

}
}
context.response().end();
}

});

} else {
LOG.debug("HTTP POST and " + HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString()
+ " content type must be used with the Back channel logout request");
context.response().setStatusCode(400);
context.response().end();
}
}

private boolean verifyLogoutTokenClaims(TokenVerificationResult result) {
// events
JsonObject events = result.localVerificationResult.getJsonObject(OidcConstants.BACK_CHANNEL_EVENTS_CLAIM);
if (events == null || events.getJsonObject(OidcConstants.BACK_CHANNEL_EVENT_NAME) == null) {
LOG.debug("Back channel logout token does not have a valid 'events' claim");
return false;
}
if (!result.localVerificationResult.containsKey(Claims.sub.name())
&& !result.localVerificationResult.containsKey(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM)) {
LOG.debug("Back channel logout token does not have 'sub' or 'sid' claim");
return false;
}
if (result.localVerificationResult.containsKey(Claims.nonce.name())) {
LOG.debug("Back channel logout token must not contain 'nonce' claim");
return false;
}
return true;
}

private TenantConfigContext getTenantConfigContext(RoutingContext context) {
String requestPath = context.request().path();
if (isMatchingTenant(requestPath, resolver.getTenantConfigBean().getDefaultTenant())) {
return resolver.getTenantConfigBean().getDefaultTenant();
}
for (TenantConfigContext tenant : resolver.getTenantConfigBean().getStaticTenantsConfig().values()) {
if (isMatchingTenant(requestPath, tenant)) {
return tenant;
}
}
return null;
}

private boolean isMatchingTenant(String requestPath, TenantConfigContext tenant) {
return tenant.oidcConfig.isTenantEnabled()
&& tenant.oidcConfig.getTenantId().get().equals(oidcTenantConfig.getTenantId().get())
&& requestPath.equals(tenant.oidcConfig.logout.backchannel.path.orElse(null));
}
}
}
Loading