diff --git a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc index b46b26c3fcf65..91a6f403129e6 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -468,6 +468,53 @@ 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.back-channel-logout.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. + +Note you may also have to authenticate the incoming back-channel logout request using a path-based authentication mechanism configuration, for example: + +[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.back-channel-logout.path=/back-channel-logout + +# Request OpenId Connect Authorization Code authentication for all the endpoint paths starting from '/services*' +quarkus.http.auth.permission.codeflow.paths=/service/* +quarkus.http.auth.permission.codeflow.policy=authenticated +quarkus.http.auth.permission.codeflow.auth-mechanism=code + +# Request Basic Authentication for Back-Channel Logout requests + +quarkus.http.auth.permission.backchannellogout.paths=/back-channel-logout +quarkus.http.auth.permission.backchannellogout.policy=authenticated +quarkus.http.auth.permission.backchannellogout.auth-mechanism=basic + +quarkus.http.auth.basic=true +quarkus.security.users.embedded.enabled=true +quarkus.security.users.embedded.plain-text=true +quarkus.security.users.embedded.users.oidc-backchannel-logout-manager=oidc-backchannel-logout-manager-password +---- + [[local-logout]] ==== Local Logout diff --git a/extensions/elytron-security-properties-file/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronPropertiesFileRecorder.java b/extensions/elytron-security-properties-file/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronPropertiesFileRecorder.java index e1040f1a30c46..db6bd5bb8d44b 100644 --- a/extensions/elytron-security-properties-file/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronPropertiesFileRecorder.java +++ b/extensions/elytron-security-properties-file/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronPropertiesFileRecorder.java @@ -155,7 +155,7 @@ public void run() { List 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); diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java index f73eec0182de2..743d21274645c 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java @@ -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"; } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index 0d5af51006b8e..38dc994428988 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -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; @@ -89,7 +90,8 @@ public void additionalBeans(BuildProducer additionalBea .addBeanClass(OidcIdentityProvider.class) .addBeanClass(DefaultTenantConfigResolver.class) .addBeanClass(DefaultTokenStateManager.class) - .addBeanClass(OidcSessionImpl.class); + .addBeanClass(OidcSessionImpl.class) + .addBeanClass(BackChannelLogoutHandler.class); additionalBeans.produce(builder.build()); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index 7a0112c1d1d66..61882469fa2eb 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -98,11 +98,17 @@ public class OidcTenantConfig extends OidcCommonConfig { public Token token = new Token(); /** - * Logout configuration + * RP Initiated Logout configuration */ @ConfigItem public Logout logout = new Logout(); + /** + * Back-Channel Logout configuration + */ + @ConfigItem + public BackChannelLogout backChannelLogout = new BackChannelLogout(); + /** * Different options to configure authorization requests */ @@ -204,6 +210,23 @@ public void setPostLogoutUriParam(String postLogoutUriParam) { } } + @ConfigGroup + public static class BackChannelLogout { + /** + * The relative path of the Back-Channel Logout endpoint at the application. + */ + @ConfigItem + public Optional path = Optional.empty(); + + public void setPath(Optional path) { + this.path = path; + } + + public String getPath() { + return path.get(); + } + } + /** * Default Authorization Code token state manager configuration */ diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java new file mode 100644 index 0000000000000..14f9d89622b85 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java @@ -0,0 +1,144 @@ +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.backChannelLogout.path.isPresent()) { + router.route(oidcTenantConfig.backChannelLogout.path.get()).handler(new RouteHandler(oidcTenantConfig)); + } + } + + class RouteHandler implements Handler { + 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() { + @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 + .verifyJwtToken(encodedLogoutToken); + + if (verifyLogoutTokenClaims(result)) { + resolver.getBackChannelLogoutTokens().put(oidcTenantConfig.tenantId.get(), + result); + } 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.backChannelLogout.path.orElse(null)); + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index 9be8d0b93cde6..3ca5e1169fbe8 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -15,10 +15,10 @@ import java.util.Optional; import java.util.UUID; import java.util.function.BiFunction; -import java.util.function.Consumer; import java.util.function.Function; import java.util.regex.Pattern; +import org.eclipse.microprofile.jwt.Claims; import org.jboss.logging.Logger; import org.jose4j.jwt.consumer.ErrorCodes; import org.jose4j.jwt.consumer.InvalidJwtException; @@ -43,12 +43,9 @@ import io.smallrye.jwt.build.JwtClaimsBuilder; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.subscription.UniEmitter; -import io.vertx.core.Handler; import io.vertx.core.MultiMap; import io.vertx.core.http.Cookie; import io.vertx.core.http.HttpHeaders; -import io.vertx.core.http.HttpMethod; import io.vertx.core.http.impl.CookieImpl; import io.vertx.core.http.impl.ServerCookie; import io.vertx.core.json.JsonObject; @@ -66,7 +63,6 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha static final Uni VOID_UNI = Uni.createFrom().voidItem(); static final Integer MAX_COOKIE_VALUE_LENGTH = 4096; static final String NO_OIDC_COOKIES_AVAILABLE = "no_oidc_cookies"; - static final String FORM_URL_ENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded"; private static final String INTERNAL_IDTOKEN_HEADER = "internal"; private static final Logger LOG = Logger.getLogger(CodeAuthenticationMechanism.class); @@ -97,32 +93,18 @@ public Uni apply(TenantConfigContext tenantContext) { // if the state cookie is available then try to complete the code flow and start a new session if (stateCookie != null) { if (ResponseMode.FORM_POST == oidcTenantConfig.authentication.responseMode.orElse(ResponseMode.QUERY)) { - String contentType = context.request().getHeader("Content-Type"); - if (context.request().method() == HttpMethod.POST - && contentType != null - && (contentType.equals(FORM_URL_ENCODED_CONTENT_TYPE) - || contentType.startsWith(FORM_URL_ENCODED_CONTENT_TYPE + ";"))) { - context.request().setExpectMultipart(true); - return Uni.createFrom().emitter(new Consumer>() { - @Override - public void accept(UniEmitter t) { - context.request().endHandler(new Handler() { + if (OidcUtils.isFormUrlEncodedRequest(context)) { + return OidcUtils.getFormUrlEncodedData(context).onItem() + .transformToUni(new Function>() { @Override - public void handle(Void event) { - t.complete(context.request().formAttributes()); + public Uni apply(MultiMap requestParams) { + return processRedirectFromOidc(context, oidcTenantConfig, identityProviderManager, + stateCookie, + requestParams); } }); - context.request().resume(); - } - }).onItem().transformToUni(new Function>() { - @Override - public Uni apply(MultiMap requestParams) { - return processRedirectFromOidc(context, oidcTenantConfig, identityProviderManager, stateCookie, - requestParams); - } - }); } - LOG.debug("HTTP POST and " + FORM_URL_ENCODED_CONTENT_TYPE + LOG.debug("HTTP POST and " + HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString() + " content type must be used with the form_post response mode"); return Uni.createFrom().failure(new AuthenticationFailedException()); } else { @@ -218,6 +200,19 @@ private Uni reAuthenticate(Cookie sessionCookie, .chain(new Function>() { @Override public Uni apply(AuthorizationCodeTokens session) { + if (isBackChannelLogoutPendingAndValid(configContext, session.getIdToken())) { + return OidcUtils + .removeSessionCookie(context, configContext.oidcConfig, sessionCookie.getName(), + resolver.getTokenStateManager()) + .chain(new Function>() { + @Override + public Uni 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, @@ -273,6 +268,47 @@ public Uni apply(Throwable t) { }); } + private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configContext, String idToken) { + 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()); + return false; + } + + return true; + } + return false; + } + private boolean isInternalIdToken(String idToken, TenantConfigContext configContext) { if (!configContext.oidcConfig.authentication.idTokenRequired.orElse(true)) { JsonObject headers = OidcUtils.decodeJwtHeaders(idToken); @@ -601,6 +637,9 @@ public Uni apply(Void t) { final long sessionMaxAge = maxAge; context.put(SESSION_MAX_AGE_PARAM, maxAge); context.put(TenantConfigContext.class.getName(), configContext); + // Just in case, remove the stale Back-Channel Logout data if the previous session was not terminated correctly + resolver.getBackChannelLogoutTokens().remove(configContext.oidcConfig.tenantId.get()); + return resolver.getTokenStateManager() .createTokenState(context, configContext.oidcConfig, tokens, createTokenStateRequestContext) .map(new Function() { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java index b6de9dfc3eb03..b72658eead883 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java @@ -1,5 +1,7 @@ package io.quarkus.oidc.runtime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import javax.annotation.PostConstruct; @@ -60,6 +62,8 @@ public class DefaultTenantConfigResolver { private volatile boolean securityEventObserved; + private ConcurrentHashMap backChannelLogoutTokens = new ConcurrentHashMap<>(); + @PostConstruct public void verifyResolvers() { if (tenantConfigResolver.isResolvable() && tenantConfigResolver.isAmbiguous()) { @@ -219,4 +223,12 @@ boolean isEnableHttpForwardedPrefix() { return enableHttpForwardedPrefix; } + public Map getBackChannelLogoutTokens() { + return backChannelLogoutTokens; + } + + public TenantConfigBean getTenantConfigBean() { + return tenantConfigBean; + } + } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index 0ad4bd675edd5..80ae9d078ba6f 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -74,6 +74,10 @@ public Uni apply(OidcTenantConfig config) { private Uni createDynamicTenantContext(Vertx vertx, OidcTenantConfig oidcConfig, TlsConfig tlsConfig, String tenantId) { + if (oidcConfig.backChannelLogout.path.isPresent()) { + throw new ConfigurationException( + "BackChannel Logout is currently not supported for dynamic tenants"); + } if (!dynamicTenantsConfig.containsKey(tenantId)) { Uni uniContext = createTenantContext(vertx, oidcConfig, tlsConfig, tenantId); uniContext.onFailure().transform(t -> logTenantConfigContextFailure(t, tenantId)); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 5200606cbd385..bc6bd92f7daad 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -10,6 +10,7 @@ import java.util.LinkedList; import java.util.List; import java.util.StringTokenizer; +import java.util.function.Consumer; import java.util.regex.Pattern; import javax.crypto.SecretKey; @@ -40,6 +41,11 @@ import io.smallrye.jwt.algorithm.ContentEncryptionAlgorithm; import io.smallrye.jwt.algorithm.KeyEncryptionAlgorithm; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.subscription.UniEmitter; +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; import io.vertx.core.http.impl.ServerCookie; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -423,4 +429,28 @@ public static String decryptString(String jweString, SecretKey key) throws Excep jwe.setCompactSerialization(jweString); return jwe.getPlaintextString(); } + + public static boolean isFormUrlEncodedRequest(RoutingContext context) { + String contentType = context.request().getHeader("Content-Type"); + return context.request().method() == HttpMethod.POST + && contentType != null + && (contentType.equals(HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + || contentType.startsWith(HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString() + ";")); + } + + public static Uni getFormUrlEncodedData(RoutingContext context) { + context.request().setExpectMultipart(true); + return Uni.createFrom().emitter(new Consumer>() { + @Override + public void accept(UniEmitter t) { + context.request().endHandler(new Handler() { + @Override + public void handle(Void event) { + t.complete(context.request().formAttributes()); + } + }); + context.request().resume(); + } + }); + } } diff --git a/integration-tests/oidc-wiremock/pom.xml b/integration-tests/oidc-wiremock/pom.xml index 3eb96fedbef00..5edce22103e29 100644 --- a/integration-tests/oidc-wiremock/pom.xml +++ b/integration-tests/oidc-wiremock/pom.xml @@ -77,6 +77,23 @@ + + io.quarkus + quarkus-elytron-security-properties-file + + + io.quarkus + quarkus-elytron-security-properties-file-deployment + ${project.version} + pom + test + + + * + * + + + diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowFormPostResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowFormPostResource.java index 7d8663fbcb59f..048136373ff3d 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowFormPostResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowFormPostResource.java @@ -1,10 +1,10 @@ package io.quarkus.it.keycloak; +import javax.annotation.security.RolesAllowed; import javax.inject.Inject; import javax.ws.rs.GET; import javax.ws.rs.Path; -import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; @Path("/code-flow-form-post") @@ -14,7 +14,7 @@ public class CodeFlowFormPostResource { SecurityIdentity identity; @GET - @Authenticated + @RolesAllowed("user") public String access() { return identity.getPrincipal().getName(); } diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index cc377cce773e7..014886758fe98 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -32,6 +32,8 @@ quarkus.oidc.code-flow-form-post.authorization-path=/ 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.back-channel-logout.path=/back-channel-logout + quarkus.oidc.code-flow-user-info-only.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.code-flow-user-info-only.discovery-enabled=false @@ -98,3 +100,16 @@ 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.codeflow.paths=/code-flow-form-post +quarkus.http.auth.permission.codeflow.policy=authenticated +quarkus.http.auth.permission.codeflow.auth-mechanism=code + +quarkus.http.auth.permission.backchannellogout.paths=/back-channel-logout +quarkus.http.auth.permission.backchannellogout.policy=authenticated +quarkus.http.auth.permission.backchannellogout.auth-mechanism=basic + +quarkus.http.auth.basic=true +quarkus.security.users.embedded.enabled=true +quarkus.security.users.embedded.plain-text=true +quarkus.security.users.embedded.users.keycloak-logout-manager=keycloak-logout-manager-password diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 1d12e7ef372eb..9869ae209b004 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -9,12 +9,15 @@ import static org.junit.jupiter.api.Assertions.assertNull; import java.io.IOException; +import java.net.URI; import java.util.Set; import org.junit.jupiter.api.Test; import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.WebResponse; import com.gargoylesoftware.htmlunit.html.HtmlForm; import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.util.Cookie; @@ -26,6 +29,8 @@ 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; import io.vertx.core.json.JsonObject; @QuarkusTest @@ -73,7 +78,31 @@ public void testCodeFlowFormPost() throws IOException { 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 assertNotNull(getSessionCookie(webClient, "code-flow-form-post")); + + // request a back channel logout + RestAssured.given().auth() + .preemptive().basic("keycloak-logout-manager", "keycloak-logout-manager-password") + .when().contentType(ContentType.URLENC).body("logout_token=" + OidcWiremockTestResource.getLogoutToken()) + .post("/back-channel-logout") + .then() + .statusCode(200); + + // Confirm 302 is returned and the session cookie is null + webClient.getOptions().setRedirectEnabled(false); + WebResponse 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(); } } diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java index a9a2cb80c138f..4190e78fe26fb 100644 --- a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java @@ -17,6 +17,9 @@ import java.util.Map; import java.util.Set; +import javax.json.Json; +import javax.json.JsonObject; + import org.jboss.logging.Logger; import org.jose4j.keys.X509Util; @@ -281,11 +284,27 @@ public static String generateJwtToken(String userName, Set groups) { .groups(groups) .issuer(TOKEN_ISSUER) .audience(TOKEN_AUDIENCE) + .subject("123456") .jws() .keyId("1") .sign("privateKey.jwk"); } + public static String getLogoutToken() { + return Jwt.issuer(TOKEN_ISSUER) + .audience(TOKEN_AUDIENCE) + .subject("123456") + .claim("events", createEventsClaim()) + .jws() + .keyId("1") + .sign("privateKey.jwk"); + } + + private static JsonObject createEventsClaim() { + return Json.createObjectBuilder().add("http://schemas.openid.net/event/backchannel-logout", + Json.createObjectBuilder().build()).build(); + } + @Override public void inject(TestInjector testInjector) { testInjector.injectIntoFields(server,